diff --git a/.gitignore b/.gitignore index 0c3174e84ee88..e9356134e5444 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ cypress/screenshots/* cypress/downloads/* *.swp CHANGELOG-*.md +packages/cli/oclif.manifest.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d18b61e3f764..ddaf945357ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,45 @@ +# [1.14.0](https://github.com/n8n-io/n8n/compare/n8n@1.13.0...n8n@1.14.0) (2023-10-25) + + +### Features + +* **Switch Node:** Add support for infinite Switch outputs ([#7499](https://github.com/n8n-io/n8n/issues/7499)) ([2febc61](https://github.com/n8n-io/n8n/commit/2febc61ec94928eb196e1b5f815fffa13f8bae07)) + + + +# [1.13.0](https://github.com/n8n-io/n8n/compare/n8n@1.12.0...n8n@1.13.0) (2023-10-25) + + +### Bug Fixes + +* **core:** Always derive `instanceId` from the encryption key (no-changlog) ([#7501](https://github.com/n8n-io/n8n/issues/7501)) ([a9fdd01](https://github.com/n8n-io/n8n/commit/a9fdd018f4f5ba1e11cc10dc3a3b7929a586f818)) +* **core:** Do not return `inviteAcceptUrl` in response if email was sent ([#7465](https://github.com/n8n-io/n8n/issues/7465)) ([55c6a1b](https://github.com/n8n-io/n8n/commit/55c6a1b0d394265fa4018a7023971589d8e61b4a)) +* **core:** Ensure nodes post-processors run in the correct order ([#7500](https://github.com/n8n-io/n8n/issues/7500)) ([6f45298](https://github.com/n8n-io/n8n/commit/6f45298d3d61b33e762f520129f4775e216707c8)), closes [#7497](https://github.com/n8n-io/n8n/issues/7497) +* **core:** Fix `frontend.settings` external hook execution ([#7496](https://github.com/n8n-io/n8n/issues/7496)) ([774fe20](https://github.com/n8n-io/n8n/commit/774fe202bfde4f2c5cc95f28a33185e261b031a5)) +* **core:** Handle gzip and deflate compressed request payloads ([#7461](https://github.com/n8n-io/n8n/issues/7461)) ([83762e0](https://github.com/n8n-io/n8n/commit/83762e051d5e34d9e43caebd6275780da05c6a46)) +* **core:** Reduce logging overhead for levels that do not output ([#7479](https://github.com/n8n-io/n8n/issues/7479)) ([76c0481](https://github.com/n8n-io/n8n/commit/76c04815f7f53bf6b4c06bbe5afa52f51f28750d)) +* **Customer.io Node:** Fix api endpoint when using EU region ([#7485](https://github.com/n8n-io/n8n/issues/7485)) ([519680c](https://github.com/n8n-io/n8n/commit/519680c2cf37f3b7341e87e71b911ac2fee8bdfa)), closes [#7484](https://github.com/n8n-io/n8n/issues/7484) +* **editor:** Allow importing the same workflow multiple times ([#7458](https://github.com/n8n-io/n8n/issues/7458)) ([3c0a166](https://github.com/n8n-io/n8n/commit/3c0a166f7f1cf225e5d1b4da91f7449f2deed5ca)), closes [#7457](https://github.com/n8n-io/n8n/issues/7457) +* **editor:** Fix canvas selection breaking after interacting with node actions ([#7466](https://github.com/n8n-io/n8n/issues/7466)) ([bc47365](https://github.com/n8n-io/n8n/commit/bc473655fbc09b1172cd6949236ca2871c9d3b8e)) +* **editor:** Fix connections disappearing after reactivating canvas and renaming a node ([#7483](https://github.com/n8n-io/n8n/issues/7483)) ([450e0cc](https://github.com/n8n-io/n8n/commit/450e0cc66abbe57697f66835a837e53b5eb883a3)) +* **Google Sheets Node:** Append or update runs forever when without column headers ([#7463](https://github.com/n8n-io/n8n/issues/7463)) ([ab6a9bb](https://github.com/n8n-io/n8n/commit/ab6a9bbac29a2caf34f4dd8211cd18116f659804)) +* **Microsoft SQL Node:** Prevent SQL injection ([#7467](https://github.com/n8n-io/n8n/issues/7467)) ([a739245](https://github.com/n8n-io/n8n/commit/a7392453323fe06371988fd5bb28d659a7a00cd8)) +* **MQTT Trigger Node:** Fix node causing a start up hang when active ([#7498](https://github.com/n8n-io/n8n/issues/7498)) ([baecb93](https://github.com/n8n-io/n8n/commit/baecb93bef30ac00f09b46ea987bb4c9a2fca394)) +* **MySQL Node:** Resolve expressions in v1 ([#7464](https://github.com/n8n-io/n8n/issues/7464)) ([5c46bb0](https://github.com/n8n-io/n8n/commit/5c46bb09c137023608119093cabdf896555b22b9)) +* **Redis Node:** Fix adding sets data types ([#7444](https://github.com/n8n-io/n8n/issues/7444)) ([4e66023](https://github.com/n8n-io/n8n/commit/4e66023cd428513b76626795c27ba0713c6c4ea9)), closes [#6339](https://github.com/n8n-io/n8n/issues/6339) +* **Spreadsheet File Node:** Fix include empty cells not working with v2 ([#7505](https://github.com/n8n-io/n8n/issues/7505)) ([05e6f2a](https://github.com/n8n-io/n8n/commit/05e6f2a6ac43fb4059e7e6cc40af6c5d75e01c8b)), closes [Ticket#763644](https://github.com/Ticket/issues/763644) + + +### Features + +* **core:** Add support for oauth based service accounts with UM SMTP ([#7311](https://github.com/n8n-io/n8n/issues/7311)) ([647372b](https://github.com/n8n-io/n8n/commit/647372be275c46e9963c96163c9e913a17f13e5f)) +* **editor:** Add PH tracking to event ([#7511](https://github.com/n8n-io/n8n/issues/7511)) ([c47d27d](https://github.com/n8n-io/n8n/commit/c47d27dd6da9420add7dad243b2701876f39a95b)) +* **Facebook Lead Ads Trigger Node:** Add Facebook Lead Ads Trigger Node ([#7113](https://github.com/n8n-io/n8n/issues/7113)) ([ac814a9](https://github.com/n8n-io/n8n/commit/ac814a9c613f6f9943be8002110ca9e2433918b2)) +* **Ghost Node:** Add support for lexical format ([#7488](https://github.com/n8n-io/n8n/issues/7488)) ([7b1973c](https://github.com/n8n-io/n8n/commit/7b1973c058e0cb7dfa436953c6f046c2b3b145eb)) +* **RSS Feed Trigger Node:** Add RSS feed trigger node ([#7386](https://github.com/n8n-io/n8n/issues/7386)) ([689360e](https://github.com/n8n-io/n8n/commit/689360ee069043415838f1488486ce8deaef9e38)) + + + # [1.12.0](https://github.com/n8n-io/n8n/compare/n8n@1.11.0...n8n@1.12.0) (2023-10-18) diff --git a/cypress/e2e/10-undo-redo.cy.ts b/cypress/e2e/10-undo-redo.cy.ts index 5800499bc2a67..d986fe6577718 100644 --- a/cypress/e2e/10-undo-redo.cy.ts +++ b/cypress/e2e/10-undo-redo.cy.ts @@ -43,7 +43,9 @@ describe('Undo/Redo', () => { WorkflowPage.actions.zoomToFit(); WorkflowPage.getters .canvasNodeByName('Code') - .should('have.attr', 'style', 'left: 860px; top: 220px;'); + .should('have.css', 'left', '860px') + .should('have.css', 'top', '220px') + WorkflowPage.actions.hitUndo(); WorkflowPage.getters.canvasNodes().should('have.have.length', 2); WorkflowPage.getters.nodeConnections().should('have.length', 1); @@ -59,7 +61,8 @@ describe('Undo/Redo', () => { // Last node should be added back to original position WorkflowPage.getters .canvasNodeByName('Code') - .should('have.attr', 'style', 'left: 860px; top: 220px;'); + .should('have.css', 'left', '860px') + .should('have.css', 'top', '220px') }); it('should undo/redo deleting node using delete button', () => { @@ -133,15 +136,19 @@ describe('Undo/Redo', () => { cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], { clickToFinish: true }); WorkflowPage.getters .canvasNodeByName('Code') - .should('have.attr', 'style', 'left: 740px; top: 320px;'); + .should('have.css', 'left', '740px') + .should('have.css', 'top', '320px') + WorkflowPage.actions.hitUndo(); WorkflowPage.getters .canvasNodeByName('Code') - .should('have.attr', 'style', 'left: 640px; top: 220px;'); + .should('have.css', 'left', '640px') + .should('have.css', 'top', '220px') WorkflowPage.actions.hitRedo(); WorkflowPage.getters .canvasNodeByName('Code') - .should('have.attr', 'style', 'left: 740px; top: 320px;'); + .should('have.css', 'left', '740px') + .should('have.css', 'top', '320px') }); it('should undo/redo deleting a connection by pressing delete button', () => { @@ -269,8 +276,8 @@ describe('Undo/Redo', () => { }); it('should undo/redo multiple steps', () => { - const initialPosition = 'left: 420px; top: 220px;'; - const movedPosition = 'left: 540px; top: 360px;'; + const initialPosition = {left: '420px', top: '220px'}; + const movedPosition = {left: '540px', top: '360px'}; WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); @@ -283,10 +290,17 @@ describe('Undo/Redo', () => { WorkflowPage.getters.canvasNodes().last().click(); WorkflowPage.actions.hitDisableNodeShortcut(); // Move first one - WorkflowPage.getters.canvasNodes().first().should('have.attr', 'style', initialPosition); + WorkflowPage.getters.canvasNodes() + .first() + .should('have.css', 'left', initialPosition.left) + .should('have.css', 'top', initialPosition.top) + WorkflowPage.getters.canvasNodes().first().click(); cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], { clickToFinish: true }); - WorkflowPage.getters.canvasNodes().first().should('have.attr', 'style', movedPosition); + WorkflowPage.getters.canvasNodes() + .first() + .should('have.css', 'left', movedPosition.left) + .should('have.css', 'top', movedPosition.top) // Delete the set node WorkflowPage.getters.canvasNodeByName(EDIT_FIELDS_SET_NODE_NAME).click().click(); cy.get('body').type('{backspace}'); @@ -297,7 +311,10 @@ describe('Undo/Redo', () => { WorkflowPage.getters.nodeConnections().should('have.length', 3); // Second undo: Should move first node to it's original position WorkflowPage.actions.hitUndo(); - WorkflowPage.getters.canvasNodes().first().should('have.attr', 'style', initialPosition); + WorkflowPage.getters.canvasNodes() + .first() + .should('have.css', 'left', initialPosition.left) + .should('have.css', 'top', initialPosition.top) // Third undo: Should enable last node WorkflowPage.actions.hitUndo(); WorkflowPage.getters.disabledNodes().should('have.length', 0); @@ -307,7 +324,10 @@ describe('Undo/Redo', () => { WorkflowPage.getters.disabledNodes().should('have.length', 1); // Second redo: Should move the first node WorkflowPage.actions.hitRedo(); - WorkflowPage.getters.canvasNodes().first().should('have.attr', 'style', movedPosition); + WorkflowPage.getters.canvasNodes() + .first() + .should('have.css', 'left', movedPosition.left) + .should('have.css', 'top', movedPosition.top) // Third redo: Should delete the Set node WorkflowPage.actions.hitRedo(); WorkflowPage.getters.canvasNodes().should('have.length', 3); diff --git a/cypress/e2e/12-canvas-actions.cy.ts b/cypress/e2e/12-canvas-actions.cy.ts index bf45b7da6bd54..c34a9189702a8 100644 --- a/cypress/e2e/12-canvas-actions.cy.ts +++ b/cypress/e2e/12-canvas-actions.cy.ts @@ -133,7 +133,8 @@ describe('Canvas Actions', () => { WorkflowPage.getters .canvasNodes() .last() - .should('have.attr', 'style', 'left: 860px; top: 220px;'); + .should('have.css', 'left', '860px') + .should('have.css', 'top', '220px') }); it('should delete connections by pressing the delete button', () => { diff --git a/cypress/e2e/12-canvas.cy.ts b/cypress/e2e/12-canvas.cy.ts index 75e05fc712ff6..9e2b8abe06c48 100644 --- a/cypress/e2e/12-canvas.cy.ts +++ b/cypress/e2e/12-canvas.cy.ts @@ -8,9 +8,11 @@ import { MERGE_NODE_NAME, } from './../constants'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; +import { NDV, WorkflowExecutionsTab } from '../pages'; const WorkflowPage = new WorkflowPageClass(); - +const ExecutionsTab = new WorkflowExecutionsTab(); +const NDVDialog = new NDV(); const DEFAULT_ZOOM_FACTOR = 1; const ZOOM_IN_X1_FACTOR = 1.25; // Zoom in factor after one click const ZOOM_IN_X2_FACTOR = 1.5625; // Zoom in factor after two clicks @@ -27,10 +29,15 @@ describe('Canvas Node Manipulation and Navigation', () => { }); it('should add switch node and test connections', () => { - WorkflowPage.actions.addNodeToCanvas(SWITCH_NODE_NAME, true); + const desiredOutputs = 4; + WorkflowPage.actions.addNodeToCanvas(SWITCH_NODE_NAME, true, true); + + for (let i = 0; i < desiredOutputs; i++) { + cy.contains('Add Routing Rule').click() + } - // Switch has 4 output endpoints - for (let i = 0; i < 4; i++) { + NDVDialog.actions.close() + for (let i = 0; i < desiredOutputs; i++) { WorkflowPage.getters.canvasNodePlusEndpointByName(SWITCH_NODE_NAME, i).click({ force: true }); WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible'); WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, false); @@ -40,7 +47,7 @@ describe('Canvas Node Manipulation and Navigation', () => { cy.reload(); cy.waitForLoad(); // Make sure all connections are there after reload - for (let i = 0; i < 4; i++) { + for (let i = 0; i < desiredOutputs; i++) { const setName = `${EDIT_FIELDS_SET_NODE_NAME}${i > 0 ? i : ''}`; WorkflowPage.getters .canvasNodeInputEndpointByName(setName) @@ -165,7 +172,8 @@ describe('Canvas Node Manipulation and Navigation', () => { WorkflowPage.getters .canvasNodes() .last() - .should('have.attr', 'style', 'left: 740px; top: 320px;'); + .should('have.css', 'left', '740px') + .should('have.css', 'top', '320px') }); it('should zoom in', () => { @@ -306,4 +314,35 @@ describe('Canvas Node Manipulation and Navigation', () => { WorkflowPage.getters.canvasNodes().should('have.length', 3); WorkflowPage.getters.nodeConnections().should('have.length', 1); }); + + // ADO-1240: Connections would get deleted after activating and deactivating NodeView + it('should preserve connections after rename & node-view switch', () => { + WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); + WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + WorkflowPage.actions.executeWorkflow(); + + ExecutionsTab.actions.switchToExecutionsTab(); + ExecutionsTab.getters.successfulExecutionListItems().should('have.length', 1); + + ExecutionsTab.actions.switchToEditorTab(); + + ExecutionsTab.actions.switchToExecutionsTab(); + ExecutionsTab.getters.successfulExecutionListItems().should('have.length', 1); + + ExecutionsTab.actions.switchToEditorTab(); + WorkflowPage.getters.canvasNodes().should('have.length', 2); + + WorkflowPage.getters.canvasNodes().last().click(); + cy.get('body').trigger('keydown', { key: 'F2' }); + cy.get('.rename-prompt').should('be.visible'); + cy.get('body').type(RENAME_NODE_NAME); + cy.get('body').type('{enter}'); + WorkflowPage.getters.canvasNodeByName(RENAME_NODE_NAME).should('exist'); + // Make sure all connections are there after save & reload + WorkflowPage.actions.saveWorkflowOnButtonClick(); + cy.reload(); + cy.waitForLoad(); + WorkflowPage.getters.canvasNodes().should('have.length', 2); + cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 1); + }) }); diff --git a/package.json b/package.json index 512fb7a6227a2..2ce4e34681dd2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.12.0", + "version": "1.14.0", "private": true, "homepage": "https://n8n.io", "engines": { diff --git a/packages/cli/jest.config.js b/packages/cli/jest.config.js index d946590a51d71..9c1d248a000df 100644 --- a/packages/cli/jest.config.js +++ b/packages/cli/jest.config.js @@ -6,7 +6,11 @@ module.exports = { }, globalSetup: '/test/setup.ts', globalTeardown: '/test/teardown.ts', - setupFilesAfterEnv: ['/test/setup-mocks.ts', '/test/extend-expect.ts'], + setupFilesAfterEnv: [ + '/test/setup-test-folder.ts', + '/test/setup-mocks.ts', + '/test/extend-expect.ts', + ], coveragePathIgnorePatterns: ['/src/databases/migrations/'], testTimeout: 10_000, }; diff --git a/packages/cli/package.json b/packages/cli/package.json index ef1218b6c5ff9..d68f5c3798260 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.12.0", + "version": "1.14.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -30,7 +30,7 @@ "lint": "eslint . --quiet", "lintfix": "eslint . --fix", "postpack": "rm -f oclif.manifest.json", - "prepack": "oclif-dev manifest", + "prepack": "OCLIF_TS_NODE=0 oclif-dev manifest", "start": "run-script-os", "start:default": "cd bin && ./n8n", "start:windows": "cd bin && n8n", @@ -99,7 +99,7 @@ }, "dependencies": { "@n8n/client-oauth2": "workspace:*", - "@n8n_io/license-sdk": "~2.6.1", + "@n8n_io/license-sdk": "~2.7.1", "@oclif/command": "^1.8.16", "@oclif/config": "^1.18.17", "@oclif/core": "^1.16.4", @@ -121,7 +121,6 @@ "connect-history-api-fallback": "^1.6.0", "convict": "^6.2.4", "cookie-parser": "^1.4.6", - "crypto-js": "~4.1.1", "csrf": "^3.1.0", "curlconverter": "3.21.0", "dotenv": "^8.0.0", diff --git a/packages/cli/src/AbstractServer.ts b/packages/cli/src/AbstractServer.ts index 1002476c5b8dc..4376e02e3fbdf 100644 --- a/packages/cli/src/AbstractServer.ts +++ b/packages/cli/src/AbstractServer.ts @@ -45,8 +45,6 @@ export abstract class AbstractServer { protected endpointWebhookWaiting: string; - protected instanceId = ''; - protected webhooksEnabled = true; protected testWebhooksEnabled = false; diff --git a/packages/cli/src/CrashJournal.ts b/packages/cli/src/CrashJournal.ts index b0cd5ad3f8015..7aa6c93731105 100644 --- a/packages/cli/src/CrashJournal.ts +++ b/packages/cli/src/CrashJournal.ts @@ -1,7 +1,8 @@ import { existsSync } from 'fs'; import { mkdir, utimes, open, rm } from 'fs/promises'; import { join, dirname } from 'path'; -import { UserSettings } from 'n8n-core'; +import { Container } from 'typedi'; +import { InstanceSettings } from 'n8n-core'; import { LoggerProxy, sleep } from 'n8n-workflow'; import { inProduction } from '@/constants'; @@ -16,7 +17,8 @@ export const touchFile = async (filePath: string): Promise => { } }; -const journalFile = join(UserSettings.getUserN8nFolderPath(), 'crash.journal'); +const { n8nFolder } = Container.get(InstanceSettings); +const journalFile = join(n8nFolder, 'crash.journal'); export const init = async () => { if (!inProduction) return; diff --git a/packages/cli/src/CredentialsHelper.ts b/packages/cli/src/CredentialsHelper.ts index f8e08e7210fc0..cc23fa6e5c0ac 100644 --- a/packages/cli/src/CredentialsHelper.ts +++ b/packages/cli/src/CredentialsHelper.ts @@ -3,7 +3,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-call */ - import { Credentials, NodeExecuteFunctions } from 'n8n-core'; import get from 'lodash/get'; @@ -53,7 +52,7 @@ import { CredentialTypes } from '@/CredentialTypes'; import { CredentialsOverwrites } from '@/CredentialsOverwrites'; import { whereClause } from './UserManagement/UserManagementHelper'; import { RESPONSE_ERROR_MESSAGES } from './constants'; -import { Container } from 'typedi'; +import { Service } from 'typedi'; import { isObjectLiteral } from './utils'; const { OAUTH2_CREDENTIAL_TEST_SUCCEEDED, OAUTH2_CREDENTIAL_TEST_FAILED } = RESPONSE_ERROR_MESSAGES; @@ -87,12 +86,15 @@ const mockNodeTypes: INodeTypes = { }, }; +@Service() export class CredentialsHelper extends ICredentialsHelper { - private credentialTypes = Container.get(CredentialTypes); - - private nodeTypes = Container.get(NodeTypes); - - private credentialsOverwrites = Container.get(CredentialsOverwrites); + constructor( + private readonly credentialTypes: CredentialTypes, + private readonly nodeTypes: NodeTypes, + private readonly credentialsOverwrites: CredentialsOverwrites, + ) { + super(); + } /** * Add the required authentication information to the request @@ -349,7 +351,7 @@ export class CredentialsHelper extends ICredentialsHelper { expressionResolveValues?: ICredentialsExpressionResolveValues, ): Promise { const credentials = await this.getCredentials(nodeCredentials, type); - const decryptedDataOriginal = credentials.getData(this.encryptionKey); + const decryptedDataOriginal = credentials.getData(); if (raw === true) { return decryptedDataOriginal; @@ -469,7 +471,7 @@ export class CredentialsHelper extends ICredentialsHelper { ): Promise { const credentials = await this.getCredentials(nodeCredentials, type); - credentials.setData(data, this.encryptionKey); + credentials.setData(data); const newCredentialsData = credentials.getDataToSave() as ICredentialsDb; // Add special database related data diff --git a/packages/cli/src/ExternalSecrets/ExternalSecretsManager.ee.ts b/packages/cli/src/ExternalSecrets/ExternalSecretsManager.ee.ts index d9cdab44be91e..18fd006fca6da 100644 --- a/packages/cli/src/ExternalSecrets/ExternalSecretsManager.ee.ts +++ b/packages/cli/src/ExternalSecrets/ExternalSecretsManager.ee.ts @@ -5,13 +5,12 @@ import type { SecretsProviderSettings, } from '@/Interfaces'; -import { UserSettings } from 'n8n-core'; +import { Cipher } from 'n8n-core'; import Container, { Service } from 'typedi'; -import { AES, enc } from 'crypto-js'; import { getLogger } from '@/Logger'; -import type { IDataObject } from 'n8n-workflow'; +import { jsonParse, type IDataObject } from 'n8n-workflow'; import { EXTERNAL_SECRETS_INITIAL_BACKOFF, EXTERNAL_SECRETS_MAX_BACKOFF, @@ -42,6 +41,7 @@ export class ExternalSecretsManager { private settingsRepo: SettingsRepository, private license: License, private secretsProviders: ExternalSecretsProviders, + private cipher: Cipher, ) {} async init(): Promise { @@ -86,15 +86,10 @@ export class ExternalSecretsManager { await Container.get(OrchestrationMainService).broadcastReloadExternalSecretsProviders(); } - private async getEncryptionKey(): Promise { - return UserSettings.getEncryptionKey(); - } - - private decryptSecretsSettings(value: string, encryptionKey: string): ExternalSecretsSettings { - const decryptedData = AES.decrypt(value, encryptionKey); - + private decryptSecretsSettings(value: string): ExternalSecretsSettings { + const decryptedData = this.cipher.decrypt(value); try { - return JSON.parse(decryptedData.toString(enc.Utf8)) as ExternalSecretsSettings; + return jsonParse(decryptedData); } catch (e) { throw new Error( 'External Secrets Settings could not be decrypted. The likely reason is that a different "encryptionKey" was used to encrypt the data.', @@ -109,8 +104,7 @@ export class ExternalSecretsManager { if (encryptedSettings === null) { return null; } - const encryptionKey = await this.getEncryptionKey(); - return this.decryptSecretsSettings(encryptedSettings, encryptionKey); + return this.decryptSecretsSettings(encryptedSettings); } private async internalInit() { @@ -327,13 +321,12 @@ export class ExternalSecretsManager { }); } - encryptSecretsSettings(settings: ExternalSecretsSettings, encryptionKey: string): string { - return AES.encrypt(JSON.stringify(settings), encryptionKey).toString(); + private encryptSecretsSettings(settings: ExternalSecretsSettings): string { + return this.cipher.encrypt(settings); } async saveAndSetSettings(settings: ExternalSecretsSettings, settingsRepo: SettingsRepository) { - const encryptionKey = await this.getEncryptionKey(); - const encryptedSettings = this.encryptSecretsSettings(settings, encryptionKey); + const encryptedSettings = this.encryptSecretsSettings(settings); await settingsRepo.saveEncryptedSecretsProviderSettings(encryptedSettings); } diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index 6e3911de87fc7..4f4c365b19ff5 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -31,6 +31,7 @@ import { ExecutionRepository } from '@db/repositories'; import { RoleService } from './services/role.service'; import type { EventPayloadWorkflow } from './eventbus/EventMessageClasses/EventMessageWorkflow'; import { determineFinalExecutionStatus } from './executionLifecycleHooks/shared/sharedHookFunctions'; +import { InstanceSettings } from 'n8n-core'; function userToPayload(user: User): { userId: string; @@ -50,22 +51,13 @@ function userToPayload(user: User): { @Service() export class InternalHooks implements IInternalHooksClass { - private instanceId: string; - - public get telemetryInstanceId(): string { - return this.instanceId; - } - - public get telemetryInstance(): Telemetry { - return this.telemetry; - } - constructor( private telemetry: Telemetry, private nodeTypes: NodeTypes, private roleService: RoleService, private executionRepository: ExecutionRepository, eventsService: EventsService, + private readonly instanceSettings: InstanceSettings, ) { eventsService.on('telemetry.onFirstProductionWorkflowSuccess', async (metrics) => this.onFirstProductionWorkflowSuccess(metrics), @@ -75,9 +67,7 @@ export class InternalHooks implements IInternalHooksClass { ); } - async init(instanceId: string) { - this.instanceId = instanceId; - this.telemetry.setInstanceId(instanceId); + async init() { await this.telemetry.init(); } @@ -813,7 +803,7 @@ export class InternalHooks implements IInternalHooksClass { user_id: userCreatedCredentialsData.user.id, credential_type: userCreatedCredentialsData.credential_type, credential_id: userCreatedCredentialsData.credential_id, - instance_id: this.instanceId, + instance_id: this.instanceSettings.instanceId, }), ]); } @@ -847,7 +837,7 @@ export class InternalHooks implements IInternalHooksClass { user_id_sharer: userSharedCredentialsData.user_id_sharer, user_ids_sharees_added: userSharedCredentialsData.user_ids_sharees_added, sharees_removed: userSharedCredentialsData.sharees_removed, - instance_id: this.instanceId, + instance_id: this.instanceSettings.instanceId, }), ]); } diff --git a/packages/cli/src/Ldap/helpers.ts b/packages/cli/src/Ldap/helpers.ts index 2ddbca37f0115..aecf57399aa53 100644 --- a/packages/cli/src/Ldap/helpers.ts +++ b/packages/cli/src/Ldap/helpers.ts @@ -1,9 +1,8 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ -import { AES, enc } from 'crypto-js'; import type { Entry as LdapUser } from 'ldapts'; import { Filter } from 'ldapts/filters/Filter'; import { Container } from 'typedi'; -import { UserSettings } from 'n8n-core'; +import { Cipher } from 'n8n-core'; import { validate } from 'jsonschema'; import * as Db from '@/Db'; import config from '@/config'; @@ -110,22 +109,6 @@ export const validateLdapConfigurationSchema = ( return { valid, message }; }; -/** - * Encrypt password using the instance's encryption key - */ -export const encryptPassword = async (password: string): Promise => { - const encryptionKey = await UserSettings.getEncryptionKey(); - return AES.encrypt(password, encryptionKey).toString(); -}; - -/** - * Decrypt password using the instance's encryption key - */ -export const decryptPassword = async (password: string): Promise => { - const encryptionKey = await UserSettings.getEncryptionKey(); - return AES.decrypt(password, encryptionKey).toString(enc.Utf8); -}; - /** * Retrieve the LDAP configuration (decrypted) form the database */ @@ -134,7 +117,7 @@ export const getLdapConfig = async (): Promise => { key: LDAP_FEATURE_NAME, }); const configurationData = jsonParse(configuration.value); - configurationData.bindingAdminPassword = await decryptPassword( + configurationData.bindingAdminPassword = Container.get(Cipher).decrypt( configurationData.bindingAdminPassword, ); return configurationData; @@ -173,7 +156,7 @@ export const updateLdapConfig = async (ldapConfig: LdapConfig): Promise => LdapManager.updateConfig({ ...ldapConfig }); - ldapConfig.bindingAdminPassword = await encryptPassword(ldapConfig.bindingAdminPassword); + ldapConfig.bindingAdminPassword = Container.get(Cipher).encrypt(ldapConfig.bindingAdminPassword); if (!ldapConfig.loginEnabled) { ldapConfig.synchronizationEnabled = false; diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 920bb1a739baa..f42fd0dac4737 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -12,10 +12,11 @@ import { UNLIMITED_LICENSE_QUOTA, } from './constants'; import Container, { Service } from 'typedi'; +import { WorkflowRepository } from '@/databases/repositories'; import type { BooleanLicenseFeature, N8nInstanceType, NumericLicenseFeature } from './Interfaces'; import type { RedisServicePubSubPublisher } from './services/redis/RedisServicePubSubPublisher'; import { RedisService } from './services/redis.service'; -import { ObjectStoreService } from 'n8n-core'; +import { InstanceSettings, ObjectStoreService } from 'n8n-core'; type FeatureReturnType = Partial< { @@ -29,20 +30,17 @@ export class License { private manager: LicenseManager | undefined; - instanceId: string | undefined; - private redisPublisher: RedisServicePubSubPublisher; - constructor() { + constructor(private readonly instanceSettings: InstanceSettings) { this.logger = getLogger(); } - async init(instanceId: string, instanceType: N8nInstanceType = 'main') { + async init(instanceType: N8nInstanceType = 'main') { if (this.manager) { return; } - this.instanceId = instanceId; const isMainInstance = instanceType === 'main'; const server = config.getEnv('license.serverUrl'); const autoRenewEnabled = isMainInstance && config.getEnv('license.autoRenewEnabled'); @@ -54,6 +52,9 @@ export class License { const onFeatureChange = isMainInstance ? async (features: TFeatures) => this.onFeatureChange(features) : async () => {}; + const collectUsageMetrics = isMainInstance + ? async () => this.collectUsageMetrics() + : async () => []; try { this.manager = new LicenseManager({ @@ -67,7 +68,8 @@ export class License { logger: this.logger, loadCertStr: async () => this.loadCertStr(), saveCertStr, - deviceFingerprint: () => instanceId, + deviceFingerprint: () => this.instanceSettings.instanceId, + collectUsageMetrics, onFeatureChange, }); @@ -79,6 +81,15 @@ export class License { } } + async collectUsageMetrics() { + return [ + { + name: 'activeWorkflows', + value: await Container.get(WorkflowRepository).count({ where: { active: true } }), + }, + ]; + } + async loadCertStr(): Promise { // if we have an ephemeral license, we don't want to load it from the database const ephemeralLicense = config.get('license.cert'); diff --git a/packages/cli/src/LoadNodesAndCredentials.ts b/packages/cli/src/LoadNodesAndCredentials.ts index 9c1ddd900b142..384e021d38445 100644 --- a/packages/cli/src/LoadNodesAndCredentials.ts +++ b/packages/cli/src/LoadNodesAndCredentials.ts @@ -6,7 +6,7 @@ import fsPromises from 'fs/promises'; import type { DirectoryLoader, Types } from 'n8n-core'; import { CUSTOM_EXTENSION_ENV, - UserSettings, + InstanceSettings, CustomDirectoryLoader, PackageDirectoryLoader, LazyPackageDirectoryLoader, @@ -47,10 +47,10 @@ export class LoadNodesAndCredentials { includeNodes = config.getEnv('nodes.include'); - private downloadFolder: string; - private postProcessors: Array<() => Promise> = []; + constructor(private readonly instanceSettings: InstanceSettings) {} + async init() { if (inTest) throw new Error('Not available in tests'); @@ -67,8 +67,6 @@ export class LoadNodesAndCredentials { this.excludeNodes.push('n8n-nodes-base.e2eTest'); } - this.downloadFolder = UserSettings.getUserN8nFolderDownloadedNodesPath(); - // Load nodes from `n8n-nodes-base` const basePathsToScan = [ // In case "n8n" package is in same node_modules folder. @@ -84,7 +82,9 @@ export class LoadNodesAndCredentials { // Load nodes from any other `n8n-nodes-*` packages in the download directory // This includes the community nodes - await this.loadNodesFromNodeModules(path.join(this.downloadFolder, 'node_modules')); + await this.loadNodesFromNodeModules( + path.join(this.instanceSettings.nodesDownloadDir, 'node_modules'), + ); await this.loadNodesFromCustomDirectories(); await this.postProcessLoaders(); @@ -155,7 +155,7 @@ export class LoadNodesAndCredentials { } getCustomDirectories(): string[] { - const customDirectories = [UserSettings.getUserN8nFolderCustomExtensionPath()]; + const customDirectories = [this.instanceSettings.customExtensionDir]; if (process.env[CUSTOM_EXTENSION_ENV] !== undefined) { const customExtensionFolders = process.env[CUSTOM_EXTENSION_ENV].split(';'); @@ -172,7 +172,11 @@ export class LoadNodesAndCredentials { } async loadPackage(packageName: string) { - const finalNodeUnpackedPath = path.join(this.downloadFolder, 'node_modules', packageName); + const finalNodeUnpackedPath = path.join( + this.instanceSettings.nodesDownloadDir, + 'node_modules', + packageName, + ); return this.runDirectoryLoader(PackageDirectoryLoader, finalNodeUnpackedPath); } diff --git a/packages/cli/src/Logger.ts b/packages/cli/src/Logger.ts index 2a113cb8cc892..0395946f62b59 100644 --- a/packages/cli/src/Logger.ts +++ b/packages/cli/src/Logger.ts @@ -9,22 +9,32 @@ import callsites from 'callsites'; import { basename } from 'path'; import config from '@/config'; +const noOp = () => {}; +const levelNames = ['debug', 'verbose', 'info', 'warn', 'error'] as const; + export class Logger implements ILogger { private logger: winston.Logger; constructor() { const level = config.getEnv('logs.level'); - const output = config - .getEnv('logs.output') - .split(',') - .map((output) => output.trim()); - this.logger = winston.createLogger({ level, silent: level === 'silent', }); + // Change all methods with higher log-level to no-op + for (const levelName of levelNames) { + if (this.logger.levels[levelName] > this.logger.levels[level]) { + Object.defineProperty(this, levelName, { value: noOp }); + } + } + + const output = config + .getEnv('logs.output') + .split(',') + .map((output) => output.trim()); + if (output.includes('console')) { let format: winston.Logform.Format; if (['debug', 'verbose'].includes(level)) { diff --git a/packages/cli/src/Mfa/mfa.service.ts b/packages/cli/src/Mfa/mfa.service.ts index 50b0d29f89c44..7e2ce1dd827a0 100644 --- a/packages/cli/src/Mfa/mfa.service.ts +++ b/packages/cli/src/Mfa/mfa.service.ts @@ -1,15 +1,15 @@ import { v4 as uuid } from 'uuid'; -import { AES, enc } from 'crypto-js'; -import { TOTPService } from './totp.service'; import { Service } from 'typedi'; -import { UserRepository } from '@/databases/repositories'; +import { Cipher } from 'n8n-core'; +import { UserRepository } from '@db/repositories'; +import { TOTPService } from './totp.service'; @Service() export class MfaService { constructor( private userRepository: UserRepository, public totp: TOTPService, - private encryptionKey: string, + private cipher: Cipher, ) {} public generateRecoveryCodes(n = 10) { @@ -17,9 +17,7 @@ export class MfaService { } public generateEncryptedRecoveryCodes() { - return this.generateRecoveryCodes().map((code) => - AES.encrypt(code, this.encryptionKey).toString(), - ); + return this.generateRecoveryCodes().map((code) => this.cipher.encrypt(code)); } public async saveSecretAndRecoveryCodes(userId: string, secret: string, recoveryCodes: string[]) { @@ -34,10 +32,8 @@ export class MfaService { } public encryptSecretAndRecoveryCodes(rawSecret: string, rawRecoveryCodes: string[]) { - const encryptedSecret = AES.encrypt(rawSecret, this.encryptionKey).toString(), - encryptedRecoveryCodes = rawRecoveryCodes.map((code) => - AES.encrypt(code, this.encryptionKey).toString(), - ); + const encryptedSecret = this.cipher.encrypt(rawSecret), + encryptedRecoveryCodes = rawRecoveryCodes.map((code) => this.cipher.encrypt(code)); return { encryptedRecoveryCodes, encryptedSecret, @@ -46,10 +42,8 @@ export class MfaService { private decryptSecretAndRecoveryCodes(mfaSecret: string, mfaRecoveryCodes: string[]) { return { - decryptedSecret: AES.decrypt(mfaSecret, this.encryptionKey).toString(enc.Utf8), - decryptedRecoveryCodes: mfaRecoveryCodes.map((code) => - AES.decrypt(code, this.encryptionKey).toString(enc.Utf8), - ), + decryptedSecret: this.cipher.decrypt(mfaSecret), + decryptedRecoveryCodes: mfaRecoveryCodes.map((code) => this.cipher.decrypt(code)), }; } @@ -66,7 +60,7 @@ export class MfaService { } public encryptRecoveryCodes(mfaRecoveryCodes: string[]) { - return mfaRecoveryCodes.map((code) => AES.encrypt(code, this.encryptionKey).toString()); + return mfaRecoveryCodes.map((code) => this.cipher.encrypt(code)); } public async disableMfa(userId: string) { diff --git a/packages/cli/src/Mfa/totp.service.ts b/packages/cli/src/Mfa/totp.service.ts index ee85c3f100c55..cbb1f65aacab6 100644 --- a/packages/cli/src/Mfa/totp.service.ts +++ b/packages/cli/src/Mfa/totp.service.ts @@ -1,4 +1,7 @@ import OTPAuth from 'otpauth'; +import { Service } from 'typedi'; + +@Service() export class TOTPService { generateSecret(): string { return new OTPAuth.Secret()?.base32; diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts index 22f814a209637..4ce14886076c5 100644 --- a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts @@ -93,7 +93,7 @@ export = { return res.status(404).json({ message: 'Not Found' }); } - const schema = new CredentialsHelper('') + const schema = Container.get(CredentialsHelper) .getCredentialsProperties(credentialTypeName) .filter((property) => property.type !== 'hidden'); diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.middleware.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.middleware.ts index d314151f9581d..b7c2412923ecd 100644 --- a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.middleware.ts +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.middleware.ts @@ -30,7 +30,7 @@ export const validCredentialsProperties = ( ): express.Response | void => { const { type, data } = req.body; - const properties = new CredentialsHelper('') + const properties = Container.get(CredentialsHelper) .getCredentialsProperties(type) .filter((property) => property.type !== 'hidden'); diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts index b15640e160485..25ab1e6dbad23 100644 --- a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts @@ -1,4 +1,4 @@ -import { UserSettings, Credentials } from 'n8n-core'; +import { Credentials } from 'n8n-core'; import type { IDataObject, INodeProperties, INodePropertyOptions } from 'n8n-workflow'; import * as Db from '@/Db'; import type { ICredentialsDb } from '@/Interfaces'; @@ -87,8 +87,6 @@ export async function removeCredential(credentials: CredentialsEntity): Promise< } export async function encryptCredential(credential: CredentialsEntity): Promise { - const encryptionKey = await UserSettings.getEncryptionKey(); - // Encrypt the data const coreCredential = new Credentials( { id: null, name: credential.name }, @@ -97,7 +95,7 @@ export async function encryptCredential(credential: CredentialsEntity): Promise< ); // @ts-ignore - coreCredential.setData(credential.data, encryptionKey); + coreCredential.setData(credential.data); return coreCredential.getDataToSave() as ICredentialsDb; } diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts index a6b42c96ee38d..111638d716822 100644 --- a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts @@ -31,7 +31,10 @@ export = { return res.status(404).json({ message: 'Not Found' }); } - await Container.get(ExecutionRepository).softDelete(execution.id); + await Container.get(ExecutionRepository).hardDelete({ + workflowId: execution.workflowId as string, + executionId: execution.id, + }); execution.id = id; diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts index a14cb091dcd7c..a6f58d44eb0da 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts @@ -2,6 +2,7 @@ import type express from 'express'; import { Container } from 'typedi'; import type { FindOptionsWhere } from 'typeorm'; import { In } from 'typeorm'; +import { v4 as uuid } from 'uuid'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import config from '@/config'; @@ -26,7 +27,6 @@ import { import { WorkflowsService } from '@/workflows/workflows.services'; import { InternalHooks } from '@/InternalHooks'; import { RoleService } from '@/services/role.service'; -import { isWorkflowHistoryLicensed } from '@/workflows/workflowHistory/workflowHistoryHelper.ee'; import { WorkflowHistoryService } from '@/workflows/workflowHistory/workflowHistory.service.ee'; export = { @@ -36,6 +36,7 @@ export = { const workflow = req.body; workflow.active = false; + workflow.versionId = uuid(); await replaceInvalidCredentials(workflow); @@ -45,6 +46,12 @@ export = { const createdWorkflow = await createWorkflow(workflow, req.user, role); + await Container.get(WorkflowHistoryService).saveVersion( + req.user, + createdWorkflow, + createdWorkflow.id, + ); + await Container.get(ExternalHooks).run('workflow.afterCreate', [createdWorkflow]); void Container.get(InternalHooks).onWorkflowCreated(req.user, createdWorkflow, true); @@ -151,6 +158,7 @@ export = { const updateData = new WorkflowEntity(); Object.assign(updateData, req.body); updateData.id = id; + updateData.versionId = uuid(); const sharedWorkflow = await getSharedWorkflow(req.user, id); @@ -179,10 +187,6 @@ export = { } } - if (isWorkflowHistoryLicensed()) { - await Container.get(WorkflowHistoryService).saveVersion(req.user, sharedWorkflow.workflow); - } - if (sharedWorkflow.workflow.active) { try { await workflowRunner.add(sharedWorkflow.workflowId, 'update'); @@ -195,6 +199,14 @@ export = { const updatedWorkflow = await getWorkflowById(sharedWorkflow.workflowId); + if (updatedWorkflow) { + await Container.get(WorkflowHistoryService).saveVersion( + req.user, + updatedWorkflow, + sharedWorkflow.workflowId, + ); + } + await Container.get(ExternalHooks).run('workflow.afterUpdate', [updateData]); void Container.get(InternalHooks).onWorkflowSaved(req.user, updateData, true); diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index c6dd61317ddbb..cfe52d906af3f 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -30,7 +30,6 @@ import { LoadMappingOptions, LoadNodeParameterOptions, LoadNodeListSearch, - UserSettings, } from 'n8n-core'; import type { @@ -124,7 +123,7 @@ import { toHttpNodeParameters } from '@/CurlConverterHelper'; import { EventBusController } from '@/eventbus/eventBus.controller'; import { EventBusControllerEE } from '@/eventbus/eventBus.controller.ee'; import { licenseController } from './license/license.controller'; -import { Push, setupPushServer, setupPushHandler } from '@/push'; +import { setupPushServer, setupPushHandler } from '@/push'; import { setupAuthMiddlewares } from './middlewares'; import { handleLdapInit, isLdapEnabled } from './Ldap/helpers'; import { AbstractServer } from './AbstractServer'; @@ -146,7 +145,6 @@ import { SourceControlService } from '@/environments/sourceControl/sourceControl import { SourceControlController } from '@/environments/sourceControl/sourceControl.controller.ee'; import { ExecutionRepository, SettingsRepository } from '@db/repositories'; import type { ExecutionEntity } from '@db/entities/ExecutionEntity'; -import { TOTPService } from './Mfa/totp.service'; import { MfaService } from './Mfa/mfa.service'; import { handleMfaDisable, isMfaFeatureEnabled } from './Mfa/helpers'; import type { FrontendService } from './services/frontend.service'; @@ -159,25 +157,23 @@ import { WorkflowHistoryController } from './workflows/workflowHistory/workflowH const exec = promisify(callbackExec); export class Server extends AbstractServer { - endpointPresetCredentials: string; + private endpointPresetCredentials: string; - waitTracker: WaitTracker; + private waitTracker: WaitTracker; - activeExecutionsInstance: ActiveExecutions; + private activeExecutionsInstance: ActiveExecutions; - presetCredentialsLoaded: boolean; + private presetCredentialsLoaded: boolean; - loadNodesAndCredentials: LoadNodesAndCredentials; + private loadNodesAndCredentials: LoadNodesAndCredentials; - nodeTypes: NodeTypes; + private nodeTypes: NodeTypes; - credentialTypes: ICredentialTypes; + private credentialTypes: ICredentialTypes; - frontendService: FrontendService; + private frontendService?: FrontendService; - postHog: PostHogClient; - - push: Push; + private postHog: PostHogClient; constructor() { super('main'); @@ -196,13 +192,8 @@ export class Server extends AbstractServer { this.nodeTypes = Container.get(NodeTypes); if (!config.getEnv('endpoints.disableUi')) { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { FrontendService } = await import('@/services/frontend.service'); - this.frontendService = Container.get(FrontendService); - this.loadNodesAndCredentials.addPostProcessor(async () => - this.frontendService.generateTypes(), - ); - await this.frontendService.generateTypes(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + this.frontendService = Container.get(require('@/services/frontend.service').FrontendService); } this.activeExecutionsInstance = Container.get(ActiveExecutions); @@ -212,8 +203,6 @@ export class Server extends AbstractServer { this.presetCredentialsLoaded = false; this.endpointPresetCredentials = config.getEnv('credentials.overwrite.endpoint'); - this.push = Container.get(Push); - await super.start(); LoggerProxy.debug(`Server ID: ${this.uniqueInstanceId}`); @@ -285,15 +274,13 @@ export class Server extends AbstractServer { const repositories = Db.collections; setupAuthMiddlewares(app, ignoredEndpoints, this.restEndpoint); - const encryptionKey = await UserSettings.getEncryptionKey(); - const logger = LoggerProxy; const internalHooks = Container.get(InternalHooks); const mailer = Container.get(UserManagementMailer); const userService = Container.get(UserService); const jwtService = Container.get(JwtService); const postHog = this.postHog; - const mfaService = new MfaService(repositories.User, new TOTPService(), encryptionKey); + const mfaService = Container.get(MfaService); const controllers: object[] = [ new EventBusController(), @@ -376,19 +363,19 @@ export class Server extends AbstractServer { await Container.get(MetricsService).configureMetrics(this.app); } - this.instanceId = await UserSettings.getInstanceId(); - - this.frontendService.addToSettings({ - isNpmAvailable: await exec('npm --version') - .then(() => true) - .catch(() => false), - versionCli: N8N_VERSION, - instanceId: this.instanceId, - }); + const { frontendService } = this; + if (frontendService) { + frontendService.addToSettings({ + isNpmAvailable: await exec('npm --version') + .then(() => true) + .catch(() => false), + versionCli: N8N_VERSION, + }); - await this.externalHooks.run('frontend.settings', [this.frontendService.getSettings()]); + await this.externalHooks.run('frontend.settings', [frontendService.getSettings()]); + } - await this.postHog.init(this.instanceId); + await this.postHog.init(); const publicApiEndpoint = config.getEnv('publicApi.path'); const excludeEndpoints = config.getEnv('security.excludeEndpoints'); @@ -415,7 +402,9 @@ export class Server extends AbstractServer { if (isApiEnabled()) { const { apiRouters, apiLatestVersion } = await loadPublicApiVersions(publicApiEndpoint); this.app.use(...apiRouters); - this.frontendService.settings.publicApi.latestVersion = apiLatestVersion; + if (frontendService) { + frontendService.settings.publicApi.latestVersion = apiLatestVersion; + } } // Parse cookies for easier access this.app.use(cookieParser()); @@ -739,18 +728,11 @@ export class Server extends AbstractServer { throw new ResponseHelper.NotFoundError(RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL); } - let encryptionKey: string; - try { - encryptionKey = await UserSettings.getEncryptionKey(); - } catch (error) { - throw new ResponseHelper.InternalServerError(error.message); - } - const additionalData = await WorkflowExecuteAdditionalData.getBase(req.user.id); const mode: WorkflowExecuteMode = 'internal'; const timezone = config.getEnv('generic.timezone'); - const credentialsHelper = new CredentialsHelper(encryptionKey); + const credentialsHelper = Container.get(CredentialsHelper); const decryptedDataOriginal = await credentialsHelper.getDecrypted( additionalData, credential as INodeCredentialsDetails, @@ -835,7 +817,7 @@ export class Server extends AbstractServer { credential.nodesAccess, ); - credentials.setData(decryptedDataOriginal, encryptionKey); + credentials.setData(decryptedDataOriginal); const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb; // Add special database related data @@ -889,18 +871,11 @@ export class Server extends AbstractServer { return ResponseHelper.sendErrorResponse(res, errorResponse); } - let encryptionKey: string; - try { - encryptionKey = await UserSettings.getEncryptionKey(); - } catch (error) { - throw new ResponseHelper.InternalServerError(error.message); - } - const additionalData = await WorkflowExecuteAdditionalData.getBase(req.user.id); const mode: WorkflowExecuteMode = 'internal'; const timezone = config.getEnv('generic.timezone'); - const credentialsHelper = new CredentialsHelper(encryptionKey); + const credentialsHelper = Container.get(CredentialsHelper); const decryptedDataOriginal = await credentialsHelper.getDecrypted( additionalData, credential as INodeCredentialsDetails, @@ -952,7 +927,7 @@ export class Server extends AbstractServer { credential.type, credential.nodesAccess, ); - credentials.setData(decryptedDataOriginal, encryptionKey); + credentials.setData(decryptedDataOriginal); const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb; // Add special database related data newCredentialsData.updatedAt = new Date(); @@ -1208,17 +1183,21 @@ export class Server extends AbstractServer { // Settings // ---------------------------------------- - // Returns the current settings for the UI - this.app.get( - `/${this.restEndpoint}/settings`, - ResponseHelper.send( - async (req: express.Request, res: express.Response): Promise => { - void Container.get(InternalHooks).onFrontendSettingsAPI(req.headers.sessionid as string); + if (frontendService) { + // Returns the current settings for the UI + this.app.get( + `/${this.restEndpoint}/settings`, + ResponseHelper.send( + async (req: express.Request, res: express.Response): Promise => { + void Container.get(InternalHooks).onFrontendSettingsAPI( + req.headers.sessionid as string, + ); - return this.frontendService.getSettings(); - }, - ), - ); + return frontendService.getSettings(); + }, + ), + ); + } // ---------------------------------------- // EventBus Setup @@ -1248,7 +1227,7 @@ export class Server extends AbstractServer { Container.get(CredentialsOverwrites).setData(body); - await this.frontendService?.generateTypes(); + await frontendService?.generateTypes(); this.presetCredentialsLoaded = true; @@ -1260,7 +1239,7 @@ export class Server extends AbstractServer { ); } - if (!config.getEnv('endpoints.disableUi')) { + if (frontendService) { const staticOptions: ServeStaticOptions = { cacheControl: false, setHeaders: (res: express.Response, path: string) => { diff --git a/packages/cli/src/UserManagement/email/NodeMailer.ts b/packages/cli/src/UserManagement/email/NodeMailer.ts index fbf58ca4b6a41..106ac14bc9b29 100644 --- a/packages/cli/src/UserManagement/email/NodeMailer.ts +++ b/packages/cli/src/UserManagement/email/NodeMailer.ts @@ -26,6 +26,20 @@ export class NodeMailer { }; } + if ( + config.getEnv('userManagement.emails.smtp.auth.serviceClient') && + config.getEnv('userManagement.emails.smtp.auth.privateKey') + ) { + transportConfig.auth = { + type: 'OAuth2', + user: config.getEnv('userManagement.emails.smtp.auth.user'), + serviceClient: config.getEnv('userManagement.emails.smtp.auth.serviceClient'), + privateKey: config + .getEnv('userManagement.emails.smtp.auth.privateKey') + .replace(/\\n/g, '\n'), + }; + } + this.transport = createTransport(transportConfig); } diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 7a5daab98ec85..edbc93561c507 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -1,15 +1,11 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ - /* eslint-disable @typescript-eslint/no-use-before-define */ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ - /* eslint-disable id-denylist */ - /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unused-vars */ - /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { UserSettings, WorkflowExecute } from 'n8n-core'; +import { WorkflowExecute } from 'n8n-core'; import type { IDataObject, @@ -516,7 +512,10 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { } if (isManualMode && !saveManualExecutions && !fullRunData.waitTill) { - await Container.get(ExecutionRepository).softDelete(this.executionId); + await Container.get(ExecutionRepository).hardDelete({ + workflowId: this.workflowData.id as string, + executionId: this.executionId, + }); return; } @@ -547,7 +546,10 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { this.executionId, this.retryOf, ); - await Container.get(ExecutionRepository).softDelete(this.executionId); + await Container.get(ExecutionRepository).hardDelete({ + workflowId: this.workflowData.id as string, + executionId: this.executionId, + }); return; } @@ -1027,14 +1029,10 @@ export async function getBase( const webhookWaitingBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhookWaiting'); const webhookTestBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhookTest'); - const [encryptionKey, variables] = await Promise.all([ - UserSettings.getEncryptionKey(), - WorkflowHelpers.getVariables(), - ]); + const variables = await WorkflowHelpers.getVariables(); return { - credentialsHelper: new CredentialsHelper(encryptionKey), - encryptionKey, + credentialsHelper: Container.get(CredentialsHelper), executeWorkflow, restApiUrl: urlBaseWebhook + config.getEnv('endpoints.rest'), timezone, diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index a14bccb0fefe9..3483bb180587d 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -645,7 +645,10 @@ export class WorkflowRunner { (workflowDidSucceed && saveDataSuccessExecution === 'none') || (!workflowDidSucceed && saveDataErrorExecution === 'none') ) { - await Container.get(ExecutionRepository).softDelete(executionId); + await Container.get(ExecutionRepository).hardDelete({ + workflowId: data.workflowData.id as string, + executionId, + }); } // eslint-disable-next-line id-denylist } catch (err) { diff --git a/packages/cli/src/WorkflowRunnerProcess.ts b/packages/cli/src/WorkflowRunnerProcess.ts index dee14511f8784..133b729c2f5e1 100644 --- a/packages/cli/src/WorkflowRunnerProcess.ts +++ b/packages/cli/src/WorkflowRunnerProcess.ts @@ -10,7 +10,7 @@ import { setDefaultResultOrder } from 'dns'; import { Container } from 'typedi'; import type { IProcessMessage } from 'n8n-core'; -import { BinaryDataService, UserSettings, WorkflowExecute } from 'n8n-core'; +import { BinaryDataService, WorkflowExecute } from 'n8n-core'; import type { ExecutionError, @@ -107,26 +107,21 @@ class WorkflowRunnerProcess { // Init db since we need to read the license. await Db.init(); - const userSettings = await UserSettings.prepareUserSettings(); - - const loadNodesAndCredentials = Container.get(LoadNodesAndCredentials); - await loadNodesAndCredentials.init(); - const nodeTypes = Container.get(NodeTypes); + await Container.get(LoadNodesAndCredentials).init(); // Load all external hooks const externalHooks = Container.get(ExternalHooks); await externalHooks.init(); - const instanceId = userSettings.instanceId ?? ''; - await Container.get(PostHogClient).init(instanceId); - await Container.get(InternalHooks).init(instanceId); + await Container.get(PostHogClient).init(); + await Container.get(InternalHooks).init(); const binaryDataConfig = config.getEnv('binaryDataManager'); await Container.get(BinaryDataService).init(binaryDataConfig); const license = Container.get(License); - await license.init(instanceId); + await license.init(); const workflowSettings = this.data.workflowData.settings ?? {}; diff --git a/packages/cli/src/audit/risks/instance.risk.ts b/packages/cli/src/audit/risks/instance.risk.ts index 7465e6228d4c9..0005047676ed8 100644 --- a/packages/cli/src/audit/risks/instance.risk.ts +++ b/packages/cli/src/audit/risks/instance.risk.ts @@ -1,5 +1,6 @@ import axios from 'axios'; -import { UserSettings } from 'n8n-core'; +import { Container } from 'typedi'; +import { InstanceSettings } from 'n8n-core'; import config from '@/config'; import { toFlaggedNode } from '@/audit/utils'; import { separate } from '@/utils'; @@ -81,7 +82,7 @@ function getUnprotectedWebhookNodes(workflows: WorkflowEntity[]) { async function getNextVersions(currentVersionName: string) { const BASE_URL = config.getEnv('versionNotifications.endpoint'); - const instanceId = await UserSettings.getInstanceId(); + const { instanceId } = Container.get(InstanceSettings); const response = await axios.get(BASE_URL + currentVersionName, { // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 8397034df5dd4..09d8874323235 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -1,9 +1,9 @@ +import 'reflect-metadata'; import { Command } from '@oclif/command'; import { ExitError } from '@oclif/errors'; import { Container } from 'typedi'; import { LoggerProxy, ErrorReporterProxy as ErrorReporter, sleep } from 'n8n-workflow'; -import type { IUserSettings } from 'n8n-core'; -import { BinaryDataService, ObjectStoreService, UserSettings } from 'n8n-core'; +import { BinaryDataService, InstanceSettings, ObjectStoreService } from 'n8n-core'; import type { AbstractServer } from '@/AbstractServer'; import { getLogger } from '@/Logger'; import config from '@/config'; @@ -30,11 +30,9 @@ export abstract class BaseCommand extends Command { protected nodeTypes: NodeTypes; - protected userSettings: IUserSettings; + protected instanceSettings: InstanceSettings; - protected instanceId: string; - - instanceType: N8nInstanceType = 'main'; + private instanceType: N8nInstanceType = 'main'; queueModeId: string; @@ -48,10 +46,10 @@ export abstract class BaseCommand extends Command { process.once('SIGINT', async () => this.stopProcess()); // Make sure the settings exist - this.userSettings = await UserSettings.prepareUserSettings(); + this.instanceSettings = Container.get(InstanceSettings); - await Container.get(LoadNodesAndCredentials).init(); this.nodeTypes = Container.get(NodeTypes); + await Container.get(LoadNodesAndCredentials).init(); await Db.init().catch(async (error: Error) => this.exitWithCrash('There was an error initializing DB', error), @@ -76,9 +74,8 @@ export abstract class BaseCommand extends Command { ); } - this.instanceId = this.userSettings.instanceId ?? ''; - await Container.get(PostHogClient).init(this.instanceId); - await Container.get(InternalHooks).init(this.instanceId); + await Container.get(PostHogClient).init(); + await Container.get(InternalHooks).init(); } protected setInstanceType(instanceType: N8nInstanceType) { @@ -241,7 +238,7 @@ export abstract class BaseCommand extends Command { async initLicense(): Promise { const license = Container.get(License); - await license.init(this.instanceId, this.instanceType ?? 'main'); + await license.init(this.instanceType ?? 'main'); const activationKey = config.getEnv('license.activationKey'); diff --git a/packages/cli/src/commands/export/credentials.ts b/packages/cli/src/commands/export/credentials.ts index c47c75346f7e1..6c1304e67181e 100644 --- a/packages/cli/src/commands/export/credentials.ts +++ b/packages/cli/src/commands/export/credentials.ts @@ -2,7 +2,7 @@ import { flags } from '@oclif/command'; import fs from 'fs'; import path from 'path'; import type { FindOptionsWhere } from 'typeorm'; -import { Credentials, UserSettings } from 'n8n-core'; +import { Credentials } from 'n8n-core'; import * as Db from '@/Db'; import type { ICredentialsDb, ICredentialsDecryptedDb } from '@/Interfaces'; import { BaseCommand } from '../BaseCommand'; @@ -113,13 +113,11 @@ export class ExportCredentialsCommand extends BaseCommand { const credentials: ICredentialsDb[] = await Db.collections.Credentials.findBy(findQuery); if (flags.decrypted) { - const encryptionKey = await UserSettings.getEncryptionKey(); - for (let i = 0; i < credentials.length; i++) { const { name, type, nodesAccess, data } = credentials[i]; const id = credentials[i].id; const credential = new Credentials({ id, name }, type, nodesAccess, data); - const plainData = credential.getData(encryptionKey); + const plainData = credential.getData(); (credentials[i] as ICredentialsDecryptedDb).data = plainData; } } diff --git a/packages/cli/src/commands/import/credentials.ts b/packages/cli/src/commands/import/credentials.ts index 942d5c518b12e..81f724dda41d4 100644 --- a/packages/cli/src/commands/import/credentials.ts +++ b/packages/cli/src/commands/import/credentials.ts @@ -72,8 +72,6 @@ export class ImportCredentialsCommand extends BaseCommand { await this.initOwnerCredentialRole(); const user = flags.userId ? await this.getAssignee(flags.userId) : await this.getOwner(); - const encryptionKey = this.userSettings.encryptionKey; - if (flags.separate) { let { input: inputPath } = flags; @@ -97,7 +95,7 @@ export class ImportCredentialsCommand extends BaseCommand { if (typeof credential.data === 'object') { // plain data / decrypted input. Should be encrypted first. - Credentials.prototype.setData.call(credential, credential.data, encryptionKey); + Credentials.prototype.setData.call(credential, credential.data); } await this.storeCredential(credential, user); @@ -125,7 +123,7 @@ export class ImportCredentialsCommand extends BaseCommand { for (const credential of credentials) { if (typeof credential.data === 'object') { // plain data / decrypted input. Should be encrypted first. - Credentials.prototype.setData.call(credential, credential.data, encryptionKey); + Credentials.prototype.setData.call(credential, credential.data); } await this.storeCredential(credential, user); } diff --git a/packages/cli/src/commands/license/info.ts b/packages/cli/src/commands/license/info.ts index f4ebd406dc1e9..dee106a581e3c 100644 --- a/packages/cli/src/commands/license/info.ts +++ b/packages/cli/src/commands/license/info.ts @@ -9,7 +9,7 @@ export class LicenseInfoCommand extends BaseCommand { async run() { const license = Container.get(License); - await license.init(this.instanceId); + await license.init(); this.logger.info('Printing license information:\n' + license.getInfo()); } diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index b6d01aa0e18ba..b2e4431c2e6c3 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -6,7 +6,6 @@ import path from 'path'; import { mkdir } from 'fs/promises'; import { createReadStream, createWriteStream, existsSync } from 'fs'; import localtunnel from 'localtunnel'; -import { TUNNEL_SUBDOMAIN_ENV, UserSettings } from 'n8n-core'; import { flags } from '@oclif/command'; import stream from 'stream'; import replaceStream from 'replacestream'; @@ -245,7 +244,7 @@ export class Start extends BaseCommand { if (!config.getEnv('userManagement.jwtSecret')) { // If we don't have a JWT secret set, generate // one based and save to config. - const encryptionKey = await UserSettings.getEncryptionKey(); + const { encryptionKey } = this.instanceSettings; // For a key off every other letter from encryption key // CAREFUL: do not change this or it breaks all existing tokens. @@ -256,8 +255,6 @@ export class Start extends BaseCommand { config.set('userManagement.jwtSecret', createHash('sha256').update(baseKey).digest('hex')); } - await UserSettings.getEncryptionKey(); - // Load settings from database and set them to config. const databaseSettings = await Db.collections.Settings.findBy({ loadOnStartup: true }); databaseSettings.forEach((setting) => { @@ -285,28 +282,19 @@ export class Start extends BaseCommand { if (flags.tunnel) { this.log('\nWaiting for tunnel ...'); - let tunnelSubdomain; - if ( - process.env[TUNNEL_SUBDOMAIN_ENV] !== undefined && - process.env[TUNNEL_SUBDOMAIN_ENV] !== '' - ) { - tunnelSubdomain = process.env[TUNNEL_SUBDOMAIN_ENV]; - } else if (this.userSettings.tunnelSubdomain !== undefined) { - tunnelSubdomain = this.userSettings.tunnelSubdomain; - } + let tunnelSubdomain = + process.env.N8N_TUNNEL_SUBDOMAIN ?? this.instanceSettings.tunnelSubdomain ?? ''; - if (tunnelSubdomain === undefined) { + if (tunnelSubdomain === '') { // When no tunnel subdomain did exist yet create a new random one const availableCharacters = 'abcdefghijklmnopqrstuvwxyz0123456789'; - this.userSettings.tunnelSubdomain = Array.from({ length: 24 }) - .map(() => { - return availableCharacters.charAt( - Math.floor(Math.random() * availableCharacters.length), - ); - }) + tunnelSubdomain = Array.from({ length: 24 }) + .map(() => + availableCharacters.charAt(Math.floor(Math.random() * availableCharacters.length)), + ) .join(''); - await UserSettings.writeUserSettings(this.userSettings); + this.instanceSettings.update({ tunnelSubdomain }); } const tunnelSettings: localtunnel.TunnelConfig = { diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index 1f84a50ef6d52..15447cc2223ee 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -318,7 +318,6 @@ export class Worker extends BaseCommand { await Container.get(OrchestrationWorkerService).init(); await Container.get(OrchestrationHandlerWorkerService).initWithOptions({ queueModeId: this.queueModeId, - instanceId: this.instanceId, redisPublisher: Container.get(OrchestrationWorkerService).redisPublisher, getRunningJobIds: () => Object.keys(Worker.runningJobs), getRunningJobsSummary: () => Object.values(Worker.runningJobsSummary), diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index 2c2890412f50e..59909553768ac 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -21,12 +21,9 @@ if (inE2ETests) { N8N_AI_ENABLED: 'true', }; } else if (inTest) { - const testsDir = join(tmpdir(), 'n8n-tests/'); - mkdirSync(testsDir, { recursive: true }); process.env.N8N_LOG_LEVEL = 'silent'; process.env.N8N_ENCRYPTION_KEY = 'test-encryption-key'; process.env.N8N_PUBLIC_API_DISABLED = 'true'; - process.env.N8N_USER_FOLDER = mkdtempSync(testsDir); process.env.SKIP_STATISTICS_EVENTS = 'true'; } else { dotenv.config(); diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index a7bef6f5cc897..829bf783044eb 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -1,6 +1,7 @@ import path from 'path'; import convict from 'convict'; -import { UserSettings } from 'n8n-core'; +import { Container } from 'typedi'; +import { InstanceSettings } from 'n8n-core'; import { jsonParse } from 'n8n-workflow'; import { ensureStringArray } from './utils'; @@ -767,6 +768,18 @@ export const schema = { default: '', env: 'N8N_SMTP_PASS', }, + serviceClient: { + doc: 'SMTP OAuth Service Client', + format: String, + default: '', + env: 'N8N_SMTP_OAUTH_SERVICE_CLIENT', + }, + privateKey: { + doc: 'SMTP OAuth Private Key', + format: String, + default: '', + env: 'N8N_SMTP_OAUTH_PRIVATE_KEY', + }, }, sender: { doc: 'How to display sender name', @@ -869,7 +882,7 @@ export const schema = { location: { doc: 'Log file location; only used if log output is set to file.', format: String, - default: path.join(UserSettings.getUserN8nFolderPath(), 'logs/n8n.log'), + default: path.join(Container.get(InstanceSettings).n8nFolder, 'logs/n8n.log'), env: 'N8N_LOG_FILE_LOCATION', }, }, @@ -935,7 +948,7 @@ export const schema = { }, localStoragePath: { format: String, - default: path.join(UserSettings.getUserN8nFolderPath(), 'binaryData'), + default: path.join(Container.get(InstanceSettings).n8nFolder, 'binaryData'), env: 'N8N_BINARY_DATA_STORAGE_PATH', doc: 'Path for binary data storage in "filesystem" mode', }, diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index dcb4ab5e50dbe..c9209f4c84ad6 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -1,7 +1,8 @@ import { readFileSync } from 'fs'; import { resolve, join, dirname } from 'path'; +import { Container } from 'typedi'; import type { n8n } from 'n8n-core'; -import { RESPONSE_ERROR_MESSAGES as CORE_RESPONSE_ERROR_MESSAGES, UserSettings } from 'n8n-core'; +import { InstanceSettings } from 'n8n-core'; import { jsonParse } from 'n8n-workflow'; const { NODE_ENV, E2E_TESTS } = process.env; @@ -16,7 +17,10 @@ export const CUSTOM_API_CALL_KEY = '__CUSTOM_API_CALL__'; export const CLI_DIR = resolve(__dirname, '..'); export const TEMPLATES_DIR = join(CLI_DIR, 'templates'); export const NODES_BASE_DIR = dirname(require.resolve('n8n-nodes-base')); -export const GENERATED_STATIC_DIR = join(UserSettings.getUserHome(), '.cache/n8n/public'); +export const GENERATED_STATIC_DIR = join( + Container.get(InstanceSettings).userHome, + '.cache/n8n/public', +); export const EDITOR_UI_DIST_DIR = join(dirname(require.resolve('n8n-editor-ui')), 'dist'); export function getN8nPackageJson() { @@ -34,7 +38,6 @@ export const STARTER_TEMPLATE_NAME = `${NODE_PACKAGE_PREFIX}starter`; export const RESPONSE_ERROR_MESSAGES = { NO_CREDENTIAL: 'Credential not found', NO_NODE: 'Node not found', - NO_ENCRYPTION_KEY: CORE_RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, PACKAGE_NAME_NOT_PROVIDED: 'Package name is required', PACKAGE_NAME_NOT_VALID: `Package name is not valid - it must start with "${NODE_PACKAGE_PREFIX}"`, PACKAGE_NOT_INSTALLED: 'This package is not installed - you must install it first', diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index 24b499747675c..c81f59fc61d38 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -12,9 +12,7 @@ import { LICENSE_FEATURES, inE2ETests } from '@/constants'; import { NoAuthRequired, Patch, Post, RestController } from '@/decorators'; import type { UserSetupPayload } from '@/requests'; import type { BooleanLicenseFeature } from '@/Interfaces'; -import { UserSettings } from 'n8n-core'; import { MfaService } from '@/Mfa/mfa.service'; -import { TOTPService } from '@/Mfa/totp.service'; if (!inE2ETests) { console.error('E2E endpoints only allowed during E2E tests'); @@ -77,6 +75,7 @@ export class E2EController { private settingsRepo: SettingsRepository, private userRepo: UserRepository, private workflowRunner: ActiveWorkflowRunner, + private mfaService: MfaService, ) { license.isFeatureEnabled = (feature: BooleanLicenseFeature) => this.enabledFeatures[feature] ?? false; @@ -141,10 +140,6 @@ export class E2EController { roles.map(([name, scope], index) => ({ name, scope, id: (index + 1).toString() })), ); - const encryptionKey = await UserSettings.getEncryptionKey(); - - const mfaService = new MfaService(this.userRepo, new TOTPService(), encryptionKey); - const instanceOwner = { id: uuid(), ...owner, @@ -153,10 +148,8 @@ export class E2EController { }; if (owner?.mfaSecret && owner.mfaRecoveryCodes?.length) { - const { encryptedRecoveryCodes, encryptedSecret } = mfaService.encryptSecretAndRecoveryCodes( - owner.mfaSecret, - owner.mfaRecoveryCodes, - ); + const { encryptedRecoveryCodes, encryptedSecret } = + this.mfaService.encryptSecretAndRecoveryCodes(owner.mfaSecret, owner.mfaRecoveryCodes); instanceOwner.mfaSecret = encryptedSecret; instanceOwner.mfaRecoveryCodes = encryptedRecoveryCodes; } diff --git a/packages/cli/src/credentials/credentials.controller.ee.ts b/packages/cli/src/credentials/credentials.controller.ee.ts index 0aa25c4588850..a5841a68313c0 100644 --- a/packages/cli/src/credentials/credentials.controller.ee.ts +++ b/packages/cli/src/credentials/credentials.controller.ee.ts @@ -61,11 +61,7 @@ EECredentialsController.get( const { data: _, ...rest } = credential; - const key = await EECredentials.getEncryptionKey(); - const decryptedData = EECredentials.redact( - await EECredentials.decrypt(key, credential), - credential, - ); + const decryptedData = EECredentials.redact(EECredentials.decrypt(credential), credential); return { data: decryptedData, ...rest }; }), @@ -81,8 +77,6 @@ EECredentialsController.post( ResponseHelper.send(async (req: CredentialRequest.Test): Promise => { const { credentials } = req.body; - const encryptionKey = await EECredentials.getEncryptionKey(); - const credentialId = credentials.id; const { ownsCredential } = await EECredentials.isOwned(req.user, credentialId); @@ -92,17 +86,17 @@ EECredentialsController.post( throw new ResponseHelper.UnauthorizedError('Forbidden'); } - const decryptedData = await EECredentials.decrypt(encryptionKey, sharing.credentials); + const decryptedData = EECredentials.decrypt(sharing.credentials); Object.assign(credentials, { data: decryptedData }); } const mergedCredentials = deepCopy(credentials); if (mergedCredentials.data && sharing?.credentials) { - const decryptedData = await EECredentials.decrypt(encryptionKey, sharing.credentials); + const decryptedData = EECredentials.decrypt(sharing.credentials); mergedCredentials.data = EECredentials.unredact(mergedCredentials.data, decryptedData); } - return EECredentials.test(req.user, encryptionKey, mergedCredentials); + return EECredentials.test(req.user, mergedCredentials); }), ); diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 098872cbc0f98..d7105b5bf57c6 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -86,9 +86,8 @@ credentialsController.get( return { ...rest }; } - const key = await CredentialsService.getEncryptionKey(); const decryptedData = CredentialsService.redact( - await CredentialsService.decrypt(key, credential), + CredentialsService.decrypt(credential), credential, ); @@ -106,16 +105,15 @@ credentialsController.post( ResponseHelper.send(async (req: CredentialRequest.Test): Promise => { const { credentials } = req.body; - const encryptionKey = await CredentialsService.getEncryptionKey(); const sharing = await CredentialsService.getSharing(req.user, credentials.id); const mergedCredentials = deepCopy(credentials); if (mergedCredentials.data && sharing?.credentials) { - const decryptedData = await CredentialsService.decrypt(encryptionKey, sharing.credentials); + const decryptedData = CredentialsService.decrypt(sharing.credentials); mergedCredentials.data = CredentialsService.unredact(mergedCredentials.data, decryptedData); } - return CredentialsService.test(req.user, encryptionKey, mergedCredentials); + return CredentialsService.test(req.user, mergedCredentials); }), ); @@ -127,8 +125,7 @@ credentialsController.post( ResponseHelper.send(async (req: CredentialRequest.Create) => { const newCredential = await CredentialsService.prepareCreateData(req.body); - const key = await CredentialsService.getEncryptionKey(); - const encryptedData = CredentialsService.createEncryptedData(key, null, newCredential); + const encryptedData = CredentialsService.createEncryptedData(null, newCredential); const credential = await CredentialsService.save(newCredential, encryptedData, req.user); void Container.get(InternalHooks).onUserCreatedCredentials({ @@ -165,14 +162,12 @@ credentialsController.patch( const { credentials: credential } = sharing; - const key = await CredentialsService.getEncryptionKey(); - const decryptedData = await CredentialsService.decrypt(key, credential); + const decryptedData = CredentialsService.decrypt(credential); const preparedCredentialData = await CredentialsService.prepareUpdateData( req.body, decryptedData, ); const newCredentialData = CredentialsService.createEncryptedData( - key, credentialId, preparedCredentialData, ); diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index c141952a68598..f6f72a3ec4536 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -1,4 +1,4 @@ -import { Credentials, UserSettings } from 'n8n-core'; +import { Credentials } from 'n8n-core'; import type { ICredentialDataDecryptedObject, ICredentialsDecrypted, @@ -12,10 +12,9 @@ import type { FindManyOptions, FindOptionsWhere } from 'typeorm'; import { In, Like } from 'typeorm'; import * as Db from '@/Db'; -import * as ResponseHelper from '@/ResponseHelper'; import type { ICredentialsDb } from '@/Interfaces'; import { CredentialsHelper, createCredentialsFromCredentialsEntity } from '@/CredentialsHelper'; -import { CREDENTIAL_BLANKING_VALUE, RESPONSE_ERROR_MESSAGES } from '@/constants'; +import { CREDENTIAL_BLANKING_VALUE } from '@/constants'; import { CredentialsEntity } from '@db/entities/CredentialsEntity'; import { SharedCredentials } from '@db/entities/SharedCredentials'; import { validateEntity } from '@/GenericHelpers'; @@ -205,18 +204,14 @@ export class CredentialsService { return updateData; } - static createEncryptedData( - encryptionKey: string, - credentialId: string | null, - data: CredentialsEntity, - ): ICredentialsDb { + static createEncryptedData(credentialId: string | null, data: CredentialsEntity): ICredentialsDb { const credentials = new Credentials( { id: credentialId, name: data.name }, data.type, data.nodesAccess, ); - credentials.setData(data.data as unknown as ICredentialDataDecryptedObject, encryptionKey); + credentials.setData(data.data as unknown as ICredentialDataDecryptedObject); const newCredentialData = credentials.getDataToSave() as ICredentialsDb; @@ -226,22 +221,9 @@ export class CredentialsService { return newCredentialData; } - static async getEncryptionKey(): Promise { - try { - return await UserSettings.getEncryptionKey(); - } catch (error) { - throw new ResponseHelper.InternalServerError(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY); - } - } - - static async decrypt( - encryptionKey: string, - credential: CredentialsEntity, - ): Promise { + static decrypt(credential: CredentialsEntity): ICredentialDataDecryptedObject { const coreCredential = createCredentialsFromCredentialsEntity(credential); - const data = coreCredential.getData(encryptionKey); - - return data; + return coreCredential.getData(); } static async update( @@ -303,11 +285,9 @@ export class CredentialsService { static async test( user: User, - encryptionKey: string, credentials: ICredentialsDecrypted, ): Promise { - const helper = new CredentialsHelper(encryptionKey); - + const helper = Container.get(CredentialsHelper); return helper.testCredentials(user, credentials.type, credentials); } diff --git a/packages/cli/src/credentials/oauth2Credential.api.ts b/packages/cli/src/credentials/oauth2Credential.api.ts index dda9b486e957d..175b0f2bee526 100644 --- a/packages/cli/src/credentials/oauth2Credential.api.ts +++ b/packages/cli/src/credentials/oauth2Credential.api.ts @@ -9,12 +9,8 @@ import omit from 'lodash/omit'; import set from 'lodash/set'; import split from 'lodash/split'; import unset from 'lodash/unset'; -import { Credentials, UserSettings } from 'n8n-core'; -import type { - WorkflowExecuteMode, - INodeCredentialsDetails, - ICredentialsEncrypted, -} from 'n8n-workflow'; +import { Credentials } from 'n8n-core'; +import type { WorkflowExecuteMode, INodeCredentialsDetails } from 'n8n-workflow'; import { LoggerProxy, jsonStringify } from 'n8n-workflow'; import { resolve as pathResolve } from 'path'; @@ -76,20 +72,13 @@ oauth2CredentialController.get( throw new ResponseHelper.NotFoundError(RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL); } - let encryptionKey: string; - try { - encryptionKey = await UserSettings.getEncryptionKey(); - } catch (error) { - throw new ResponseHelper.InternalServerError((error as Error).message); - } - const additionalData = await WorkflowExecuteAdditionalData.getBase(req.user.id); - const credentialType = (credential as unknown as ICredentialsEncrypted).type; + const credentialType = credential.type; const mode: WorkflowExecuteMode = 'internal'; const timezone = config.getEnv('generic.timezone'); - const credentialsHelper = new CredentialsHelper(encryptionKey); + const credentialsHelper = Container.get(CredentialsHelper); const decryptedDataOriginal = await credentialsHelper.getDecrypted( additionalData, credential as INodeCredentialsDetails, @@ -152,7 +141,7 @@ oauth2CredentialController.get( const credentials = new Credentials( credential as INodeCredentialsDetails, credentialType, - (credential as unknown as ICredentialsEncrypted).nodesAccess, + credential.nodesAccess, ); decryptedDataOriginal.csrfSecret = csrfSecret; @@ -166,7 +155,7 @@ oauth2CredentialController.get( decryptedDataOriginal.codeVerifier = code_verifier; } - credentials.setData(decryptedDataOriginal, encryptionKey); + credentials.setData(decryptedDataOriginal); const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb; // Add special database related data @@ -228,16 +217,15 @@ oauth2CredentialController.get( return renderCallbackError(res, errorMessage); } - const encryptionKey = await UserSettings.getEncryptionKey(); const additionalData = await WorkflowExecuteAdditionalData.getBase(state.cid); const mode: WorkflowExecuteMode = 'internal'; const timezone = config.getEnv('generic.timezone'); - const credentialsHelper = new CredentialsHelper(encryptionKey); + const credentialsHelper = Container.get(CredentialsHelper); const decryptedDataOriginal = await credentialsHelper.getDecrypted( additionalData, credential as INodeCredentialsDetails, - (credential as unknown as ICredentialsEncrypted).type, + credential.type, mode, timezone, true, @@ -245,7 +233,7 @@ oauth2CredentialController.get( const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites( additionalData, decryptedDataOriginal, - (credential as unknown as ICredentialsEncrypted).type, + credential.type, mode, timezone, ); @@ -330,10 +318,10 @@ oauth2CredentialController.get( const credentials = new Credentials( credential as INodeCredentialsDetails, - (credential as unknown as ICredentialsEncrypted).type, - (credential as unknown as ICredentialsEncrypted).nodesAccess, + credential.type, + credential.nodesAccess, ); - credentials.setData(decryptedDataOriginal, encryptionKey); + credentials.setData(decryptedDataOriginal); const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb; // Add special database related data newCredentialsData.updatedAt = new Date(); diff --git a/packages/cli/src/databases/config.ts b/packages/cli/src/databases/config.ts index a8e42da625463..1354fca0ebbbf 100644 --- a/packages/cli/src/databases/config.ts +++ b/packages/cli/src/databases/config.ts @@ -1,8 +1,9 @@ import path from 'path'; +import { Container } from 'typedi'; import type { SqliteConnectionOptions } from 'typeorm/driver/sqlite/SqliteConnectionOptions'; import type { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions'; import type { MysqlConnectionOptions } from 'typeorm/driver/mysql/MysqlConnectionOptions'; -import { UserSettings } from 'n8n-core'; +import { InstanceSettings } from 'n8n-core'; import { entities } from './entities'; import { mysqlMigrations } from './migrations/mysqldb'; @@ -21,7 +22,7 @@ const getDBConnectionOptions = (dbType: DatabaseType) => { configDBType === 'sqlite' ? { database: path.resolve( - UserSettings.getUserN8nFolderPath(), + Container.get(InstanceSettings).n8nFolder, config.getEnv('database.sqlite.database'), ), enableWAL: config.getEnv('database.sqlite.enableWAL'), diff --git a/packages/cli/src/databases/migrations/sqlite/1690000000002-MigrateIntegerKeysToString.ts b/packages/cli/src/databases/migrations/sqlite/1690000000002-MigrateIntegerKeysToString.ts index c3f164bbebdf5..48dfc3ad46f57 100644 --- a/packages/cli/src/databases/migrations/sqlite/1690000000002-MigrateIntegerKeysToString.ts +++ b/packages/cli/src/databases/migrations/sqlite/1690000000002-MigrateIntegerKeysToString.ts @@ -1,6 +1,7 @@ import { statSync } from 'fs'; import path from 'path'; -import { UserSettings } from 'n8n-core'; +import { Container } from 'typedi'; +import { InstanceSettings } from 'n8n-core'; import type { MigrationContext, IrreversibleMigration } from '@db/types'; import config from '@/config'; @@ -191,7 +192,7 @@ const migrationsPruningEnabled = process.env.MIGRATIONS_PRUNING_ENABLED === 'tru function getSqliteDbFileSize(): number { const filename = path.resolve( - UserSettings.getUserN8nFolderPath(), + Container.get(InstanceSettings).n8nFolder, config.getEnv('database.sqlite.database'), ); const { size } = statSync(filename); diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 4fa541f307593..1360871bfe088 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -79,18 +79,17 @@ function parseFiltersToQueryBuilder( export class ExecutionRepository extends Repository { private logger = Logger; - deletionBatchSize = 100; - - private intervals: Record = { - softDeletion: undefined, - hardDeletion: undefined, - }; + private hardDeletionBatchSize = 100; private rates: Record = { softDeletion: config.getEnv('executions.pruneDataIntervals.softDelete') * TIME.MINUTE, hardDeletion: config.getEnv('executions.pruneDataIntervals.hardDelete') * TIME.MINUTE, }; + private softDeletionInterval: NodeJS.Timer | undefined; + + private hardDeletionTimeout: NodeJS.Timeout | undefined; + private isMainInstance = config.get('generic.instanceType') === 'main'; private isPruningEnabled = config.getEnv('executions.pruneData'); @@ -106,37 +105,36 @@ export class ExecutionRepository extends Repository { if (this.isPruningEnabled) this.setSoftDeletionInterval(); - this.setHardDeletionInterval(); + this.scheduleHardDeletion(); } clearTimers() { if (!this.isMainInstance) return; - this.logger.debug('Clearing soft-deletion and hard-deletion intervals for executions'); + this.logger.debug('Clearing soft-deletion interval and hard-deletion timeout (pruning cycle)'); - clearInterval(this.intervals.softDeletion); - clearInterval(this.intervals.hardDeletion); + clearInterval(this.softDeletionInterval); + clearTimeout(this.hardDeletionTimeout); } - setSoftDeletionInterval() { - this.logger.debug( - `Setting soft-deletion interval (pruning) for executions every ${ - this.rates.softDeletion / TIME.MINUTE - } min`, - ); + setSoftDeletionInterval(rateMs = this.rates.softDeletion) { + const when = [(rateMs / TIME.MINUTE).toFixed(2), 'min'].join(' '); - this.intervals.softDeletion = setInterval(async () => this.prune(), this.rates.softDeletion); - } + this.logger.debug(`Setting soft-deletion interval at every ${when} (pruning cycle)`); - setHardDeletionInterval() { - this.logger.debug( - `Setting hard-deletion interval for executions every ${ - this.rates.hardDeletion / TIME.MINUTE - } min`, + this.softDeletionInterval = setInterval( + async () => this.softDeleteOnPruningCycle(), + this.rates.softDeletion, ); + } - this.intervals.hardDeletion = setInterval( - async () => this.hardDelete(), + scheduleHardDeletion(rateMs = this.rates.hardDeletion) { + const when = [(rateMs / TIME.MINUTE).toFixed(2), 'min'].join(' '); + + this.logger.debug(`Scheduling hard-deletion for next ${when} (pruning cycle)`); + + this.hardDeletionTimeout = setTimeout( + async () => this.hardDeleteOnPruningCycle(), this.rates.hardDeletion, ); } @@ -294,6 +292,13 @@ export class ExecutionRepository extends Repository { ); } + /** + * Permanently remove a single execution and its binary data. + */ + async hardDelete(ids: { workflowId: string; executionId: string }) { + return Promise.all([this.delete(ids.executionId), this.binaryDataService.deleteMany([ids])]); + } + async updateExistingExecution(executionId: string, execution: Partial) { // Se isolate startedAt because it must be set when the execution starts and should never change. // So we prevent updating it, if it's sent (it usually is and causes problems to executions that @@ -466,13 +471,16 @@ export class ExecutionRepository extends Repository { const executionIds = executions.map(({ id }) => id); do { // Delete in batches to avoid "SQLITE_ERROR: Expression tree is too large (maximum depth 1000)" error - const batch = executionIds.splice(0, this.deletionBatchSize); - await this.softDelete(batch); + const batch = executionIds.splice(0, this.hardDeletionBatchSize); + await this.delete(batch); } while (executionIds.length > 0); } - async prune() { - Logger.verbose('Soft-deleting (pruning) execution data from database'); + /** + * Mark executions as deleted based on age and count, in a pruning cycle. + */ + async softDeleteOnPruningCycle() { + Logger.debug('Starting soft-deletion of executions (pruning cycle)'); const maxAge = config.getEnv('executions.pruneDataMaxAge'); // in h const maxCount = config.getEnv('executions.pruneDataMaxCount'); @@ -501,7 +509,7 @@ export class ExecutionRepository extends Repository { const [timeBasedWhere, countBasedWhere] = toPrune; - await this.createQueryBuilder() + const result = await this.createQueryBuilder() .update(ExecutionEntity) .set({ deletedAt: new Date() }) .where({ @@ -517,12 +525,16 @@ export class ExecutionRepository extends Repository { ), ) .execute(); + + if (result.affected === 0) { + Logger.debug('Found no executions to soft-delete (pruning cycle)'); + } } /** - * Permanently delete all soft-deleted executions and their binary data, in batches. + * Permanently remove all soft-deleted executions and their binary data, in a pruning cycle. */ - private async hardDelete() { + private async hardDeleteOnPruningCycle() { const date = new Date(); date.setHours(date.getHours() - config.getEnv('executions.pruneDataHardDeleteBuffer')); @@ -532,7 +544,7 @@ export class ExecutionRepository extends Repository { where: { deletedAt: LessThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(date)), }, - take: this.deletionBatchSize, + take: this.hardDeletionBatchSize, /** * @important This ensures soft-deleted executions are included, @@ -545,35 +557,33 @@ export class ExecutionRepository extends Repository { const executionIds = workflowIdsAndExecutionIds.map((o) => o.executionId); if (executionIds.length === 0) { - this.logger.debug('Found no executions to hard-delete from database'); + this.logger.debug('Found no executions to hard-delete (pruning cycle)'); + this.scheduleHardDeletion(); return; } - await this.binaryDataService.deleteMany(workflowIdsAndExecutionIds); + try { + this.logger.debug('Starting hard-deletion of executions (pruning cycle)', { + executionIds, + }); - this.logger.debug(`Hard-deleting ${executionIds.length} executions from database`, { - executionIds, - }); + await this.binaryDataService.deleteMany(workflowIdsAndExecutionIds); - // Actually delete these executions - await this.delete({ id: In(executionIds) }); + await this.delete({ id: In(executionIds) }); + } catch (error) { + this.logger.error('Failed to hard-delete executions (pruning cycle)', { + executionIds, + error: error instanceof Error ? error.message : `${error}`, + }); + } /** - * If the volume of executions to prune is as high as the batch size, there is a risk - * that the pruning process is unable to catch up to the creation of new executions, - * with high concurrency possibly leading to errors from duplicate deletions. - * - * Therefore, in this high-volume case we speed up the hard deletion cycle, until - * the number of executions to prune is low enough to fit in a single batch. + * For next batch, speed up hard-deletion cycle in high-volume case + * to prevent high concurrency from causing duplicate deletions. */ - if (executionIds.length === this.deletionBatchSize) { - clearInterval(this.intervals.hardDeletion); - - setTimeout(async () => this.hardDelete(), 1 * TIME.SECOND); - } else { - if (this.intervals.hardDeletion) return; + const isHighVolume = executionIds.length >= this.hardDeletionBatchSize; + const rate = isHighVolume ? 1 * TIME.SECOND : this.rates.hardDeletion; - this.setHardDeletionInterval(); - } + this.scheduleHardDeletion(rate); } } diff --git a/packages/cli/src/databases/utils/migrationHelpers.ts b/packages/cli/src/databases/utils/migrationHelpers.ts index dc502738068e1..0d1b45da1ad45 100644 --- a/packages/cli/src/databases/utils/migrationHelpers.ts +++ b/packages/cli/src/databases/utils/migrationHelpers.ts @@ -1,6 +1,6 @@ import { Container } from 'typedi'; import { readFileSync, rmSync } from 'fs'; -import { UserSettings } from 'n8n-core'; +import { InstanceSettings } from 'n8n-core'; import type { ObjectLiteral } from 'typeorm'; import type { QueryRunner } from 'typeorm/query-runner/QueryRunner'; import { jsonParse } from 'n8n-workflow'; @@ -16,9 +16,10 @@ const logger = getLogger(); const PERSONALIZATION_SURVEY_FILENAME = 'personalizationSurvey.json'; function loadSurveyFromDisk(): string | null { - const userSettingsPath = UserSettings.getUserN8nFolderPath(); try { - const filename = `${userSettingsPath}/${PERSONALIZATION_SURVEY_FILENAME}`; + const filename = `${ + Container.get(InstanceSettings).n8nFolder + }/${PERSONALIZATION_SURVEY_FILENAME}`; const surveyFile = readFileSync(filename, 'utf-8'); rmSync(filename); const personalizationSurvey = JSON.parse(surveyFile) as object; diff --git a/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts index 365971cefc93a..5140421a72280 100644 --- a/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts @@ -12,14 +12,10 @@ import type { SourceControlPreferences } from './types/sourceControlPreferences' import { SOURCE_CONTROL_DEFAULT_EMAIL, SOURCE_CONTROL_DEFAULT_NAME, - SOURCE_CONTROL_GIT_FOLDER, SOURCE_CONTROL_README, - SOURCE_CONTROL_SSH_FOLDER, - SOURCE_CONTROL_SSH_KEY_NAME, } from './constants'; import { LoggerProxy } from 'n8n-workflow'; import { SourceControlGitService } from './sourceControlGit.service.ee'; -import { UserSettings } from 'n8n-core'; import type { PushResult } from 'simple-git'; import { SourceControlExportService } from './sourceControlExport.service.ee'; import { BadRequestError } from '@/ResponseHelper'; @@ -55,10 +51,10 @@ export class SourceControlService { private sourceControlImportService: SourceControlImportService, private tagRepository: TagRepository, ) { - const userFolder = UserSettings.getUserN8nFolderPath(); - this.sshFolder = path.join(userFolder, SOURCE_CONTROL_SSH_FOLDER); - this.gitFolder = path.join(userFolder, SOURCE_CONTROL_GIT_FOLDER); - this.sshKeyName = path.join(this.sshFolder, SOURCE_CONTROL_SSH_KEY_NAME); + const { gitFolder, sshFolder, sshKeyName } = sourceControlPreferencesService; + this.gitFolder = gitFolder; + this.sshFolder = sshFolder; + this.sshKeyName = sshKeyName; } async init(): Promise { diff --git a/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts index 8d53fceae873b..a87d21b34ae0f 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts @@ -11,7 +11,7 @@ import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; import { LoggerProxy } from 'n8n-workflow'; import { writeFile as fsWriteFile, rm as fsRm } from 'fs/promises'; import { rmSync } from 'fs'; -import { Credentials, UserSettings } from 'n8n-core'; +import { Credentials, InstanceSettings } from 'n8n-core'; import type { ExportableWorkflow } from './types/exportableWorkflow'; import type { ExportableCredential } from './types/exportableCredential'; import type { ExportResult } from './types/exportResult'; @@ -39,9 +39,9 @@ export class SourceControlExportService { constructor( private readonly variablesService: VariablesService, private readonly tagRepository: TagRepository, + instanceSettings: InstanceSettings, ) { - const userFolder = UserSettings.getUserN8nFolderPath(); - this.gitFolder = path.join(userFolder, SOURCE_CONTROL_GIT_FOLDER); + this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER); this.workflowExportFolder = path.join(this.gitFolder, SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER); this.credentialExportFolder = path.join( this.gitFolder, @@ -248,12 +248,11 @@ export class SourceControlExportService { (remote) => foundCredentialIds.findIndex((local) => local === remote) === -1, ); } - const encryptionKey = await UserSettings.getEncryptionKey(); await Promise.all( credentialsToBeExported.map(async (sharedCredential) => { const { name, type, nodesAccess, data, id } = sharedCredential.credentials; const credentialObject = new Credentials({ id, name }, type, nodesAccess, data); - const plainData = credentialObject.getData(encryptionKey); + const plainData = credentialObject.getData(); const sanitizedData = this.replaceCredentialData(plainData); const fileName = this.getCredentialsPath(sharedCredential.credentials.id); const sanitizedCredential: ExportableCredential = { diff --git a/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts index 85a6ad83fdc8e..ebc45f464aabb 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts @@ -11,7 +11,7 @@ import * as Db from '@/Db'; import glob from 'fast-glob'; import { LoggerProxy, jsonParse } from 'n8n-workflow'; import { readFile as fsReadFile } from 'fs/promises'; -import { Credentials, UserSettings } from 'n8n-core'; +import { Credentials, InstanceSettings } from 'n8n-core'; import type { IWorkflowToImport } from '@/Interfaces'; import type { ExportableCredential } from './types/exportableCredential'; import type { Variables } from '@db/entities/Variables'; @@ -41,9 +41,9 @@ export class SourceControlImportService { private readonly variablesService: VariablesService, private readonly activeWorkflowRunner: ActiveWorkflowRunner, private readonly tagRepository: TagRepository, + instanceSettings: InstanceSettings, ) { - const userFolder = UserSettings.getUserN8nFolderPath(); - this.gitFolder = path.join(userFolder, SOURCE_CONTROL_GIT_FOLDER); + this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER); this.workflowExportFolder = path.join(this.gitFolder, SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER); this.credentialExportFolder = path.join( this.gitFolder, @@ -81,69 +81,6 @@ export class SourceControlImportService { return workflowOwnerRole; } - private async importCredentialsFromFiles( - userId: string, - ): Promise> { - const credentialFiles = await glob('*.json', { - cwd: this.credentialExportFolder, - absolute: true, - }); - const existingCredentials = await Db.collections.Credentials.find(); - const ownerCredentialRole = await this.getCredentialOwnerRole(); - const ownerGlobalRole = await this.getOwnerGlobalRole(); - const encryptionKey = await UserSettings.getEncryptionKey(); - let importCredentialsResult: Array<{ id: string; name: string; type: string }> = []; - importCredentialsResult = await Promise.all( - credentialFiles.map(async (file) => { - LoggerProxy.debug(`Importing credentials file ${file}`); - const credential = jsonParse( - await fsReadFile(file, { encoding: 'utf8' }), - ); - const existingCredential = existingCredentials.find( - (e) => e.id === credential.id && e.type === credential.type, - ); - const sharedOwner = await Db.collections.SharedCredentials.findOne({ - select: ['userId'], - where: { - credentialsId: credential.id, - roleId: In([ownerCredentialRole.id, ownerGlobalRole.id]), - }, - }); - - const { name, type, data, id, nodesAccess } = credential; - const newCredentialObject = new Credentials({ id, name }, type, []); - if (existingCredential?.data) { - newCredentialObject.data = existingCredential.data; - } else { - newCredentialObject.setData(data, encryptionKey); - } - newCredentialObject.nodesAccess = nodesAccess || existingCredential?.nodesAccess || []; - - LoggerProxy.debug(`Updating credential id ${newCredentialObject.id as string}`); - await Db.collections.Credentials.upsert(newCredentialObject, ['id']); - - if (!sharedOwner) { - const newSharedCredential = new SharedCredentials(); - newSharedCredential.credentialsId = newCredentialObject.id as string; - newSharedCredential.userId = userId; - newSharedCredential.roleId = ownerGlobalRole.id; - - await Db.collections.SharedCredentials.upsert({ ...newSharedCredential }, [ - 'credentialsId', - 'userId', - ]); - } - - return { - id: newCredentialObject.id as string, - name: newCredentialObject.name, - type: newCredentialObject.type, - }; - }), - ); - return importCredentialsResult.filter((e) => e !== undefined); - } - public async getRemoteVersionIdsFromFiles(): Promise { const remoteWorkflowFiles = await glob('*.json', { cwd: this.workflowExportFolder, @@ -407,7 +344,6 @@ export class SourceControlImportService { roleId: In([ownerCredentialRole.id, ownerGlobalRole.id]), }, }); - const encryptionKey = await UserSettings.getEncryptionKey(); let importCredentialsResult: Array<{ id: string; name: string; type: string }> = []; importCredentialsResult = await Promise.all( candidates.map(async (candidate) => { @@ -427,7 +363,7 @@ export class SourceControlImportService { if (existingCredential?.data) { newCredentialObject.data = existingCredential.data; } else { - newCredentialObject.setData(data, encryptionKey); + newCredentialObject.setData(data); } newCredentialObject.nodesAccess = nodesAccess || existingCredential?.nodesAccess || []; diff --git a/packages/cli/src/environments/sourceControl/sourceControlPreferences.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlPreferences.service.ee.ts index ad99093196313..52fe8b8f6c733 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlPreferences.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlPreferences.service.ee.ts @@ -9,7 +9,7 @@ import { isSourceControlLicensed, sourceControlFoldersExistCheck, } from './sourceControlHelper.ee'; -import { UserSettings } from 'n8n-core'; +import { InstanceSettings } from 'n8n-core'; import { LoggerProxy, jsonParse } from 'n8n-workflow'; import * as Db from '@/Db'; import { @@ -26,16 +26,15 @@ import config from '@/config'; export class SourceControlPreferencesService { private _sourceControlPreferences: SourceControlPreferences = new SourceControlPreferences(); - private sshKeyName: string; + readonly sshKeyName: string; - private sshFolder: string; + readonly sshFolder: string; - private gitFolder: string; + readonly gitFolder: string; - constructor() { - const userFolder = UserSettings.getUserN8nFolderPath(); - this.sshFolder = path.join(userFolder, SOURCE_CONTROL_SSH_FOLDER); - this.gitFolder = path.join(userFolder, SOURCE_CONTROL_GIT_FOLDER); + constructor(instanceSettings: InstanceSettings) { + this.sshFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_SSH_FOLDER); + this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER); this.sshKeyName = path.join(this.sshFolder, SOURCE_CONTROL_SSH_KEY_NAME); } diff --git a/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationWebhook.ee.ts b/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationWebhook.ee.ts index 6af27c1b76b56..181fe82ca33ea 100644 --- a/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationWebhook.ee.ts +++ b/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationWebhook.ee.ts @@ -18,7 +18,6 @@ import type { IWorkflowExecuteAdditionalData, } from 'n8n-workflow'; import { CredentialsHelper } from '@/CredentialsHelper'; -import { UserSettings } from 'n8n-core'; import { Agent as HTTPSAgent } from 'https'; import config from '@/config'; import { isLogStreamingEnabled } from '../MessageEventBus/MessageEventBusHelper'; @@ -26,6 +25,7 @@ import { eventMessageGenericDestinationTestEvent } from '../EventMessageClasses/ import { MessageEventBus } from '../MessageEventBus/MessageEventBus'; import type { MessageWithCallback } from '../MessageEventBus/MessageEventBus'; import * as SecretsHelpers from '@/ExternalSecrets/externalSecretsHelper.ee'; +import Container from 'typedi'; export const isMessageEventBusDestinationWebhookOptions = ( candidate: unknown, @@ -135,13 +135,7 @@ export class MessageEventBusDestinationWebhook } as AxiosRequestConfig; if (this.credentialsHelper === undefined) { - let encryptionKey: string | undefined; - try { - encryptionKey = await UserSettings.getEncryptionKey(); - } catch {} - if (encryptionKey) { - this.credentialsHelper = new CredentialsHelper(encryptionKey); - } + this.credentialsHelper = Container.get(CredentialsHelper); } const sendQuery = this.sendQuery; diff --git a/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts b/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts index d004b061a8394..f370e5b394667 100644 --- a/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts +++ b/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { isEventMessageOptions } from '../EventMessageClasses/AbstractEventMessage'; -import { UserSettings } from 'n8n-core'; +import { InstanceSettings } from 'n8n-core'; import path, { parse } from 'path'; import { Worker } from 'worker_threads'; import { createReadStream, existsSync, rmSync } from 'fs'; @@ -19,6 +19,7 @@ import { } from '../EventMessageClasses/EventMessageConfirm'; import { once as eventOnce } from 'events'; import { inTest } from '@/constants'; +import Container from 'typedi'; interface MessageEventBusLogWriterConstructorOptions { logBaseName?: string; @@ -66,7 +67,7 @@ export class MessageEventBusLogWriter { MessageEventBusLogWriter.instance = new MessageEventBusLogWriter(); MessageEventBusLogWriter.options = { logFullBasePath: path.join( - options?.logBasePath ?? UserSettings.getUserN8nFolderPath(), + options?.logBasePath ?? Container.get(InstanceSettings).n8nFolder, options?.logBaseName ?? config.getEnv('eventBus.logWriter.logBaseName'), ), keepNumberOfFiles: diff --git a/packages/cli/src/posthog/index.ts b/packages/cli/src/posthog/index.ts index df390c53b39a8..202a8cea97453 100644 --- a/packages/cli/src/posthog/index.ts +++ b/packages/cli/src/posthog/index.ts @@ -1,6 +1,7 @@ import { Service } from 'typedi'; import type { PostHog } from 'posthog-node'; import type { FeatureFlags, ITelemetryTrackProperties } from 'n8n-workflow'; +import { InstanceSettings } from 'n8n-core'; import config from '@/config'; import type { PublicUser } from '@/Interfaces'; @@ -8,10 +9,9 @@ import type { PublicUser } from '@/Interfaces'; export class PostHogClient { private postHog?: PostHog; - private instanceId?: string; + constructor(private readonly instanceSettings: InstanceSettings) {} - async init(instanceId: string) { - this.instanceId = instanceId; + async init() { const enabled = config.getEnv('diagnostics.enabled'); if (!enabled) { return; @@ -46,7 +46,7 @@ export class PostHogClient { async getFeatureFlags(user: Pick): Promise { if (!this.postHog) return {}; - const fullId = [this.instanceId, user.id].join('#'); + const fullId = [this.instanceSettings.instanceId, user.id].join('#'); // cannot use local evaluation because that requires PostHog personal api key with org-wide // https://github.com/PostHog/posthog/issues/4849 diff --git a/packages/cli/src/services/communityPackages.service.ts b/packages/cli/src/services/communityPackages.service.ts index a1fc549d1de43..f8be2016e0f44 100644 --- a/packages/cli/src/services/communityPackages.service.ts +++ b/packages/cli/src/services/communityPackages.service.ts @@ -6,7 +6,7 @@ import axios from 'axios'; import { LoggerProxy as Logger } from 'n8n-workflow'; import type { PublicInstalledPackage } from 'n8n-workflow'; -import { UserSettings } from 'n8n-core'; +import { InstanceSettings } from 'n8n-core'; import type { PackageDirectoryLoader } from 'n8n-core'; import { toError } from '@/utils'; @@ -47,6 +47,7 @@ export class CommunityPackagesService { missingPackages: string[] = []; constructor( + private readonly instanceSettings: InstanceSettings, private readonly installedPackageRepository: InstalledPackagesRepository, private readonly loadNodesAndCredentials: LoadNodesAndCredentials, ) {} @@ -114,7 +115,7 @@ export class CommunityPackagesService { } async executeNpmCommand(command: string, options?: { doNotHandleError?: boolean }) { - const downloadFolder = UserSettings.getUserN8nFolderDownloadedNodesPath(); + const downloadFolder = this.instanceSettings.nodesDownloadDir; const execOptions = { cwd: downloadFolder, diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index fd776cc56cae1..89c57f6b82c21 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -10,6 +10,7 @@ import type { INodeTypeBaseDescription, ITelemetrySettings, } from 'n8n-workflow'; +import { InstanceSettings } from 'n8n-core'; import { GENERATED_STATIC_DIR, LICENSE_FEATURES } from '@/constants'; import { CredentialsOverwrites } from '@/CredentialsOverwrites'; @@ -29,19 +30,33 @@ import { getWorkflowHistoryPruneTime, } from '@/workflows/workflowHistory/workflowHistoryHelper.ee'; import { UserManagementMailer } from '@/UserManagement/email'; +import type { CommunityPackagesService } from '@/services/communityPackages.service'; @Service() export class FrontendService { settings: IN8nUISettings; + private communityPackagesService?: CommunityPackagesService; + constructor( private readonly loadNodesAndCredentials: LoadNodesAndCredentials, private readonly credentialTypes: CredentialTypes, private readonly credentialsOverwrites: CredentialsOverwrites, private readonly license: License, private readonly mailer: UserManagementMailer, + private readonly instanceSettings: InstanceSettings, ) { + loadNodesAndCredentials.addPostProcessor(async () => this.generateTypes()); + void this.generateTypes(); + this.initSettings(); + + if (config.getEnv('nodes.communityPackages.enabled')) { + // eslint-disable-next-line @typescript-eslint/naming-convention + void import('@/services/communityPackages.service').then(({ CommunityPackagesService }) => { + this.communityPackagesService = Container.get(CommunityPackagesService); + }); + } } private initSettings() { @@ -87,7 +102,7 @@ export class FrontendService { endpoint: config.getEnv('versionNotifications.endpoint'), infoUrl: config.getEnv('versionNotifications.infoUrl'), }, - instanceId: '', + instanceId: this.instanceSettings.instanceId, telemetry: telemetrySettings, posthog: { enabled: config.getEnv('diagnostics.enabled'), @@ -196,7 +211,7 @@ export class FrontendService { this.writeStaticJSON('credentials', credentials); } - async getSettings(): Promise { + getSettings(): IN8nUISettings { const restEndpoint = config.getEnv('endpoints.rest'); // Update all urls, in case `WEBHOOK_URL` was updated by `--tunnel` @@ -273,10 +288,8 @@ export class FrontendService { }); } - if (config.getEnv('nodes.communityPackages.enabled')) { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { CommunityPackagesService } = await import('@/services/communityPackages.service'); - this.settings.missingPackages = Container.get(CommunityPackagesService).hasMissingPackages; + if (this.communityPackagesService) { + this.settings.missingPackages = this.communityPackagesService.hasMissingPackages; } this.settings.mfa.enabled = config.get('mfa.enabled'); diff --git a/packages/cli/src/services/orchestration/worker/types.ts b/packages/cli/src/services/orchestration/worker/types.ts index d95a3c5da3065..351c56394a31c 100644 --- a/packages/cli/src/services/orchestration/worker/types.ts +++ b/packages/cli/src/services/orchestration/worker/types.ts @@ -3,7 +3,6 @@ import type { RedisServicePubSubPublisher } from '../../redis/RedisServicePubSub export interface WorkerCommandReceivedHandlerOptions { queueModeId: string; - instanceId: string; redisPublisher: RedisServicePubSubPublisher; getRunningJobIds: () => string[]; getRunningJobsSummary: () => WorkerJobStatusSummary[]; diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 75b87a2c6b801..349f8a6019d01 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -10,6 +10,7 @@ import { LicenseService } from '@/license/License.service'; import { N8N_VERSION } from '@/constants'; import Container, { Service } from 'typedi'; import { SourceControlPreferencesService } from '../environments/sourceControl/sourceControlPreferences.service.ee'; +import { InstanceSettings } from 'n8n-core'; type ExecutionTrackDataKey = 'manual_error' | 'manual_success' | 'prod_error' | 'prod_success'; @@ -30,8 +31,6 @@ interface IExecutionsBuffer { @Service() export class Telemetry { - private instanceId: string; - private rudderStack?: RudderStack; private pulseIntervalReference: NodeJS.Timeout; @@ -41,12 +40,9 @@ export class Telemetry { constructor( private postHog: PostHogClient, private license: License, + private readonly instanceSettings: InstanceSettings, ) {} - setInstanceId(instanceId: string) { - this.instanceId = instanceId; - } - async init() { const enabled = config.getEnv('diagnostics.enabled'); if (enabled) { @@ -172,15 +168,13 @@ export class Telemetry { async identify(traits?: { [key: string]: string | number | boolean | object | undefined | null; }): Promise { + const { instanceId } = this.instanceSettings; return new Promise((resolve) => { if (this.rudderStack) { this.rudderStack.identify( { - userId: this.instanceId, - traits: { - ...traits, - instanceId: this.instanceId, - }, + userId: instanceId, + traits: { ...traits, instanceId }, }, resolve, ); @@ -195,17 +189,18 @@ export class Telemetry { properties: ITelemetryTrackProperties = {}, { withPostHog } = { withPostHog: false }, // whether to additionally track with PostHog ): Promise { + const { instanceId } = this.instanceSettings; return new Promise((resolve) => { if (this.rudderStack) { const { user_id } = properties; const updatedProperties: ITelemetryTrackProperties = { ...properties, - instance_id: this.instanceId, + instance_id: instanceId, version_cli: N8N_VERSION, }; const payload = { - userId: `${this.instanceId}${user_id ? `#${user_id}` : ''}`, + userId: `${instanceId}${user_id ? `#${user_id}` : ''}`, event: eventName, properties: updatedProperties, }; diff --git a/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts b/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts index fffe2f4c8c473..c9a7cdd348323 100644 --- a/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts +++ b/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts @@ -6,6 +6,7 @@ import { SharedWorkflowRepository } from '@/databases/repositories'; import { WorkflowHistoryRepository } from '@db/repositories/workflowHistory.repository'; import { Service } from 'typedi'; import { isWorkflowHistoryEnabled } from './workflowHistoryHelper.ee'; +import { getLogger } from '@/Logger'; export class SharedWorkflowNotFoundError extends Error {} export class HistoryVersionNotFoundError extends Error {} @@ -64,15 +65,22 @@ export class WorkflowHistoryService { return hist; } - async saveVersion(user: User, workflow: WorkflowEntity) { + async saveVersion(user: User, workflow: WorkflowEntity, workflowId: string) { if (isWorkflowHistoryEnabled()) { - await this.workflowHistoryRepository.insert({ - authors: user.firstName + ' ' + user.lastName, - connections: workflow.connections, - nodes: workflow.nodes, - versionId: workflow.versionId, - workflowId: workflow.id, - }); + try { + await this.workflowHistoryRepository.insert({ + authors: user.firstName + ' ' + user.lastName, + connections: workflow.connections, + nodes: workflow.nodes, + versionId: workflow.versionId, + workflowId, + }); + } catch (e) { + getLogger().error( + `Failed to save workflow history version for workflow ${workflowId}`, + e as Error, + ); + } } } } diff --git a/packages/cli/src/workflows/workflows.controller.ee.ts b/packages/cli/src/workflows/workflows.controller.ee.ts index a23f3d9613f78..c2d75dafe850f 100644 --- a/packages/cli/src/workflows/workflows.controller.ee.ts +++ b/packages/cli/src/workflows/workflows.controller.ee.ts @@ -22,6 +22,7 @@ import { RoleService } from '@/services/role.service'; import * as utils from '@/utils'; import { listQueryMiddleware } from '@/middlewares'; import { TagService } from '@/services/tag.service'; +import { WorkflowHistoryService } from './workflowHistory/workflowHistory.service.ee'; // eslint-disable-next-line @typescript-eslint/naming-convention export const EEWorkflowController = express.Router(); @@ -186,6 +187,12 @@ EEWorkflowController.post( ); } + await Container.get(WorkflowHistoryService).saveVersion( + req.user, + savedWorkflow, + savedWorkflow.id, + ); + if (tagIds && !config.getEnv('workflowTagsDisabled') && savedWorkflow.tags) { savedWorkflow.tags = Container.get(TagService).sortByRequestOrder(savedWorkflow.tags, { requestOrder: tagIds, diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index aa0f5dcc683fe..c6f79b492209a 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -26,6 +26,7 @@ import { RoleService } from '@/services/role.service'; import * as utils from '@/utils'; import { listQueryMiddleware } from '@/middlewares'; import { TagService } from '@/services/tag.service'; +import { WorkflowHistoryService } from './workflowHistory/workflowHistory.service.ee'; export const workflowsController = express.Router(); @@ -99,6 +100,12 @@ workflowsController.post( throw new ResponseHelper.InternalServerError('Failed to save workflow'); } + await Container.get(WorkflowHistoryService).saveVersion( + req.user, + savedWorkflow, + savedWorkflow.id, + ); + if (tagIds && !config.getEnv('workflowTagsDisabled') && savedWorkflow.tags) { savedWorkflow.tags = Container.get(TagService).sortByRequestOrder(savedWorkflow.tags, { requestOrder: tagIds, diff --git a/packages/cli/src/workflows/workflows.services.ts b/packages/cli/src/workflows/workflows.services.ts index c6115a1f3de07..02e8b5ba26e71 100644 --- a/packages/cli/src/workflows/workflows.services.ts +++ b/packages/cli/src/workflows/workflows.services.ts @@ -33,7 +33,6 @@ import { WorkflowRepository } from '@/databases/repositories'; import { RoleService } from '@/services/role.service'; import { OwnershipService } from '@/services/ownership.service'; import { isStringArray, isWorkflowIdValid } from '@/utils'; -import { isWorkflowHistoryLicensed } from './workflowHistory/workflowHistoryHelper.ee'; import { WorkflowHistoryService } from './workflowHistory/workflowHistory.service.ee'; import { BinaryDataService } from 'n8n-core'; @@ -222,13 +221,19 @@ export class WorkflowsService { ); } + let onlyActiveUpdate = false; + if ( - Object.keys(workflow).length === 3 && - workflow.id !== undefined && - workflow.versionId !== undefined && - workflow.active !== undefined + (Object.keys(workflow).length === 3 && + workflow.id !== undefined && + workflow.versionId !== undefined && + workflow.active !== undefined) || + (Object.keys(workflow).length === 2 && + workflow.versionId !== undefined && + workflow.active !== undefined) ) { // we're just updating the active status of the workflow, don't update the versionId + onlyActiveUpdate = true; } else { // Update the workflow's version workflow.versionId = uuid(); @@ -301,8 +306,8 @@ export class WorkflowsService { ); } - if (isWorkflowHistoryLicensed()) { - await Container.get(WorkflowHistoryService).saveVersion(user, shared.workflow); + if (!onlyActiveUpdate && workflow.versionId !== shared.workflow.versionId) { + await Container.get(WorkflowHistoryService).saveVersion(user, workflow, workflowId); } const relations = config.getEnv('workflowTagsDisabled') ? [] : ['tags']; diff --git a/packages/cli/test/integration/ExternalSecrets/externalSecrets.api.test.ts b/packages/cli/test/integration/ExternalSecrets/externalSecrets.api.test.ts index 8ea55e7e7317b..2a6a4fc836644 100644 --- a/packages/cli/test/integration/ExternalSecrets/externalSecrets.api.test.ts +++ b/packages/cli/test/integration/ExternalSecrets/externalSecrets.api.test.ts @@ -3,10 +3,9 @@ import { License } from '@/License'; import * as testDb from '../shared/testDb'; import * as utils from '../shared/utils/'; import type { ExternalSecretsSettings, SecretsProviderState } from '@/Interfaces'; -import { UserSettings } from 'n8n-core'; +import { Cipher } from 'n8n-core'; import { SettingsRepository } from '@/databases/repositories/settings.repository'; import Container from 'typedi'; -import { AES, enc } from 'crypto-js'; import { ExternalSecretsProviders } from '@/ExternalSecrets/ExternalSecretsProviders.ee'; import { DummyProvider, @@ -17,7 +16,7 @@ import { import config from '@/config'; import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager.ee'; import { CREDENTIAL_BLANKING_VALUE } from '@/constants'; -import type { IDataObject } from 'n8n-workflow'; +import { jsonParse, type IDataObject } from 'n8n-workflow'; let authOwnerAgent: SuperAgentTest; let authMemberAgent: SuperAgentTest; @@ -28,29 +27,24 @@ const licenseLike = utils.mockInstance(License, { }); const mockProvidersInstance = new MockProviders(); -let providersMock: ExternalSecretsProviders = utils.mockInstance( - ExternalSecretsProviders, - mockProvidersInstance, -); +utils.mockInstance(ExternalSecretsProviders, mockProvidersInstance); const testServer = utils.setupTestServer({ endpointGroups: ['externalSecrets'] }); const connectedDate = '2023-08-01T12:32:29.000Z'; async function setExternalSecretsSettings(settings: ExternalSecretsSettings) { - const encryptionKey = await UserSettings.getEncryptionKey(); return Container.get(SettingsRepository).saveEncryptedSecretsProviderSettings( - AES.encrypt(JSON.stringify(settings), encryptionKey).toString(), + Container.get(Cipher).encrypt(settings), ); } async function getExternalSecretsSettings(): Promise { - const encryptionKey = await UserSettings.getEncryptionKey(); const encSettings = await Container.get(SettingsRepository).getEncryptedSecretsProviderSettings(); if (encSettings === null) { return null; } - return JSON.parse(AES.decrypt(encSettings, encryptionKey).toString(enc.Utf8)); + return jsonParse(Container.get(Cipher).decrypt(encSettings)); } const resetManager = async () => { @@ -61,6 +55,7 @@ const resetManager = async () => { Container.get(SettingsRepository), licenseLike, mockProvidersInstance, + Container.get(Cipher), ), ); @@ -100,8 +95,6 @@ const getDummyProviderData = ({ }; beforeAll(async () => { - await utils.initEncryptionKey(); - const owner = await testDb.createOwner(); authOwnerAgent = testServer.authAgentFor(owner); const member = await testDb.createUser(); diff --git a/packages/cli/test/integration/credentials.ee.test.ts b/packages/cli/test/integration/credentials.ee.test.ts index a1dc4f7be00b5..bc7222b8501a1 100644 --- a/packages/cli/test/integration/credentials.ee.test.ts +++ b/packages/cli/test/integration/credentials.ee.test.ts @@ -1,10 +1,8 @@ import type { SuperAgentTest } from 'supertest'; import { In } from 'typeorm'; -import { UserSettings } from 'n8n-core'; import type { IUser } from 'n8n-workflow'; import * as Db from '@/Db'; -import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import type { Credentials } from '@/requests'; import * as UserManagementHelpers from '@/UserManagement/UserManagementHelper'; import type { Role } from '@db/entities/Role'; @@ -304,21 +302,6 @@ describe('GET /credentials/:id', () => { expect(response.body.data).toBeUndefined(); // owner's cred not returned }); - test('should fail with missing encryption key', async () => { - const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); - - const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); - mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY)); - - const response = await authOwnerAgent - .get(`/credentials/${savedCredential.id}`) - .query({ includeData: true }); - - expect(response.statusCode).toBe(500); - - mock.mockRestore(); - }); - test('should return 404 if cred not found', async () => { const response = await authOwnerAgent.get('/credentials/789'); expect(response.statusCode).toBe(404); diff --git a/packages/cli/test/integration/credentials.test.ts b/packages/cli/test/integration/credentials.test.ts index b62f0e9cab8c3..3916865fabb58 100644 --- a/packages/cli/test/integration/credentials.test.ts +++ b/packages/cli/test/integration/credentials.test.ts @@ -1,9 +1,7 @@ import type { SuperAgentTest } from 'supertest'; -import { UserSettings } from 'n8n-core'; import * as Db from '@/Db'; import config from '@/config'; -import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import * as UserManagementHelpers from '@/UserManagement/UserManagementHelper'; import type { Credentials } from '@/requests'; import type { Role } from '@db/entities/Role'; @@ -130,17 +128,6 @@ describe('POST /credentials', () => { } }); - test('should fail with missing encryption key', async () => { - const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); - mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY)); - - const response = await authOwnerAgent.post('/credentials').send(randomCredentialPayload()); - - expect(response.statusCode).toBe(500); - - mock.mockRestore(); - }); - test('should ignore ID in payload', async () => { const firstResponse = await authOwnerAgent .post('/credentials') @@ -385,17 +372,6 @@ describe('PATCH /credentials/:id', () => { expect(response.statusCode).toBe(404); }); - - test('should fail with missing encryption key', async () => { - const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); - mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY)); - - const response = await authOwnerAgent.post('/credentials').send(randomCredentialPayload()); - - expect(response.statusCode).toBe(500); - - mock.mockRestore(); - }); }); describe('GET /credentials/new', () => { @@ -504,21 +480,6 @@ describe('GET /credentials/:id', () => { expect(response.body.data).toBeUndefined(); // owner's cred not returned }); - test('should fail with missing encryption key', async () => { - const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); - - const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); - mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY)); - - const response = await authOwnerAgent - .get(`/credentials/${savedCredential.id}`) - .query({ includeData: true }); - - expect(response.statusCode).toBe(500); - - mock.mockRestore(); - }); - test('should return 404 if cred not found', async () => { const response = await authOwnerAgent.get('/credentials/789'); expect(response.statusCode).toBe(404); diff --git a/packages/cli/test/integration/eventbus.ee.test.ts b/packages/cli/test/integration/eventbus.ee.test.ts index d8e5cf6137a0f..c52910929928a 100644 --- a/packages/cli/test/integration/eventbus.ee.test.ts +++ b/packages/cli/test/integration/eventbus.ee.test.ts @@ -89,7 +89,6 @@ beforeAll(async () => { mockedSyslog.createClient.mockImplementation(() => new syslog.Client()); - await utils.initEncryptionKey(); config.set('eventBus.logWriter.logBaseName', 'n8n-test-logwriter'); config.set('eventBus.logWriter.keepLogCount', 1); diff --git a/packages/cli/test/integration/ldap/ldap.api.test.ts b/packages/cli/test/integration/ldap/ldap.api.test.ts index c225bbffbbbd4..521f9e63003bb 100644 --- a/packages/cli/test/integration/ldap/ldap.api.test.ts +++ b/packages/cli/test/integration/ldap/ldap.api.test.ts @@ -11,13 +11,15 @@ import type { User } from '@db/entities/User'; import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/Ldap/constants'; import { LdapManager } from '@/Ldap/LdapManager.ee'; import { LdapService } from '@/Ldap/LdapService.ee'; -import { encryptPassword, saveLdapSynchronization } from '@/Ldap/helpers'; +import { saveLdapSynchronization } from '@/Ldap/helpers'; import type { LdapConfig } from '@/Ldap/types'; import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; import { randomEmail, randomName, uniqueId } from './../shared/random'; import * as testDb from './../shared/testDb'; import * as utils from '../shared/utils/'; +import Container from 'typedi'; +import { Cipher } from 'n8n-core'; jest.mock('@/telemetry'); @@ -54,12 +56,10 @@ beforeAll(async () => { owner = await testDb.createUser({ globalRole: globalOwnerRole }); authOwnerAgent = testServer.authAgentFor(owner); - defaultLdapConfig.bindingAdminPassword = await encryptPassword( + defaultLdapConfig.bindingAdminPassword = Container.get(Cipher).encrypt( defaultLdapConfig.bindingAdminPassword, ); - await utils.initEncryptionKey(); - await setCurrentAuthenticationMethod('email'); }); diff --git a/packages/cli/test/integration/passwordReset.api.test.ts b/packages/cli/test/integration/passwordReset.api.test.ts index 9a17e401ce954..5c9a01017fb68 100644 --- a/packages/cli/test/integration/passwordReset.api.test.ts +++ b/packages/cli/test/integration/passwordReset.api.test.ts @@ -35,7 +35,6 @@ const testServer = utils.setupTestServer({ endpointGroups: ['passwordReset'] }); const jwtService = Container.get(JwtService); beforeAll(async () => { - await utils.initEncryptionKey(); globalOwnerRole = await testDb.getGlobalOwnerRole(); globalMemberRole = await testDb.getGlobalMemberRole(); }); diff --git a/packages/cli/test/integration/publicApi/credentials.test.ts b/packages/cli/test/integration/publicApi/credentials.test.ts index bc5c414142a17..2a6e79ea06665 100644 --- a/packages/cli/test/integration/publicApi/credentials.test.ts +++ b/packages/cli/test/integration/publicApi/credentials.test.ts @@ -1,9 +1,7 @@ import type { SuperAgentTest } from 'supertest'; -import { UserSettings } from 'n8n-core'; import * as Db from '@/Db'; import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; -import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import { randomApiKey, randomName, randomString } from '../shared/random'; import * as utils from '../shared/utils/'; @@ -22,9 +20,6 @@ let saveCredential: SaveCredentialFunction; const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); beforeAll(async () => { - // TODO: mock encryption key - await utils.initEncryptionKey(); - const [globalOwnerRole, fetchedGlobalMemberRole, _, fetchedCredentialOwnerRole] = await testDb.getAllRoles(); @@ -87,17 +82,6 @@ describe('POST /credentials', () => { expect(response.statusCode === 400 || response.statusCode === 415).toBe(true); } }); - - test('should fail with missing encryption key', async () => { - const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); - mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY)); - - const response = await authOwnerAgent.post('/credentials').send(credentialPayload()); - - expect(response.statusCode).toBe(500); - - mock.mockRestore(); - }); }); describe('DELETE /credentials/:id', () => { diff --git a/packages/cli/test/integration/publicApi/workflows.test.ts b/packages/cli/test/integration/publicApi/workflows.test.ts index 910bfbb3970c8..c23c1f3213a0f 100644 --- a/packages/cli/test/integration/publicApi/workflows.test.ts +++ b/packages/cli/test/integration/publicApi/workflows.test.ts @@ -10,6 +10,9 @@ import * as utils from '../shared/utils/'; import * as testDb from '../shared/testDb'; import type { INode } from 'n8n-workflow'; import { STARTING_NODES } from '@/constants'; +import { License } from '@/License'; +import { WorkflowHistoryRepository } from '@/databases/repositories'; +import Container from 'typedi'; let workflowOwnerRole: Role; let owner: User; @@ -20,6 +23,11 @@ let workflowRunner: ActiveWorkflowRunner; const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); +const licenseLike = utils.mockInstance(License, { + isWorkflowHistoryLicensed: jest.fn().mockReturnValue(false), + isWithinUsersLimit: jest.fn().mockReturnValue(true), +}); + beforeAll(async () => { const [globalOwnerRole, globalMemberRole, fetchedWorkflowOwnerRole] = await testDb.getAllRoles(); @@ -35,16 +43,23 @@ beforeAll(async () => { apiKey: randomApiKey(), }); - await utils.initEncryptionKey(); await utils.initNodeTypes(); workflowRunner = await utils.initActiveWorkflowRunner(); }); beforeEach(async () => { - await testDb.truncate(['SharedCredentials', 'SharedWorkflow', 'Tag', 'Workflow', 'Credentials']); + await testDb.truncate([ + 'SharedCredentials', + 'SharedWorkflow', + 'Tag', + 'Workflow', + 'Credentials', + WorkflowHistoryRepository, + ]); authOwnerAgent = testServer.publicApiAgentFor(owner); authMemberAgent = testServer.publicApiAgentFor(member); + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(false); }); afterEach(async () => { @@ -679,6 +694,90 @@ describe('POST /workflows', () => { expect(sharedWorkflow?.role).toEqual(workflowOwnerRole); }); + test('should create workflow history version when licensed', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(true); + const payload = { + name: 'testing', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + ], + connections: {}, + staticData: null, + settings: { + saveExecutionProgress: true, + saveManualExecutions: true, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }; + + const response = await authMemberAgent.post('/workflows').send(payload); + + expect(response.statusCode).toBe(200); + + const { id } = response.body; + + expect(id).toBeDefined(); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(1); + const historyVersion = await Container.get(WorkflowHistoryRepository).findOne({ + where: { + workflowId: id, + }, + }); + expect(historyVersion).not.toBeNull(); + expect(historyVersion!.connections).toEqual(payload.connections); + expect(historyVersion!.nodes).toEqual(payload.nodes); + }); + + test('should not create workflow history version when not licensed', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(false); + const payload = { + name: 'testing', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + ], + connections: {}, + staticData: null, + settings: { + saveExecutionProgress: true, + saveManualExecutions: true, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }; + + const response = await authMemberAgent.post('/workflows').send(payload); + + expect(response.statusCode).toBe(200); + + const { id } = response.body; + + expect(id).toBeDefined(); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(0); + }); + test('should not add a starting node if the payload has no starting nodes', async () => { const response = await authMemberAgent.post('/workflows').send({ name: 'testing', @@ -835,6 +934,108 @@ describe('PUT /workflows/:id', () => { ); }); + test('should create workflow history version when licensed', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(true); + const workflow = await testDb.createWorkflow({}, member); + const payload = { + name: 'name updated', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + { + id: 'uuid-1234', + parameters: {}, + name: 'Cron', + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [400, 300], + }, + ], + connections: {}, + staticData: '{"id":1}', + settings: { + saveExecutionProgress: false, + saveManualExecutions: false, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }; + + const response = await authMemberAgent.put(`/workflows/${workflow.id}`).send(payload); + + const { id } = response.body; + + expect(response.statusCode).toBe(200); + + expect(id).toBe(workflow.id); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(1); + const historyVersion = await Container.get(WorkflowHistoryRepository).findOne({ + where: { + workflowId: id, + }, + }); + expect(historyVersion).not.toBeNull(); + expect(historyVersion!.connections).toEqual(payload.connections); + expect(historyVersion!.nodes).toEqual(payload.nodes); + }); + + test('should not create workflow history when not licensed', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(false); + const workflow = await testDb.createWorkflow({}, member); + const payload = { + name: 'name updated', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + { + id: 'uuid-1234', + parameters: {}, + name: 'Cron', + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [400, 300], + }, + ], + connections: {}, + staticData: '{"id":1}', + settings: { + saveExecutionProgress: false, + saveManualExecutions: false, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }; + + const response = await authMemberAgent.put(`/workflows/${workflow.id}`).send(payload); + + const { id } = response.body; + + expect(response.statusCode).toBe(200); + + expect(id).toBe(workflow.id); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(0); + }); + test('should update non-owned workflow if owner', async () => { const workflow = await testDb.createWorkflow({}, member); diff --git a/packages/cli/test/integration/repositories/execution.repository.test.ts b/packages/cli/test/integration/repositories/execution.repository.test.ts index 954e38b23b6fb..27cb16277385b 100644 --- a/packages/cli/test/integration/repositories/execution.repository.test.ts +++ b/packages/cli/test/integration/repositories/execution.repository.test.ts @@ -9,7 +9,7 @@ import type { ExecutionRepository } from '../../../src/databases/repositories'; import type { ExecutionEntity } from '../../../src/databases/entities/ExecutionEntity'; import { TIME } from '../../../src/constants'; -describe('ExecutionRepository.prune()', () => { +describe('softDeleteOnPruningCycle()', () => { const now = new Date(); const yesterday = new Date(Date.now() - TIME.DAY); let executionRepository: ExecutionRepository; @@ -57,7 +57,7 @@ describe('ExecutionRepository.prune()', () => { await testDb.createSuccessfulExecution(workflow), ]; - await executionRepository.prune(); + await executionRepository.softDeleteOnPruningCycle(); const result = await findAllExecutions(); expect(result).toEqual([ @@ -76,7 +76,7 @@ describe('ExecutionRepository.prune()', () => { await testDb.createSuccessfulExecution(workflow), ]; - await executionRepository.prune(); + await executionRepository.softDeleteOnPruningCycle(); const result = await findAllExecutions(); expect(result).toEqual([ @@ -98,7 +98,7 @@ describe('ExecutionRepository.prune()', () => { await testDb.createSuccessfulExecution(workflow), ]; - await executionRepository.prune(); + await executionRepository.softDeleteOnPruningCycle(); const result = await findAllExecutions(); expect(result).toEqual([ @@ -117,7 +117,7 @@ describe('ExecutionRepository.prune()', () => { await testDb.createSuccessfulExecution(workflow), ]; - await executionRepository.prune(); + await executionRepository.softDeleteOnPruningCycle(); const result = await findAllExecutions(); expect(result).toEqual([ @@ -145,7 +145,7 @@ describe('ExecutionRepository.prune()', () => { ), ]; - await executionRepository.prune(); + await executionRepository.softDeleteOnPruningCycle(); const result = await findAllExecutions(); expect(result).toEqual([ @@ -169,7 +169,7 @@ describe('ExecutionRepository.prune()', () => { await testDb.createSuccessfulExecution(workflow), ]; - await executionRepository.prune(); + await executionRepository.softDeleteOnPruningCycle(); const result = await findAllExecutions(); expect(result).toEqual([ @@ -188,7 +188,7 @@ describe('ExecutionRepository.prune()', () => { ])('should prune %s executions', async (status, attributes) => { const execution = await testDb.createExecution({ status, ...attributes }, workflow); - await executionRepository.prune(); + await executionRepository.softDeleteOnPruningCycle(); const result = await findAllExecutions(); expect(result).toEqual([ @@ -206,7 +206,7 @@ describe('ExecutionRepository.prune()', () => { await testDb.createSuccessfulExecution(workflow), ]; - await executionRepository.prune(); + await executionRepository.softDeleteOnPruningCycle(); const result = await findAllExecutions(); expect(result).toEqual([ diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index 7f8f2e33b8e38..7187a67fb17c1 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -1,4 +1,3 @@ -import { UserSettings } from 'n8n-core'; import type { DataSourceOptions as ConnectionOptions, Repository } from 'typeorm'; import { DataSource as Connection } from 'typeorm'; import { Container } from 'typedi'; @@ -213,8 +212,6 @@ export async function createLdapUser(attributes: Partial, ldapId: string): export async function createUserWithMfaEnabled( data: { numberOfRecoveryCodes: number } = { numberOfRecoveryCodes: 10 }, ) { - const encryptionKey = await UserSettings.getEncryptionKey(); - const email = randomEmail(); const password = randomPassword(); @@ -222,7 +219,7 @@ export async function createUserWithMfaEnabled( const secret = toptService.generateSecret(); - const mfaService = new MfaService(Db.collections.User, toptService, encryptionKey); + const mfaService = Container.get(MfaService); const recoveryCodes = mfaService.generateRecoveryCodes(data.numberOfRecoveryCodes); @@ -450,7 +447,7 @@ export async function createManyWorkflows( * @param user user to assign the workflow to */ export async function createWorkflow(attributes: Partial = {}, user?: User) { - const { active, name, nodes, connections } = attributes; + const { active, name, nodes, connections, versionId } = attributes; const workflowEntity = Db.collections.Workflow.create({ active: active ?? false, @@ -466,6 +463,7 @@ export async function createWorkflow(attributes: Partial = {}, u }, ], connections: connections ?? {}, + versionId: versionId ?? uuid(), ...attributes, }); @@ -687,12 +685,10 @@ const getDBOptions = (type: TestDBType, name: string) => ({ // ---------------------------------- async function encryptCredentialData(credential: CredentialsEntity) { - const encryptionKey = await UserSettings.getEncryptionKey(); - const coreCredential = createCredentialsFromCredentialsEntity(credential, true); // @ts-ignore - coreCredential.setData(credential.data, encryptionKey); + coreCredential.setData(credential.data); return coreCredential.getDataToSave() as ICredentialsDb; } diff --git a/packages/cli/test/integration/shared/utils/index.ts b/packages/cli/test/integration/shared/utils/index.ts index cd5d215f60b84..f4265e59e90ed 100644 --- a/packages/cli/test/integration/shared/utils/index.ts +++ b/packages/cli/test/integration/shared/utils/index.ts @@ -1,7 +1,5 @@ import { Container } from 'typedi'; -import { randomBytes } from 'crypto'; -import { existsSync } from 'fs'; -import { BinaryDataService, UserSettings } from 'n8n-core'; +import { BinaryDataService } from 'n8n-core'; import type { INode } from 'n8n-workflow'; import { GithubApi } from 'n8n-nodes-base/credentials/GithubApi.credentials'; import { Ftp } from 'n8n-nodes-base/credentials/Ftp.credentials'; @@ -84,19 +82,6 @@ export async function initBinaryDataService(mode: 'default' | 'filesystem' = 'de Container.set(BinaryDataService, binaryDataService); } -/** - * Initialize a user settings config file if non-existent. - */ -// TODO: this should be mocked -export async function initEncryptionKey() { - const settingsPath = UserSettings.getUserSettingsPath(); - - if (!existsSync(settingsPath)) { - const userSettings = { encryptionKey: randomBytes(24).toString('base64') }; - await UserSettings.writeUserSettings(userSettings, settingsPath); - } -} - /** * Extract the value (token) of the auth cookie in a response. */ diff --git a/packages/cli/test/integration/shared/utils/testServer.ts b/packages/cli/test/integration/shared/utils/testServer.ts index 233cf8a76c3a6..4bd2d9055690e 100644 --- a/packages/cli/test/integration/shared/utils/testServer.ts +++ b/packages/cli/test/integration/shared/utils/testServer.ts @@ -7,7 +7,6 @@ import request from 'supertest'; import { URL } from 'url'; import config from '@/config'; -import * as Db from '@/Db'; import { ExternalHooks } from '@/ExternalHooks'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import { workflowsController } from '@/workflows/workflows.controller'; @@ -50,8 +49,6 @@ import type { EndpointGroup, SetupProps, TestServer } from '../types'; import { mockInstance } from './mocking'; import { ExternalSecretsController } from '@/ExternalSecrets/ExternalSecrets.controller.ee'; import { MfaService } from '@/Mfa/mfa.service'; -import { TOTPService } from '@/Mfa/totp.service'; -import { UserSettings } from 'n8n-core'; import { MetricsService } from '@/services/metrics.service'; import { SettingsRepository, @@ -200,12 +197,10 @@ export const setupTestServer = ({ } if (functionEndpoints.length) { - const encryptionKey = await UserSettings.getEncryptionKey(); - const repositories = Db.collections; const externalHooks = Container.get(ExternalHooks); const internalHooks = Container.get(InternalHooks); const mailer = Container.get(UserManagementMailer); - const mfaService = new MfaService(repositories.User, new TOTPService(), encryptionKey); + const mfaService = Container.get(MfaService); const userService = Container.get(UserService); for (const group of functionEndpoints) { diff --git a/packages/cli/test/integration/variables.test.ts b/packages/cli/test/integration/variables.test.ts index b0c0c458ef130..701e6c4b08266 100644 --- a/packages/cli/test/integration/variables.test.ts +++ b/packages/cli/test/integration/variables.test.ts @@ -16,7 +16,6 @@ const licenseLike = { const testServer = utils.setupTestServer({ endpointGroups: ['variables'] }); beforeAll(async () => { - await utils.initEncryptionKey(); utils.mockInstance(License, licenseLike); const owner = await testDb.createOwner(); diff --git a/packages/cli/test/integration/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows.controller.ee.test.ts index 6c63bbc8ae9ff..f09c716537d36 100644 --- a/packages/cli/test/integration/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows.controller.ee.test.ts @@ -12,6 +12,10 @@ import { createWorkflow, getGlobalMemberRole, getGlobalOwnerRole } from './share import type { SaveCredentialFunction } from './shared/types'; import { makeWorkflow } from './shared/utils/'; import { randomCredentialPayload } from './shared/random'; +import { License } from '@/License'; +import { WorkflowHistoryRepository } from '@/databases/repositories'; +import Container from 'typedi'; +import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; let owner: User; let member: User; @@ -21,6 +25,12 @@ let authMemberAgent: SuperAgentTest; let authAnotherMemberAgent: SuperAgentTest; let saveCredential: SaveCredentialFunction; +const licenseLike = utils.mockInstance(License, { + isWorkflowHistoryLicensed: jest.fn().mockReturnValue(false), + isWithinUsersLimit: jest.fn().mockReturnValue(true), +}); +const activeWorkflowRunnerLike = utils.mockInstance(ActiveWorkflowRunner); + const sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true); const testServer = utils.setupTestServer({ endpointGroups: ['workflows'], @@ -46,7 +56,11 @@ beforeAll(async () => { }); beforeEach(async () => { - await testDb.truncate(['Workflow', 'SharedWorkflow']); + activeWorkflowRunnerLike.add.mockReset(); + activeWorkflowRunnerLike.remove.mockReset(); + + await testDb.truncate(['Workflow', 'SharedWorkflow', WorkflowHistoryRepository]); + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(false); }); describe('router should switch based on flag', () => { @@ -399,6 +413,96 @@ describe('POST /workflows', () => { const response = await authAnotherMemberAgent.post('/workflows').send(workflow); expect(response.statusCode).toBe(200); }); + + test('Should create workflow history version when licensed', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(true); + const payload = { + name: 'testing', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + ], + connections: {}, + staticData: null, + settings: { + saveExecutionProgress: true, + saveManualExecutions: true, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + active: false, + }; + + const response = await authOwnerAgent.post('/workflows').send(payload); + + expect(response.statusCode).toBe(200); + + const { + data: { id }, + } = response.body; + + expect(id).toBeDefined(); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(1); + const historyVersion = await Container.get(WorkflowHistoryRepository).findOne({ + where: { + workflowId: id, + }, + }); + expect(historyVersion).not.toBeNull(); + expect(historyVersion!.connections).toEqual(payload.connections); + expect(historyVersion!.nodes).toEqual(payload.nodes); + }); + + test('Should not create workflow history version when not licensed', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(false); + const payload = { + name: 'testing', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + ], + connections: {}, + staticData: null, + settings: { + saveExecutionProgress: true, + saveManualExecutions: true, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + active: false, + }; + + const response = await authOwnerAgent.post('/workflows').send(payload); + + expect(response.statusCode).toBe(200); + + const { + data: { id }, + } = response.body; + + expect(id).toBeDefined(); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(0); + }); }); describe('PATCH /workflows/:id - validate credential permissions to user', () => { @@ -831,3 +935,160 @@ describe('getSharedWorkflowIds', () => { expect(sharedWorkflowIds).toContain(workflow3.id); }); }); + +describe('PATCH /workflows/:id - workflow history', () => { + test('Should create workflow history version when licensed', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(true); + const workflow = await testDb.createWorkflow({}, owner); + const payload = { + name: 'name updated', + versionId: workflow.versionId, + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + { + id: 'uuid-1234', + parameters: {}, + name: 'Cron', + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [400, 300], + }, + ], + connections: {}, + staticData: '{"id":1}', + settings: { + saveExecutionProgress: false, + saveManualExecutions: false, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }; + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + + const { + data: { id }, + } = response.body; + + expect(response.statusCode).toBe(200); + + expect(id).toBe(workflow.id); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(1); + const historyVersion = await Container.get(WorkflowHistoryRepository).findOne({ + where: { + workflowId: id, + }, + }); + expect(historyVersion).not.toBeNull(); + expect(historyVersion!.connections).toEqual(payload.connections); + expect(historyVersion!.nodes).toEqual(payload.nodes); + }); + + test('Should not create workflow history version when not licensed', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(false); + const workflow = await testDb.createWorkflow({}, owner); + const payload = { + name: 'name updated', + versionId: workflow.versionId, + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + { + id: 'uuid-1234', + parameters: {}, + name: 'Cron', + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [400, 300], + }, + ], + connections: {}, + staticData: '{"id":1}', + settings: { + saveExecutionProgress: false, + saveManualExecutions: false, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }; + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + + const { + data: { id }, + } = response.body; + + expect(response.statusCode).toBe(200); + + expect(id).toBe(workflow.id); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(0); + }); +}); + +describe('PATCH /workflows/:id - activate workflow', () => { + test('should activate workflow without changing version ID', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(false); + const workflow = await testDb.createWorkflow({}, owner); + const payload = { + versionId: workflow.versionId, + active: true, + }; + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + + expect(response.statusCode).toBe(200); + expect(activeWorkflowRunnerLike.add).toBeCalled(); + + const { + data: { id, versionId, active }, + } = response.body; + + expect(id).toBe(workflow.id); + expect(versionId).toBe(workflow.versionId); + expect(active).toBe(true); + }); + + test('should deactivate workflow without changing version ID', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(false); + const workflow = await testDb.createWorkflow({ active: true }, owner); + const payload = { + versionId: workflow.versionId, + active: false, + }; + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + + expect(response.statusCode).toBe(200); + expect(activeWorkflowRunnerLike.add).not.toBeCalled(); + expect(activeWorkflowRunnerLike.remove).toBeCalled(); + + const { + data: { id, versionId, active }, + } = response.body; + + expect(id).toBe(workflow.id); + expect(versionId).toBe(workflow.versionId); + expect(active).toBe(false); + }); +}); diff --git a/packages/cli/test/integration/workflows.controller.test.ts b/packages/cli/test/integration/workflows.controller.test.ts index fb56237fd7a6b..2a816de1dbf28 100644 --- a/packages/cli/test/integration/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows.controller.test.ts @@ -11,6 +11,9 @@ import { v4 as uuid } from 'uuid'; import { RoleService } from '@/services/role.service'; import Container from 'typedi'; import type { ListQuery } from '@/requests'; +import { License } from '@/License'; +import { WorkflowHistoryRepository } from '@/databases/repositories'; +import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; let owner: User; let authOwnerAgent: SuperAgentTest; @@ -20,13 +23,22 @@ const testServer = utils.setupTestServer({ endpointGroups: ['workflows'] }); const { objectContaining, arrayContaining, any } = expect; +const licenseLike = utils.mockInstance(License, { + isWorkflowHistoryLicensed: jest.fn().mockReturnValue(false), + isWithinUsersLimit: jest.fn().mockReturnValue(true), +}); + +const activeWorkflowRunnerLike = utils.mockInstance(ActiveWorkflowRunner); + beforeAll(async () => { owner = await testDb.createOwner(); authOwnerAgent = testServer.authAgentFor(owner); }); beforeEach(async () => { - await testDb.truncate(['Workflow', 'SharedWorkflow', 'Tag']); + jest.resetAllMocks(); + await testDb.truncate(['Workflow', 'SharedWorkflow', 'Tag', WorkflowHistoryRepository]); + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(false); }); describe('POST /workflows', () => { @@ -46,6 +58,96 @@ describe('POST /workflows', () => { const pinData = await testWithPinData(false); expect(pinData).toBeNull(); }); + + test('should create workflow history version when licensed', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(true); + const payload = { + name: 'testing', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + ], + connections: {}, + staticData: null, + settings: { + saveExecutionProgress: true, + saveManualExecutions: true, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + active: false, + }; + + const response = await authOwnerAgent.post('/workflows').send(payload); + + expect(response.statusCode).toBe(200); + + const { + data: { id }, + } = response.body; + + expect(id).toBeDefined(); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(1); + const historyVersion = await Container.get(WorkflowHistoryRepository).findOne({ + where: { + workflowId: id, + }, + }); + expect(historyVersion).not.toBeNull(); + expect(historyVersion!.connections).toEqual(payload.connections); + expect(historyVersion!.nodes).toEqual(payload.nodes); + }); + + test('should not create workflow history version when not licensed', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(false); + const payload = { + name: 'testing', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + ], + connections: {}, + staticData: null, + settings: { + saveExecutionProgress: true, + saveManualExecutions: true, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + active: false, + }; + + const response = await authOwnerAgent.post('/workflows').send(payload); + + expect(response.statusCode).toBe(200); + + const { + data: { id }, + } = response.body; + + expect(id).toBeDefined(); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(0); + }); }); describe('GET /workflows/:id', () => { @@ -109,7 +211,7 @@ describe('GET /workflows', () => { createdAt: any(String), updatedAt: any(String), tags: [{ id: any(String), name: 'A' }], - versionId: null, + versionId: any(String), ownedBy: { id: owner.id }, }), objectContaining({ @@ -119,7 +221,7 @@ describe('GET /workflows', () => { createdAt: any(String), updatedAt: any(String), tags: [], - versionId: null, + versionId: any(String), ownedBy: { id: owner.id }, }), ]), @@ -318,3 +420,158 @@ describe('GET /workflows', () => { }); }); }); + +describe('PATCH /workflows/:id', () => { + test('should create workflow history version when licensed', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(true); + const workflow = await testDb.createWorkflow({}, owner); + const payload = { + name: 'name updated', + versionId: workflow.versionId, + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + { + id: 'uuid-1234', + parameters: {}, + name: 'Cron', + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [400, 300], + }, + ], + connections: {}, + staticData: '{"id":1}', + settings: { + saveExecutionProgress: false, + saveManualExecutions: false, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }; + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + + const { + data: { id }, + } = response.body; + + expect(response.statusCode).toBe(200); + + expect(id).toBe(workflow.id); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(1); + const historyVersion = await Container.get(WorkflowHistoryRepository).findOne({ + where: { + workflowId: id, + }, + }); + expect(historyVersion).not.toBeNull(); + expect(historyVersion!.connections).toEqual(payload.connections); + expect(historyVersion!.nodes).toEqual(payload.nodes); + }); + + test('should not create workflow history version when not licensed', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(false); + const workflow = await testDb.createWorkflow({}, owner); + const payload = { + name: 'name updated', + versionId: workflow.versionId, + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + { + id: 'uuid-1234', + parameters: {}, + name: 'Cron', + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [400, 300], + }, + ], + connections: {}, + staticData: '{"id":1}', + settings: { + saveExecutionProgress: false, + saveManualExecutions: false, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }; + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + + const { + data: { id }, + } = response.body; + + expect(response.statusCode).toBe(200); + + expect(id).toBe(workflow.id); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(0); + }); + + test('should activate workflow without changing version ID', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(false); + const workflow = await testDb.createWorkflow({}, owner); + const payload = { + versionId: workflow.versionId, + active: true, + }; + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + + expect(response.statusCode).toBe(200); + expect(activeWorkflowRunnerLike.add).toBeCalled(); + + const { + data: { id, versionId, active }, + } = response.body; + + expect(id).toBe(workflow.id); + expect(versionId).toBe(workflow.versionId); + expect(active).toBe(true); + }); + + test('should deactivate workflow without changing version ID', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(false); + const workflow = await testDb.createWorkflow({ active: true }, owner); + const payload = { + versionId: workflow.versionId, + active: false, + }; + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + + expect(response.statusCode).toBe(200); + expect(activeWorkflowRunnerLike.add).not.toBeCalled(); + expect(activeWorkflowRunnerLike.remove).toBeCalled(); + + const { + data: { id, versionId, active }, + } = response.body; + + expect(id).toBe(workflow.id); + expect(versionId).toBe(workflow.versionId); + expect(active).toBe(false); + }); +}); diff --git a/packages/cli/test/setup-test-folder.ts b/packages/cli/test/setup-test-folder.ts new file mode 100644 index 0000000000000..07a8095373919 --- /dev/null +++ b/packages/cli/test/setup-test-folder.ts @@ -0,0 +1,16 @@ +import { tmpdir } from 'os'; +import { join } from 'path'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'; + +const baseDir = join(tmpdir(), 'n8n-tests/'); +mkdirSync(baseDir, { recursive: true }); + +const testDir = mkdtempSync(baseDir); +mkdirSync(join(testDir, '.n8n')); +process.env.N8N_USER_FOLDER = testDir; + +writeFileSync( + join(testDir, '.n8n/config'), + JSON.stringify({ encryptionKey: 'testkey', instanceId: '123' }), + 'utf-8', +); diff --git a/packages/cli/test/unit/CredentialsHelper.test.ts b/packages/cli/test/unit/CredentialsHelper.test.ts index 96907b725bf5e..52db57e27f394 100644 --- a/packages/cli/test/unit/CredentialsHelper.test.ts +++ b/packages/cli/test/unit/CredentialsHelper.test.ts @@ -12,6 +12,7 @@ import { CredentialsHelper } from '@/CredentialsHelper'; import { NodeTypes } from '@/NodeTypes'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { mockInstance } from '../integration/shared/utils'; +import Container from 'typedi'; describe('CredentialsHelper', () => { const TEST_ENCRYPTION_KEY = 'test'; @@ -277,7 +278,7 @@ describe('CredentialsHelper', () => { }, }; - const credentialsHelper = new CredentialsHelper(TEST_ENCRYPTION_KEY); + const credentialsHelper = Container.get(CredentialsHelper); const result = await credentialsHelper.authenticate( testData.input.credentials, diff --git a/packages/cli/test/unit/ExternalSecrets/ExternalSecretsManager.test.ts b/packages/cli/test/unit/ExternalSecrets/ExternalSecretsManager.test.ts index 0789b87d67caf..60cdb04fcb2ea 100644 --- a/packages/cli/test/unit/ExternalSecrets/ExternalSecretsManager.test.ts +++ b/packages/cli/test/unit/ExternalSecrets/ExternalSecretsManager.test.ts @@ -1,11 +1,11 @@ -import type { SettingsRepository } from '@/databases/repositories'; +import { Container } from 'typedi'; +import { Cipher } from 'n8n-core'; +import { SettingsRepository } from '@/databases/repositories'; import type { ExternalSecretsSettings } from '@/Interfaces'; import { License } from '@/License'; import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager.ee'; import { ExternalSecretsProviders } from '@/ExternalSecrets/ExternalSecretsProviders.ee'; -import { mock } from 'jest-mock-extended'; -import { UserSettings } from 'n8n-core'; -import Container from 'typedi'; +import { InternalHooks } from '@/InternalHooks'; import { mockInstance } from '../../integration/shared/utils'; import { DummyProvider, @@ -13,56 +13,42 @@ import { FailedProvider, MockProviders, } from '../../shared/ExternalSecrets/utils'; -import { AES, enc } from 'crypto-js'; -import { InternalHooks } from '@/InternalHooks'; -const connectedDate = '2023-08-01T12:32:29.000Z'; -const encryptionKey = 'testkey'; -let settings: string | null = null; -const mockProvidersInstance = new MockProviders(); -const settingsRepo = mock({ - async getEncryptedSecretsProviderSettings() { - return settings; - }, - async saveEncryptedSecretsProviderSettings(data) { - settings = data; - }, -}); -let licenseMock: License; -let providersMock: ExternalSecretsProviders; -let manager: ExternalSecretsManager | undefined; +describe('External Secrets Manager', () => { + const connectedDate = '2023-08-01T12:32:29.000Z'; + let settings: string | null = null; -const createMockSettings = (settings: ExternalSecretsSettings): string => { - return AES.encrypt(JSON.stringify(settings), encryptionKey).toString(); -}; + const mockProvidersInstance = new MockProviders(); + const license = mockInstance(License); + const settingsRepo = mockInstance(SettingsRepository); + mockInstance(InternalHooks); + const cipher = Container.get(Cipher); -const decryptSettings = (settings: string) => { - return JSON.parse(AES.decrypt(settings ?? '', encryptionKey).toString(enc.Utf8)); -}; + let providersMock: ExternalSecretsProviders; + let manager: ExternalSecretsManager; + + const createMockSettings = (settings: ExternalSecretsSettings): string => { + return cipher.encrypt(settings); + }; + + const decryptSettings = (settings: string) => { + return JSON.parse(cipher.decrypt(settings)); + }; -describe('External Secrets Manager', () => { beforeAll(() => { - jest - .spyOn(UserSettings, 'getEncryptionKey') - .mockReturnValue(new Promise((resolve) => resolve(encryptionKey))); providersMock = mockInstance(ExternalSecretsProviders, mockProvidersInstance); - licenseMock = mockInstance(License, { - isExternalSecretsEnabled() { - return true; - }, + settings = createMockSettings({ + dummy: { connected: true, connectedAt: new Date(connectedDate), settings: {} }, }); - mockInstance(InternalHooks); }); beforeEach(() => { mockProvidersInstance.setProviders({ dummy: DummyProvider, }); - settings = createMockSettings({ - dummy: { connected: true, connectedAt: new Date(connectedDate), settings: {} }, - }); - - Container.remove(ExternalSecretsManager); + license.isExternalSecretsEnabled.mockReturnValue(true); + settingsRepo.getEncryptedSecretsProviderSettings.mockResolvedValue(settings); + manager = new ExternalSecretsManager(settingsRepo, license, providersMock, cipher); }); afterEach(() => { @@ -71,8 +57,6 @@ describe('External Secrets Manager', () => { }); test('should get secret', async () => { - manager = new ExternalSecretsManager(settingsRepo, licenseMock, providersMock); - await manager.init(); expect(manager.getSecret('dummy', 'test1')).toBe('value1'); @@ -82,8 +66,6 @@ describe('External Secrets Manager', () => { mockProvidersInstance.setProviders({ dummy: ErrorProvider, }); - manager = new ExternalSecretsManager(settingsRepo, licenseMock, providersMock); - expect(async () => manager!.init()).not.toThrow(); }); @@ -91,16 +73,12 @@ describe('External Secrets Manager', () => { mockProvidersInstance.setProviders({ dummy: ErrorProvider, }); - manager = new ExternalSecretsManager(settingsRepo, licenseMock, providersMock); await manager.init(); expect(() => manager!.shutdown()).not.toThrow(); - manager = undefined; }); test('should save provider settings', async () => { - manager = new ExternalSecretsManager(settingsRepo, licenseMock, providersMock); - const settingsSpy = jest.spyOn(settingsRepo, 'saveEncryptedSecretsProviderSettings'); await manager.init(); @@ -122,8 +100,6 @@ describe('External Secrets Manager', () => { test('should call provider update functions on a timer', async () => { jest.useFakeTimers(); - manager = new ExternalSecretsManager(settingsRepo, licenseMock, providersMock); - await manager.init(); const updateSpy = jest.spyOn(manager.getProvider('dummy')!, 'update'); @@ -138,15 +114,7 @@ describe('External Secrets Manager', () => { test('should not call provider update functions if the not licensed', async () => { jest.useFakeTimers(); - manager = new ExternalSecretsManager( - settingsRepo, - mock({ - isExternalSecretsEnabled() { - return false; - }, - }), - providersMock, - ); + license.isExternalSecretsEnabled.mockReturnValue(false); await manager.init(); @@ -165,7 +133,6 @@ describe('External Secrets Manager', () => { mockProvidersInstance.setProviders({ dummy: FailedProvider, }); - manager = new ExternalSecretsManager(settingsRepo, licenseMock, providersMock); await manager.init(); @@ -179,8 +146,6 @@ describe('External Secrets Manager', () => { }); test('should reinitialize a provider when save provider settings', async () => { - manager = new ExternalSecretsManager(settingsRepo, licenseMock, providersMock); - await manager.init(); const dummyInitSpy = jest.spyOn(DummyProvider.prototype, 'init'); diff --git a/packages/cli/test/unit/License.test.ts b/packages/cli/test/unit/License.test.ts index 99d90daac32d4..2c29a597953de 100644 --- a/packages/cli/test/unit/License.test.ts +++ b/packages/cli/test/unit/License.test.ts @@ -1,7 +1,9 @@ import { LicenseManager } from '@n8n_io/license-sdk'; +import { InstanceSettings } from 'n8n-core'; import config from '@/config'; import { License } from '@/License'; import { N8N_VERSION } from '@/constants'; +import { mockInstance } from '../integration/shared/utils'; jest.mock('@n8n_io/license-sdk'); @@ -21,10 +23,11 @@ describe('License', () => { }); let license: License; + const instanceSettings = mockInstance(InstanceSettings, { instanceId: MOCK_INSTANCE_ID }); beforeEach(async () => { - license = new License(); - await license.init(MOCK_INSTANCE_ID); + license = new License(instanceSettings); + await license.init(); }); test('initializes license manager', async () => { @@ -39,14 +42,15 @@ describe('License', () => { loadCertStr: expect.any(Function), saveCertStr: expect.any(Function), onFeatureChange: expect.any(Function), + collectUsageMetrics: expect.any(Function), server: MOCK_SERVER_URL, tenantId: 1, }); }); test('initializes license manager for worker', async () => { - license = new License(); - await license.init(MOCK_INSTANCE_ID, 'worker'); + license = new License(instanceSettings); + await license.init('worker'); expect(LicenseManager).toHaveBeenCalledWith({ autoRenewEnabled: false, autoRenewOffset: MOCK_RENEW_OFFSET, @@ -58,6 +62,7 @@ describe('License', () => { loadCertStr: expect.any(Function), saveCertStr: expect.any(Function), onFeatureChange: expect.any(Function), + collectUsageMetrics: expect.any(Function), server: MOCK_SERVER_URL, tenantId: 1, }); diff --git a/packages/cli/test/unit/PostHog.test.ts b/packages/cli/test/unit/PostHog.test.ts index 901f39c39f3b6..6c53b448916f4 100644 --- a/packages/cli/test/unit/PostHog.test.ts +++ b/packages/cli/test/unit/PostHog.test.ts @@ -1,6 +1,8 @@ import { PostHog } from 'posthog-node'; +import { InstanceSettings } from 'n8n-core'; import { PostHogClient } from '@/posthog'; import config from '@/config'; +import { mockInstance } from '../integration/shared/utils'; jest.mock('posthog-node'); @@ -10,6 +12,8 @@ describe('PostHog', () => { const apiKey = 'api-key'; const apiHost = 'api-host'; + const instanceSettings = mockInstance(InstanceSettings, { instanceId }); + beforeAll(() => { config.set('diagnostics.config.posthog.apiKey', apiKey); config.set('diagnostics.config.posthog.apiHost', apiHost); @@ -21,8 +25,8 @@ describe('PostHog', () => { }); it('inits PostHog correctly', async () => { - const ph = new PostHogClient(); - await ph.init(instanceId); + const ph = new PostHogClient(instanceSettings); + await ph.init(); expect(PostHog.prototype.constructor).toHaveBeenCalledWith(apiKey, { host: apiHost }); }); @@ -30,8 +34,8 @@ describe('PostHog', () => { it('does not initialize or track if diagnostics are not enabled', async () => { config.set('diagnostics.enabled', false); - const ph = new PostHogClient(); - await ph.init(instanceId); + const ph = new PostHogClient(instanceSettings); + await ph.init(); ph.track({ userId: 'test', @@ -50,8 +54,8 @@ describe('PostHog', () => { test: true, }; - const ph = new PostHogClient(); - await ph.init(instanceId); + const ph = new PostHogClient(instanceSettings); + await ph.init(); ph.track({ userId, @@ -70,8 +74,8 @@ describe('PostHog', () => { it('gets feature flags', async () => { const createdAt = new Date(); - const ph = new PostHogClient(); - await ph.init(instanceId); + const ph = new PostHogClient(instanceSettings); + await ph.init(); await ph.getFeatureFlags({ id: userId, diff --git a/packages/cli/test/unit/SourceControl.test.ts b/packages/cli/test/unit/SourceControl.test.ts index d25678ea66e36..1123d177fef6b 100644 --- a/packages/cli/test/unit/SourceControl.test.ts +++ b/packages/cli/test/unit/SourceControl.test.ts @@ -9,12 +9,11 @@ import { } from '@/environments/sourceControl/sourceControlHelper.ee'; import { License } from '@/License'; import { SourceControlPreferencesService } from '@/environments/sourceControl/sourceControlPreferences.service.ee'; -import { UserSettings } from 'n8n-core'; +import { InstanceSettings } from 'n8n-core'; import path from 'path'; import { SOURCE_CONTROL_SSH_FOLDER, SOURCE_CONTROL_GIT_FOLDER, - SOURCE_CONTROL_SSH_KEY_NAME, } from '@/environments/sourceControl/constants'; import { LoggerProxy } from 'n8n-workflow'; import { getLogger } from '@/Logger'; @@ -184,10 +183,9 @@ describe('Source Control', () => { }); it('should check for git and ssh folders and create them if required', async () => { - const userFolder = UserSettings.getUserN8nFolderPath(); - const sshFolder = path.join(userFolder, SOURCE_CONTROL_SSH_FOLDER); - const gitFolder = path.join(userFolder, SOURCE_CONTROL_GIT_FOLDER); - const sshKeyName = path.join(sshFolder, SOURCE_CONTROL_SSH_KEY_NAME); + const { n8nFolder } = Container.get(InstanceSettings); + const sshFolder = path.join(n8nFolder, SOURCE_CONTROL_SSH_FOLDER); + const gitFolder = path.join(n8nFolder, SOURCE_CONTROL_GIT_FOLDER); let hasThrown = false; try { accessSync(sshFolder, fsConstants.F_OK); diff --git a/packages/cli/test/unit/Telemetry.test.ts b/packages/cli/test/unit/Telemetry.test.ts index 4df728add1f2f..8a38710fdf18d 100644 --- a/packages/cli/test/unit/Telemetry.test.ts +++ b/packages/cli/test/unit/Telemetry.test.ts @@ -4,6 +4,8 @@ import config from '@/config'; import { flushPromises } from './Helpers'; import { PostHogClient } from '@/posthog'; import { mock } from 'jest-mock-extended'; +import { mockInstance } from '../integration/shared/utils'; +import { InstanceSettings } from 'n8n-core'; jest.unmock('@/telemetry'); jest.mock('@/license/License.service', () => { @@ -28,6 +30,7 @@ describe('Telemetry', () => { let telemetry: Telemetry; const instanceId = 'Telemetry unit test'; const testDateTime = new Date('2022-01-01 00:00:00'); + const instanceSettings = mockInstance(InstanceSettings, { instanceId }); beforeAll(() => { startPulseSpy = jest @@ -49,11 +52,10 @@ describe('Telemetry', () => { beforeEach(async () => { spyTrack.mockClear(); - const postHog = new PostHogClient(); - await postHog.init(instanceId); + const postHog = new PostHogClient(instanceSettings); + await postHog.init(); - telemetry = new Telemetry(postHog, mock()); - telemetry.setInstanceId(instanceId); + telemetry = new Telemetry(postHog, mock(), instanceSettings); (telemetry as any).rudderStack = mockRudderStack; }); diff --git a/packages/core/bin/generate-known b/packages/core/bin/generate-known index 5cabfa9b801d9..df38a7ecc6d13 100755 --- a/packages/core/bin/generate-known +++ b/packages/core/bin/generate-known @@ -6,10 +6,7 @@ const { LoggerProxy } = require('n8n-workflow'); const { packageDir, writeJSON } = require('./common'); const { loadClassInIsolation } = require('../dist/ClassLoader'); -LoggerProxy.init({ - log: console.log.bind(console), - warn: console.warn.bind(console), -}); +LoggerProxy.init(console); const loadClass = (sourcePath) => { try { diff --git a/packages/core/bin/generate-ui-types b/packages/core/bin/generate-ui-types index 76ceae31fc16d..317c0e121edc2 100755 --- a/packages/core/bin/generate-ui-types +++ b/packages/core/bin/generate-ui-types @@ -4,10 +4,7 @@ const { LoggerProxy, NodeHelpers } = require('n8n-workflow'); const { PackageDirectoryLoader } = require('../dist/DirectoryLoader'); const { packageDir, writeJSON } = require('./common'); -LoggerProxy.init({ - log: console.log.bind(console), - warn: console.warn.bind(console), -}); +LoggerProxy.init(console); function findReferencedMethods(obj, refs = {}, latestName = '') { for (const key in obj) { @@ -29,6 +26,21 @@ function findReferencedMethods(obj, refs = {}, latestName = '') { return refs; } +function addWebhookLifecycle(nodeType) { + if (nodeType.description.webhooks) { + nodeType.description.webhooks = nodeType.description.webhooks.map((webhook) => { + const webhookMethods = + nodeType?.webhookMethods?.[webhook.name] ?? nodeType?.webhookMethods?.default; + webhook.hasLifecycleMethods = Boolean( + webhookMethods?.checkExists && webhookMethods?.create && webhookMethods?.delete, + ); + return webhook; + }); + } + + return nodeType; +} + (async () => { const loader = new PackageDirectoryLoader(packageDir); await loader.loadAll(); @@ -60,6 +72,7 @@ function findReferencedMethods(obj, refs = {}, latestName = '') { .map((data) => { const nodeType = NodeHelpers.getVersionedNodeType(data.type); NodeHelpers.applySpecialNodeParameters(nodeType); + addWebhookLifecycle(nodeType); return data.type; }) .flatMap((nodeData) => { diff --git a/packages/core/package.json b/packages/core/package.json index 8de88518f97df..deaf1281b38f2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.12.0", + "version": "1.14.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -37,7 +37,7 @@ "@types/aws4": "^1.5.1", "@types/concat-stream": "^2.0.0", "@types/cron": "~1.7.1", - "@types/crypto-js": "^4.0.1", + "@types/crypto-js": "^4.1.3", "@types/express": "^4.17.6", "@types/lodash": "^4.14.195", "@types/mime-types": "^2.1.0", @@ -54,7 +54,7 @@ "axios": "^0.21.1", "concat-stream": "^2.0.0", "cron": "~1.7.2", - "crypto-js": "~4.1.1", + "crypto-js": "^4.1.1", "fast-glob": "^3.2.5", "file-type": "^16.5.4", "flatted": "^3.2.4", diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/BinaryData/BinaryData.service.ts index 393e42b4d6f8a..b96e87d915554 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/BinaryData/BinaryData.service.ts @@ -151,7 +151,7 @@ export class BinaryDataService { if (!manager) return; - await manager.deleteMany(ids); + if (manager.deleteMany) await manager.deleteMany(ids); } @LogCatch((error) => diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/BinaryData/ObjectStore.manager.ts index 9a6040b1b911a..37a0f944d1195 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/BinaryData/ObjectStore.manager.ts @@ -83,19 +83,6 @@ export class ObjectStoreManager implements BinaryData.Manager { return { fileId: targetFileId, fileSize: sourceFile.length }; } - async deleteMany(ids: BinaryData.IdsForDeletion) { - const prefixes = ids.map( - ({ workflowId, executionId }) => - `workflows/${workflowId}/executions/${executionId}/binary_data/`, - ); - - await Promise.all( - prefixes.map(async (prefix) => { - await this.objectStoreService.deleteMany(prefix); - }), - ); - } - async rename(oldFileId: string, newFileId: string) { const oldFile = await this.objectStoreService.get(oldFileId, { mode: 'buffer' }); const oldFileMetadata = await this.objectStoreService.getMetadata(oldFileId); diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/BinaryData/types.ts index 2067d90c276c0..ef39197b3e200 100644 --- a/packages/core/src/BinaryData/types.ts +++ b/packages/core/src/BinaryData/types.ts @@ -55,7 +55,10 @@ export namespace BinaryData { getAsStream(fileId: string, chunkSize?: number): Promise; getMetadata(fileId: string): Promise; - deleteMany(ids: IdsForDeletion): Promise; + /** + * Present for `FileSystem`, absent for `ObjectStore` (delegated to S3 lifecycle config) + */ + deleteMany?(ids: IdsForDeletion): Promise; copyByFileId(workflowId: string, executionId: string, sourceFileId: string): Promise; copyByFilePath( diff --git a/packages/core/src/Cipher.ts b/packages/core/src/Cipher.ts new file mode 100644 index 0000000000000..08af32a26ced7 --- /dev/null +++ b/packages/core/src/Cipher.ts @@ -0,0 +1,19 @@ +import { Service } from 'typedi'; +import { AES, enc } from 'crypto-js'; +import { InstanceSettings } from './InstanceSettings'; + +@Service() +export class Cipher { + constructor(private readonly instanceSettings: InstanceSettings) {} + + encrypt(data: string | object) { + return AES.encrypt( + typeof data === 'string' ? data : JSON.stringify(data), + this.instanceSettings.encryptionKey, + ).toString(); + } + + decrypt(data: string) { + return AES.decrypt(data, this.instanceSettings.encryptionKey).toString(enc.Utf8); + } +} diff --git a/packages/core/src/Constants.ts b/packages/core/src/Constants.ts index f3fc5149e3360..4ce7b66341e80 100644 --- a/packages/core/src/Constants.ts +++ b/packages/core/src/Constants.ts @@ -1,17 +1,6 @@ export const CUSTOM_EXTENSION_ENV = 'N8N_CUSTOM_EXTENSIONS'; -export const DOWNLOADED_NODES_SUBDIRECTORY = 'nodes'; -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'; export const PLACEHOLDER_EMPTY_EXECUTION_ID = '__UNKNOWN__'; export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__'; -export const TUNNEL_SUBDOMAIN_ENV = 'N8N_TUNNEL_SUBDOMAIN'; - -export const RESPONSE_ERROR_MESSAGES = { - NO_ENCRYPTION_KEY: 'Encryption key is missing or was not set', -}; export const CUSTOM_NODES_CATEGORY = 'Custom Nodes'; diff --git a/packages/core/src/Credentials.ts b/packages/core/src/Credentials.ts index 7837fbcabbc85..00873c1da66ad 100644 --- a/packages/core/src/Credentials.ts +++ b/packages/core/src/Credentials.ts @@ -1,13 +1,11 @@ -import type { - CredentialInformation, - ICredentialDataDecryptedObject, - ICredentialsEncrypted, -} from 'n8n-workflow'; -import { ICredentials } from 'n8n-workflow'; - -import { AES, enc } from 'crypto-js'; +import { Container } from 'typedi'; +import type { ICredentialDataDecryptedObject, ICredentialsEncrypted } from 'n8n-workflow'; +import { ICredentials, jsonParse } from 'n8n-workflow'; +import { Cipher } from './Cipher'; export class Credentials extends ICredentials { + private readonly cipher = Container.get(Cipher); + /** * Returns if the given nodeType has access to data */ @@ -24,30 +22,14 @@ export class Credentials extends ICredentials { /** * 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); + setData(data: ICredentialDataDecryptedObject): void { + this.data = this.cipher.encrypt(data); } /** * Returns the decrypted credential object */ - getData(encryptionKey: string, nodeType?: string): ICredentialDataDecryptedObject { + getData(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}".`, @@ -58,11 +40,10 @@ export class Credentials extends ICredentials { throw new Error('No data is set so nothing can be returned.'); } - const decryptedData = AES.decrypt(this.data, encryptionKey); + const decryptedData = this.cipher.decrypt(this.data); try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return JSON.parse(decryptedData.toString(enc.Utf8)); + return jsonParse(decryptedData); } catch (e) { throw new Error( 'Credentials could not be decrypted. The likely reason is that a different "encryptionKey" was used to encrypt the data.', @@ -70,23 +51,6 @@ export class Credentials extends ICredentials { } } - /** - * 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 was set.'); - } - - if (!fullData.hasOwnProperty(key)) { - throw new Error(`No data for key "${key}" exists.`); - } - - return fullData[key]; - } - /** * Returns the encrypted credentials to be saved */ diff --git a/packages/core/src/InstanceSettings.ts b/packages/core/src/InstanceSettings.ts new file mode 100644 index 0000000000000..6802914c974ff --- /dev/null +++ b/packages/core/src/InstanceSettings.ts @@ -0,0 +1,90 @@ +import path from 'path'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { createHash, randomBytes } from 'crypto'; +import { Service } from 'typedi'; +import { jsonParse } from 'n8n-workflow'; + +interface ReadOnlySettings { + encryptionKey: string; +} + +interface WritableSettings { + tunnelSubdomain?: string; +} + +type Settings = ReadOnlySettings & WritableSettings; + +@Service() +export class InstanceSettings { + readonly userHome = this.getUserHome(); + + /** The path to the n8n folder in which all n8n related data gets saved */ + readonly n8nFolder = path.join(this.userHome, '.n8n'); + + /** The path to the folder containing custom nodes and credentials */ + readonly customExtensionDir = path.join(this.n8nFolder, 'custom'); + + /** The path to the folder containing installed nodes (like community nodes) */ + readonly nodesDownloadDir = path.join(this.n8nFolder, 'nodes'); + + private readonly settingsFile = path.join(this.n8nFolder, 'config'); + + private settings = this.loadOrCreate(); + + readonly instanceId = this.generateInstanceId(); + + get encryptionKey() { + return this.settings.encryptionKey; + } + + get tunnelSubdomain() { + return this.settings.tunnelSubdomain; + } + + update(newSettings: WritableSettings) { + this.save({ ...this.settings, ...newSettings }); + } + + /** + * The home folder path of the user. + * If none can be found it falls back to the current working directory + */ + private getUserHome() { + const homeVarName = process.platform === 'win32' ? 'USERPROFILE' : 'HOME'; + return process.env.N8N_USER_FOLDER ?? process.env[homeVarName] ?? process.cwd(); + } + + private loadOrCreate(): Settings { + let settings: Settings; + const { settingsFile } = this; + if (existsSync(settingsFile)) { + const content = readFileSync(settingsFile, 'utf8'); + settings = jsonParse(content, { + errorMessage: `Error parsing n8n-config file "${settingsFile}". It does not seem to be valid JSON.`, + }); + } else { + // Ensure that the `.n8n` folder exists + mkdirSync(this.n8nFolder, { recursive: true }); + // If file doesn't exist, create new settings + const encryptionKey = process.env.N8N_ENCRYPTION_KEY ?? randomBytes(24).toString('base64'); + settings = { encryptionKey }; + this.save(settings); + // console.info(`UserSettings were generated and saved to: ${settingsFile}`); + } + + const { encryptionKey, tunnelSubdomain } = settings; + return { encryptionKey, tunnelSubdomain }; + } + + private generateInstanceId() { + const { encryptionKey } = this; + return createHash('sha256') + .update(encryptionKey.slice(Math.round(encryptionKey.length / 2))) + .digest('hex'); + } + + private save(settings: Settings) { + this.settings = settings; + writeFileSync(this.settingsFile, JSON.stringify(settings, null, '\t'), 'utf-8'); + } +} diff --git a/packages/core/src/Interfaces.ts b/packages/core/src/Interfaces.ts index aa723045fdcd1..777f9055a7231 100644 --- a/packages/core/src/Interfaces.ts +++ b/packages/core/src/Interfaces.ts @@ -15,12 +15,6 @@ export interface IResponseError extends Error { statusCode?: number; } -export interface IUserSettings { - encryptionKey?: string; - tunnelSubdomain?: string; - instanceId?: string; -} - export interface IWorkflowSettings extends IWorkflowSettingsWorkflow { errorWorkflow?: string; timezone?: string; diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index bee0190a07a81..8ba714ae5f7c1 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -3,14 +3,11 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ /* eslint-disable @typescript-eslint/naming-convention */ - /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ - /* eslint-disable @typescript-eslint/no-shadow */ - import type { ClientOAuth2Options, ClientOAuth2RequestObject, @@ -143,9 +140,9 @@ import { setWorkflowExecutionMetadata, } from './WorkflowExecutionMetadata'; import { getSecretsProxy } from './Secrets'; -import { getUserN8nFolderPath, getInstanceId } from './UserSettings'; import Container from 'typedi'; import type { BinaryData } from './BinaryData/types'; +import { InstanceSettings } from './InstanceSettings'; axios.defaults.timeout = 300000; // Prevent axios from adding x-form-www-urlencoded headers by default @@ -2469,6 +2466,10 @@ const addExecutionDataFunctions = async ( }); } + if (get(runExecutionData, 'executionData.metadata', undefined) === undefined) { + runExecutionData.executionData!.metadata = {}; + } + let sourceTaskData = get(runExecutionData, `executionData.metadata[${sourceNodeName}]`); if (!sourceTaskData) { @@ -2506,7 +2507,7 @@ const getCommonWorkflowFunctions = ( getRestApiUrl: () => additionalData.restApiUrl, getInstanceBaseUrl: () => additionalData.instanceBaseUrl, - getInstanceId: async () => getInstanceId(), + getInstanceId: () => Container.get(InstanceSettings).instanceId, getTimezone: () => getTimezone(workflow, additionalData), prepareOutputData: async (outputData) => [outputData], @@ -2596,7 +2597,6 @@ const getAllowedPaths = () => { function isFilePathBlocked(filePath: string): boolean { const allowedPaths = getAllowedPaths(); const resolvedFilePath = path.resolve(filePath); - const userFolder = getUserN8nFolderPath(); const blockFileAccessToN8nFiles = process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] !== 'false'; //if allowed paths are defined, allow access only to those paths @@ -2612,7 +2612,8 @@ function isFilePathBlocked(filePath: string): boolean { //restrict access to .n8n folder and other .env config related paths if (blockFileAccessToN8nFiles) { - const restrictedPaths: string[] = [userFolder]; + const { n8nFolder } = Container.get(InstanceSettings); + const restrictedPaths = [n8nFolder]; if (process.env[CONFIG_FILES]) { restrictedPaths.push(...process.env[CONFIG_FILES].split(',')); @@ -2670,7 +2671,7 @@ const getFileSystemHelperFunctions = (node: INode): FileSystemHelperFunctions => }, getStoragePath() { - return path.join(getUserN8nFolderPath(), `storage/${node.type}`); + return path.join(Container.get(InstanceSettings).n8nFolder, `storage/${node.type}`); }, async writeContentToFile(filePath, content, flag) { @@ -3033,7 +3034,7 @@ export function getExecuteFunctions( }; try { - return await nodeType.supplyData.call(context); + return await nodeType.supplyData.call(context, itemIndex); } catch (error) { if (!(error instanceof ExecutionBaseError)) { error = new NodeOperationError(connectedNode, error, { diff --git a/packages/core/src/UserSettings.ts b/packages/core/src/UserSettings.ts deleted file mode 100644 index ee947c3061b64..0000000000000 --- a/packages/core/src/UserSettings.ts +++ /dev/null @@ -1,272 +0,0 @@ -/* eslint-disable @typescript-eslint/no-use-before-define */ - -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import fs from 'fs'; -import path from 'path'; -import { createHash, randomBytes } from 'crypto'; -import { promisify } from 'util'; -import { deepCopy } from 'n8n-workflow'; -import { - ENCRYPTION_KEY_ENV_OVERWRITE, - EXTENSIONS_SUBDIRECTORY, - DOWNLOADED_NODES_SUBDIRECTORY, - RESPONSE_ERROR_MESSAGES, - USER_FOLDER_ENV_OVERWRITE, - USER_SETTINGS_FILE_NAME, - USER_SETTINGS_SUBFOLDER, -} from './Constants'; -import type { IUserSettings } from './Interfaces'; - -const fsAccess = promisify(fs.access); -const fsReadFile = promisify(fs.readFile); -const fsMkdir = promisify(fs.mkdir); -const fsWriteFile = promisify(fs.writeFile); - -let settingsCache: IUserSettings | undefined; - -/** - * Creates the user settings if they do not exist yet - * - */ -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 - if (userSettings.instanceId === undefined) { - userSettings.instanceId = await generateInstanceId(userSettings.encryptionKey); - settingsCache = userSettings; - } - - return userSettings; - } - } else { - userSettings = {}; - } - - if (process.env[ENCRYPTION_KEY_ENV_OVERWRITE] !== undefined) { - // Use the encryption key which got set via environment - userSettings.encryptionKey = process.env[ENCRYPTION_KEY_ENV_OVERWRITE]; - } else { - // Generate a new encryption key - userSettings.encryptionKey = randomBytes(24).toString('base64'); - } - - userSettings.instanceId = await generateInstanceId(userSettings.encryptionKey); - - console.log(`UserSettings were generated and saved to: ${settingsPath}`); - - return writeUserSettings(userSettings, settingsPath); -} - -/** - * Returns the encryption key which is used to encrypt - * the credentials. - * - */ - -export async function getEncryptionKey(): Promise { - if (process.env[ENCRYPTION_KEY_ENV_OVERWRITE] !== undefined) { - return process.env[ENCRYPTION_KEY_ENV_OVERWRITE]; - } - - const userSettings = await getUserSettings(); - - if (userSettings?.encryptionKey === undefined) { - throw new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY); - } - - return userSettings.encryptionKey; -} - -/** - * Returns the instance ID - * - */ -export async function getInstanceId(): Promise { - const userSettings = await getUserSettings(); - - if (userSettings === undefined) { - return ''; - } - - if (userSettings.instanceId === undefined) { - return ''; - } - - return userSettings.instanceId; -} - -async function generateInstanceId(key?: string) { - const hash = key - ? createHash('sha256') - .update(key.slice(Math.round(key.length / 2))) - .digest('hex') - : undefined; - - return hash; -} - -/** - * Adds/Overwrite the given settings in the currently - * saved user settings - * - * @param {IUserSettings} addSettings The settings to add/overwrite - * @param {string} [settingsPath] Optional settings file path - */ -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 - * - * @param {IUserSettings} userSettings The settings to write - * @param {string} [settingsPath] Optional settings file path - */ -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)); - } - - const settingsToWrite = { ...userSettings }; - if (settingsToWrite.instanceId !== undefined) { - delete settingsToWrite.instanceId; - } - - await fsWriteFile(settingsPath, JSON.stringify(settingsToWrite, null, '\t')); - settingsCache = deepCopy(userSettings); - - return userSettings; -} - -/** - * Returns the content of the user settings - * - */ -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'); - - try { - settingsCache = JSON.parse(settingsFile); - } catch (error) { - throw new Error( - `Error parsing n8n-config file "${settingsPath}". It does not seem to be valid JSON.`, - ); - } - - return settingsCache as IUserSettings; -} - -/** - * Returns the path to the user settings - * - */ -export function getUserSettingsPath(): string { - const n8nFolder = getUserN8nFolderPath(); - - return path.join(n8nFolder, USER_SETTINGS_FILE_NAME); -} - -/** - * Returns the path to the n8n folder in which all n8n - * related data gets saved - * - */ -export function getUserN8nFolderPath(): string { - return path.join(getUserHome(), USER_SETTINGS_SUBFOLDER); -} - -/** - * Returns the path to the n8n user folder with the custom - * extensions like nodes and credentials - * - */ -export function getUserN8nFolderCustomExtensionPath(): string { - return path.join(getUserN8nFolderPath(), EXTENSIONS_SUBDIRECTORY); -} - -/** - * Returns the path to the n8n user folder with the nodes that - * have been downloaded - * - */ -export function getUserN8nFolderDownloadedNodesPath(): string { - return path.join(getUserN8nFolderPath(), DOWNLOADED_NODES_SUBDIRECTORY); -} - -/** - * Returns the home folder path of the user if - * none can be found it falls back to the current - * working directory - * - */ -export function getUserHome(): string { - if (process.env[USER_FOLDER_ENV_OVERWRITE] !== undefined) { - return process.env[USER_FOLDER_ENV_OVERWRITE]; - } else { - 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/index.ts b/packages/core/src/index.ts index 0325dcfdb8276..6ec3e0cfdec10 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,20 +1,21 @@ import * as NodeExecuteFunctions from './NodeExecuteFunctions'; -import * as UserSettings from './UserSettings'; export * from './ActiveWorkflows'; export * from './BinaryData/BinaryData.service'; export * from './BinaryData/types'; +export { Cipher } from './Cipher'; export * from './ClassLoader'; export * from './Constants'; export * from './Credentials'; export * from './DirectoryLoader'; export * from './Interfaces'; +export { InstanceSettings } from './InstanceSettings'; export * from './LoadMappingOptions'; export * from './LoadNodeParameterOptions'; export * from './LoadNodeListSearch'; export * from './NodeExecuteFunctions'; export * from './WorkflowExecute'; -export { NodeExecuteFunctions, UserSettings }; +export { NodeExecuteFunctions }; export * from './errors'; export { ObjectStoreService } from './ObjectStore/ObjectStore.service.ee'; export { BinaryData } from './BinaryData/types'; diff --git a/packages/core/test/Credentials.test.ts b/packages/core/test/Credentials.test.ts index e2794b6c24caf..542d8eb8b65d1 100644 --- a/packages/core/test/Credentials.test.ts +++ b/packages/core/test/Credentials.test.ts @@ -1,23 +1,39 @@ +import { Container } from 'typedi'; +import { mock } from 'jest-mock-extended'; +import type { CredentialInformation } from 'n8n-workflow'; +import { Cipher } from '@/Cipher'; import { Credentials } from '@/Credentials'; +import type { InstanceSettings } from '@/InstanceSettings'; describe('Credentials', () => { + const cipher = new Cipher(mock({ encryptionKey: 'password' })); + Container.set(Cipher, cipher); + + const setDataKey = (credentials: Credentials, key: string, data: CredentialInformation) => { + let fullData; + try { + fullData = credentials.getData(); + } catch (e) { + fullData = {}; + } + fullData[key] = data; + return credentials.setData(fullData); + }; + describe('without nodeType set', () => { test('should be able to set and read key data without initial data set', () => { const credentials = new Credentials({ id: null, name: 'testName' }, 'testType', []); const key = 'key1'; - const password = 'password'; - // const nodeType = 'base.noOp'; const newData = 1234; - credentials.setDataKey(key, newData, password); + setDataKey(credentials, key, newData); - expect(credentials.getDataKey(key, password)).toEqual(newData); + expect(credentials.getData()[key]).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; @@ -33,11 +49,11 @@ describe('Credentials', () => { const newData = 1234; // Set and read new data - credentials.setDataKey(key, newData, password); - expect(credentials.getDataKey(key, password)).toEqual(newData); + setDataKey(credentials, key, newData); + expect(credentials.getData()[key]).toEqual(newData); // Read the data which got provided encrypted on init - expect(credentials.getDataKey('key1', password)).toEqual(initialData); + expect(credentials.getData().key1).toEqual(initialData); }); }); @@ -54,19 +70,18 @@ describe('Credentials', () => { const credentials = new Credentials({ id: null, name: 'testName' }, 'testType', nodeAccess); const key = 'key1'; - const password = 'password'; const nodeType = 'base.noOp'; const newData = 1234; - credentials.setDataKey(key, newData, password); + setDataKey(credentials, key, newData); // Should be able to read with nodeType which has access - expect(credentials.getDataKey(key, password, nodeType)).toEqual(newData); + expect(credentials.getData(nodeType)[key]).toEqual(newData); // Should not be able to read with nodeType which does NOT have access - // expect(credentials.getDataKey(key, password, 'base.otherNode')).toThrowError(Error); + // expect(credentials.getData('base.otherNode')[key]).toThrowError(Error); try { - credentials.getDataKey(key, password, 'base.otherNode'); + credentials.getData('base.otherNode'); expect(true).toBe(false); } catch (e) { expect(e.message).toBe( diff --git a/packages/core/test/InstanceSettings.test.ts b/packages/core/test/InstanceSettings.test.ts new file mode 100644 index 0000000000000..05899e9c89490 --- /dev/null +++ b/packages/core/test/InstanceSettings.test.ts @@ -0,0 +1,65 @@ +import fs from 'fs'; +import { InstanceSettings } from '@/InstanceSettings'; + +describe('InstanceSettings', () => { + process.env.N8N_USER_FOLDER = '/test'; + + const existSpy = jest.spyOn(fs, 'existsSync'); + beforeEach(() => jest.resetAllMocks()); + + describe('If the settings file exists', () => { + const readSpy = jest.spyOn(fs, 'readFileSync'); + beforeEach(() => existSpy.mockReturnValue(true)); + + it('should load settings from the file', () => { + readSpy.mockReturnValue(JSON.stringify({ encryptionKey: 'test_key' })); + const settings = new InstanceSettings(); + expect(settings.encryptionKey).toEqual('test_key'); + expect(settings.instanceId).toEqual( + '6ce26c63596f0cc4323563c529acfca0cccb0e57f6533d79a60a42c9ff862ae7', + ); + }); + + it('should throw error if settings file is not valid JSON', () => { + readSpy.mockReturnValue('{"encryptionKey":"test_key"'); + expect(() => new InstanceSettings()).toThrowError(); + }); + }); + + describe('If the settings file does not exist', () => { + const mkdirSpy = jest.spyOn(fs, 'mkdirSync'); + const writeFileSpy = jest.spyOn(fs, 'writeFileSync'); + beforeEach(() => { + existSpy.mockReturnValue(false); + mkdirSpy.mockReturnValue(''); + writeFileSpy.mockReturnValue(); + }); + + it('should create a new settings file', () => { + const settings = new InstanceSettings(); + expect(settings.encryptionKey).not.toEqual('test_key'); + expect(mkdirSpy).toHaveBeenCalledWith('/test/.n8n', { recursive: true }); + expect(writeFileSpy).toHaveBeenCalledWith( + '/test/.n8n/config', + expect.stringContaining('"encryptionKey":'), + 'utf-8', + ); + }); + + it('should pick up the encryption key from env var N8N_ENCRYPTION_KEY', () => { + process.env.N8N_ENCRYPTION_KEY = 'env_key'; + const settings = new InstanceSettings(); + expect(settings.encryptionKey).toEqual('env_key'); + expect(settings.instanceId).toEqual( + '2c70e12b7a0646f92279f427c7b38e7334d8e5389cff167a1dc30e73f826b683', + ); + expect(settings.encryptionKey).not.toEqual('test_key'); + expect(mkdirSpy).toHaveBeenCalledWith('/test/.n8n', { recursive: true }); + expect(writeFileSpy).toHaveBeenCalledWith( + '/test/.n8n/config', + expect.stringContaining('"encryptionKey":'), + 'utf-8', + ); + }); + }); +}); diff --git a/packages/core/test/ObjectStore.manager.test.ts b/packages/core/test/ObjectStore.manager.test.ts index 79fa51910934f..dc91e3322173b 100644 --- a/packages/core/test/ObjectStore.manager.test.ts +++ b/packages/core/test/ObjectStore.manager.test.ts @@ -116,21 +116,6 @@ describe('copyByFilePath()', () => { }); }); -describe('deleteMany()', () => { - it('should delete many files by prefix', async () => { - const ids = [ - { workflowId, executionId }, - { workflowId: otherWorkflowId, executionId: otherExecutionId }, - ]; - - const promise = objectStoreManager.deleteMany(ids); - - await expect(promise).resolves.not.toThrow(); - - expect(objectStoreService.deleteMany).toHaveBeenCalledTimes(2); - }); -}); - describe('rename()', () => { it('should rename a file', async () => { const promise = objectStoreManager.rename(fileId, otherFileId); diff --git a/packages/core/test/helpers/index.ts b/packages/core/test/helpers/index.ts index 8ffe9159e1959..f5d46f34a039c 100644 --- a/packages/core/test/helpers/index.ts +++ b/packages/core/test/helpers/index.ts @@ -52,6 +52,7 @@ export class CredentialsHelper extends ICredentialsHelper { } async getDecrypted( + additionalData: IWorkflowExecuteAdditionalData, nodeCredentials: INodeCredentialsDetails, type: string, ): Promise { @@ -128,15 +129,12 @@ export function WorkflowExecuteAdditionalData( connections: {}, }; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore return { - credentialsHelper: new CredentialsHelper(''), + credentialsHelper: new CredentialsHelper(), hooks: new WorkflowHooks(hookFunctions, 'trigger', '1', workflowData), executeWorkflow: async (workflowInfo: IExecuteWorkflowInfo) => {}, - sendMessageToUI: (message: string) => {}, + sendDataToUI: (message: string) => {}, restApiUrl: '', - encryptionKey: 'test', timezone: 'America/New_York', webhookBaseUrl: 'webhook', webhookWaitingBaseUrl: 'webhook-waiting', diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 06af25b8df8c2..e00204c67bf44 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "1.12.0", + "version": "1.14.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/editor-ui/src/components/ChatEmbedModal.vue b/packages/editor-ui/src/components/ChatEmbedModal.vue index 683ac4e72e84a..6bc28ad6e03a2 100644 --- a/packages/editor-ui/src/components/ChatEmbedModal.vue +++ b/packages/editor-ui/src/components/ChatEmbedModal.vue @@ -69,9 +69,9 @@ import { createChat } from '@n8n/chat';`, })); const cdnCode = computed( - () => ` + () => `