diff --git a/CHANGELOG.md b/CHANGELOG.md index eac93ca428d56..2033351b7ab4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,52 @@ +# [1.22.0](https://github.com/n8n-io/n8n/compare/n8n@1.21.0...n8n@1.22.0) (2023-12-21) + + +### Bug Fixes + +* **ActiveCampaign Node:** Fix pagination issue when loading tags ([#8017](https://github.com/n8n-io/n8n/issues/8017)) ([1943857](https://github.com/n8n-io/n8n/commit/19438572312cf9354c333aeb52ccbf1ab81fc51f)) +* **core:** Close db connection gracefully when exiting ([#8045](https://github.com/n8n-io/n8n/issues/8045)) ([e69707e](https://github.com/n8n-io/n8n/commit/e69707efd4bd947fdf6b9c66f373da63d34f41e9)) +* **core:** Consider timeout in shutdown an error ([#8050](https://github.com/n8n-io/n8n/issues/8050)) ([4cae976](https://github.com/n8n-io/n8n/commit/4cae976a3b428bd528fe71ef0b240c0fd6e23bbf)) +* **core:** Do not display error when stopping jobless execution in queue mode ([#8007](https://github.com/n8n-io/n8n/issues/8007)) ([8e6b951](https://github.com/n8n-io/n8n/commit/8e6b951a76e08b9ee9740fdd853f77553ad60cd6)) +* **core:** Fix shutdown if terminating before hooks are initialized ([#8047](https://github.com/n8n-io/n8n/issues/8047)) ([6ae2f5e](https://github.com/n8n-io/n8n/commit/6ae2f5efea65e23029475ccdc5a65ec7c8152423)) +* **core:** Handle multiple termination signals correctly ([#8046](https://github.com/n8n-io/n8n/issues/8046)) ([67bd8ad](https://github.com/n8n-io/n8n/commit/67bd8ad698bd0afe6ff7183d75da8bca4085598e)) +* **core:** Initialize queue once in queue mode ([#8025](https://github.com/n8n-io/n8n/issues/8025)) ([53c0b49](https://github.com/n8n-io/n8n/commit/53c0b49d15047461e3b65baed65c9d76dff99539)) +* **core:** Prevent axios from force setting a form-urlencoded content-type ([#8117](https://github.com/n8n-io/n8n/issues/8117)) ([bba9576](https://github.com/n8n-io/n8n/commit/bba95761e2f2b54af1fcab8a7b1d626ca10d537e)), closes [/github.com/axios/axios/blob/v1.x/lib/core/dispatchRequest.js#L45-L47](https://github.com//github.com/axios/axios/blob/v1.x/lib/core/dispatchRequest.js/issues/L45-L47) +* **core:** Remove circular references before serializing executions in public API ([#8043](https://github.com/n8n-io/n8n/issues/8043)) ([989888d](https://github.com/n8n-io/n8n/commit/989888d9bcec6f4eb3c811ce10d480737d96b102)), closes [#8030](https://github.com/n8n-io/n8n/issues/8030) +* **core:** Restore workflow ID during execution creation ([#8031](https://github.com/n8n-io/n8n/issues/8031)) ([c5e6ba8](https://github.com/n8n-io/n8n/commit/c5e6ba8cdd4a8f117ccc2e89e55497117156d8af)), closes [/github.com/n8n-io/n8n/pull/8002/files#diff-c8cbb62ca9ab2ae45e5f565cd8c63fff6475809a6241ea0b90acc575615224](https://github.com//github.com/n8n-io/n8n/pull/8002/files/issues/diff-c8cbb62ca9ab2ae45e5f565cd8c63fff6475809a6241ea0b90acc575615224) +* **core:** Use relative imports for dynamic imports in SecurityAuditService ([#8086](https://github.com/n8n-io/n8n/issues/8086)) ([785bf99](https://github.com/n8n-io/n8n/commit/785bf9974e38ea84c016e210a3108f4af567510d)), closes [#8085](https://github.com/n8n-io/n8n/issues/8085) +* **editor:** Add back credential `use` permission ([#8023](https://github.com/n8n-io/n8n/issues/8023)) ([329e5bf](https://github.com/n8n-io/n8n/commit/329e5bf9eed8556aba2bbd50bad9dbd6d3b373ad)) +* **editor:** Cleanup Executions page component ([#8053](https://github.com/n8n-io/n8n/issues/8053)) ([2689c37](https://github.com/n8n-io/n8n/commit/2689c37e87c5b3ae5029121f4d3dc878841e8844)) +* **editor:** Disable auto scroll and list size check when clicking on executions ([#7983](https://github.com/n8n-io/n8n/issues/7983)) ([fcb8b91](https://github.com/n8n-io/n8n/commit/fcb8b91f37e1fb0ef42f411c84390180e1ed7bbe)) +* **editor:** Ensure execution data overrides pinned data when copying in executions view ([#8009](https://github.com/n8n-io/n8n/issues/8009)) ([1d1cb0d](https://github.com/n8n-io/n8n/commit/1d1cb0d3c530856e0c26d8f146f60b2555625ab6)) +* **editor:** Fix copy/paste issue when switch node is in workflow ([#8103](https://github.com/n8n-io/n8n/issues/8103)) ([4b86926](https://github.com/n8n-io/n8n/commit/4b86926752fb1304a46385cb46bdf34fda0d53b6)) +* **editor:** Make keyboard shortcuts more strict; don't accept extra Ctrl/Alt/Shift keys ([#8024](https://github.com/n8n-io/n8n/issues/8024)) ([8df49e1](https://github.com/n8n-io/n8n/commit/8df49e134d886267f9f7475573d013371220dcac)) +* **editor:** Show credential share info only to appropriate users ([#8020](https://github.com/n8n-io/n8n/issues/8020)) ([b29b4d4](https://github.com/n8n-io/n8n/commit/b29b4d442bb0617aa516748ec48379eae0996cf0)) +* **editor:** Turn off executions list auto-refresh after leaving the page ([#8005](https://github.com/n8n-io/n8n/issues/8005)) ([e3c363d](https://github.com/n8n-io/n8n/commit/e3c363d72cf4ee49086d012f92a7b34be958482f)) +* **editor:** Update image sizes in template description not to be full width always ([#8037](https://github.com/n8n-io/n8n/issues/8037)) ([63a6e7e](https://github.com/n8n-io/n8n/commit/63a6e7e0340e1b00719f212ac620600a90d70ef1)) +* **HTTP Request Node:** Do not create circular references in HTTP request node output ([#8030](https://github.com/n8n-io/n8n/issues/8030)) ([5b7ea16](https://github.com/n8n-io/n8n/commit/5b7ea16d9a20880c72779b02620e99ebe9f3617a)) +* Stop binary data restoration from preventing execution from finishing ([#8082](https://github.com/n8n-io/n8n/issues/8082)) ([5ffff1b](https://github.com/n8n-io/n8n/commit/5ffff1bb22691c09c5ca8b3ada2a19d5ce155a0b)) +* Upgrade axios to address CVE-2023-45857 ([#7713](https://github.com/n8n-io/n8n/issues/7713)) ([64eb9bb](https://github.com/n8n-io/n8n/commit/64eb9bbc3624ee8f2fa90812711ad568926fdca8)) + + +### Features + +* Add config option to prefer GET request over LIST when using Hashicorp Vault ([#8049](https://github.com/n8n-io/n8n/issues/8049)) ([439a22d](https://github.com/n8n-io/n8n/commit/439a22d68f7bf32f281b1078b71607307640a09b)) +* Add option to `returnIntermediateSteps` for AI agents ([#8113](https://github.com/n8n-io/n8n/issues/8113)) ([7806a65](https://github.com/n8n-io/n8n/commit/7806a65229878a473f5526bad0b94614e8bfa8aa)) +* **core:** Add N8N_GRACEFUL_SHUTDOWN_TIMEOUT env var ([#8068](https://github.com/n8n-io/n8n/issues/8068)) ([614f488](https://github.com/n8n-io/n8n/commit/614f48838626e2af8e3f2e76ee4a144af2d40f72)) +* **editor:** Add lead enrichment suggestions to workflow list ([#8042](https://github.com/n8n-io/n8n/issues/8042)) ([36a923c](https://github.com/n8n-io/n8n/commit/36a923cf7bd4d42b8f8decbf01255c41d6dc1671)), closes [-update-workflows-list-page-to-show-fake-door-templates#comment-b6644c99](https://github.com/-update-workflows-list-page-to-show-fake-door-templates/issues/comment-b6644c99) +* **editor:** Finalize workers view ([#8052](https://github.com/n8n-io/n8n/issues/8052)) ([edfa784](https://github.com/n8n-io/n8n/commit/edfa78414d6bce901becc05e9d860f2521139688)) +* **editor:** Gracefully ignore invalid payloads in postMessage handler ([#8096](https://github.com/n8n-io/n8n/issues/8096)) ([9d22c7a](https://github.com/n8n-io/n8n/commit/9d22c7a2782a1908f81bcf80260cd91cb296e239)) +* **editor:** Upgrade frontend tooling to address a few vulnerabilities ([#8100](https://github.com/n8n-io/n8n/issues/8100)) ([19b7f1f](https://github.com/n8n-io/n8n/commit/19b7f1ffb17dcd6ac77839f97c2544f60f4ad55e)) +* **Filter Node:** Overhaul UI by adding the new filter component ([#8016](https://github.com/n8n-io/n8n/issues/8016)) ([3d53052](https://github.com/n8n-io/n8n/commit/3d530522f828dfc985ae98e4bb551aa3a2bd44c6)) +* **Respond to Webhook Node:** Overhaul with improvements like returning all items ([#8093](https://github.com/n8n-io/n8n/issues/8093)) ([32d397e](https://github.com/n8n-io/n8n/commit/32d397eff315fdc77677c0b134a7a25bcd8ca5d0)) + + +### Performance Improvements + +* **editor:** Improve canvas rendering performance ([#8022](https://github.com/n8n-io/n8n/issues/8022)) ([b780436](https://github.com/n8n-io/n8n/commit/b780436a6b445dc5951217b5a1f2c61b34961757)) + + + # [1.21.0](https://github.com/n8n-io/n8n/compare/n8n@1.20.0...n8n@1.21.0) (2023-12-13) diff --git a/cypress/e2e/10-undo-redo.cy.ts b/cypress/e2e/10-undo-redo.cy.ts index e904d891b1a53..4182c75507b21 100644 --- a/cypress/e2e/10-undo-redo.cy.ts +++ b/cypress/e2e/10-undo-redo.cy.ts @@ -1,12 +1,14 @@ import { CODE_NODE_NAME, SET_NODE_NAME, EDIT_FIELDS_SET_NODE_NAME } from './../constants'; import { SCHEDULE_TRIGGER_NODE_NAME } from '../constants'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; +import { MessageBox as MessageBoxClass } from '../pages/modals/message-box'; import { NDV } from '../pages/ndv'; // Suite-specific constants const CODE_NODE_NEW_NAME = 'Something else'; const WorkflowPage = new WorkflowPageClass(); +const messageBox = new MessageBoxClass(); const ndv = new NDV(); describe('Undo/Redo', () => { @@ -354,4 +356,36 @@ describe('Undo/Redo', () => { .should('have.css', 'left', `637px`) .should('have.css', 'top', `501px`); }); + + it('should not undo/redo when NDV or a modal is open', () => { + WorkflowPage.actions.addInitialNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME, { keepNdvOpen: true }); + // Try while NDV is open + WorkflowPage.actions.hitUndo(); + WorkflowPage.getters.canvasNodes().should('have.have.length', 1); + ndv.getters.backToCanvas().click(); + // Try while modal is open + cy.getByTestId('menu-item').contains('About n8n').click({ force: true }); + cy.getByTestId('about-modal').should('be.visible'); + WorkflowPage.actions.hitUndo(); + WorkflowPage.getters.canvasNodes().should('have.have.length', 1); + cy.getByTestId('close-about-modal-button').click(); + // Should work now + WorkflowPage.actions.hitUndo(); + WorkflowPage.getters.canvasNodes().should('have.have.length', 0); + }); + + it('should not undo/redo when NDV or a prompt is open', () => { + WorkflowPage.actions.addInitialNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME, { keepNdvOpen: false }); + WorkflowPage.getters.workflowMenu().click(); + WorkflowPage.getters.workflowMenuItemImportFromURLItem().should('be.visible'); + WorkflowPage.getters.workflowMenuItemImportFromURLItem().click(); + // Try while prompt is open + messageBox.getters.header().click(); + WorkflowPage.actions.hitUndo(); + WorkflowPage.getters.canvasNodes().should('have.have.length', 1); + // Close prompt and try again + messageBox.actions.cancel(); + WorkflowPage.actions.hitUndo(); + WorkflowPage.getters.canvasNodes().should('have.have.length', 0); + }); }); diff --git a/cypress/e2e/27-cloud.cy.ts b/cypress/e2e/27-cloud.cy.ts new file mode 100644 index 0000000000000..965bc5bccfedf --- /dev/null +++ b/cypress/e2e/27-cloud.cy.ts @@ -0,0 +1,119 @@ +import { + BannerStack, + MainSidebar, + WorkflowPage, + visitPublicApiPage, + getPublicApiUpgradeCTA, +} from '../pages'; +import planData from '../fixtures/Plan_data_opt_in_trial.json'; +import { INSTANCE_OWNER } from '../constants'; + +const mainSidebar = new MainSidebar(); +const bannerStack = new BannerStack(); +const workflowPage = new WorkflowPage(); + +describe('Cloud', { disableAutoLogin: true }, () => { + before(() => { + const now = new Date(); + const fiveDaysFromNow = new Date(now.getTime() + 5 * 24 * 60 * 60 * 1000); + planData.expirationDate = fiveDaysFromNow.toJSON(); + }); + + describe('BannerStack', () => { + it('should render trial banner for opt-in cloud user', () => { + cy.intercept('GET', '/rest/admin/cloud-plan', { + body: planData, + }).as('getPlanData'); + + cy.intercept('GET', '/rest/settings', (req) => { + req.on('response', (res) => { + res.send({ + data: { ...res.body.data, deployment: { type: 'cloud' }, n8nMetadata: { userId: 1 } }, + }); + }); + }).as('loadSettings'); + + cy.signin({ email: INSTANCE_OWNER.email, password: INSTANCE_OWNER.password }); + + cy.visit(workflowPage.url); + + cy.wait('@getPlanData'); + + bannerStack.getters.banner().should('be.visible'); + + mainSidebar.actions.signout(); + + bannerStack.getters.banner().should('not.be.visible'); + + cy.signin({ email: INSTANCE_OWNER.email, password: INSTANCE_OWNER.password }); + + cy.visit(workflowPage.url); + + bannerStack.getters.banner().should('be.visible'); + + mainSidebar.actions.signout(); + }); + + it('should not render opt-in-trial banner for non cloud deployment', () => { + cy.intercept('GET', '/rest/settings', (req) => { + req.on('response', (res) => { + res.send({ + data: { ...res.body.data, deployment: { type: 'default' } }, + }); + }); + }).as('loadSettings'); + + cy.signin({ email: INSTANCE_OWNER.email, password: INSTANCE_OWNER.password }); + + cy.visit(workflowPage.url); + + bannerStack.getters.banner().should('not.be.visible'); + + mainSidebar.actions.signout(); + }); + }); + + describe('Admin Home', () => { + it('Should show admin button', () => { + cy.intercept('GET', '/rest/settings', (req) => { + req.on('response', (res) => { + res.send({ + data: { ...res.body.data, deployment: { type: 'cloud' }, n8nMetadata: { userId: 1 } }, + }); + }); + }).as('loadSettings'); + + cy.signin({ email: INSTANCE_OWNER.email, password: INSTANCE_OWNER.password }); + + cy.visit(workflowPage.url); + + mainSidebar.getters.adminPanel().should('be.visible'); + }); + }); + + describe('Public API', () => { + it('Should show upgrade CTA for Public API if user is trialing', () => { + cy.intercept('GET', '/rest/admin/cloud-plan', { + body: planData, + }).as('getPlanData'); + + cy.intercept('GET', '/rest/settings', (req) => { + req.on('response', (res) => { + res.send({ + data: { + ...res.body.data, + deployment: { type: 'cloud' }, + n8nMetadata: { userId: 1 }, + }, + }); + }); + }).as('loadSettings'); + + cy.signin({ email: INSTANCE_OWNER.email, password: INSTANCE_OWNER.password }); + + visitPublicApiPage(); + + getPublicApiUpgradeCTA().should('be.visible'); + }); + }); +}); diff --git a/cypress/e2e/27-opt-in-trial-banner.cy.ts b/cypress/e2e/27-opt-in-trial-banner.cy.ts deleted file mode 100644 index 6e24343bc770a..0000000000000 --- a/cypress/e2e/27-opt-in-trial-banner.cy.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { BannerStack, MainSidebar, WorkflowPage } from '../pages'; -import planData from '../fixtures/Plan_data_opt_in_trial.json'; -import { INSTANCE_OWNER } from '../constants'; - -const mainSidebar = new MainSidebar(); -const bannerStack = new BannerStack(); -const workflowPage = new WorkflowPage(); - -describe('BannerStack', { disableAutoLogin: true }, () => { - before(() => { - const now = new Date(); - const fiveDaysFromNow = new Date(now.getTime() + 5 * 24 * 60 * 60 * 1000); - planData.expirationDate = fiveDaysFromNow.toJSON(); - }); - - it('should render trial banner for opt-in cloud user', () => { - cy.intercept('GET', '/rest/admin/cloud-plan', { - body: planData, - }).as('getPlanData'); - - cy.intercept('GET', '/rest/settings', (req) => { - req.on('response', (res) => { - res.send({ - data: { ...res.body.data, deployment: { type: 'cloud' }, n8nMetadata: { userId: 1 } }, - }); - }); - }).as('loadSettings'); - - cy.signin({ email: INSTANCE_OWNER.email, password: INSTANCE_OWNER.password }); - - cy.visit(workflowPage.url); - - cy.wait('@getPlanData'); - - bannerStack.getters.banner().should('be.visible'); - - mainSidebar.actions.signout(); - - bannerStack.getters.banner().should('not.be.visible'); - - cy.signin({ email: INSTANCE_OWNER.email, password: INSTANCE_OWNER.password }); - - cy.visit(workflowPage.url); - - bannerStack.getters.banner().should('be.visible'); - - mainSidebar.actions.signout(); - }); - - it('should not render opt-in-trial banner for non cloud deployment', () => { - cy.intercept('GET', '/rest/settings', (req) => { - req.on('response', (res) => { - res.send({ - data: { ...res.body.data, deployment: { type: 'default' } }, - }); - }); - }).as('loadSettings'); - - cy.signin({ email: INSTANCE_OWNER.email, password: INSTANCE_OWNER.password }); - - cy.visit(workflowPage.url); - - bannerStack.getters.banner().should('not.be.visible'); - - mainSidebar.actions.signout(); - }); - - it('Should show admin button', () => { - cy.intercept('GET', '/rest/settings', (req) => { - req.on('response', (res) => { - res.send({ - data: { ...res.body.data, deployment: { type: 'cloud' }, n8nMetadata: { userId: 1 } }, - }); - }); - }).as('loadSettings'); - - cy.signin({ email: INSTANCE_OWNER.email, password: INSTANCE_OWNER.password }); - - cy.visit(workflowPage.url); - - mainSidebar.getters.adminPanel().should('be.visible'); - }); -}); diff --git a/cypress/e2e/29-templates.cy.ts b/cypress/e2e/29-templates.cy.ts index bd46f90fe7080..3eab7d0d8f8b0 100644 --- a/cypress/e2e/29-templates.cy.ts +++ b/cypress/e2e/29-templates.cy.ts @@ -29,12 +29,35 @@ describe('Templates', () => { cy.url().then(($url) => { expect($url).to.include('/workflow/new?templateId=1234'); }); - + workflowPage.getters.canvasNodes().should('have.length', 4); workflowPage.getters.stickies().should('have.length', 1); workflowPage.actions.shouldHaveWorkflowName(OnboardingWorkflow.name); }); + it('should save template id with the workflow', () => { + cy.visit(templatesPage.url); + templatesPage.getters.firstTemplateCard().click(); + cy.url().should('include', '/templates/'); + + cy.url().then(($url) => { + const templateId = $url.split('/').pop(); + + templatesPage.getters.useTemplateButton().click(); + cy.url().should('include', '/workflow/new'); + workflowPage.actions.saveWorkflowOnButtonClick(); + + workflowPage.actions.selectAll(); + workflowPage.actions.hitCopy(); + + cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite'); + // Check workflow JSON by copying it to clipboard + cy.readClipboard().then((workflowJSON) => { + expect(workflowJSON).to.contain(`"templateId": "${templateId}"`); + }); + }); + }); + it('can open template with images and hides workflow screenshots', () => { templateWorkflowPage.actions.openTemplate(WorkflowTemplate); diff --git a/cypress/e2e/30-editor-after-route-changes.cy.ts b/cypress/e2e/30-editor-after-route-changes.cy.ts index 8ce31cbbdc636..656d7e9b781c3 100644 --- a/cypress/e2e/30-editor-after-route-changes.cy.ts +++ b/cypress/e2e/30-editor-after-route-changes.cy.ts @@ -99,17 +99,17 @@ const switchBetweenEditorAndHistory = () => { workflowPage.getters.canvasNodes().first().should('be.visible'); workflowPage.getters.canvasNodes().last().should('be.visible'); -} +}; const switchBetweenEditorAndWorkflowlist = () => { cy.getByTestId('menu-item').first().click(); - cy.wait(['@getUsers', '@getWorkflows', '@getActive', '@getCredentials']); + cy.wait(['@getUsers', '@getWorkflows', '@getActiveWorkflows', '@getCredentials']); cy.getByTestId('resources-list-item').first().click(); workflowPage.getters.canvasNodes().first().should('be.visible'); workflowPage.getters.canvasNodes().last().should('be.visible'); -} +}; const zoomInAndCheckNodes = () => { cy.getByTestId('zoom-in-button').click(); @@ -119,7 +119,7 @@ const zoomInAndCheckNodes = () => { workflowPage.getters.canvasNodes().first().should('not.be.visible'); workflowPage.getters.canvasNodes().last().should('not.be.visible'); -} +}; describe('Editor actions should work', () => { beforeEach(() => { @@ -199,7 +199,7 @@ describe('Editor zoom should work after route changes', () => { cy.intercept('GET', '/rest/workflow-history/workflow/*').as('getHistory'); cy.intercept('GET', '/rest/users').as('getUsers'); cy.intercept('GET', '/rest/workflows').as('getWorkflows'); - cy.intercept('GET', '/rest/active').as('getActive'); + cy.intercept('GET', '/rest/active-workflows').as('getActiveWorkflows'); cy.intercept('GET', '/rest/credentials').as('getCredentials'); switchBetweenEditorAndHistory(); diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index b7711b36e81d5..0437dd3a06fe0 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -471,7 +471,8 @@ describe('NDV', () => { workflowPage.getters.selectedNodes().should('have.length', 1); workflowPage.getters.selectedNodes().first().should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME); }); - }) + }); + it('should show node name and version in settings', () => { cy.createFixtureWorkflow('Test_workflow_ndv_version.json', `NDV test version ${uuid()}`); @@ -490,4 +491,73 @@ describe('NDV', () => { ndv.getters.nodeVersion().should('have.text', 'Function node version 1 (Deprecated)'); ndv.actions.close(); }); + + it('Should render xml and html tags as strings and can search', () => { + cy.createFixtureWorkflow('Test_workflow_xml_output.json', `test`); + + workflowPage.actions.executeWorkflow(); + + workflowPage.actions.openNode('Edit Fields'); + + ndv.getters.outputDisplayMode().find('[class*=active]').should('contain', 'Table'); + + ndv.getters.outputTableRow(1).should('include.text', ' '); + + cy.document().trigger('keyup', { key: '/' }); + ndv.getters.searchInput().filter(':focus').type(' Introduction to XML John Doe 2020 1234567890 Data Science Basics Jane Smith 2019 0987654321 Programming in Python Bob Johnson 2021 5432109876 "}]'); + ndv.getters.outputDataContainer().find('mark').should('have.text', ' span').should('include.text', ''); + }); + + it('should properly show node execution indicator', () => { + workflowPage.actions.addInitialNodeToCanvas('Code'); + workflowPage.actions.openNode('Code'); + // Should not show run info before execution + ndv.getters.nodeRunSuccessIndicator().should('not.exist'); + ndv.getters.nodeRunErrorIndicator().should('not.exist'); + ndv.getters.nodeExecuteButton().click(); + ndv.getters.nodeRunSuccessIndicator().should('exist'); + }); + + it('should properly show node execution indicator for multiple nodes', () => { + workflowPage.actions.addInitialNodeToCanvas('Code'); + workflowPage.actions.openNode('Code'); + ndv.actions.typeIntoParameterInput('jsCode', 'testets'); + ndv.getters.backToCanvas().click(); + workflowPage.actions.executeWorkflow(); + // Manual tigger node should show success indicator + workflowPage.actions.openNode('When clicking "Execute Workflow"'); + ndv.getters.nodeRunSuccessIndicator().should('exist'); + // Code node should show error + ndv.getters.backToCanvas().click(); + workflowPage.actions.openNode('Code'); + ndv.getters.nodeRunErrorIndicator().should('exist'); + }); + + it('Should handle mismatched option attributes', () => { + workflowPage.actions.addInitialNodeToCanvas('LDAP', { keepNdvOpen: true, action: 'Create a new entry' }); + // Add some attributes in Create operation + cy.getByTestId('parameter-item').contains('Add Attributes').click(); + ndv.actions.changeNodeOperation('Update'); + // Attributes should be empty after operation change + cy.getByTestId('parameter-item').contains('Currently no items exist').should('exist'); + }); + + it('Should keep RLC values after operation change', () => { + const TEST_DOC_ID = '1111'; + workflowPage.actions.addInitialNodeToCanvas('Google Sheets', { keepNdvOpen: true, action: 'Append row in sheet' }); + ndv.actions.setRLCValue('documentId', TEST_DOC_ID); + ndv.actions.changeNodeOperation('Update Row'); + ndv.getters.resourceLocatorInput('documentId').find('input').should('have.value', TEST_DOC_ID); + }); }); diff --git a/cypress/fixtures/Plan_data_opt_in_trial.json b/cypress/fixtures/Plan_data_opt_in_trial.json index 504805de320e1..7a805708c651d 100644 --- a/cypress/fixtures/Plan_data_opt_in_trial.json +++ b/cypress/fixtures/Plan_data_opt_in_trial.json @@ -13,8 +13,7 @@ "feat:advancedExecutionFilters": true, "quota:users": -1, "quota:maxVariables": -1, - "feat:variables": true, - "feat:apiDisabled": true + "feat:variables": true }, "metadata": { "version": "v1", diff --git a/cypress/fixtures/Test_workflow_xml_output.json b/cypress/fixtures/Test_workflow_xml_output.json new file mode 100644 index 0000000000000..b8422c101e7c7 --- /dev/null +++ b/cypress/fixtures/Test_workflow_xml_output.json @@ -0,0 +1,53 @@ +{ + "meta": { + "instanceId": "2d1cf27f75b18bb9e146336f791c37884f4fc7ddb97c2def27c0444d106778bf" + }, + "nodes": [ + { + "parameters": {}, + "id": "8108d313-8b03-4aa4-963d-cd1c0fe8f85c", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 420, + 220 + ] + }, + { + "parameters": { + "fields": { + "values": [ + { + "name": "body", + "stringValue": " Introduction to XML John Doe 2020 1234567890 Data Science Basics Jane Smith 2019 0987654321 Programming in Python Bob Johnson 2021 5432109876 " + } + ] + }, + "options": {} + }, + "id": "45888152-7c5f-4d88-9039-660c594da084", + "name": "Edit Fields", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [ + 640, + 220 + ] + } + ], + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Edit Fields", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": {} + } \ No newline at end of file diff --git a/cypress/pages/index.ts b/cypress/pages/index.ts index 6f03962c2ac13..39c9be3b5648b 100644 --- a/cypress/pages/index.ts +++ b/cypress/pages/index.ts @@ -12,3 +12,4 @@ export * from './workflow-executions-tab'; export * from './signin'; export * from './workflow-history'; export * from './workerView'; +export * from './settings-public-api'; diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index 0eaa1361cfa82..66116b0fc4e09 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -98,6 +98,8 @@ export class NDV extends BasePage { pagination: () => cy.getByTestId('ndv-data-pagination'), nodeVersion: () => cy.getByTestId('node-version'), nodeSettingsTab: () => cy.getByTestId('tab-settings'), + nodeRunSuccessIndicator: () => cy.getByTestId('node-run-info-success'), + nodeRunErrorIndicator: () => cy.getByTestId('node-run-info-danger'), }; actions = { @@ -246,10 +248,14 @@ export class NDV extends BasePage { }); this.actions.validateExpressionPreview(fieldName, `node doesn't exist`); }, - openSettings: () => { this.getters.nodeSettingsTab().click(); }, + changeNodeOperation: (operation: string) => { + this.getters.parameterInput('operation').click(); + cy.get('.el-select-dropdown__item').contains(new RegExp(`^${operation}$`)).click({ force: true }); + this.getters.parameterInput('operation').find('input').should('have.value', operation); + }, }; } diff --git a/cypress/pages/settings-public-api.ts b/cypress/pages/settings-public-api.ts new file mode 100644 index 0000000000000..1a7d668136775 --- /dev/null +++ b/cypress/pages/settings-public-api.ts @@ -0,0 +1,5 @@ +export const getPublicApiUpgradeCTA = () => cy.getByTestId('public-api-upgrade-cta'); + +export const visitPublicApiPage = () => { + cy.visit('/settings/api'); +}; diff --git a/cypress/pages/templates.ts b/cypress/pages/templates.ts index d3a3c95420a5c..7b2ff91e794d7 100644 --- a/cypress/pages/templates.ts +++ b/cypress/pages/templates.ts @@ -4,7 +4,9 @@ export class TemplatesPage extends BasePage { url = '/templates'; getters = { - useTemplateButton: () => cy.get('[data-testid="use-template-button"]'), + useTemplateButton: () => cy.getByTestId('use-template-button'), + templateCards: () => cy.getByTestId('template-card'), + firstTemplateCard: () => this.getters.templateCards().first(), }; actions = { diff --git a/docker/images/n8n/README.md b/docker/images/n8n/README.md index eb8549bcbca0e..848c21c7cdf9d 100644 --- a/docker/images/n8n/README.md +++ b/docker/images/n8n/README.md @@ -63,7 +63,7 @@ You can then access n8n by opening: To be able to use webhooks which all triggers of external services like Github rely on n8n has to be reachable from the web. To make that easy n8n has a -special tunnel service (uses this code: [https://github.com/localtunnel/localtunnel](https://github.com/localtunnel/localtunnel)) which redirects requests from our servers to your local +special tunnel service (uses this code: [https://github.com/n8n-io/localtunnel](https://github.com/n8n-io/localtunnel)) which redirects requests from our servers to your local n8n instance. To use it simply start n8n with `--tunnel` diff --git a/package.json b/package.json index 3bf6fa6fd5cc5..4e65f45069d90 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.21.0", + "version": "1.22.0", "private": true, "homepage": "https://n8n.io", "engines": { @@ -61,7 +61,7 @@ "p-limit": "^3.1.0", "rimraf": "^5.0.1", "run-script-os": "^1.0.7", - "start-server-and-test": "^2.0.0", + "start-server-and-test": "^2.0.3", "supertest": "^6.3.3", "ts-jest": "^29.1.1", "tsc-alias": "^1.8.7", diff --git a/packages/@n8n/chat/package.json b/packages/@n8n/chat/package.json index 932ff3363bba0..63b3a080c721f 100644 --- a/packages/@n8n/chat/package.json +++ b/packages/@n8n/chat/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/chat", - "version": "0.4.0", + "version": "0.5.0", "scripts": { "dev": "pnpm run storybook", "build": "pnpm type-check && pnpm build:vite && pnpm build:prepare", diff --git a/packages/@n8n/client-oauth2/package.json b/packages/@n8n/client-oauth2/package.json index a75c1bafbefdd..a379529a9771e 100644 --- a/packages/@n8n/client-oauth2/package.json +++ b/packages/@n8n/client-oauth2/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/client-oauth2", - "version": "0.10.0", + "version": "0.11.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/description.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/description.ts index b65b2fddf2c5c..0bb67fa0a9d46 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/description.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/description.ts @@ -67,6 +67,13 @@ export const conversationalAgentProperties: INodeProperties[] = [ default: 10, description: 'The maximum number of iterations the agent will run before stopping', }, + { + displayName: 'Return Intermediate Steps', + name: 'returnIntermediateSteps', + type: 'boolean', + default: false, + description: 'Whether or not the output should include intermediate steps the agent took', + }, ], }, ]; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts index 5d435059e26fe..abc820b325f61 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts @@ -41,6 +41,7 @@ export async function conversationalAgentExecute( systemMessage?: string; humanMessage?: string; maxIterations?: number; + returnIntermediateSteps?: boolean; }; const agentExecutor = await initializeAgentExecutorWithOptions(tools, model, { @@ -50,6 +51,7 @@ export async function conversationalAgentExecute( // memory option, but the memoryKey set on it must be "chat_history". agentType: 'chat-conversational-react-description', memory, + returnIntermediateSteps: options?.returnIntermediateSteps === true, maxIterations: options.maxIterations ?? 10, agentArgs: { systemMessage: options.systemMessage, diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/description.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/description.ts index 5d7d4cc81bedb..34007d1084744 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/description.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/description.ts @@ -57,6 +57,13 @@ export const openAiFunctionsAgentProperties: INodeProperties[] = [ default: 10, description: 'The maximum number of iterations the agent will run before stopping', }, + { + displayName: 'Return Intermediate Steps', + name: 'returnIntermediateSteps', + type: 'boolean', + default: false, + description: 'Whether or not the output should include intermediate steps the agent took', + }, ], }, ]; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts index ecdd9e7ca2cf3..e21b5bdf420fd 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts @@ -40,6 +40,7 @@ export async function openAiFunctionsAgentExecute( const options = this.getNodeParameter('options', 0, {}) as { systemMessage?: string; maxIterations?: number; + returnIntermediateSteps?: boolean; }; const agentConfig: AgentExecutorInput = { @@ -49,6 +50,7 @@ export async function openAiFunctionsAgentExecute( }), tools, maxIterations: options.maxIterations ?? 10, + returnIntermediateSteps: options?.returnIntermediateSteps === true, memory: memory ?? new BufferMemory({ diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/description.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/description.ts index 8c806b005c092..0feec7bfa6c4b 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/description.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/description.ts @@ -82,6 +82,13 @@ export const reActAgentAgentProperties: INodeProperties[] = [ rows: 6, }, }, + { + displayName: 'Return Intermediate Steps', + name: 'returnIntermediateSteps', + type: 'boolean', + default: false, + description: 'Whether or not the output should include intermediate steps the agent took', + }, ], }, ]; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts index 13e77ad76c7ce..0366fedf73127 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts @@ -34,6 +34,7 @@ export async function reActAgentAgentExecute( suffix?: string; suffixChat?: string; humanMessageTemplate?: string; + returnIntermediateSteps?: boolean; }; let agent: ChatAgent | ZeroShotAgent; @@ -50,7 +51,11 @@ export async function reActAgentAgentExecute( }); } - const agentExecutor = AgentExecutor.fromAgentAndTools({ agent, tools }); + const agentExecutor = AgentExecutor.fromAgentAndTools({ + agent, + tools, + returnIntermediateSteps: options?.returnIntermediateSteps === true, + }); const returnData: INodeExecutionData[] = []; diff --git a/packages/@n8n/nodes-langchain/nodes/memory/MemoryRedisChat/MemoryRedisChat.node.ts b/packages/@n8n/nodes-langchain/nodes/memory/MemoryRedisChat/MemoryRedisChat.node.ts index 10402a2893637..538ce06f38e1d 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryRedisChat/MemoryRedisChat.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryRedisChat/MemoryRedisChat.node.ts @@ -114,7 +114,12 @@ export class MemoryRedisChat implements INodeType { outputKey: 'output', }); + async function closeFunction() { + void client.disconnect(); + } + return { + closeFunction, response: logWrapper(memory, this), }; } diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWikipedia/ToolWikipedia.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWikipedia/ToolWikipedia.node.ts index 7892d8d54b38a..bc8ff97c0a268 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWikipedia/ToolWikipedia.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWikipedia/ToolWikipedia.node.ts @@ -43,8 +43,13 @@ export class ToolWikipedia implements INodeType { }; async supplyData(this: IExecuteFunctions): Promise { + const WikiTool = new WikipediaQueryRun(); + + WikiTool.description = + 'A tool for interacting with and fetching data from the Wikipedia API. The input should always be a string query.'; + return { - response: logWrapper(new WikipediaQueryRun(), this), + response: logWrapper(WikiTool, this), }; } } diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 64319d75c2275..12e4dceda96c7 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-nodes-langchain", - "version": "0.6.0", + "version": "0.7.0", "description": "", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/@n8n/permissions/package.json b/packages/@n8n/permissions/package.json index 49911148b8b0d..0423854b9d0e9 100644 --- a/packages/@n8n/permissions/package.json +++ b/packages/@n8n/permissions/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/permissions", - "version": "0.4.0", + "version": "0.5.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n_io/eslint-config/local-rules.js b/packages/@n8n_io/eslint-config/local-rules.js index 16e405a602d06..0d3ff867b3cff 100644 --- a/packages/@n8n_io/eslint-config/local-rules.js +++ b/packages/@n8n_io/eslint-config/local-rules.js @@ -396,6 +396,32 @@ module.exports = { }; }, }, + + 'no-dynamic-import-template': { + meta: { + type: 'error', + docs: { + description: + 'Disallow non-relative imports in template string argument to `await import()`, because `tsc-alias` as of 1.8.7 is unable to resolve aliased paths in this scenario.', + recommended: true, + }, + }, + create: function (context) { + return { + 'AwaitExpression > ImportExpression TemplateLiteral'(node) { + const templateValue = node.quasis[0].value.cooked; + + if (!templateValue?.startsWith('@/')) return; + + context.report({ + node, + message: + 'Use relative imports in template string argument to `await import()`, because `tsc-alias` as of 1.8.7 is unable to resolve aliased paths in this scenario.', + }); + }, + }; + }, + }, }; const isJsonParseCall = (node) => diff --git a/packages/cli/.eslintrc.js b/packages/cli/.eslintrc.js index 75676e3a4bc95..1840061479716 100644 --- a/packages/cli/.eslintrc.js +++ b/packages/cli/.eslintrc.js @@ -19,6 +19,8 @@ module.exports = { ], rules: { + 'n8n-local-rules/no-dynamic-import-template': 'error', + // TODO: Remove this 'import/no-cycle': 'warn', 'import/order': 'off', diff --git a/packages/cli/BREAKING-CHANGES.md b/packages/cli/BREAKING-CHANGES.md index 10326e784fa73..1e045ad55359d 100644 --- a/packages/cli/BREAKING-CHANGES.md +++ b/packages/cli/BREAKING-CHANGES.md @@ -2,6 +2,17 @@ This list shows all the versions which include breaking changes and how to upgrade. +## 1.22.0 + +### What changed? + +Hash algorithm `ripemd160` is dropped from `.hash()` expressions. +`sha3` hash algorithm now returns a valid sha3-512 has, unlike the previous implementation that returned a `Keccak` hash instead. + +### When is action necessary? + +If you are using `.hash` helpers in expressions with hash algorithm `ripemd160`, you need to switch to one of the other supported algorithms. + ## 1.15.0 ### What changed? diff --git a/packages/cli/package.json b/packages/cli/package.json index 880558bbbd979..ceaaf4628018a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.21.0", + "version": "1.22.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -76,7 +76,6 @@ "@types/formidable": "^3.4.0", "@types/json-diff": "^1.0.0", "@types/jsonwebtoken": "^9.0.1", - "@types/localtunnel": "^2.0.4", "@types/lodash": "^4.14.195", "@types/passport-jwt": "^3.0.6", "@types/psl": "^1.1.0", @@ -99,6 +98,7 @@ }, "dependencies": { "@n8n/client-oauth2": "workspace:*", + "@n8n/localtunnel": "2.1.0", "@n8n/permissions": "workspace:*", "@n8n_io/license-sdk": "2.7.2", "@oclif/command": "1.8.18", @@ -145,7 +145,6 @@ "jsonwebtoken": "9.0.0", "jwks-rsa": "3.0.1", "ldapts": "4.2.6", - "localtunnel": "2.0.2", "lodash": "4.17.21", "luxon": "3.3.0", "mysql2": "2.3.3", @@ -168,7 +167,7 @@ "pg": "8.8.0", "picocolors": "1.0.0", "pkce-challenge": "3.0.0", - "posthog-node": "2.2.2", + "posthog-node": "3.2.1", "prom-client": "13.2.0", "psl": "1.9.0", "raw-body": "2.5.1", diff --git a/packages/cli/src/AbstractServer.ts b/packages/cli/src/AbstractServer.ts index e97e4ce0a6a83..679e22142a147 100644 --- a/packages/cli/src/AbstractServer.ts +++ b/packages/cli/src/AbstractServer.ts @@ -1,4 +1,4 @@ -import { Container } from 'typedi'; +import { Container, Service } from 'typedi'; import { readFile } from 'fs/promises'; import type { Server } from 'http'; import express from 'express'; @@ -9,7 +9,8 @@ import config from '@/config'; import { N8N_VERSION, inDevelopment, inTest } from '@/constants'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import * as Db from '@/Db'; -import type { N8nInstanceType, IExternalHooksClass } from '@/Interfaces'; +import { N8nInstanceType } from '@/Interfaces'; +import type { IExternalHooksClass } from '@/Interfaces'; import { ExternalHooks } from '@/ExternalHooks'; import { send, sendErrorResponse } from '@/ResponseHelper'; import { rawBodyReader, bodyParser, corsMiddleware } from '@/middlewares'; @@ -20,7 +21,9 @@ import { webhookRequestHandler } from '@/WebhookHelpers'; import { generateHostInstanceId } from './databases/utils/generators'; import { Logger } from '@/Logger'; import { ServiceUnavailableError } from './errors/response-errors/service-unavailable.error'; +import { OnShutdown } from '@/decorators/OnShutdown'; +@Service() export abstract class AbstractServer { protected logger: Logger; @@ -246,4 +249,26 @@ export abstract class AbstractServer { await this.externalHooks.run('n8n.ready', [this, config]); } } + + /** + * Stops the HTTP(S) server from accepting new connections. Gives all + * connections configured amount of time to finish their work and + * then closes them forcefully. + */ + @OnShutdown() + async onShutdown(): Promise { + if (!this.server) { + return; + } + + this.logger.debug(`Shutting down ${this.protocol} server`); + + this.server.close((error) => { + if (error) { + this.logger.error(`Error while shutting down ${this.protocol} server`, { error }); + } + + this.logger.debug(`${this.protocol} server shut down`); + }); + } } diff --git a/packages/cli/src/ActiveWorkflowRunner.ts b/packages/cli/src/ActiveWorkflowRunner.ts index 5b4a15f40c39a..b45d0602d453d 100644 --- a/packages/cli/src/ActiveWorkflowRunner.ts +++ b/packages/cli/src/ActiveWorkflowRunner.ts @@ -1,8 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ - -import { Container, Service } from 'typedi'; +import { Service } from 'typedi'; import { ActiveWorkflows, NodeExecuteFunctions } from 'n8n-core'; import type { @@ -46,10 +45,9 @@ import type { import * as WebhookHelpers from '@/WebhookHelpers'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; -import type { User } from '@db/entities/User'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { ActiveExecutions } from '@/ActiveExecutions'; -import { createErrorExecution } from '@/GenericHelpers'; +import { ExecutionsService } from './executions/executions.service'; import { STARTING_NODES, WORKFLOW_REACTIVATE_INITIAL_TIMEOUT, @@ -58,42 +56,41 @@ import { import { NodeTypes } from '@/NodeTypes'; import { WorkflowRunner } from '@/WorkflowRunner'; import { ExternalHooks } from '@/ExternalHooks'; -import { whereClause } from './UserManagement/UserManagementHelper'; -import { WorkflowService } from './workflows/workflow.service'; import { WebhookNotFoundError } from './errors/response-errors/webhook-not-found.error'; -import { In } from 'typeorm'; import { WebhookService } from './services/webhook.service'; import { Logger } from './Logger'; -import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { MultiMainSetup } from '@/services/orchestration/main/MultiMainSetup.ee'; import { ActivationErrorsService } from '@/ActivationErrors.service'; -import type { Scope } from '@n8n/permissions'; import { NotFoundError } from './errors/response-errors/not-found.error'; +import { ActiveWorkflowsService } from '@/services/activeWorkflows.service'; +import { WorkflowStaticDataService } from '@/workflows/workflowStaticData.service'; +import { OnShutdown } from '@/decorators/OnShutdown'; + +interface QueuedActivation { + activationMode: WorkflowActivateMode; + lastTimeout: number; + timeout: NodeJS.Timeout; + workflowData: IWorkflowDb; +} @Service() export class ActiveWorkflowRunner implements IWebhookManager { - activeWorkflows = new ActiveWorkflows(); - - private queuedActivations: { - [workflowId: string]: { - activationMode: WorkflowActivateMode; - lastTimeout: number; - timeout: NodeJS.Timeout; - workflowData: IWorkflowDb; - }; - } = {}; + private queuedActivations: { [workflowId: string]: QueuedActivation } = {}; constructor( private readonly logger: Logger, + private readonly activeWorkflows: ActiveWorkflows, private readonly activeExecutions: ActiveExecutions, private readonly externalHooks: ExternalHooks, private readonly nodeTypes: NodeTypes, private readonly webhookService: WebhookService, private readonly workflowRepository: WorkflowRepository, - private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly multiMainSetup: MultiMainSetup, private readonly activationErrorsService: ActivationErrorsService, + private readonly executionService: ExecutionsService, + private readonly workflowStaticDataService: WorkflowStaticDataService, + private readonly activeWorkflowsService: ActiveWorkflowsService, ) {} async init() { @@ -118,7 +115,7 @@ export class ActiveWorkflowRunner implements IWebhookManager { activeWorkflowIds.push(...this.activeWorkflows.allActiveWorkflows()); - const activeWorkflows = await this.allActiveInStorage(); + const activeWorkflows = await this.activeWorkflowsService.getAllActiveIdsInStorage(); activeWorkflowIds = [...activeWorkflowIds, ...activeWorkflows]; // Make sure IDs are unique activeWorkflowIds = Array.from(new Set(activeWorkflowIds)); @@ -213,10 +210,12 @@ export class ActiveWorkflowRunner implements IWebhookManager { undefined, request, response, - (error: Error | null, data: object) => { + async (error: Error | null, data: object) => { if (error !== null) { return reject(error); } + // Save static data if it changed + await this.workflowStaticDataService.saveStaticData(workflow); resolve(data); }, ); @@ -266,50 +265,6 @@ export class ActiveWorkflowRunner implements IWebhookManager { return this.activeWorkflows.allActiveWorkflows(); } - /** - * Get the IDs of active workflows from storage. - */ - async allActiveInStorage(options?: { user: User; scope: Scope | Scope[] }) { - const isFullAccess = !options?.user || options.user.hasGlobalScope(options.scope); - - const activationErrors = await this.activationErrorsService.getAll(); - - if (isFullAccess) { - const activeWorkflows = await this.workflowRepository.find({ - select: ['id'], - where: { active: true }, - }); - - return activeWorkflows - .map((workflow) => workflow.id) - .filter((workflowId) => !activationErrors[workflowId]); - } - - const where = whereClause({ - user: options.user, - globalScope: 'workflow:list', - entityType: 'workflow', - }); - - const activeWorkflows = await this.workflowRepository.find({ - select: ['id'], - where: { active: true }, - }); - - const activeIds = activeWorkflows.map((workflow) => workflow.id); - - Object.assign(where, { workflowId: In(activeIds) }); - - const sharings = await this.sharedWorkflowRepository.find({ - select: ['workflowId'], - where, - }); - - return sharings - .map((sharing) => sharing.workflowId) - .filter((workflowId) => !activationErrors[workflowId]); - } - /** * Returns if the workflow is stored as `active`. * @@ -325,13 +280,6 @@ export class ActiveWorkflowRunner implements IWebhookManager { return !!workflow?.active; } - /** - * Return error if there was a problem activating the workflow - */ - async getActivationError(workflowId: string) { - return this.activationErrorsService.get(workflowId); - } - /** * Register workflow-defined webhooks in the `workflow_entity` table. */ @@ -412,7 +360,7 @@ export class ActiveWorkflowRunner implements IWebhookManager { } await this.webhookService.populateCache(); - await Container.get(WorkflowService).saveStaticData(workflow); + await this.workflowStaticDataService.saveStaticData(workflow); } /** @@ -451,7 +399,7 @@ export class ActiveWorkflowRunner implements IWebhookManager { await workflow.deleteWebhook(webhookData, NodeExecuteFunctions, mode, 'update'); } - await Container.get(WorkflowService).saveStaticData(workflow); + await this.workflowStaticDataService.saveStaticData(workflow); await this.webhookService.deleteWorkflowWebhooks(workflowId); } @@ -524,7 +472,7 @@ export class ActiveWorkflowRunner implements IWebhookManager { donePromise?: IDeferredPromise, ): void => { this.logger.debug(`Received event to trigger execution for workflow "${workflow.name}"`); - void Container.get(WorkflowService).saveStaticData(workflow); + void this.workflowStaticDataService.saveStaticData(workflow); const executePromise = this.runWorkflow( workflowData, node, @@ -547,9 +495,11 @@ export class ActiveWorkflowRunner implements IWebhookManager { }; returnFunctions.__emitError = (error: ExecutionError): void => { - void createErrorExecution(error, node, workflowData, workflow, mode).then(() => { - this.executeErrorWorkflow(error, workflowData, mode); - }); + void this.executionService + .createErrorExecution(error, node, workflowData, workflow, mode) + .then(() => { + this.executeErrorWorkflow(error, workflowData, mode); + }); }; return returnFunctions; }; @@ -579,7 +529,7 @@ export class ActiveWorkflowRunner implements IWebhookManager { donePromise?: IDeferredPromise, ): void => { this.logger.debug(`Received trigger for workflow "${workflow.name}"`); - void Container.get(WorkflowService).saveStaticData(workflow); + void this.workflowStaticDataService.saveStaticData(workflow); const executePromise = this.runWorkflow( workflowData, @@ -715,6 +665,7 @@ export class ActiveWorkflowRunner implements IWebhookManager { await this.addActiveWorkflows('leadershipChange'); } + @OnShutdown() async removeAllTriggerAndPollerBasedWorkflows() { await this.activeWorkflows.removeAllTriggerAndPollerBasedWorkflows(); } @@ -814,7 +765,7 @@ export class ActiveWorkflowRunner implements IWebhookManager { await this.activationErrorsService.unset(workflowId); const triggerCount = this.countTriggers(workflow, additionalData); - await Container.get(WorkflowService).updateWorkflowTriggerCount(workflow.id, triggerCount); + await this.workflowRepository.updateWorkflowTriggerCount(workflow.id, triggerCount); } catch (e) { const error = e instanceof Error ? e : new Error(`${e}`); await this.activationErrorsService.set(workflowId, error.message); @@ -824,7 +775,7 @@ export class ActiveWorkflowRunner implements IWebhookManager { // If for example webhooks get created it sometimes has to save the // id of them in the static data. So make sure that data gets persisted. - await Container.get(WorkflowService).saveStaticData(workflow); + await this.workflowStaticDataService.saveStaticData(workflow); } /** diff --git a/packages/cli/src/GenericHelpers.ts b/packages/cli/src/GenericHelpers.ts index 4d62c05d622f8..b37d395882505 100644 --- a/packages/cli/src/GenericHelpers.ts +++ b/packages/cli/src/GenericHelpers.ts @@ -1,38 +1,12 @@ import type express from 'express'; -import type { - ExecutionError, - INode, - IRunExecutionData, - Workflow, - WorkflowExecuteMode, -} from 'n8n-workflow'; import { validate } from 'class-validator'; -import { Container } from 'typedi'; -import config from '@/config'; -import type { ExecutionPayload, IWorkflowDb } from '@/Interfaces'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import type { TagEntity } from '@db/entities/TagEntity'; import type { User } from '@db/entities/User'; import type { UserUpdatePayload } from '@/requests'; -import { ExecutionRepository } from '@db/repositories/execution.repository'; import { BadRequestError } from './errors/response-errors/bad-request.error'; -/** - * Returns the base URL n8n is reachable from - */ -export function getBaseUrl(): string { - const protocol = config.getEnv('protocol'); - const host = config.getEnv('host'); - const port = config.getEnv('port'); - const path = config.getEnv('path'); - - if ((protocol === 'http' && port === 80) || (protocol === 'https' && port === 443)) { - return `${protocol}://${host}${path}`; - } - return `${protocol}://${host}:${port}${path}`; -} - /** * Returns the session id if one is set */ @@ -58,85 +32,4 @@ export async function validateEntity( } } -/** - * Create an error execution - * - * @param {INode} node - * @param {IWorkflowDb} workflowData - * @param {Workflow} workflow - * @param {WorkflowExecuteMode} mode - * @returns - * @memberof ActiveWorkflowRunner - */ - -export async function createErrorExecution( - error: ExecutionError, - node: INode, - workflowData: IWorkflowDb, - workflow: Workflow, - mode: WorkflowExecuteMode, -): Promise { - const saveDataErrorExecutionDisabled = workflowData?.settings?.saveDataErrorExecution === 'none'; - - if (saveDataErrorExecutionDisabled) return; - - const executionData: IRunExecutionData = { - startData: { - destinationNode: node.name, - runNodeFilter: [node.name], - }, - executionData: { - contextData: {}, - metadata: {}, - nodeExecutionStack: [ - { - node, - data: { - main: [ - [ - { - json: {}, - pairedItem: { - item: 0, - }, - }, - ], - ], - }, - source: null, - }, - ], - waitingExecution: {}, - waitingExecutionSource: {}, - }, - resultData: { - runData: { - [node.name]: [ - { - startTime: 0, - executionTime: 0, - error, - source: [], - }, - ], - }, - error, - lastNodeExecuted: node.name, - }, - }; - - const fullExecutionData: ExecutionPayload = { - data: executionData, - mode, - finished: false, - startedAt: new Date(), - workflowData, - workflowId: workflow.id, - stoppedAt: new Date(), - status: 'error', - }; - - await Container.get(ExecutionRepository).createNewExecution(fullExecutionData); -} - export const DEFAULT_EXECUTIONS_GET_ALL_LIMIT = 20; diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index fec5f6cd8cacb..e10bacd77e2ac 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -17,6 +17,7 @@ import type { BooleanLicenseFeature, N8nInstanceType, NumericLicenseFeature } fr import type { RedisServicePubSubPublisher } from './services/redis/RedisServicePubSubPublisher'; import { RedisService } from './services/redis.service'; import { MultiMainSetup } from '@/services/orchestration/main/MultiMainSetup.ee'; +import { OnShutdown } from '@/decorators/OnShutdown'; type FeatureReturnType = Partial< { @@ -30,6 +31,8 @@ export class License { private redisPublisher: RedisServicePubSubPublisher; + private isShuttingDown = false; + constructor( private readonly logger: Logger, private readonly instanceSettings: InstanceSettings, @@ -40,6 +43,11 @@ export class License { async init(instanceType: N8nInstanceType = 'main') { if (this.manager) { + this.logger.warn('License manager already initialized or shutting down'); + return; + } + if (this.isShuttingDown) { + this.logger.warn('License manager already shutting down'); return; } @@ -191,7 +199,12 @@ export class License { await this.manager.renew(); } + @OnShutdown() async shutdown() { + // Shut down License manager to unclaim any floating entitlements + // Note: While this saves a new license cert to DB, the previous entitlements are still kept in memory so that the shutdown process can complete + this.isShuttingDown = true; + if (!this.manager) { return; } diff --git a/packages/cli/src/PublicApi/index.ts b/packages/cli/src/PublicApi/index.ts index 8a88a51e073d8..d4cb53b26a6f2 100644 --- a/packages/cli/src/PublicApi/index.ts +++ b/packages/cli/src/PublicApi/index.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import { Container } from 'typedi'; import type { Router } from 'express'; import express from 'express'; import fs from 'fs/promises'; @@ -11,11 +12,11 @@ import type { OpenAPIV3 } from 'openapi-types'; import type { JsonObject } from 'swagger-ui-express'; import config from '@/config'; -import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; -import { Container } from 'typedi'; + import { InternalHooks } from '@/InternalHooks'; import { License } from '@/License'; import { UserRepository } from '@db/repositories/user.repository'; +import { UrlService } from '@/services/url.service'; async function createApiRouter( version: string, @@ -29,7 +30,7 @@ async function createApiRouter( // from the Swagger UI swaggerDocument.server = [ { - url: `${getInstanceBaseUrl()}/${publicApiEndpoint}/${version}}`, + url: `${Container.get(UrlService).getInstanceBaseUrl()}/${publicApiEndpoint}/${version}}`, }, ]; const apiController = express.Router(); diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index e672ec9a49c94..8d1dc08149351 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -6,6 +6,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { Container, Service } from 'typedi'; import assert from 'assert'; import { exec as callbackExec } from 'child_process'; import { access as fsAccess } from 'fs/promises'; @@ -46,7 +47,7 @@ import { TEMPLATES_DIR, } from '@/constants'; import { credentialsController } from '@/credentials/credentials.controller'; -import type { CurlHelper, ExecutionRequest, WorkflowRequest } from '@/requests'; +import type { CurlHelper, ExecutionRequest } from '@/requests'; import { registerController } from '@/decorators'; import { AuthController } from '@/controllers/auth.controller'; import { BinaryDataController } from '@/controllers/binaryData.controller'; @@ -66,7 +67,6 @@ import { WorkflowStatisticsController } from '@/controllers/workflowStatistics.c import { ExternalSecretsController } from '@/ExternalSecrets/ExternalSecrets.controller.ee'; import { executionsController } from '@/executions/executions.controller'; import { isApiEnabled, loadPublicApiVersions } from '@/PublicApi'; -import { whereClause } from '@/UserManagement/UserManagementHelper'; import type { ICredentialsOverwrite, IDiagnosticInfo, IExecutionsStopData } from '@/Interfaces'; import { ActiveExecutions } from '@/ActiveExecutions'; import { CredentialsOverwrites } from '@/CredentialsOverwrites'; @@ -85,7 +85,6 @@ import { handleLdapInit, isLdapEnabled } from './Ldap/helpers'; import { AbstractServer } from './AbstractServer'; import { PostHogClient } from './posthog'; import { eventBus } from './eventbus'; -import { Container } from 'typedi'; import { InternalHooks } from './InternalHooks'; import { License } from './License'; import { getStatusUsingPreviousExecutionStatusMethod } from './executions/executionHelpers'; @@ -112,6 +111,7 @@ import { handleMfaDisable, isMfaFeatureEnabled } from './Mfa/helpers'; import type { FrontendService } from './services/frontend.service'; import { RoleService } from './services/role.service'; import { UserService } from './services/user.service'; +import { ActiveWorkflowsController } from './controllers/activeWorkflows.controller'; import { OrchestrationController } from './controllers/orchestration.controller'; import { WorkflowHistoryController } from './workflows/workflowHistory/workflowHistory.controller.ee'; import { InvitationController } from './controllers/invitation.controller'; @@ -124,6 +124,7 @@ import { PasswordUtility } from './services/password.utility'; const exec = promisify(callbackExec); +@Service() export class Server extends AbstractServer { private endpointPresetCredentials: string; @@ -305,6 +306,7 @@ export class Server extends AbstractServer { ), Container.get(VariablesController), Container.get(RoleController), + Container.get(ActiveWorkflowsController), ]; if (Container.get(MultiMainSetup).isEnabled) { @@ -443,50 +445,6 @@ export class Server extends AbstractServer { this.logger.warn(`Source Control initialization failed: ${error.message}`); } - // ---------------------------------------- - // Active Workflows - // ---------------------------------------- - - // Returns the active workflow ids - this.app.get( - `/${this.restEndpoint}/active`, - ResponseHelper.send(async (req: WorkflowRequest.GetAllActive) => { - return this.activeWorkflowRunner.allActiveInStorage({ - user: req.user, - scope: 'workflow:list', - }); - }), - ); - - // Returns if the workflow with the given id had any activation errors - this.app.get( - `/${this.restEndpoint}/active/error/:id`, - ResponseHelper.send(async (req: WorkflowRequest.GetActivationError) => { - const { id: workflowId } = req.params; - - const shared = await Container.get(SharedWorkflowRepository).findOne({ - relations: ['workflow'], - where: whereClause({ - user: req.user, - globalScope: 'workflow:read', - entityType: 'workflow', - entityId: workflowId, - }), - }); - - if (!shared) { - this.logger.verbose('User attempted to access workflow errors without permissions', { - workflowId, - userId: req.user.id, - }); - - throw new BadRequestError(`Workflow with ID "${workflowId}" could not be found.`); - } - - return this.activeWorkflowRunner.getActivationError(workflowId); - }), - ); - // ---------------------------------------- // curl-converter // ---------------------------------------- diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts index 6b9b1a0eafec6..548719fdc0d80 100644 --- a/packages/cli/src/UserManagement/UserManagementHelper.ts +++ b/packages/cli/src/UserManagement/UserManagementHelper.ts @@ -1,39 +1,15 @@ import { In } from 'typeorm'; import { Container } from 'typedi'; +import type { Scope } from '@n8n/permissions'; import type { WhereClause } from '@/Interfaces'; import type { User } from '@db/entities/User'; -import config from '@/config'; import { License } from '@/License'; -import { getWebhookBaseUrl } from '@/WebhookHelpers'; -import { UserRepository } from '@db/repositories/user.repository'; -import type { Scope } from '@n8n/permissions'; export function isSharingEnabled(): boolean { return Container.get(License).isSharingEnabled(); } -/** - * Return the n8n instance base URL without trailing slash. - */ -export function getInstanceBaseUrl(): string { - const n8nBaseUrl = config.getEnv('editorBaseUrl') || getWebhookBaseUrl(); - - return n8nBaseUrl.endsWith('/') ? n8nBaseUrl.slice(0, n8nBaseUrl.length - 1) : n8nBaseUrl; -} - -export function generateUserInviteUrl(inviterId: string, inviteeId: string): string { - return `${getInstanceBaseUrl()}/signup?inviterId=${inviterId}&inviteeId=${inviteeId}`; -} - -export async function getUserById(userId: string): Promise { - const user = await Container.get(UserRepository).findOneOrFail({ - where: { id: userId }, - relations: ['globalRole'], - }); - return user; -} - // return the difference between two arrays export function rightDiff( [arr1, keyExtractor1]: [T1[], (item: T1) => string], diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index 0446d5a92b6fa..f86b90621ea5a 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -49,7 +49,6 @@ import type { WebhookCORSRequest, WebhookRequest, } from '@/Interfaces'; -import * as GenericHelpers from '@/GenericHelpers'; import * as ResponseHelper from '@/ResponseHelper'; import * as WorkflowHelpers from '@/WorkflowHelpers'; import { WorkflowRunner } from '@/WorkflowRunner'; @@ -60,7 +59,6 @@ import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { EventsService } from '@/services/events.service'; import { OwnershipService } from './services/ownership.service'; import { parseBody } from './middlewares'; -import { WorkflowService } from './workflows/workflow.service'; import { Logger } from './Logger'; import { NotFoundError } from './errors/response-errors/not-found.error'; import { InternalServerError } from './errors/response-errors/internal-server.error'; @@ -386,9 +384,6 @@ export async function executeWebhook( }; } - // Save static data if it changed - await Container.get(WorkflowService).saveStaticData(workflow); - const additionalKeys: IWorkflowDataProxyAdditionalKeys = { $executionId: executionId, }; @@ -824,14 +819,3 @@ export async function executeWebhook( return; } } - -/** - * Returns the base URL of the webhooks - */ -export function getWebhookBaseUrl() { - let urlBaseWebhook = process.env.WEBHOOK_URL ?? GenericHelpers.getBaseUrl(); - if (!urlBaseWebhook.endsWith('/')) { - urlBaseWebhook += '/'; - } - return urlBaseWebhook; -} diff --git a/packages/cli/src/WebhookServer.ts b/packages/cli/src/WebhookServer.ts index a8daadc51a7f9..60f59f606d95e 100644 --- a/packages/cli/src/WebhookServer.ts +++ b/packages/cli/src/WebhookServer.ts @@ -1,5 +1,7 @@ +import { Service } from 'typedi'; import { AbstractServer } from '@/AbstractServer'; +@Service() export class WebhookServer extends AbstractServer { constructor() { super('webhook'); diff --git a/packages/cli/src/WorkflowCredentials.ts b/packages/cli/src/WorkflowCredentials.ts deleted file mode 100644 index f7f915d32cf59..0000000000000 --- a/packages/cli/src/WorkflowCredentials.ts +++ /dev/null @@ -1,51 +0,0 @@ -import Container from 'typedi'; -import { ApplicationError, type INode, type IWorkflowCredentials } from 'n8n-workflow'; -import { CredentialsRepository } from '@db/repositories/credentials.repository'; - -// eslint-disable-next-line @typescript-eslint/naming-convention -export async function WorkflowCredentials(nodes: INode[]): Promise { - // Go through all nodes to find which credentials are needed to execute the workflow - const returnCredentials: IWorkflowCredentials = {}; - - let node; - let type; - let nodeCredentials; - let foundCredentials; - - for (node of nodes) { - if (node.disabled === true || !node.credentials) { - continue; - } - - for (type of Object.keys(node.credentials)) { - if (!returnCredentials[type]) { - returnCredentials[type] = {}; - } - nodeCredentials = node.credentials[type]; - - if (!nodeCredentials.id) { - throw new ApplicationError( - `Credentials with name "${nodeCredentials.name}" for type "${type}" miss an ID.`, - { extra: { credentialName: nodeCredentials.name }, tags: { credentialType: type } }, - ); - } - - if (!returnCredentials[type][nodeCredentials.id]) { - foundCredentials = await Container.get(CredentialsRepository).findOneBy({ - id: nodeCredentials.id, - type, - }); - if (!foundCredentials) { - throw new ApplicationError('Could not find credential.', { - tags: { credentialType: type }, - extra: { credentialId: nodeCredentials.id }, - }); - } - - returnCredentials[type][nodeCredentials.id] = foundCredentials; - } - } - } - - return returnCredentials; -} diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 1f6bcc627b4e9..cdb809a83ce03 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -48,11 +48,9 @@ import type { } from '@/Interfaces'; import { NodeTypes } from '@/NodeTypes'; import { Push } from '@/push'; -import * as WebhookHelpers from '@/WebhookHelpers'; import * as WorkflowHelpers from '@/WorkflowHelpers'; import { findSubworkflowStart, isWorkflowIdValid } from '@/utils'; import { PermissionChecker } from './UserManagement/PermissionChecker'; -import { WorkflowService } from './workflows/workflow.service'; import { InternalHooks } from '@/InternalHooks'; import { ExecutionRepository } from '@db/repositories/execution.repository'; import { EventsService } from '@/services/events.service'; @@ -67,6 +65,9 @@ import { restoreBinaryDataId } from './executionLifecycleHooks/restoreBinaryData import { toSaveSettings } from './executionLifecycleHooks/toSaveSettings'; import { Logger } from './Logger'; import { saveExecutionProgress } from './executionLifecycleHooks/saveExecutionProgress'; +import { WorkflowStaticDataService } from './workflows/workflowStaticData.service'; +import { WorkflowRepository } from './databases/repositories/workflow.repository'; +import { UrlService } from './services/url.service'; const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType'); @@ -132,7 +133,7 @@ export function executeErrorWorkflow( // Check if there was an error and if so if an errorWorkflow or a trigger is set let pastExecutionUrl: string | undefined; if (executionId !== undefined) { - pastExecutionUrl = `${WebhookHelpers.getWebhookBaseUrl()}workflow/${ + pastExecutionUrl = `${Container.get(UrlService).getWebhookBaseUrl()}workflow/${ workflowData.id }/executions/${executionId}`; } @@ -418,7 +419,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { if (!isManualMode && isWorkflowIdValid(this.workflowData.id) && newStaticData) { // Workflow is saved so update in database try { - await Container.get(WorkflowService).saveStaticDataById( + await Container.get(WorkflowStaticDataService).saveStaticDataById( this.workflowData.id as string, newStaticData, ); @@ -564,7 +565,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { if (isWorkflowIdValid(this.workflowData.id) && newStaticData) { // Workflow is saved so update in database try { - await Container.get(WorkflowService).saveStaticDataById( + await Container.get(WorkflowStaticDataService).saveStaticDataById( this.workflowData.id as string, newStaticData, ); @@ -714,7 +715,10 @@ export async function getWorkflowData( if (workflowInfo.id !== undefined) { const relations = config.getEnv('workflowTagsDisabled') ? [] : ['tags']; - workflowData = await Container.get(WorkflowService).get({ id: workflowInfo.id }, { relations }); + workflowData = await Container.get(WorkflowRepository).get( + { id: workflowInfo.id }, + { relations }, + ); if (workflowData === undefined || workflowData === null) { throw new ApplicationError('Workflow does not exist.', { @@ -961,7 +965,7 @@ export async function getBase( currentNodeParameters?: INodeParameters, executionTimeoutTimestamp?: number, ): Promise { - const urlBaseWebhook = WebhookHelpers.getWebhookBaseUrl(); + const urlBaseWebhook = Container.get(UrlService).getWebhookBaseUrl(); const formWaitingBaseUrl = urlBaseWebhook + config.getEnv('endpoints.formWaiting'); diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 1949bc6f69cfc..91a356b4e1617 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -22,6 +22,7 @@ import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager import { initExpressionEvaluator } from '@/ExpressionEvaluator'; import { generateHostInstanceId } from '@db/utils/generators'; import { WorkflowHistoryManager } from '@/workflows/workflowHistory/workflowHistoryManager.ee'; +import { ShutdownService } from '@/shutdown/Shutdown.service'; export abstract class BaseCommand extends Command { protected logger = Container.get(Logger); @@ -38,7 +39,7 @@ export abstract class BaseCommand extends Command { protected server?: AbstractServer; - protected isShuttingDown = false; + protected shutdownService: ShutdownService = Container.get(ShutdownService); /** * How long to wait for graceful shutdown before force killing the process. @@ -309,7 +310,7 @@ export abstract class BaseCommand extends Command { private onTerminationSignal(signal: string) { return async () => { - if (this.isShuttingDown) { + if (this.shutdownService.isShuttingDown()) { this.logger.info(`Received ${signal}. Already shutting down...`); return; } @@ -323,9 +324,9 @@ export abstract class BaseCommand extends Command { }, this.gracefulShutdownTimeoutInS * 1000); this.logger.info(`Received ${signal}. Shutting down...`); - this.isShuttingDown = true; + this.shutdownService.shutdown(); - await this.stopProcess(); + await Promise.all([this.stopProcess(), this.shutdownService.waitForShutdown()]); clearTimeout(forceShutdownTimer); }; diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index e2eeecc981e17..a2d674d9adabb 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -4,7 +4,6 @@ import { Container } from 'typedi'; import path from 'path'; import { mkdir } from 'fs/promises'; import { createReadStream, createWriteStream, existsSync } from 'fs'; -import localtunnel from 'localtunnel'; import { flags } from '@oclif/command'; import stream from 'stream'; import replaceStream from 'replacestream'; @@ -16,7 +15,6 @@ import config from '@/config'; import { ActiveExecutions } from '@/ActiveExecutions'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; -import * as GenericHelpers from '@/GenericHelpers'; import { Server } from '@/Server'; import { EDITOR_UI_DIST_DIR, LICENSE_FEATURES } from '@/constants'; import { eventBus } from '@/eventbus'; @@ -28,6 +26,7 @@ import { SingleMainSetup } from '@/services/orchestration/main/SingleMainSetup'; import { OrchestrationHandlerMainService } from '@/services/orchestration/main/orchestration.handler.main.service'; import { PruningService } from '@/services/pruning.service'; import { MultiMainSetup } from '@/services/orchestration/main/MultiMainSetup.ee'; +import { UrlService } from '@/services/url.service'; import { SettingsRepository } from '@db/repositories/settings.repository'; import { ExecutionRepository } from '@db/repositories/execution.repository'; import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; @@ -64,7 +63,7 @@ export class Start extends BaseCommand { protected activeWorkflowRunner: ActiveWorkflowRunner; - protected server = new Server(); + protected server = Container.get(Server); private pruningService: PruningService; @@ -78,7 +77,7 @@ export class Start extends BaseCommand { * Opens the UI in browser */ private openBrowser() { - const editorUrl = GenericHelpers.getBaseUrl(); + const editorUrl = Container.get(UrlService).baseUrl; // eslint-disable-next-line @typescript-eslint/no-unused-vars open(editorUrl, { wait: true }).catch((error: Error) => { @@ -102,14 +101,6 @@ export class Start extends BaseCommand { await this.externalHooks?.run('n8n.stop', []); - // Shut down License manager to unclaim any floating entitlements - // Note: While this saves a new license cert to DB, the previous entitlements are still kept in memory so that the shutdown process can complete - await Container.get(License).shutdown(); - - if (this.pruningService.isPruningEnabled()) { - this.pruningService.stopPruning(); - } - if (Container.get(MultiMainSetup).isEnabled) { await this.activeWorkflowRunner.removeAllTriggerAndPollerBasedWorkflows(); @@ -308,14 +299,13 @@ export class Start extends BaseCommand { this.instanceSettings.update({ tunnelSubdomain }); } - const tunnelSettings: localtunnel.TunnelConfig = { - host: 'https://hooks.n8n.cloud', - subdomain: tunnelSubdomain, - }; - + const { default: localtunnel } = await import('@n8n/localtunnel'); const port = config.getEnv('port'); - const webhookTunnel = await localtunnel(port, tunnelSettings); + const webhookTunnel = await localtunnel(port, { + host: 'https://hooks.n8n.cloud', + subdomain: tunnelSubdomain, + }); process.env.WEBHOOK_URL = `${webhookTunnel.url}/`; this.log(`Tunnel URL: ${process.env.WEBHOOK_URL}\n`); @@ -331,7 +321,7 @@ export class Start extends BaseCommand { // Start to get active workflows and run their triggers await this.activeWorkflowRunner.init(); - const editorUrl = GenericHelpers.getBaseUrl(); + const editorUrl = Container.get(UrlService).baseUrl; this.log(`\nEditor is now accessible via:\n${editorUrl}`); // Allow to open n8n editor by pressing "o" diff --git a/packages/cli/src/commands/webhook.ts b/packages/cli/src/commands/webhook.ts index 07374ecf03a88..1ff1dd8bb8224 100644 --- a/packages/cli/src/commands/webhook.ts +++ b/packages/cli/src/commands/webhook.ts @@ -19,7 +19,7 @@ export class Webhook extends BaseCommand { help: flags.help({ char: 'h' }), }; - protected server = new WebhookServer(); + protected server = Container.get(WebhookServer); constructor(argv: string[], cmdConfig: IConfig) { super(argv, cmdConfig); diff --git a/packages/cli/src/controllers/activeWorkflows.controller.ts b/packages/cli/src/controllers/activeWorkflows.controller.ts new file mode 100644 index 0000000000000..e1a427b3ec148 --- /dev/null +++ b/packages/cli/src/controllers/activeWorkflows.controller.ts @@ -0,0 +1,25 @@ +import { Service } from 'typedi'; +import { Authorized, Get, RestController } from '@/decorators'; +import { WorkflowRequest } from '@/requests'; +import { ActiveWorkflowsService } from '@/services/activeWorkflows.service'; + +@Service() +@Authorized() +@RestController('/active-workflows') +export class ActiveWorkflowsController { + constructor(private readonly activeWorkflowsService: ActiveWorkflowsService) {} + + @Get('/') + async getActiveWorkflows(req: WorkflowRequest.GetAllActive) { + return this.activeWorkflowsService.getAllActiveIdsFor(req.user); + } + + @Get('/error/:id') + async getActivationError(req: WorkflowRequest.GetActivationError) { + const { + user, + params: { id: workflowId }, + } = req; + return this.activeWorkflowsService.getActivationError(workflowId, user); + } +} diff --git a/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts b/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts index 9229c3aaa47d0..185a9afa015cf 100644 --- a/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts +++ b/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts @@ -7,13 +7,13 @@ import type { User } from '@db/entities/User'; import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import type { ICredentialsDb } from '@/Interfaces'; -import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; import type { OAuthRequest } from '@/requests'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import { CredentialsHelper } from '@/CredentialsHelper'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import { Logger } from '@/Logger'; import { ExternalHooks } from '@/ExternalHooks'; +import { UrlService } from '@/services/url.service'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; @@ -27,10 +27,11 @@ export abstract class AbstractOAuthController { private readonly credentialsHelper: CredentialsHelper, private readonly credentialsRepository: CredentialsRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository, + private readonly urlService: UrlService, ) {} get baseUrl() { - const restUrl = `${getInstanceBaseUrl()}/${config.getEnv('endpoints.rest')}`; + const restUrl = `${this.urlService.getInstanceBaseUrl()}/${config.getEnv('endpoints.rest')}`; return `${restUrl}/oauth${this.oauthVersion}-credential`; } diff --git a/packages/cli/src/controllers/passwordReset.controller.ts b/packages/cli/src/controllers/passwordReset.controller.ts index 06b1dd250c2d7..dce0338e2e0c5 100644 --- a/packages/cli/src/controllers/passwordReset.controller.ts +++ b/packages/cli/src/controllers/passwordReset.controller.ts @@ -5,7 +5,6 @@ import { IsNull, Not } from 'typeorm'; import validator from 'validator'; import { Get, Post, RestController } from '@/decorators'; -import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; import { PasswordUtility } from '@/services/password.utility'; import { UserManagementMailer } from '@/UserManagement/email'; import { PasswordResetRequest } from '@/requests'; @@ -19,6 +18,7 @@ import { MfaService } from '@/Mfa/mfa.service'; import { Logger } from '@/Logger'; import { ExternalHooks } from '@/ExternalHooks'; import { InternalHooks } from '@/InternalHooks'; +import { UrlService } from '@/services/url.service'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; @@ -41,6 +41,7 @@ export class PasswordResetController { private readonly mailer: UserManagementMailer, private readonly userService: UserService, private readonly mfaService: MfaService, + private readonly urlService: UrlService, private readonly license: License, private readonly passwordUtility: PasswordUtility, ) {} @@ -130,7 +131,7 @@ export class PasswordResetController { firstName, lastName, passwordResetUrl: url, - domain: getInstanceBaseUrl(), + domain: this.urlService.getInstanceBaseUrl(), }); } catch (error) { void this.internalHooks.onEmailFailed({ diff --git a/packages/cli/src/credentials/credentials.controller.ee.ts b/packages/cli/src/credentials/credentials.controller.ee.ts index c2f23d9b15323..49131babbe129 100644 --- a/packages/cli/src/credentials/credentials.controller.ee.ts +++ b/packages/cli/src/credentials/credentials.controller.ee.ts @@ -14,6 +14,7 @@ import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; export const EECredentialsController = express.Router(); @@ -155,10 +156,11 @@ EECredentialsController.put( let newShareeIds: string[] = []; await Db.transaction(async (trx) => { // remove all sharings that are not supposed to exist anymore - const { affected } = await EECredentials.pruneSharings(trx, credentialId, [ - ...ownerIds, - ...shareWithIds, - ]); + const { affected } = await Container.get(CredentialsRepository).pruneSharings( + trx, + credentialId, + [...ownerIds, ...shareWithIds], + ); if (affected) amountRemoved = affected; const sharings = await EECredentials.getSharings(trx, credentialId); diff --git a/packages/cli/src/credentials/credentials.service.ee.ts b/packages/cli/src/credentials/credentials.service.ee.ts index 627af5c678345..e9f016c75eae2 100644 --- a/packages/cli/src/credentials/credentials.service.ee.ts +++ b/packages/cli/src/credentials/credentials.service.ee.ts @@ -1,7 +1,6 @@ -import type { DeleteResult, EntityManager, FindOptionsWhere } from 'typeorm'; -import { In, Not } from 'typeorm'; +import type { EntityManager, FindOptionsWhere } from 'typeorm'; import { CredentialsEntity } from '@db/entities/CredentialsEntity'; -import { SharedCredentials } from '@db/entities/SharedCredentials'; +import type { SharedCredentials } from '@db/entities/SharedCredentials'; import type { User } from '@db/entities/User'; import { UserService } from '@/services/user.service'; import { CredentialsService, type CredentialsGetSharedOptions } from './credentials.service'; @@ -62,18 +61,6 @@ export class EECredentialsService extends CredentialsService { return credential?.shared ?? []; } - static async pruneSharings( - transaction: EntityManager, - credentialId: string, - userIds: string[], - ): Promise { - const conditions: FindOptionsWhere = { - credentialsId: credentialId, - userId: Not(In(userIds)), - }; - return transaction.delete(SharedCredentials, conditions); - } - static async share( transaction: EntityManager, credential: CredentialsEntity, diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index ab5e2379770ea..f75f1eccd6982 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -35,10 +35,7 @@ export type CredentialsGetSharedOptions = | { allowGlobalScope: false }; export class CredentialsService { - static async get( - where: FindOptionsWhere, - options?: { relations: string[] }, - ): Promise { + static async get(where: FindOptionsWhere, options?: { relations: string[] }) { return Container.get(CredentialsRepository).findOne({ relations: options?.relations, where, diff --git a/packages/cli/src/databases/migrations/sqlite/1690000000002-MigrateIntegerKeysToString.ts b/packages/cli/src/databases/migrations/sqlite/1690000000002-MigrateIntegerKeysToString.ts index 48dfc3ad46f57..83b55787b2f51 100644 --- a/packages/cli/src/databases/migrations/sqlite/1690000000002-MigrateIntegerKeysToString.ts +++ b/packages/cli/src/databases/migrations/sqlite/1690000000002-MigrateIntegerKeysToString.ts @@ -208,20 +208,24 @@ const pruneExecutionsData = async ({ queryRunner, tablePrefix, logger }: Migrati } console.time('pruningData'); - const counting = (await queryRunner.query( - `select count(id) as rows from "${tablePrefix}execution_entity";`, - )) as Array<{ rows: number }>; - - const averageExecutionSize = dbFileSize / counting[0].rows; - const numberOfExecutionsToKeep = Math.floor(DESIRED_DATABASE_FILE_SIZE / averageExecutionSize); - - const query = `SELECT id FROM "${tablePrefix}execution_entity" ORDER BY id DESC limit ${numberOfExecutionsToKeep}, 1`; - const idToKeep = await queryRunner - .query(query) - .then((rows: Array<{ id: number }>) => rows[0].id); - - const removalQuery = `DELETE FROM "${tablePrefix}execution_entity" WHERE id < ${idToKeep} and status IN ('success')`; - await queryRunner.query(removalQuery); + const [{ rowCount }] = (await queryRunner.query( + `select count(id) as rowCount from "${tablePrefix}execution_entity";`, + )) as Array<{ rowCount: number }>; + + if (rowCount > 0) { + const averageExecutionSize = dbFileSize / rowCount; + const numberOfExecutionsToKeep = Math.floor( + DESIRED_DATABASE_FILE_SIZE / averageExecutionSize, + ); + + const query = `SELECT id FROM "${tablePrefix}execution_entity" ORDER BY id DESC limit ${numberOfExecutionsToKeep}, 1`; + const idToKeep = await queryRunner + .query(query) + .then((rows: Array<{ id: number }>) => rows[0].id); + + const removalQuery = `DELETE FROM "${tablePrefix}execution_entity" WHERE id < ${idToKeep} and status IN ('success')`; + await queryRunner.query(removalQuery); + } console.timeEnd('pruningData'); } else { logger.debug('Pruning was requested, but was not enabled'); diff --git a/packages/cli/src/databases/repositories/credentials.repository.ts b/packages/cli/src/databases/repositories/credentials.repository.ts index 43bc18dfb128b..5687bd934e483 100644 --- a/packages/cli/src/databases/repositories/credentials.repository.ts +++ b/packages/cli/src/databases/repositories/credentials.repository.ts @@ -1,10 +1,39 @@ import { Service } from 'typedi'; -import { DataSource, Repository } from 'typeorm'; +import { + DataSource, + In, + Not, + Repository, + type DeleteResult, + type EntityManager, + type FindOptionsWhere, + Like, +} from 'typeorm'; import { CredentialsEntity } from '../entities/CredentialsEntity'; +import { SharedCredentials } from '../entities/SharedCredentials'; @Service() export class CredentialsRepository extends Repository { constructor(dataSource: DataSource) { super(CredentialsEntity, dataSource.manager); } + + async pruneSharings( + transaction: EntityManager, + credentialId: string, + userIds: string[], + ): Promise { + const conditions: FindOptionsWhere = { + credentialsId: credentialId, + userId: Not(In(userIds)), + }; + return transaction.delete(SharedCredentials, conditions); + } + + async findStartingWith(credentialName: string) { + return this.find({ + select: ['name'], + where: { name: Like(`${credentialName}%`) }, + }); + } } diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 093430326763d..67b3d0de4bf34 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -1,5 +1,14 @@ import { Service } from 'typedi'; -import { DataSource, In, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; +import { + Brackets, + DataSource, + In, + IsNull, + LessThanOrEqual, + MoreThanOrEqual, + Not, + Repository, +} from 'typeorm'; import { DateUtils } from 'typeorm/util/DateUtils'; import type { FindManyOptions, @@ -425,4 +434,86 @@ export class ExecutionRepository extends Repository { await this.delete(batch); } while (executionIds.length > 0); } + + async getIdsSince(date: Date) { + return this.find({ + select: ['id'], + where: { + startedAt: MoreThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(date)), + }, + }).then((executions) => executions.map(({ id }) => id)); + } + + async softDeletePrunableExecutions() { + const maxAge = config.getEnv('executions.pruneDataMaxAge'); // in h + const maxCount = config.getEnv('executions.pruneDataMaxCount'); + + // Find ids of all executions that were stopped longer that pruneDataMaxAge ago + const date = new Date(); + date.setHours(date.getHours() - maxAge); + + const toPrune: Array> = [ + // date reformatting needed - see https://github.com/typeorm/typeorm/issues/2286 + { stoppedAt: LessThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(date)) }, + ]; + + if (maxCount > 0) { + const executions = await this.find({ + select: ['id'], + skip: maxCount, + take: 1, + order: { id: 'DESC' }, + }); + + if (executions[0]) { + toPrune.push({ id: LessThanOrEqual(executions[0].id) }); + } + } + + const [timeBasedWhere, countBasedWhere] = toPrune; + + return this.createQueryBuilder() + .update(ExecutionEntity) + .set({ deletedAt: new Date() }) + .where({ + deletedAt: IsNull(), + // Only mark executions as deleted if they are in an end state + status: Not(In(['new', 'running', 'waiting'])), + }) + .andWhere( + new Brackets((qb) => + countBasedWhere + ? qb.where(timeBasedWhere).orWhere(countBasedWhere) + : qb.where(timeBasedWhere), + ), + ) + .execute(); + } + + async hardDeleteSoftDeletedExecutions() { + const date = new Date(); + date.setHours(date.getHours() - config.getEnv('executions.pruneDataHardDeleteBuffer')); + + const workflowIdsAndExecutionIds = ( + await this.find({ + select: ['workflowId', 'id'], + where: { + deletedAt: LessThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(date)), + }, + take: this.hardDeletionBatchSize, + + /** + * @important This ensures soft-deleted executions are included, + * else `@DeleteDateColumn()` at `deletedAt` will exclude them. + */ + withDeleted: true, + }) + ).map(({ id: executionId, workflowId }) => ({ workflowId, executionId })); + + return workflowIdsAndExecutionIds; + } + + async deleteByIds(executionIds: string[]) { + return this.delete({ id: In(executionIds) }); + } } diff --git a/packages/cli/src/databases/repositories/executionData.repository.ts b/packages/cli/src/databases/repositories/executionData.repository.ts index 869267b86a6ba..e0b49de7ef405 100644 --- a/packages/cli/src/databases/repositories/executionData.repository.ts +++ b/packages/cli/src/databases/repositories/executionData.repository.ts @@ -1,5 +1,5 @@ import { Service } from 'typedi'; -import { DataSource, Repository } from 'typeorm'; +import { DataSource, In, Repository } from 'typeorm'; import { ExecutionData } from '../entities/ExecutionData'; @Service() @@ -7,4 +7,13 @@ export class ExecutionDataRepository extends Repository { constructor(dataSource: DataSource) { super(ExecutionData, dataSource.manager); } + + async findByExecutionIds(executionIds: string[]) { + return this.find({ + select: ['workflowData'], + where: { + executionId: In(executionIds), + }, + }).then((executionData) => executionData.map(({ workflowData }) => workflowData)); + } } diff --git a/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts b/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts index e8c21df37985c..eaaf1d6e0db6b 100644 --- a/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts +++ b/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts @@ -1,10 +1,31 @@ import { Service } from 'typedi'; -import { DataSource, Repository } from 'typeorm'; +import { DataSource, type FindOptionsWhere, Repository, In } from 'typeorm'; import { SharedWorkflow } from '../entities/SharedWorkflow'; +import { type User } from '../entities/User'; @Service() export class SharedWorkflowRepository extends Repository { constructor(dataSource: DataSource) { super(SharedWorkflow, dataSource.manager); } + + async hasAccess(workflowId: string, user: User) { + const where: FindOptionsWhere = { + workflowId, + }; + if (!user.hasGlobalScope('workflow:read')) { + where.userId = user.id; + } + return this.exist({ where }); + } + + async getSharedWorkflowIds(workflowIds: string[]) { + const sharedWorkflows = await this.find({ + select: ['workflowId'], + where: { + workflowId: In(workflowIds), + }, + }); + return sharedWorkflows.map((sharing) => sharing.workflowId); + } } diff --git a/packages/cli/src/databases/repositories/tag.repository.ts b/packages/cli/src/databases/repositories/tag.repository.ts index 3eb848446d996..dbd763a02a463 100644 --- a/packages/cli/src/databases/repositories/tag.repository.ts +++ b/packages/cli/src/databases/repositories/tag.repository.ts @@ -1,5 +1,5 @@ import { Service } from 'typedi'; -import { DataSource, Repository } from 'typeorm'; +import { DataSource, In, Repository } from 'typeorm'; import { TagEntity } from '../entities/TagEntity'; @Service() @@ -7,4 +7,11 @@ export class TagRepository extends Repository { constructor(dataSource: DataSource) { super(TagEntity, dataSource.manager); } + + async findMany(tagIds: string[]) { + return this.find({ + select: ['id', 'name'], + where: { id: In(tagIds) }, + }); + } } diff --git a/packages/cli/src/databases/repositories/workflow.repository.ts b/packages/cli/src/databases/repositories/workflow.repository.ts index d5b193ff2678c..b1a6f76f5375f 100644 --- a/packages/cli/src/databases/repositories/workflow.repository.ts +++ b/packages/cli/src/databases/repositories/workflow.repository.ts @@ -1,6 +1,22 @@ import { Service } from 'typedi'; -import { DataSource, Repository } from 'typeorm'; +import { + DataSource, + Repository, + In, + Like, + type UpdateResult, + type FindOptionsWhere, + type FindOptionsSelect, + type FindManyOptions, + type EntityManager, + type DeleteResult, + Not, +} from 'typeorm'; +import type { ListQuery } from '@/requests'; +import { isStringArray } from '@/utils'; +import config from '@/config'; import { WorkflowEntity } from '../entities/WorkflowEntity'; +import { SharedWorkflow } from '../entities/SharedWorkflow'; @Service() export class WorkflowRepository extends Repository { @@ -8,6 +24,13 @@ export class WorkflowRepository extends Repository { super(WorkflowEntity, dataSource.manager); } + async get(where: FindOptionsWhere, options?: { relations: string[] }) { + return this.findOne({ + where, + relations: options?.relations, + }); + } + async getAllActive() { return this.find({ where: { active: true }, @@ -15,6 +38,14 @@ export class WorkflowRepository extends Repository { }); } + async getActiveIds() { + const activeWorkflows = await this.find({ + select: ['id'], + where: { active: true }, + }); + return activeWorkflows.map((workflow) => workflow.id); + } + async findById(workflowId: string) { return this.findOne({ where: { id: workflowId }, @@ -28,4 +59,124 @@ export class WorkflowRepository extends Repository { }); return totalTriggerCount ?? 0; } + + async getSharings( + transaction: EntityManager, + workflowId: string, + relations = ['shared'], + ): Promise { + const workflow = await transaction.findOne(WorkflowEntity, { + where: { id: workflowId }, + relations, + }); + return workflow?.shared ?? []; + } + + async pruneSharings( + transaction: EntityManager, + workflowId: string, + userIds: string[], + ): Promise { + return transaction.delete(SharedWorkflow, { + workflowId, + userId: Not(In(userIds)), + }); + } + + async updateWorkflowTriggerCount(id: string, triggerCount: number): Promise { + const qb = this.createQueryBuilder('workflow'); + return qb + .update() + .set({ + triggerCount, + updatedAt: () => { + if (['mysqldb', 'mariadb'].includes(config.getEnv('database.type'))) { + return 'updatedAt'; + } + return '"updatedAt"'; + }, + }) + .where('id = :id', { id }) + .execute(); + } + + async getMany(sharedWorkflowIds: string[], options?: ListQuery.Options) { + if (sharedWorkflowIds.length === 0) return { workflows: [], count: 0 }; + + const where: FindOptionsWhere = { + ...options?.filter, + id: In(sharedWorkflowIds), + }; + + const reqTags = options?.filter?.tags; + + if (isStringArray(reqTags)) { + where.tags = reqTags.map((tag) => ({ name: tag })); + } + + type Select = FindOptionsSelect & { ownedBy?: true }; + + const select: Select = options?.select + ? { ...options.select } // copy to enable field removal without affecting original + : { + name: true, + active: true, + createdAt: true, + updatedAt: true, + versionId: true, + shared: { userId: true, roleId: true }, + }; + + delete select?.ownedBy; // remove non-entity field, handled after query + + const relations: string[] = []; + + const areTagsEnabled = !config.getEnv('workflowTagsDisabled'); + const isDefaultSelect = options?.select === undefined; + const areTagsRequested = isDefaultSelect || options?.select?.tags === true; + const isOwnedByIncluded = isDefaultSelect || options?.select?.ownedBy === true; + + if (areTagsEnabled && areTagsRequested) { + relations.push('tags'); + select.tags = { id: true, name: true }; + } + + if (isOwnedByIncluded) relations.push('shared', 'shared.role', 'shared.user'); + + if (typeof where.name === 'string' && where.name !== '') { + where.name = Like(`%${where.name}%`); + } + + const findManyOptions: FindManyOptions = { + select: { ...select, id: true }, + where, + }; + + if (isDefaultSelect || options?.select?.updatedAt === true) { + findManyOptions.order = { updatedAt: 'ASC' }; + } + + if (relations.length > 0) { + findManyOptions.relations = relations; + } + + if (options?.take) { + findManyOptions.skip = options.skip; + findManyOptions.take = options.take; + } + + const [workflows, count] = (await this.findAndCount(findManyOptions)) as [ + ListQuery.Workflow.Plain[] | ListQuery.Workflow.WithSharing[], + number, + ]; + + return { workflows, count }; + } + + async findStartingWith(workflowName: string): Promise> { + return this.find({ + select: ['name'], + where: { name: Like(`${workflowName}%`) }, + }); + } } diff --git a/packages/cli/src/databases/repositories/workflowHistory.repository.ts b/packages/cli/src/databases/repositories/workflowHistory.repository.ts index 02c0beaec247d..eda4a18e313a5 100644 --- a/packages/cli/src/databases/repositories/workflowHistory.repository.ts +++ b/packages/cli/src/databases/repositories/workflowHistory.repository.ts @@ -1,5 +1,5 @@ import { Service } from 'typedi'; -import { DataSource, Repository } from 'typeorm'; +import { DataSource, LessThan, Repository } from 'typeorm'; import { WorkflowHistory } from '../entities/WorkflowHistory'; @Service() @@ -7,4 +7,8 @@ export class WorkflowHistoryRepository extends Repository { constructor(dataSource: DataSource) { super(WorkflowHistory, dataSource.manager); } + + async deleteEarlierThan(date: Date) { + return this.delete({ createdAt: LessThan(date) }); + } } diff --git a/packages/cli/src/decorators/OnShutdown.ts b/packages/cli/src/decorators/OnShutdown.ts new file mode 100644 index 0000000000000..87e8a6a45773d --- /dev/null +++ b/packages/cli/src/decorators/OnShutdown.ts @@ -0,0 +1,38 @@ +import { Container } from 'typedi'; +import { ApplicationError } from 'n8n-workflow'; +import { type ServiceClass, ShutdownService } from '@/shutdown/Shutdown.service'; + +/** + * Decorator that registers a method as a shutdown hook. The method will + * be called when the application is shutting down. + * + * Priority is used to determine the order in which the hooks are called. + * + * NOTE: Requires also @Service() decorator to be used on the class. + * + * @example + * ```ts + * @Service() + * class MyClass { + * @OnShutdown() + * async shutdown() { + * // Will be called when the app is shutting down + * } + * } + * ``` + */ +export const OnShutdown = + (priority = 100): MethodDecorator => + (prototype, propertyKey, descriptor) => { + const serviceClass = prototype.constructor as ServiceClass; + const methodName = String(propertyKey); + // TODO: assert that serviceClass is decorated with @Service + if (typeof descriptor?.value === 'function') { + Container.get(ShutdownService).register(priority, { serviceClass, methodName }); + } else { + const name = `${serviceClass.name}.${methodName}()`; + throw new ApplicationError( + `${name} must be a method on ${serviceClass.name} to use "OnShutdown"`, + ); + } + }; diff --git a/packages/cli/src/environments/variables/enviromentHelpers.ts b/packages/cli/src/environments/variables/environmentHelpers.ts similarity index 100% rename from packages/cli/src/environments/variables/enviromentHelpers.ts rename to packages/cli/src/environments/variables/environmentHelpers.ts diff --git a/packages/cli/src/environments/variables/variables.service.ee.ts b/packages/cli/src/environments/variables/variables.service.ee.ts index a962896de4631..96060e7a4ad5d 100644 --- a/packages/cli/src/environments/variables/variables.service.ee.ts +++ b/packages/cli/src/environments/variables/variables.service.ee.ts @@ -2,7 +2,7 @@ import { Container, Service } from 'typedi'; import type { Variables } from '@db/entities/Variables'; import { InternalHooks } from '@/InternalHooks'; import { generateNanoId } from '@db/utils/generators'; -import { canCreateNewVariable } from './enviromentHelpers'; +import { canCreateNewVariable } from './environmentHelpers'; import { CacheService } from '@/services/cache.service'; import { VariablesRepository } from '@db/repositories/variables.repository'; import type { DeepPartial } from 'typeorm'; diff --git a/packages/cli/src/executionLifecycleHooks/restoreBinaryDataId.ts b/packages/cli/src/executionLifecycleHooks/restoreBinaryDataId.ts index 15c03e1b4df63..84780a785e410 100644 --- a/packages/cli/src/executionLifecycleHooks/restoreBinaryDataId.ts +++ b/packages/cli/src/executionLifecycleHooks/restoreBinaryDataId.ts @@ -3,6 +3,7 @@ import { BinaryDataService } from 'n8n-core'; import type { IRun, WorkflowExecuteMode } from 'n8n-workflow'; import type { BinaryData } from 'n8n-core'; import config from '@/config'; +import { Logger } from '@/Logger'; /** * Whenever the execution ID is not available to the binary data service at the @@ -32,28 +33,43 @@ export async function restoreBinaryDataId( return; } - const { runData } = run.data.resultData; + try { + const { runData } = run.data.resultData; - const promises = Object.keys(runData).map(async (nodeName) => { - const binaryDataId = runData[nodeName]?.[0]?.data?.main?.[0]?.[0]?.binary?.data?.id; + const promises = Object.keys(runData).map(async (nodeName) => { + const binaryDataId = runData[nodeName]?.[0]?.data?.main?.[0]?.[0]?.binary?.data?.id; - if (!binaryDataId) return; + if (!binaryDataId) return; - const [mode, fileId] = binaryDataId.split(':') as [BinaryData.StoredMode, string]; + const [mode, fileId] = binaryDataId.split(':') as [BinaryData.StoredMode, string]; - const isMissingExecutionId = fileId.includes('/temp/'); + const isMissingExecutionId = fileId.includes('/temp/'); - if (!isMissingExecutionId) return; + if (!isMissingExecutionId) return; - const correctFileId = fileId.replace('temp', executionId); + const correctFileId = fileId.replace('temp', executionId); - await Container.get(BinaryDataService).rename(fileId, correctFileId); + await Container.get(BinaryDataService).rename(fileId, correctFileId); - const correctBinaryDataId = `${mode}:${correctFileId}`; + const correctBinaryDataId = `${mode}:${correctFileId}`; - // @ts-expect-error Validated at the top - run.data.resultData.runData[nodeName][0].data.main[0][0].binary.data.id = correctBinaryDataId; - }); + // @ts-expect-error Validated at the top + run.data.resultData.runData[nodeName][0].data.main[0][0].binary.data.id = correctBinaryDataId; + }); - await Promise.all(promises); + await Promise.all(promises); + } catch (e) { + const error = e instanceof Error ? e : new Error(`${e}`); + const logger = Container.get(Logger); + + if (error.message.includes('ENOENT')) { + logger.warn('Failed to restore binary data ID - No such file or dir', { + executionId, + error, + }); + return; + } + + logger.error('Failed to restore binary data ID - Unknown error', { executionId, error }); + } } diff --git a/packages/cli/src/executions/executions.service.ee.ts b/packages/cli/src/executions/executions.service.ee.ts index 9fbfa8ca71919..662ce3875948f 100644 --- a/packages/cli/src/executions/executions.service.ee.ts +++ b/packages/cli/src/executions/executions.service.ee.ts @@ -6,7 +6,7 @@ import type { IExecutionResponse, IExecutionFlattedResponse } from '@/Interfaces import { EnterpriseWorkflowService } from '../workflows/workflow.service.ee'; import type { WorkflowWithSharingsAndCredentials } from '@/workflows/workflows.types'; import Container from 'typedi'; -import { WorkflowService } from '@/workflows/workflow.service'; +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; export class EEExecutionsService extends ExecutionsService { /** @@ -26,7 +26,7 @@ export class EEExecutionsService extends ExecutionsService { const relations = ['shared', 'shared.user', 'shared.role']; - const workflow = (await Container.get(WorkflowService).get( + const workflow = (await Container.get(WorkflowRepository).get( { id: execution.workflowId }, { relations }, )) as WorkflowWithSharingsAndCredentials; diff --git a/packages/cli/src/executions/executions.service.ts b/packages/cli/src/executions/executions.service.ts index 6790636346ddd..4e2d23ec20723 100644 --- a/packages/cli/src/executions/executions.service.ts +++ b/packages/cli/src/executions/executions.service.ts @@ -1,5 +1,13 @@ import { validate as jsonSchemaValidate } from 'jsonschema'; -import type { IWorkflowBase, JsonObject, ExecutionStatus } from 'n8n-workflow'; +import type { + IWorkflowBase, + JsonObject, + ExecutionStatus, + ExecutionError, + INode, + IRunExecutionData, + WorkflowExecuteMode, +} from 'n8n-workflow'; import { ApplicationError, jsonParse, Workflow, WorkflowOperationError } from 'n8n-workflow'; import type { FindOperator } from 'typeorm'; import { In } from 'typeorm'; @@ -7,9 +15,11 @@ import { ActiveExecutions } from '@/ActiveExecutions'; import config from '@/config'; import type { User } from '@db/entities/User'; import type { + ExecutionPayload, IExecutionFlattedResponse, IExecutionResponse, IExecutionsListResponse, + IWorkflowDb, IWorkflowExecutionDataProcess, } from '@/Interfaces'; import { NodeTypes } from '@/NodeTypes'; @@ -18,7 +28,7 @@ import type { ExecutionRequest } from '@/requests'; import { getSharedWorkflowIds } from '@/WorkflowHelpers'; import { WorkflowRunner } from '@/WorkflowRunner'; import * as GenericHelpers from '@/GenericHelpers'; -import { Container } from 'typedi'; +import { Container, Service } from 'typedi'; import { getStatusUsingPreviousExecutionStatusMethod } from './executionHelpers'; import { ExecutionRepository } from '@db/repositories/execution.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; @@ -75,6 +85,7 @@ const schemaGetExecutionsQueryFilter = { const allowedExecutionsQueryFilterFields = Object.keys(schemaGetExecutionsQueryFilter.properties); +@Service() export class ExecutionsService { /** * Function to get the workflow Ids for a User @@ -362,4 +373,75 @@ export class ExecutionsService { }, ); } + + async createErrorExecution( + error: ExecutionError, + node: INode, + workflowData: IWorkflowDb, + workflow: Workflow, + mode: WorkflowExecuteMode, + ): Promise { + const saveDataErrorExecutionDisabled = + workflowData?.settings?.saveDataErrorExecution === 'none'; + + if (saveDataErrorExecutionDisabled) return; + + const executionData: IRunExecutionData = { + startData: { + destinationNode: node.name, + runNodeFilter: [node.name], + }, + executionData: { + contextData: {}, + metadata: {}, + nodeExecutionStack: [ + { + node, + data: { + main: [ + [ + { + json: {}, + pairedItem: { + item: 0, + }, + }, + ], + ], + }, + source: null, + }, + ], + waitingExecution: {}, + waitingExecutionSource: {}, + }, + resultData: { + runData: { + [node.name]: [ + { + startTime: 0, + executionTime: 0, + error, + source: [], + }, + ], + }, + error, + lastNodeExecuted: node.name, + }, + }; + + const fullExecutionData: ExecutionPayload = { + data: executionData, + mode, + finished: false, + startedAt: new Date(), + workflowData, + workflowId: workflow.id, + stoppedAt: new Date(), + status: 'error', + }; + + await Container.get(ExecutionRepository).createNewExecution(fullExecutionData); + } } diff --git a/packages/cli/src/push/abstract.push.ts b/packages/cli/src/push/abstract.push.ts index f990e212f695f..42adadaa7beae 100644 --- a/packages/cli/src/push/abstract.push.ts +++ b/packages/cli/src/push/abstract.push.ts @@ -94,4 +94,17 @@ export abstract class AbstractPush extends EventEmitter { this.sendToSessions(type, data, userSessionIds); } + + /** + * Closes all push existing connections + */ + closeAllConnections() { + for (const sessionId in this.connections) { + // Signal the connection that we want to close it. + // We are not removing the sessions here because it should be + // the implementation's responsibility to do so once the connection + // has actually closed. + this.close(this.connections[sessionId]); + } + } } diff --git a/packages/cli/src/push/index.ts b/packages/cli/src/push/index.ts index e89c5a7a5b551..d8705a475c5fe 100644 --- a/packages/cli/src/push/index.ts +++ b/packages/cli/src/push/index.ts @@ -14,6 +14,7 @@ import { WebSocketPush } from './websocket.push'; import type { PushResponse, SSEPushRequest, WebSocketPushRequest } from './types'; import type { IPushDataType } from '@/Interfaces'; import type { User } from '@db/entities/User'; +import { OnShutdown } from '@/decorators/OnShutdown'; const useWebSockets = config.getEnv('push.backend') === 'websocket'; @@ -70,6 +71,11 @@ export class Push extends EventEmitter { sendToUsers(type: IPushDataType, data: D, userIds: Array) { this.backend.sendToUsers(type, data, userIds); } + + @OnShutdown() + onShutdown(): void { + this.backend.closeAllConnections(); + } } export const setupPushServer = (restEndpoint: string, server: Server, app: Application) => { diff --git a/packages/cli/src/security-audit/risk-reporters/CredentialsRiskReporter.ts b/packages/cli/src/security-audit/risk-reporters/CredentialsRiskReporter.ts index 64adce7c88d7a..869aa7c7e3ab5 100644 --- a/packages/cli/src/security-audit/risk-reporters/CredentialsRiskReporter.ts +++ b/packages/cli/src/security-audit/risk-reporters/CredentialsRiskReporter.ts @@ -1,5 +1,3 @@ -import { In, MoreThanOrEqual } from 'typeorm'; -import { DateUtils } from 'typeorm/util/DateUtils'; import { Service } from 'typedi'; import type { IWorkflowBase } from 'n8n-workflow'; import config from '@/config'; @@ -119,23 +117,9 @@ export class CredentialsRiskReporter implements RiskReporter { date.setDate(date.getDate() - days); - const executionIds = await this.executionRepository - .find({ - select: ['id'], - where: { - startedAt: MoreThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(date) as Date), - }, - }) - .then((executions) => executions.map(({ id }) => id)); - - return this.executionDataRepository - .find({ - select: ['workflowData'], - where: { - executionId: In(executionIds), - }, - }) - .then((executionData) => executionData.map(({ workflowData }) => workflowData)); + const executionIds = await this.executionRepository.getIdsSince(date); + + return this.executionDataRepository.findByExecutionIds(executionIds); } /** diff --git a/packages/cli/src/services/activeWorkflows.service.ts b/packages/cli/src/services/activeWorkflows.service.ts new file mode 100644 index 0000000000000..7684cb32756ff --- /dev/null +++ b/packages/cli/src/services/activeWorkflows.service.ts @@ -0,0 +1,52 @@ +import { Service } from 'typedi'; + +import type { User } from '@db/entities/User'; +import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import { WorkflowRepository } from '@db/repositories/workflow.repository'; +import { ActivationErrorsService } from '@/ActivationErrors.service'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { Logger } from '@/Logger'; + +@Service() +export class ActiveWorkflowsService { + constructor( + private readonly logger: Logger, + private readonly workflowRepository: WorkflowRepository, + private readonly sharedWorkflowRepository: SharedWorkflowRepository, + private readonly activationErrorsService: ActivationErrorsService, + ) {} + + async getAllActiveIdsInStorage() { + const activationErrors = await this.activationErrorsService.getAll(); + const activeWorkflowIds = await this.workflowRepository.getActiveIds(); + return activeWorkflowIds.filter((workflowId) => !activationErrors[workflowId]); + } + + async getAllActiveIdsFor(user: User) { + const activationErrors = await this.activationErrorsService.getAll(); + const activeWorkflowIds = await this.workflowRepository.getActiveIds(); + + const hasFullAccess = user.hasGlobalScope('workflow:list'); + if (hasFullAccess) { + return activeWorkflowIds.filter((workflowId) => !activationErrors[workflowId]); + } + + const sharedWorkflowIds = + await this.sharedWorkflowRepository.getSharedWorkflowIds(activeWorkflowIds); + return sharedWorkflowIds.filter((workflowId) => !activationErrors[workflowId]); + } + + async getActivationError(workflowId: string, user: User) { + const hasAccess = await this.sharedWorkflowRepository.hasAccess(workflowId, user); + if (!hasAccess) { + this.logger.verbose('User attempted to access workflow errors without permissions', { + workflowId, + userId: user.id, + }); + + throw new BadRequestError(`Workflow with ID "${workflowId}" could not be found.`); + } + + return this.activationErrorsService.get(workflowId); + } +} diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index fe7d6141ba3d0..cac41c7fd77fb 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -12,18 +12,16 @@ import type { } from 'n8n-workflow'; import { InstanceSettings } from 'n8n-core'; +import config from '@/config'; import { LICENSE_FEATURES } from '@/constants'; import { CredentialsOverwrites } from '@/CredentialsOverwrites'; import { CredentialTypes } from '@/CredentialTypes'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { License } from '@/License'; -import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; -import * as WebhookHelpers from '@/WebhookHelpers'; -import config from '@/config'; import { getCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; import { getLdapLoginLabel } from '@/Ldap/helpers'; import { getSamlLoginLabel } from '@/sso/saml/samlHelpers'; -import { getVariablesLimit } from '@/environments/variables/enviromentHelpers'; +import { getVariablesLimit } from '@/environments/variables/environmentHelpers'; import { getWorkflowHistoryLicensePruneTime, getWorkflowHistoryPruneTime, @@ -31,6 +29,7 @@ import { import { UserManagementMailer } from '@/UserManagement/email'; import type { CommunityPackagesService } from '@/services/communityPackages.service'; import { Logger } from '@/Logger'; +import { UrlService } from './url.service'; @Service() export class FrontendService { @@ -46,6 +45,7 @@ export class FrontendService { private readonly license: License, private readonly mailer: UserManagementMailer, private readonly instanceSettings: InstanceSettings, + private readonly urlService: UrlService, ) { loadNodesAndCredentials.addPostProcessor(async () => this.generateTypes()); void this.generateTypes(); @@ -61,7 +61,7 @@ export class FrontendService { } private initSettings() { - const instanceBaseUrl = getInstanceBaseUrl(); + const instanceBaseUrl = this.urlService.getInstanceBaseUrl(); const restEndpoint = config.getEnv('endpoints.rest'); const telemetrySettings: ITelemetrySettings = { @@ -93,7 +93,7 @@ export class FrontendService { maxExecutionTimeout: config.getEnv('executions.maxTimeout'), workflowCallerPolicyDefaultOption: config.getEnv('workflows.callerPolicyDefaultOption'), timezone: config.getEnv('generic.timezone'), - urlBaseWebhook: WebhookHelpers.getWebhookBaseUrl(), + urlBaseWebhook: this.urlService.getWebhookBaseUrl(), urlBaseEditor: instanceBaseUrl, versionCli: '', releaseChannel: config.getEnv('generic.releaseChannel'), @@ -222,8 +222,8 @@ export class FrontendService { const restEndpoint = config.getEnv('endpoints.rest'); // Update all urls, in case `WEBHOOK_URL` was updated by `--tunnel` - const instanceBaseUrl = getInstanceBaseUrl(); - this.settings.urlBaseWebhook = WebhookHelpers.getWebhookBaseUrl(); + const instanceBaseUrl = this.urlService.getInstanceBaseUrl(); + this.settings.urlBaseWebhook = this.urlService.getWebhookBaseUrl(); this.settings.urlBaseEditor = instanceBaseUrl; this.settings.oauthCallbackUrls = { oauth1: `${instanceBaseUrl}/${restEndpoint}/oauth1-credential/callback`, diff --git a/packages/cli/src/services/naming.service.ts b/packages/cli/src/services/naming.service.ts index 85539bdc32947..3b49e488038ed 100644 --- a/packages/cli/src/services/naming.service.ts +++ b/packages/cli/src/services/naming.service.ts @@ -1,5 +1,4 @@ import { Service } from 'typedi'; -import { Like } from 'typeorm'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; @@ -21,10 +20,7 @@ export class NamingService { private async getUniqueName(requestedName: string, entity: 'workflow' | 'credential') { const repository = entity === 'workflow' ? this.workflowRepository : this.credentialsRepository; - const found: Array<{ name: string }> = await repository.find({ - select: ['name'], - where: { name: Like(`${requestedName}%`) }, - }); + const found = await repository.findStartingWith(requestedName); if (found.length === 0) return requestedName; diff --git a/packages/cli/src/services/pruning.service.ts b/packages/cli/src/services/pruning.service.ts index 0727881a0857b..f2b142bf1f488 100644 --- a/packages/cli/src/services/pruning.service.ts +++ b/packages/cli/src/services/pruning.service.ts @@ -1,15 +1,11 @@ import { Service } from 'typedi'; import { BinaryDataService } from 'n8n-core'; -import type { FindOptionsWhere } from 'typeorm'; -import { Brackets, In, IsNull, LessThanOrEqual, Not } from 'typeorm'; -import { DateUtils } from 'typeorm/util/DateUtils'; - import { inTest, TIME } from '@/constants'; import config from '@/config'; import { ExecutionRepository } from '@db/repositories/execution.repository'; import { Logger } from '@/Logger'; -import { ExecutionEntity } from '@db/entities/ExecutionEntity'; import { jsonStringify } from 'n8n-workflow'; +import { OnShutdown } from '@/decorators/OnShutdown'; @Service() export class PruningService { @@ -24,6 +20,8 @@ export class PruningService { public hardDeletionTimeout: NodeJS.Timeout | undefined; + private isShuttingDown = false; + constructor( private readonly logger: Logger, private readonly executionRepository: ExecutionRepository, @@ -54,6 +52,11 @@ export class PruningService { * @important Call this method only after DB migrations have completed. */ startPruning() { + if (this.isShuttingDown) { + this.logger.warn('[Pruning] Cannot start pruning while shutting down'); + return; + } + this.logger.debug('[Pruning] Starting soft-deletion and hard-deletion timers'); this.setSoftDeletionInterval(); @@ -105,50 +108,7 @@ export class PruningService { async softDeleteOnPruningCycle() { this.logger.debug('[Pruning] Starting soft-deletion of executions'); - const maxAge = config.getEnv('executions.pruneDataMaxAge'); // in h - const maxCount = config.getEnv('executions.pruneDataMaxCount'); - - // Find ids of all executions that were stopped longer that pruneDataMaxAge ago - const date = new Date(); - date.setHours(date.getHours() - maxAge); - - const toPrune: Array> = [ - // date reformatting needed - see https://github.com/typeorm/typeorm/issues/2286 - { stoppedAt: LessThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(date)) }, - ]; - - if (maxCount > 0) { - const executions = await this.executionRepository.find({ - select: ['id'], - skip: maxCount, - take: 1, - order: { id: 'DESC' }, - }); - - if (executions[0]) { - toPrune.push({ id: LessThanOrEqual(executions[0].id) }); - } - } - - const [timeBasedWhere, countBasedWhere] = toPrune; - - const result = await this.executionRepository - .createQueryBuilder() - .update(ExecutionEntity) - .set({ deletedAt: new Date() }) - .where({ - deletedAt: IsNull(), - // Only mark executions as deleted if they are in an end state - status: Not(In(['new', 'running', 'waiting'])), - }) - .andWhere( - new Brackets((qb) => - countBasedWhere - ? qb.where(timeBasedWhere).orWhere(countBasedWhere) - : qb.where(timeBasedWhere), - ), - ) - .execute(); + const result = await this.executionRepository.softDeletePrunableExecutions(); if (result.affected === 0) { this.logger.debug('[Pruning] Found no executions to soft-delete'); @@ -158,45 +118,33 @@ export class PruningService { this.logger.debug('[Pruning] Soft-deleted executions', { count: result.affected }); } + @OnShutdown() + shutdown(): void { + this.isShuttingDown = true; + this.stopPruning(); + } + /** * Permanently remove all soft-deleted executions and their binary data, in a pruning cycle. * @return Delay in ms after which the next cycle should be started */ private async hardDeleteOnPruningCycle() { - const date = new Date(); - date.setHours(date.getHours() - config.getEnv('executions.pruneDataHardDeleteBuffer')); - - const workflowIdsAndExecutionIds = ( - await this.executionRepository.find({ - select: ['workflowId', 'id'], - where: { - deletedAt: LessThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(date)), - }, - take: this.hardDeletionBatchSize, - - /** - * @important This ensures soft-deleted executions are included, - * else `@DeleteDateColumn()` at `deletedAt` will exclude them. - */ - withDeleted: true, - }) - ).map(({ id: executionId, workflowId }) => ({ workflowId, executionId })); - - const executionIds = workflowIdsAndExecutionIds.map((o) => o.executionId); + const ids = await this.executionRepository.hardDeleteSoftDeletedExecutions(); + + const executionIds = ids.map((o) => o.executionId); if (executionIds.length === 0) { this.logger.debug('[Pruning] Found no executions to hard-delete'); + return this.rates.hardDeletion; } try { - this.logger.debug('[Pruning] Starting hard-deletion of executions', { - executionIds, - }); + this.logger.debug('[Pruning] Starting hard-deletion of executions', { executionIds }); - await this.binaryDataService.deleteMany(workflowIdsAndExecutionIds); + await this.binaryDataService.deleteMany(ids); - await this.executionRepository.delete({ id: In(executionIds) }); + await this.executionRepository.deleteByIds(executionIds); this.logger.debug('[Pruning] Hard-deleted executions', { executionIds }); } catch (error) { @@ -211,7 +159,7 @@ export class PruningService { * to prevent high concurrency from causing duplicate deletions. */ const isHighVolume = executionIds.length >= this.hardDeletionBatchSize; - const rate = isHighVolume ? 1 * TIME.SECOND : this.rates.hardDeletion; - return rate; + + return isHighVolume ? 1 * TIME.SECOND : this.rates.hardDeletion; } } diff --git a/packages/cli/src/services/url.service.ts b/packages/cli/src/services/url.service.ts new file mode 100644 index 0000000000000..b1ef737c2f43e --- /dev/null +++ b/packages/cli/src/services/url.service.ts @@ -0,0 +1,40 @@ +import { Service } from 'typedi'; +import config from '@/config'; + +@Service() +export class UrlService { + /** Returns the base URL n8n is reachable from */ + readonly baseUrl: string; + + constructor() { + this.baseUrl = this.generateBaseUrl(); + } + + /** Returns the base URL of the webhooks */ + getWebhookBaseUrl() { + let urlBaseWebhook = process.env.WEBHOOK_URL ?? this.baseUrl; + if (!urlBaseWebhook.endsWith('/')) { + urlBaseWebhook += '/'; + } + return urlBaseWebhook; + } + + /** Return the n8n instance base URL without trailing slash */ + getInstanceBaseUrl(): string { + const n8nBaseUrl = config.getEnv('editorBaseUrl') || this.getWebhookBaseUrl(); + + return n8nBaseUrl.endsWith('/') ? n8nBaseUrl.slice(0, n8nBaseUrl.length - 1) : n8nBaseUrl; + } + + private generateBaseUrl(): string { + const protocol = config.getEnv('protocol'); + const host = config.getEnv('host'); + const port = config.getEnv('port'); + const path = config.getEnv('path'); + + if ((protocol === 'http' && port === 80) || (protocol === 'https' && port === 443)) { + return `${protocol}://${host}${path}`; + } + return `${protocol}://${host}:${port}${path}`; + } +} diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index 41037f26c27c1..cae8c40c3c911 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -1,10 +1,9 @@ -import Container, { Service } from 'typedi'; +import { Container, Service } from 'typedi'; import type { EntityManager, FindManyOptions, FindOneOptions, FindOptionsWhere } from 'typeorm'; import { In } from 'typeorm'; import { User } from '@db/entities/User'; import type { IUserSettings } from 'n8n-workflow'; import { UserRepository } from '@db/repositories/user.repository'; -import { generateUserInviteUrl, getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; import type { PublicUser } from '@/Interfaces'; import type { PostHogClient } from '@/posthog'; import { type JwtPayload, JwtService } from './jwt.service'; @@ -14,6 +13,7 @@ import { createPasswordSha } from '@/auth/jwt'; import { UserManagementMailer } from '@/UserManagement/email'; import { InternalHooks } from '@/InternalHooks'; import { RoleService } from '@/services/role.service'; +import { UrlService } from '@/services/url.service'; import { ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; import type { UserRequest } from '@/requests'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; @@ -26,6 +26,7 @@ export class UserService { private readonly jwtService: JwtService, private readonly mailer: UserManagementMailer, private readonly roleService: RoleService, + private readonly urlService: UrlService, ) {} async findOne(options: FindOneOptions) { @@ -78,7 +79,7 @@ export class UserService { } generatePasswordResetUrl(user: User) { - const instanceBaseUrl = getInstanceBaseUrl(); + const instanceBaseUrl = this.urlService.getInstanceBaseUrl(); const url = new URL(`${instanceBaseUrl}/change-password`); url.searchParams.append('token', this.generatePasswordResetToken(user)); @@ -161,7 +162,7 @@ export class UserService { } private addInviteUrl(inviterId: string, invitee: PublicUser) { - const url = new URL(getInstanceBaseUrl()); + const url = new URL(this.urlService.getInstanceBaseUrl()); url.pathname = '/signup'; url.searchParams.set('inviterId', inviterId); url.searchParams.set('inviteeId', invitee.id); @@ -193,11 +194,11 @@ export class UserService { toInviteUsers: { [key: string]: string }, role: 'member' | 'admin', ) { - const domain = getInstanceBaseUrl(); + const domain = this.urlService.getInstanceBaseUrl(); return Promise.all( Object.entries(toInviteUsers).map(async ([email, id]) => { - const inviteAcceptUrl = generateUserInviteUrl(owner.id, id); + const inviteAcceptUrl = `${domain}/signup?inviterId=${owner.id}&inviteeId=${id}`; const invitedUser: UserRequest.InviteResponse = { user: { id, diff --git a/packages/cli/src/shutdown/Shutdown.service.ts b/packages/cli/src/shutdown/Shutdown.service.ts new file mode 100644 index 0000000000000..b52d8ab11a0e3 --- /dev/null +++ b/packages/cli/src/shutdown/Shutdown.service.ts @@ -0,0 +1,85 @@ +import { Container, Service } from 'typedi'; +import { ApplicationError, ErrorReporterProxy, assert } from 'n8n-workflow'; +import { Logger } from '@/Logger'; + +export interface ServiceClass { + new (): Record Promise | void>; +} + +export interface ShutdownHandler { + serviceClass: ServiceClass; + methodName: string; +} + +/** Error reported when a listener fails to shutdown gracefully */ +export class ComponentShutdownError extends ApplicationError { + constructor(componentName: string, cause: Error) { + super('Failed to shutdown gracefully', { + level: 'error', + cause, + extra: { component: componentName }, + }); + } +} + +/** Service responsible for orchestrating a graceful shutdown of the application */ +@Service() +export class ShutdownService { + private readonly handlersByPriority: ShutdownHandler[][] = []; + + private shutdownPromise: Promise | undefined; + + constructor(private readonly logger: Logger) {} + + /** Registers given listener to be notified when the application is shutting down */ + register(priority: number, handler: ShutdownHandler) { + if (!this.handlersByPriority[priority]) { + this.handlersByPriority[priority] = []; + } + this.handlersByPriority[priority].push(handler); + } + + /** Signals all registered listeners that the application is shutting down */ + shutdown() { + if (this.shutdownPromise) { + throw new ApplicationError('App is already shutting down'); + } + + this.shutdownPromise = this.startShutdown(); + } + + /** Returns a promise that resolves when all the registered listeners have shut down */ + async waitForShutdown(): Promise { + if (!this.shutdownPromise) { + throw new ApplicationError('App is not shutting down'); + } + + await this.shutdownPromise; + } + + isShuttingDown() { + return !!this.shutdownPromise; + } + + private async startShutdown() { + const handlers = Object.values(this.handlersByPriority).reverse(); + for (const handlerGroup of handlers) { + await Promise.allSettled( + handlerGroup.map(async (handler) => this.shutdownComponent(handler)), + ); + } + } + + private async shutdownComponent({ serviceClass, methodName }: ShutdownHandler) { + const name = `${serviceClass.name}.${methodName}()`; + try { + this.logger.debug(`Shutting down component "${name}"`); + const service = Container.get(serviceClass); + const method = service[methodName]; + await method.call(service); + } catch (error) { + assert(error instanceof Error); + ErrorReporterProxy.error(new ComponentShutdownError(name, error)); + } + } +} diff --git a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts index 85958e83a6da8..eeaf74bf145f5 100644 --- a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts +++ b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts @@ -1,6 +1,5 @@ import express from 'express'; import { Container, Service } from 'typedi'; -import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; import { Authorized, Get, @@ -35,12 +34,16 @@ import url from 'url'; import querystring from 'querystring'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { AuthError } from '@/errors/response-errors/auth.error'; +import { UrlService } from '@/services/url.service'; @Service() @Authorized() @RestController('/sso/saml') export class SamlController { - constructor(private samlService: SamlService) {} + constructor( + private readonly samlService: SamlService, + private readonly urlService: UrlService, + ) {} @NoAuthRequired() @Get(SamlUrls.metadata) @@ -147,10 +150,10 @@ export class SamlController { if (isSamlLicensedAndEnabled()) { await issueCookie(res, loginResult.authenticatedUser); if (loginResult.onboardingRequired) { - return res.redirect(getInstanceBaseUrl() + SamlUrls.samlOnboarding); + return res.redirect(this.urlService.getInstanceBaseUrl() + SamlUrls.samlOnboarding); } else { const redirectUrl = req.body?.RelayState ?? SamlUrls.defaultRedirect; - return res.redirect(getInstanceBaseUrl() + redirectUrl); + return res.redirect(this.urlService.getInstanceBaseUrl() + redirectUrl); } } else { return res.status(202).send(loginResult.attributes); diff --git a/packages/cli/src/sso/saml/saml.service.ee.ts b/packages/cli/src/sso/saml/saml.service.ee.ts index 20ce62749ef7b..2a20c25c7d5a2 100644 --- a/packages/cli/src/sso/saml/saml.service.ee.ts +++ b/packages/cli/src/sso/saml/saml.service.ee.ts @@ -24,12 +24,12 @@ import axios from 'axios'; import https from 'https'; import type { SamlLoginBinding } from './types'; import { validateMetadata, validateResponse } from './samlValidator'; -import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; import { Logger } from '@/Logger'; import { UserRepository } from '@db/repositories/user.repository'; import { SettingsRepository } from '@db/repositories/settings.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { AuthError } from '@/errors/response-errors/auth.error'; +import { UrlService } from '@/services/url.service'; @Service() export class SamlService { @@ -55,7 +55,7 @@ export class SamlService { loginLabel: 'SAML', wantAssertionsSigned: true, wantMessageSigned: true, - relayState: getInstanceBaseUrl(), + relayState: this.urlService.getInstanceBaseUrl(), signatureConfig: { prefix: 'ds', location: { @@ -73,7 +73,10 @@ export class SamlService { }; } - constructor(private readonly logger: Logger) {} + constructor( + private readonly logger: Logger, + private readonly urlService: UrlService, + ) {} async init(): Promise { // load preferences first but do not apply so as to not load samlify unnecessarily @@ -143,14 +146,14 @@ export class SamlService { private getRedirectLoginRequestUrl(relayState?: string): BindingContext { const sp = this.getServiceProviderInstance(); - sp.entitySetting.relayState = relayState ?? getInstanceBaseUrl(); + sp.entitySetting.relayState = relayState ?? this.urlService.getInstanceBaseUrl(); const loginRequest = sp.createLoginRequest(this.getIdentityProviderInstance(), 'redirect'); return loginRequest; } private getPostLoginRequestUrl(relayState?: string): PostBindingContext { const sp = this.getServiceProviderInstance(); - sp.entitySetting.relayState = relayState ?? getInstanceBaseUrl(); + sp.entitySetting.relayState = relayState ?? this.urlService.getInstanceBaseUrl(); const loginRequest = sp.createLoginRequest( this.getIdentityProviderInstance(), 'post', diff --git a/packages/cli/src/sso/saml/serviceProvider.ee.ts b/packages/cli/src/sso/saml/serviceProvider.ee.ts index fa1fde62832d7..372d277b30386 100644 --- a/packages/cli/src/sso/saml/serviceProvider.ee.ts +++ b/packages/cli/src/sso/saml/serviceProvider.ee.ts @@ -1,21 +1,22 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; +import { Container } from 'typedi'; import type { ServiceProviderInstance } from 'samlify'; +import { UrlService } from '@/services/url.service'; import { SamlUrls } from './constants'; import type { SamlPreferences } from './types/samlPreferences'; let serviceProviderInstance: ServiceProviderInstance | undefined; export function getServiceProviderEntityId(): string { - return getInstanceBaseUrl() + SamlUrls.restMetadata; + return Container.get(UrlService).getInstanceBaseUrl() + SamlUrls.restMetadata; } export function getServiceProviderReturnUrl(): string { - return getInstanceBaseUrl() + SamlUrls.restAcs; + return Container.get(UrlService).getInstanceBaseUrl() + SamlUrls.restAcs; } export function getServiceProviderConfigTestReturnUrl(): string { - return getInstanceBaseUrl() + SamlUrls.configTestReturn; + return Container.get(UrlService).getInstanceBaseUrl() + SamlUrls.configTestReturn; } // TODO:SAML: make these configurable for the end user diff --git a/packages/cli/src/workflows/workflow.service.ee.ts b/packages/cli/src/workflows/workflow.service.ee.ts index 57c2e88ea8c23..f665bf95fd36f 100644 --- a/packages/cli/src/workflows/workflow.service.ee.ts +++ b/packages/cli/src/workflows/workflow.service.ee.ts @@ -1,9 +1,8 @@ -import type { DeleteResult, EntityManager } from 'typeorm'; -import { In, Not } from 'typeorm'; +import type { EntityManager } from 'typeorm'; import * as WorkflowHelpers from '@/WorkflowHelpers'; -import { SharedWorkflow } from '@db/entities/SharedWorkflow'; +import type { SharedWorkflow } from '@db/entities/SharedWorkflow'; import type { User } from '@db/entities/User'; -import { WorkflowEntity } from '@db/entities/WorkflowEntity'; +import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { UserService } from '@/services/user.service'; import { WorkflowService } from './workflow.service'; import type { @@ -18,6 +17,7 @@ import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; @Service() export class EnterpriseWorkflowService { @@ -26,6 +26,7 @@ export class EnterpriseWorkflowService { private readonly userService: UserService, private readonly roleService: RoleService, private readonly sharedWorkflowRepository: SharedWorkflowRepository, + private readonly workflowRepository: WorkflowRepository, ) {} async isOwned( @@ -46,29 +47,6 @@ export class EnterpriseWorkflowService { return { ownsWorkflow: true, workflow }; } - async getSharings( - transaction: EntityManager, - workflowId: string, - relations = ['shared'], - ): Promise { - const workflow = await transaction.findOne(WorkflowEntity, { - where: { id: workflowId }, - relations, - }); - return workflow?.shared ?? []; - } - - async pruneSharings( - transaction: EntityManager, - workflowId: string, - userIds: string[], - ): Promise { - return transaction.delete(SharedWorkflow, { - workflowId, - userId: Not(In(userIds)), - }); - } - async share( transaction: EntityManager, workflow: WorkflowEntity, @@ -182,7 +160,7 @@ export class EnterpriseWorkflowService { } async preventTampering(workflow: WorkflowEntity, workflowId: string, user: User) { - const previousVersion = await this.workflowService.get({ id: workflowId }); + const previousVersion = await this.workflowRepository.get({ id: workflowId }); if (!previousVersion) { throw new NotFoundError('Workflow not found'); diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index d4854d123dff0..5d98775d5786f 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -1,8 +1,7 @@ import Container, { Service } from 'typedi'; -import type { IDataObject, INode, IPinData } from 'n8n-workflow'; -import { NodeApiError, ErrorReporterProxy as ErrorReporter, Workflow } from 'n8n-workflow'; -import type { FindManyOptions, FindOptionsSelect, FindOptionsWhere, UpdateResult } from 'typeorm'; -import { In, Like } from 'typeorm'; +import type { INode, IPinData } from 'n8n-workflow'; +import { NodeApiError, Workflow } from 'n8n-workflow'; +import type { FindOptionsWhere } from 'typeorm'; import pick from 'lodash/pick'; import omit from 'lodash/omit'; import { v4 as uuid } from 'uuid'; @@ -14,7 +13,7 @@ import type { User } from '@db/entities/User'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { validateEntity } from '@/GenericHelpers'; import { ExternalHooks } from '@/ExternalHooks'; -import { type WorkflowRequest, type ListQuery, hasSharing } from '@/requests'; +import { type WorkflowRequest, hasSharing, type ListQuery } from '@/requests'; import { TagService } from '@/services/tag.service'; import type { IWorkflowDb, IWorkflowExecutionDataProcess } from '@/Interfaces'; import { NodeTypes } from '@/NodeTypes'; @@ -25,7 +24,6 @@ import { whereClause } from '@/UserManagement/UserManagementHelper'; import { InternalHooks } from '@/InternalHooks'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { OwnershipService } from '@/services/ownership.service'; -import { isStringArray, isWorkflowIdValid } from '@/utils'; import { WorkflowHistoryService } from './workflowHistory/workflowHistory.service.ee'; import { BinaryDataService } from 'n8n-core'; import type { Scope } from '@n8n/permissions'; @@ -120,82 +118,8 @@ export class WorkflowService { return pinnedTriggers.find((pt) => pt.name === checkNodeName) ?? null; // partial execution } - async get(workflow: FindOptionsWhere, options?: { relations: string[] }) { - return this.workflowRepository.findOne({ - where: workflow, - relations: options?.relations, - }); - } - async getMany(sharedWorkflowIds: string[], options?: ListQuery.Options) { - if (sharedWorkflowIds.length === 0) return { workflows: [], count: 0 }; - - const where: FindOptionsWhere = { - ...options?.filter, - id: In(sharedWorkflowIds), - }; - - const reqTags = options?.filter?.tags; - - if (isStringArray(reqTags)) { - where.tags = reqTags.map((tag) => ({ name: tag })); - } - - type Select = FindOptionsSelect & { ownedBy?: true }; - - const select: Select = options?.select - ? { ...options.select } // copy to enable field removal without affecting original - : { - name: true, - active: true, - createdAt: true, - updatedAt: true, - versionId: true, - shared: { userId: true, roleId: true }, - }; - - delete select?.ownedBy; // remove non-entity field, handled after query - - const relations: string[] = []; - - const areTagsEnabled = !config.getEnv('workflowTagsDisabled'); - const isDefaultSelect = options?.select === undefined; - const areTagsRequested = isDefaultSelect || options?.select?.tags === true; - const isOwnedByIncluded = isDefaultSelect || options?.select?.ownedBy === true; - - if (areTagsEnabled && areTagsRequested) { - relations.push('tags'); - select.tags = { id: true, name: true }; - } - - if (isOwnedByIncluded) relations.push('shared', 'shared.role', 'shared.user'); - - if (typeof where.name === 'string' && where.name !== '') { - where.name = Like(`%${where.name}%`); - } - - const findManyOptions: FindManyOptions = { - select: { ...select, id: true }, - where, - }; - - if (isDefaultSelect || options?.select?.updatedAt === true) { - findManyOptions.order = { updatedAt: 'ASC' }; - } - - if (relations.length > 0) { - findManyOptions.relations = relations; - } - - if (options?.take) { - findManyOptions.skip = options.skip; - findManyOptions.take = options.take; - } - - const [workflows, count] = (await this.workflowRepository.findAndCount(findManyOptions)) as [ - ListQuery.Workflow.Plain[] | ListQuery.Workflow.WithSharing[], - number, - ]; + const { workflows, count } = await this.workflowRepository.getMany(sharedWorkflowIds, options); return hasSharing(workflows) ? { @@ -512,56 +436,4 @@ export class WorkflowService { return sharedWorkflow.workflow; } - - async updateWorkflowTriggerCount(id: string, triggerCount: number): Promise { - const qb = this.workflowRepository.createQueryBuilder('workflow'); - return qb - .update() - .set({ - triggerCount, - updatedAt: () => { - if (['mysqldb', 'mariadb'].includes(config.getEnv('database.type'))) { - return 'updatedAt'; - } - return '"updatedAt"'; - }, - }) - .where('id = :id', { id }) - .execute(); - } - - /** - * Saves the static data if it changed - */ - async saveStaticData(workflow: Workflow): Promise { - if (workflow.staticData.__dataChanged === true) { - // Static data of workflow changed and so has to be saved - if (isWorkflowIdValid(workflow.id)) { - // Workflow is saved so update in database - try { - await this.saveStaticDataById(workflow.id, workflow.staticData); - workflow.staticData.__dataChanged = false; - } catch (error) { - ErrorReporter.error(error); - this.logger.error( - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - `There was a problem saving the workflow with id "${workflow.id}" to save changed Data: "${error.message}"`, - { workflowId: workflow.id }, - ); - } - } - } - } - - /** - * Saves the given static data on workflow - * - * @param {(string)} workflowId The id of the workflow to save data on - * @param {IDataObject} newStaticData The static data to save - */ - async saveStaticDataById(workflowId: string, newStaticData: IDataObject): Promise { - await this.workflowRepository.update(workflowId, { - staticData: newStaticData, - }); - } } diff --git a/packages/cli/src/workflows/workflowHistory/workflowHistoryManager.ee.ts b/packages/cli/src/workflows/workflowHistory/workflowHistoryManager.ee.ts index 8075c2461d29e..dac682c234af4 100644 --- a/packages/cli/src/workflows/workflowHistory/workflowHistoryManager.ee.ts +++ b/packages/cli/src/workflows/workflowHistory/workflowHistoryManager.ee.ts @@ -1,5 +1,4 @@ import { Service } from 'typedi'; -import { LessThan } from 'typeorm'; import { DateTime } from 'luxon'; import { WorkflowHistoryRepository } from '@db/repositories/workflowHistory.repository'; import { WORKFLOW_HISTORY_PRUNE_INTERVAL } from './constants'; @@ -38,8 +37,6 @@ export class WorkflowHistoryManager { } const pruneDateTime = DateTime.now().minus({ hours: pruneHours }).toJSDate(); - await this.workflowHistoryRepo.delete({ - createdAt: LessThan(pruneDateTime), - }); + await this.workflowHistoryRepo.deleteEarlierThan(pruneDateTime); } } diff --git a/packages/cli/src/workflows/workflowStaticData.service.ts b/packages/cli/src/workflows/workflowStaticData.service.ts new file mode 100644 index 0000000000000..b569c69a3002d --- /dev/null +++ b/packages/cli/src/workflows/workflowStaticData.service.ts @@ -0,0 +1,41 @@ +import { Service } from 'typedi'; +import { type IDataObject, type Workflow, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; +import { Logger } from '@/Logger'; +import { WorkflowRepository } from '@db/repositories/workflow.repository'; +import { isWorkflowIdValid } from '@/utils'; + +@Service() +export class WorkflowStaticDataService { + constructor( + private readonly logger: Logger, + private readonly workflowRepository: WorkflowRepository, + ) {} + + /** Saves the static data if it changed */ + async saveStaticData(workflow: Workflow): Promise { + if (workflow.staticData.__dataChanged === true) { + // Static data of workflow changed and so has to be saved + if (isWorkflowIdValid(workflow.id)) { + // Workflow is saved so update in database + try { + await this.saveStaticDataById(workflow.id, workflow.staticData); + workflow.staticData.__dataChanged = false; + } catch (error) { + ErrorReporter.error(error); + this.logger.error( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + `There was a problem saving the workflow with id "${workflow.id}" to save changed Data: "${error.message}"`, + { workflowId: workflow.id }, + ); + } + } + } + } + + /** Saves the given static data on workflow */ + async saveStaticDataById(workflowId: string, newStaticData: IDataObject): Promise { + await this.workflowRepository.update(workflowId, { + staticData: newStaticData, + }); + } +} diff --git a/packages/cli/src/workflows/workflows.controller.ee.ts b/packages/cli/src/workflows/workflows.controller.ee.ts index 900c8d3660249..6026b9a034dda 100644 --- a/packages/cli/src/workflows/workflows.controller.ee.ts +++ b/packages/cli/src/workflows/workflows.controller.ee.ts @@ -14,7 +14,6 @@ import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { CredentialsService } from '../credentials/credentials.service'; import type { IExecutionPushResponse } from '@/Interfaces'; import * as GenericHelpers from '@/GenericHelpers'; -import { In } from 'typeorm'; import { Container } from 'typedi'; import { InternalHooks } from '@/InternalHooks'; import { RoleService } from '@/services/role.service'; @@ -28,6 +27,8 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { WorkflowService } from './workflow.service'; +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import { TagRepository } from '@/databases/repositories/tag.repository'; export const EEWorkflowController = express.Router(); @@ -80,7 +81,7 @@ EEWorkflowController.put( } const ownerIds = ( - await Container.get(EnterpriseWorkflowService).getSharings( + await Container.get(WorkflowRepository).getSharings( Db.getConnection().createEntityManager(), workflowId, ['shared', 'shared.role'], @@ -92,12 +93,12 @@ EEWorkflowController.put( let newShareeIds: string[] = []; await Db.transaction(async (trx) => { // remove all sharings that are not supposed to exist anymore - await Container.get(EnterpriseWorkflowService).pruneSharings(trx, workflowId, [ + await Container.get(WorkflowRepository).pruneSharings(trx, workflowId, [ ...ownerIds, ...shareWithIds, ]); - const sharings = await Container.get(EnterpriseWorkflowService).getSharings(trx, workflowId); + const sharings = await Container.get(WorkflowRepository).getSharings(trx, workflowId); // extract the new sharings that need to be added newShareeIds = rightDiff( @@ -129,7 +130,7 @@ EEWorkflowController.get( relations.push('tags'); } - const workflow = await Container.get(WorkflowService).get({ id: workflowId }, { relations }); + const workflow = await Container.get(WorkflowRepository).get({ id: workflowId }, { relations }); if (!workflow) { throw new NotFoundError(`Workflow with ID "${workflowId}" does not exist`); @@ -168,12 +169,7 @@ EEWorkflowController.post( const { tags: tagIds } = req.body; if (tagIds?.length && !config.getEnv('workflowTagsDisabled')) { - newWorkflow.tags = await Container.get(TagService).findMany({ - select: ['id', 'name'], - where: { - id: In(tagIds), - }, - }); + newWorkflow.tags = await Container.get(TagRepository).findMany(tagIds); } await WorkflowHelpers.replaceInvalidCredentials(newWorkflow); diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index b32ddce0ade9f..3ed338189c50f 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -17,7 +17,6 @@ import { isBelowOnboardingThreshold } from '@/WorkflowHelpers'; import { EEWorkflowController } from './workflows.controller.ee'; import { WorkflowService } from './workflow.service'; import { whereClause } from '@/UserManagement/UserManagementHelper'; -import { In } from 'typeorm'; import { Container } from 'typedi'; import { InternalHooks } from '@/InternalHooks'; import { RoleService } from '@/services/role.service'; @@ -31,6 +30,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { NamingService } from '@/services/naming.service'; +import { TagRepository } from '@/databases/repositories/tag.repository'; export const workflowsController = express.Router(); workflowsController.use('/', EEWorkflowController); @@ -56,12 +56,7 @@ workflowsController.post( const { tags: tagIds } = req.body; if (tagIds?.length && !config.getEnv('workflowTagsDisabled')) { - newWorkflow.tags = await Container.get(TagService).findMany({ - select: ['id', 'name'], - where: { - id: In(tagIds), - }, - }); + newWorkflow.tags = await Container.get(TagRepository).findMany(tagIds); } await WorkflowHelpers.replaceInvalidCredentials(newWorkflow); diff --git a/packages/cli/test/integration/ActiveWorkflowRunner.test.ts b/packages/cli/test/integration/ActiveWorkflowRunner.test.ts index 07a147a0ebd28..d4f2baa5e2d9f 100644 --- a/packages/cli/test/integration/ActiveWorkflowRunner.test.ts +++ b/packages/cli/test/integration/ActiveWorkflowRunner.test.ts @@ -2,7 +2,6 @@ import { Container } from 'typedi'; import { NodeApiError, NodeOperationError, Workflow } from 'n8n-workflow'; import type { IWebhookData, WorkflowActivateMode } from 'n8n-workflow'; -import { ActiveWorkflows } from 'n8n-core'; import { ActiveExecutions } from '@/ActiveExecutions'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; @@ -24,12 +23,14 @@ import { setSchedulerAsLoadedNode } from './shared/utils'; import * as testDb from './shared/testDb'; import { createOwner } from './shared/db/users'; import { createWorkflow } from './shared/db/workflows'; +import { ExecutionsService } from '@/executions/executions.service'; import { WorkflowService } from '@/workflows/workflow.service'; +import { ActiveWorkflowsService } from '@/services/activeWorkflows.service'; mockInstance(ActiveExecutions); -mockInstance(ActiveWorkflows); mockInstance(Push); mockInstance(SecretsHelper); +mockInstance(ExecutionsService); mockInstance(WorkflowService); const webhookService = mockInstance(WebhookService); @@ -43,6 +44,7 @@ setSchedulerAsLoadedNode(); const externalHooks = mockInstance(ExternalHooks); +let activeWorkflowsService: ActiveWorkflowsService; let activeWorkflowRunner: ActiveWorkflowRunner; let owner: User; @@ -57,6 +59,7 @@ const NON_LEADERSHIP_CHANGE_MODES: WorkflowActivateMode[] = [ beforeAll(async () => { await testDb.init(); + activeWorkflowsService = Container.get(ActiveWorkflowsService); activeWorkflowRunner = Container.get(ActiveWorkflowRunner); owner = await createOwner(); }); @@ -88,8 +91,8 @@ describe('init()', () => { test('should start with no active workflows', async () => { await activeWorkflowRunner.init(); - const inStorage = activeWorkflowRunner.allActiveInStorage(); - await expect(inStorage).resolves.toHaveLength(0); + const inStorage = await activeWorkflowsService.getAllActiveIdsInStorage(); + expect(inStorage).toHaveLength(0); const inMemory = activeWorkflowRunner.allActiveInMemory(); expect(inMemory).toHaveLength(0); @@ -100,8 +103,8 @@ describe('init()', () => { await activeWorkflowRunner.init(); - const inStorage = activeWorkflowRunner.allActiveInStorage(); - await expect(inStorage).resolves.toHaveLength(1); + const inStorage = await activeWorkflowsService.getAllActiveIdsInStorage(); + expect(inStorage).toHaveLength(1); const inMemory = activeWorkflowRunner.allActiveInMemory(); expect(inMemory).toHaveLength(1); @@ -113,8 +116,8 @@ describe('init()', () => { await activeWorkflowRunner.init(); - const inStorage = activeWorkflowRunner.allActiveInStorage(); - await expect(inStorage).resolves.toHaveLength(2); + const inStorage = await activeWorkflowsService.getAllActiveIdsInStorage(); + expect(inStorage).toHaveLength(2); const inMemory = activeWorkflowRunner.allActiveInMemory(); expect(inMemory).toHaveLength(2); diff --git a/packages/cli/test/integration/auth.mw.test.ts b/packages/cli/test/integration/auth.mw.test.ts index 8cc77968a123f..f958a630d8fee 100644 --- a/packages/cli/test/integration/auth.mw.test.ts +++ b/packages/cli/test/integration/auth.mw.test.ts @@ -2,8 +2,12 @@ import type { SuperAgentTest } from 'supertest'; import * as utils from './shared/utils/'; import { getGlobalMemberRole } from './shared/db/roles'; import { createUser } from './shared/db/users'; +import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; +import { mockInstance } from '../shared/mocking'; describe('Auth Middleware', () => { + mockInstance(ActiveWorkflowRunner); + const testServer = utils.setupTestServer({ endpointGroups: ['me', 'auth', 'owner', 'users', 'invitations'], }); diff --git a/packages/cli/test/integration/credentials.controller.test.ts b/packages/cli/test/integration/credentials.controller.test.ts index 12aa5231c4a93..3c122f2b4eb7f 100644 --- a/packages/cli/test/integration/credentials.controller.test.ts +++ b/packages/cli/test/integration/credentials.controller.test.ts @@ -1,4 +1,4 @@ -import type { Credentials } from '@/requests'; +import type { ListQuery } from '@/requests'; import type { User } from '@db/entities/User'; import * as testDb from './shared/testDb'; import { setupTestServer } from './shared/utils/'; @@ -21,7 +21,7 @@ beforeEach(async () => { member = await createMember(); }); -type GetAllResponse = { body: { data: Credentials.WithOwnedByAndSharedWith[] } }; +type GetAllResponse = { body: { data: ListQuery.Credentials.WithOwnedByAndSharedWith[] } }; describe('GET /credentials', () => { describe('should return', () => { @@ -278,7 +278,7 @@ describe('GET /credentials', () => { }); }); -function validateCredential(credential: Credentials.WithOwnedByAndSharedWith) { +function validateCredential(credential: ListQuery.Credentials.WithOwnedByAndSharedWith) { const { name, type, nodesAccess, sharedWith, ownedBy } = credential; expect(typeof name).toBe('string'); diff --git a/packages/cli/test/integration/database/repositories/execution.repository.test.ts b/packages/cli/test/integration/database/repositories/execution.repository.test.ts index 62cb57d2b8ac5..72ebaa95969b5 100644 --- a/packages/cli/test/integration/database/repositories/execution.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/execution.repository.test.ts @@ -24,6 +24,7 @@ describe('ExecutionRepository', () => { const executionId = await executionRepo.createNewExecution({ workflowId: workflow.id, data: { + //@ts-expect-error This is not needed for tests resultData: {}, }, workflowData: workflow, diff --git a/packages/cli/test/integration/publicApi/workflows.test.ts b/packages/cli/test/integration/publicApi/workflows.test.ts index e4de396a129f1..47b0c2c15fe1b 100644 --- a/packages/cli/test/integration/publicApi/workflows.test.ts +++ b/packages/cli/test/integration/publicApi/workflows.test.ts @@ -18,6 +18,7 @@ import { createWorkflow, createWorkflowWithTrigger } from '../shared/db/workflow import { createTag } from '../shared/db/tags'; import { mockInstance } from '../../shared/mocking'; import { Push } from '@/push'; +import { ExecutionsService } from '@/executions/executions.service'; let workflowOwnerRole: Role; let owner: User; @@ -30,6 +31,7 @@ const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); const license = testServer.license; mockInstance(Push); +mockInstance(ExecutionsService); beforeAll(async () => { const [globalOwnerRole, globalMemberRole, fetchedWorkflowOwnerRole] = await getAllRoles(); diff --git a/packages/cli/test/integration/shared/utils/index.ts b/packages/cli/test/integration/shared/utils/index.ts index f2b9d4e5e33e3..63fcd48621104 100644 --- a/packages/cli/test/integration/shared/utils/index.ts +++ b/packages/cli/test/integration/shared/utils/index.ts @@ -18,6 +18,7 @@ import { SettingsRepository } from '@db/repositories/settings.repository'; import { mockNodeTypesData } from '../../../unit/Helpers'; import { MultiMainSetup } from '@/services/orchestration/main/MultiMainSetup.ee'; import { mockInstance } from '../../../shared/mocking'; +import { ExecutionsService } from '@/executions/executions.service'; export { setupTestServer } from './testServer'; @@ -31,6 +32,7 @@ export { setupTestServer } from './testServer'; export async function initActiveWorkflowRunner() { mockInstance(MultiMainSetup); + mockInstance(ExecutionsService); const { ActiveWorkflowRunner } = await import('@/ActiveWorkflowRunner'); const workflowRunner = Container.get(ActiveWorkflowRunner); await workflowRunner.init(); diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index 4e18f13d85834..2ca6bb5350bd7 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -18,6 +18,10 @@ import * as testDb from './shared/testDb'; import type { SuperAgentTest } from 'supertest'; import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; +import { ExecutionsService } from '@/executions/executions.service'; +import { mockInstance } from '../shared/mocking'; + +mockInstance(ExecutionsService); const testServer = utils.setupTestServer({ endpointGroups: ['users'], diff --git a/packages/cli/test/unit/CredentialsHelper.test.ts b/packages/cli/test/unit/CredentialsHelper.test.ts index 648fa28918304..19be100b02b84 100644 --- a/packages/cli/test/unit/CredentialsHelper.test.ts +++ b/packages/cli/test/unit/CredentialsHelper.test.ts @@ -271,6 +271,7 @@ describe('CredentialsHelper', () => { for (const testData of tests) { test(testData.description, async () => { + //@ts-expect-error `loadedCredentials` is a getter and we are replacing it here with a property mockNodesAndCredentials.loadedCredentials = { [testData.input.credentialType.name]: { type: testData.input.credentialType, diff --git a/packages/cli/test/unit/InternalHooks.test.ts b/packages/cli/test/unit/InternalHooks.test.ts index 8d33fd4672f74..e91759daddb7c 100644 --- a/packages/cli/test/unit/InternalHooks.test.ts +++ b/packages/cli/test/unit/InternalHooks.test.ts @@ -41,6 +41,7 @@ describe('InternalHooks', () => { licensePlanName, licenseTenantId, binary_data_s3: false, + multi_main_setup_enabled: false, }; const parameters = { diff --git a/packages/cli/test/unit/Telemetry.test.ts b/packages/cli/test/unit/Telemetry.test.ts index d943d83e500c9..25f4a808675b0 100644 --- a/packages/cli/test/unit/Telemetry.test.ts +++ b/packages/cli/test/unit/Telemetry.test.ts @@ -48,7 +48,7 @@ describe('Telemetry', () => { const postHog = new PostHogClient(instanceSettings); await postHog.init(); - telemetry = new Telemetry(mock(), postHog, mock(), instanceSettings); + telemetry = new Telemetry(mock(), postHog, mock(), instanceSettings, mock()); (telemetry as any).rudderStack = mockRudderStack; }); diff --git a/packages/cli/test/unit/WorkflowCredentials.test.ts b/packages/cli/test/unit/WorkflowCredentials.test.ts deleted file mode 100644 index 504848dd51ee3..0000000000000 --- a/packages/cli/test/unit/WorkflowCredentials.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { FindOptionsWhere } from 'typeorm'; -import type { INode } from 'n8n-workflow'; -import { WorkflowCredentials } from '@/WorkflowCredentials'; -import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; -import { CredentialsRepository } from '@db/repositories/credentials.repository'; -import { mockInstance } from '../shared/mocking'; - -const credentialsRepository = mockInstance(CredentialsRepository); -credentialsRepository.findOneBy.mockImplementation( - async (where: FindOptionsWhere) => { - const { id, type } = where as { - id: string; - type: string; - }; - // Simple statement that maps a return value based on the `id` parameter - if (id === notFoundNode.credentials!.test.id) { - return null; - } - - // Otherwise just build some kind of credential object and return it - return { - [type]: { - [id]: { - id, - name: type, - type, - nodesAccess: [], - data: '', - }, - }, - } as unknown as CredentialsEntity; - }, -); - -// Create an array of Nodes with info that pass or fail the checks as required. -// DB returns an object of type { [id: string]: ICredentialsEncrypted } but as it isn't checked -// All the mock will need to return is some form of Object when successful - -describe('WorkflowCredentials', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - test('Should return an error if any node has no credential ID', async () => { - const credentials = noIdNode.credentials!.test; - const expectedError = new Error( - `Credentials with name "${credentials.name}" for type "test" miss an ID.`, - ); - await expect(WorkflowCredentials([noIdNode])).rejects.toEqual(expectedError); - expect(credentialsRepository.findOneBy).toHaveBeenCalledTimes(0); - }); - - test('Should return an error if credentials cannot be found in the DB', async () => { - const expectedError = new Error('Could not find credential.'); - await expect(WorkflowCredentials([notFoundNode])).rejects.toEqual(expectedError); - expect(credentialsRepository.findOneBy).toHaveBeenCalledTimes(1); - }); - - test('Should ignore duplicates', async () => { - const response = await WorkflowCredentials([validNode, validNode, validNode]); - expect(Object.keys(response)).toEqual(['test']); - }); - - test('Should ignore Nodes with no credentials set', async () => { - const response = await WorkflowCredentials([validNode, noCredentialsNode]); - expect(Object.keys(response)).toEqual(['test']); - }); - - test('Should work for Nodes with multiple credentials', async () => { - const response = await WorkflowCredentials([multiCredNode]); - expect(Object.keys(response)).toEqual(['mcTest', 'mcTest2']); - }); -}); - -const validNode: INode = { - id: '1', - name: 'Node with Valid Credentials', - typeVersion: 1, - type: '', - position: [0, 0], - credentials: { - test: { - id: 'cred#1', - name: 'Test Credentials', - }, - }, - parameters: {}, -}; - -const noIdNode: INode = { - id: '2', - name: 'Node with no Credential ID', - typeVersion: 1, - type: '', - position: [0, 0], - credentials: { - test: { - id: null, - name: 'NOFIND', - }, - }, - parameters: {}, -}; - -const notFoundNode: INode = { - id: '3', - name: 'Node that will not be found in the DB', - typeVersion: 1, - type: '', - position: [0, 0], - credentials: { - test: { - id: 'noDB', - name: 'Not in Database', - }, - }, - parameters: {}, -}; - -const noCredentialsNode: INode = { - id: '4', - name: 'Node that has no credentials', - typeVersion: 1, - type: '', - position: [0, 0], - parameters: {}, -}; - -// Reference this as a DataObject so I can provide a null credential for testing -const multiCredNode: INode = { - id: '5', - name: 'Node that has mutliple credential elements', - typeVersion: 1, - type: '', - position: [0, 0], - credentials: { - mcTest: { - id: 'multicred#1', - name: 'First of Multiple Credentials', - }, - mcTest2: { - id: 'multicred#2', - name: 'Second of Multiple Credentials', - }, - }, - parameters: {}, -}; diff --git a/packages/cli/test/unit/decorators/OnShutdown.test.ts b/packages/cli/test/unit/decorators/OnShutdown.test.ts new file mode 100644 index 0000000000000..1870d95122fd6 --- /dev/null +++ b/packages/cli/test/unit/decorators/OnShutdown.test.ts @@ -0,0 +1,76 @@ +import Container, { Service } from 'typedi'; +import { OnShutdown } from '@/decorators/OnShutdown'; +import { ShutdownService } from '@/shutdown/Shutdown.service'; +import { mock } from 'jest-mock-extended'; + +describe('OnShutdown', () => { + let shutdownService: ShutdownService; + let registerSpy: jest.SpyInstance; + + beforeEach(() => { + shutdownService = new ShutdownService(mock()); + Container.set(ShutdownService, shutdownService); + registerSpy = jest.spyOn(shutdownService, 'register'); + }); + + it('should register a methods that is decorated with OnShutdown', () => { + @Service() + class TestClass { + @OnShutdown() + async onShutdown() {} + } + + expect(shutdownService.register).toHaveBeenCalledTimes(1); + expect(shutdownService.register).toHaveBeenCalledWith(100, { + methodName: 'onShutdown', + serviceClass: TestClass, + }); + }); + + it('should register multiple methods in the same class', () => { + @Service() + class TestClass { + @OnShutdown() + async one() {} + + @OnShutdown() + async two() {} + } + + expect(shutdownService.register).toHaveBeenCalledTimes(2); + expect(shutdownService.register).toHaveBeenCalledWith(100, { + methodName: 'one', + serviceClass: TestClass, + }); + expect(shutdownService.register).toHaveBeenCalledWith(100, { + methodName: 'two', + serviceClass: TestClass, + }); + }); + + it('should use the given priority', () => { + class TestClass { + @OnShutdown(10) + async onShutdown() { + // Will be called when the app is shutting down + } + } + + expect(shutdownService.register).toHaveBeenCalledTimes(1); + // @ts-expect-error We are checking internal parts of the shutdown service + expect(shutdownService.handlersByPriority[10].length).toEqual(1); + }); + + it('should throw an error if the decorated member is not a function', () => { + expect(() => { + @Service() + class TestClass { + // @ts-expect-error Testing invalid code + @OnShutdown() + onShutdown = 'not a function'; + } + + new TestClass(); + }).toThrow('TestClass.onShutdown() must be a method on TestClass to use "OnShutdown"'); + }); +}); diff --git a/packages/cli/test/unit/execution-lifecyle/restoreBinaryDataId.test.ts b/packages/cli/test/unit/execution-lifecyle/restoreBinaryDataId.test.ts index 05d828100cbac..3fcdb79c72c7e 100644 --- a/packages/cli/test/unit/execution-lifecyle/restoreBinaryDataId.test.ts +++ b/packages/cli/test/unit/execution-lifecyle/restoreBinaryDataId.test.ts @@ -142,6 +142,28 @@ for (const mode of ['filesystem-v2', 's3'] as const) { expect(binaryDataService.rename).not.toHaveBeenCalled(); }); + + it('should ignore error thrown on renaming', async () => { + const workflowId = '6HYhhKmJch2cYxGj'; + const executionId = 'temp'; + const binaryDataFileUuid = 'a5c3f1ed-9d59-4155-bc68-9a370b3c51f6'; + + const incorrectFileId = `workflows/${workflowId}/executions/temp/binary_data/${binaryDataFileUuid}`; + + const run = toIRun({ + binary: { + data: { id: `s3:${incorrectFileId}` }, + }, + }); + + binaryDataService.rename.mockRejectedValueOnce(new Error('ENOENT')); + + const promise = restoreBinaryDataId(run, executionId, 'webhook'); + + await expect(promise).resolves.not.toThrow(); + + expect(binaryDataService.rename).toHaveBeenCalled(); + }); }); } diff --git a/packages/cli/test/unit/services/activeWorkflows.service.test.ts b/packages/cli/test/unit/services/activeWorkflows.service.test.ts new file mode 100644 index 0000000000000..7432d22491a34 --- /dev/null +++ b/packages/cli/test/unit/services/activeWorkflows.service.test.ts @@ -0,0 +1,81 @@ +import type { ActivationErrorsService } from '@/ActivationErrors.service'; +import type { User } from '@db/entities/User'; +import type { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import type { WorkflowRepository } from '@db/repositories/workflow.repository'; +import { ActiveWorkflowsService } from '@/services/activeWorkflows.service'; +import { mock } from 'jest-mock-extended'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; + +describe('ActiveWorkflowsService', () => { + const user = mock(); + const workflowRepository = mock(); + const sharedWorkflowRepository = mock(); + const activationErrorsService = mock(); + const service = new ActiveWorkflowsService( + mock(), + workflowRepository, + sharedWorkflowRepository, + activationErrorsService, + ); + const activeIds = ['1', '2', '3', '4']; + + beforeEach(() => jest.clearAllMocks()); + + describe('getAllActiveIdsInStorage', () => { + it('should filter out any workflow ids that have activation errors', async () => { + activationErrorsService.getAll.mockResolvedValue({ 1: 'some error' }); + workflowRepository.getActiveIds.mockResolvedValue(activeIds); + + const ids = await service.getAllActiveIdsInStorage(); + expect(ids).toEqual(['2', '3', '4']); + }); + }); + + describe('getAllActiveIdsFor', () => { + beforeEach(() => { + activationErrorsService.getAll.mockResolvedValue({ 1: 'some error' }); + workflowRepository.getActiveIds.mockResolvedValue(activeIds); + }); + + it('should return all workflow ids when user has full access', async () => { + user.hasGlobalScope.mockReturnValue(true); + const ids = await service.getAllActiveIdsFor(user); + + expect(ids).toEqual(['2', '3', '4']); + expect(user.hasGlobalScope).toHaveBeenCalledWith('workflow:list'); + expect(sharedWorkflowRepository.getSharedWorkflowIds).not.toHaveBeenCalled(); + }); + + it('should filter out workflow ids that the user does not have access to', async () => { + user.hasGlobalScope.mockReturnValue(false); + sharedWorkflowRepository.getSharedWorkflowIds.mockResolvedValue(['3']); + const ids = await service.getAllActiveIdsFor(user); + + expect(ids).toEqual(['3']); + expect(user.hasGlobalScope).toHaveBeenCalledWith('workflow:list'); + expect(sharedWorkflowRepository.getSharedWorkflowIds).toHaveBeenCalledWith(activeIds); + }); + }); + + describe('getActivationError', () => { + const workflowId = 'workflowId'; + + it('should throw a BadRequestError a user does not have access to the workflow id', async () => { + sharedWorkflowRepository.hasAccess.mockResolvedValue(false); + await expect(service.getActivationError(workflowId, user)).rejects.toThrow(BadRequestError); + + expect(sharedWorkflowRepository.hasAccess).toHaveBeenCalledWith(workflowId, user); + expect(activationErrorsService.get).not.toHaveBeenCalled(); + }); + + it('should return the error when the user has access', async () => { + sharedWorkflowRepository.hasAccess.mockResolvedValue(true); + activationErrorsService.get.mockResolvedValue('some-error'); + const error = await service.getActivationError(workflowId, user); + + expect(error).toEqual('some-error'); + expect(sharedWorkflowRepository.hasAccess).toHaveBeenCalledWith(workflowId, user); + expect(activationErrorsService.get).toHaveBeenCalledWith(workflowId); + }); + }); +}); diff --git a/packages/cli/test/unit/services/naming.service.test.ts b/packages/cli/test/unit/services/naming.service.test.ts index df2ff1e9b39ce..ea2c34fb8c1ee 100644 --- a/packages/cli/test/unit/services/naming.service.test.ts +++ b/packages/cli/test/unit/services/naming.service.test.ts @@ -17,7 +17,7 @@ describe('NamingService', () => { describe('getUniqueWorkflowName()', () => { test('should return requested name if already unique', async () => { - workflowRepository.find.mockResolvedValue([]); + workflowRepository.findStartingWith.mockResolvedValue([]); const name = await namingService.getUniqueWorkflowName('foo'); @@ -25,7 +25,7 @@ describe('NamingService', () => { }); test('should return requested name suffixed if already existing once', async () => { - workflowRepository.find.mockResolvedValue([{ name: 'foo' }] as WorkflowEntity[]); + workflowRepository.findStartingWith.mockResolvedValue([{ name: 'foo' }] as WorkflowEntity[]); const name = await namingService.getUniqueWorkflowName('foo'); @@ -35,7 +35,7 @@ describe('NamingService', () => { test('should return requested name with incremented suffix if already suffixed', async () => { const existingNames = [{ name: 'foo' }, { name: 'foo 2' }] as WorkflowEntity[]; - workflowRepository.find.mockResolvedValue(existingNames); + workflowRepository.findStartingWith.mockResolvedValue(existingNames); const name = await namingService.getUniqueWorkflowName('foo'); @@ -51,7 +51,7 @@ describe('NamingService', () => { describe('getUniqueCredentialName()', () => { test('should return requested name if already unique', async () => { - credentialsRepository.find.mockResolvedValue([]); + credentialsRepository.findStartingWith.mockResolvedValue([]); const name = await namingService.getUniqueCredentialName('foo'); @@ -59,7 +59,9 @@ describe('NamingService', () => { }); test('should return requested name suffixed if already existing once', async () => { - credentialsRepository.find.mockResolvedValue([{ name: 'foo' }] as CredentialsEntity[]); + credentialsRepository.findStartingWith.mockResolvedValue([ + { name: 'foo' }, + ] as CredentialsEntity[]); const name = await namingService.getUniqueCredentialName('foo'); @@ -69,7 +71,7 @@ describe('NamingService', () => { test('should return requested name with incremented suffix if already suffixed', async () => { const existingNames = [{ name: 'foo' }, { name: 'foo 2' }] as CredentialsEntity[]; - credentialsRepository.find.mockResolvedValue(existingNames); + credentialsRepository.findStartingWith.mockResolvedValue(existingNames); const name = await namingService.getUniqueCredentialName('foo'); diff --git a/packages/cli/test/unit/shutdown/Shutdown.service.test.ts b/packages/cli/test/unit/shutdown/Shutdown.service.test.ts new file mode 100644 index 0000000000000..d0f761524ee92 --- /dev/null +++ b/packages/cli/test/unit/shutdown/Shutdown.service.test.ts @@ -0,0 +1,127 @@ +import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow'; +import { mock } from 'jest-mock-extended'; +import type { ServiceClass } from '@/shutdown/Shutdown.service'; +import { ShutdownService } from '@/shutdown/Shutdown.service'; +import Container from 'typedi'; + +class MockComponent { + onShutdown() {} +} + +describe('ShutdownService', () => { + let shutdownService: ShutdownService; + let mockComponent: MockComponent; + let onShutdownSpy: jest.SpyInstance; + let mockErrorReporterProxy: jest.SpyInstance; + + beforeEach(() => { + shutdownService = new ShutdownService(mock()); + mockComponent = new MockComponent(); + Container.set(MockComponent, mockComponent); + onShutdownSpy = jest.spyOn(mockComponent, 'onShutdown'); + mockErrorReporterProxy = jest.spyOn(ErrorReporterProxy, 'error').mockImplementation(() => {}); + }); + + describe('shutdown', () => { + it('should signal shutdown', () => { + shutdownService.register(10, { + serviceClass: MockComponent as unknown as ServiceClass, + methodName: 'onShutdown', + }); + shutdownService.shutdown(); + expect(onShutdownSpy).toBeCalledTimes(1); + }); + + it('should signal shutdown in the priority order', async () => { + class MockService { + onShutdownHighPrio() {} + + onShutdownLowPrio() {} + } + + const order: string[] = []; + const mockService = new MockService(); + Container.set(MockService, mockService); + + jest.spyOn(mockService, 'onShutdownHighPrio').mockImplementation(() => order.push('high')); + jest.spyOn(mockService, 'onShutdownLowPrio').mockImplementation(() => order.push('low')); + + shutdownService.register(100, { + serviceClass: MockService as unknown as ServiceClass, + methodName: 'onShutdownHighPrio', + }); + + shutdownService.register(10, { + serviceClass: MockService as unknown as ServiceClass, + methodName: 'onShutdownLowPrio', + }); + + shutdownService.shutdown(); + await shutdownService.waitForShutdown(); + expect(order).toEqual(['high', 'low']); + }); + + it('should throw error if shutdown is already in progress', () => { + shutdownService.register(10, { + methodName: 'onShutdown', + serviceClass: MockComponent as unknown as ServiceClass, + }); + shutdownService.shutdown(); + expect(() => shutdownService.shutdown()).toThrow('App is already shutting down'); + }); + + it('should report error if component shutdown fails', async () => { + const componentError = new Error('Something went wrong'); + onShutdownSpy.mockImplementation(() => { + throw componentError; + }); + shutdownService.register(10, { + serviceClass: MockComponent as unknown as ServiceClass, + methodName: 'onShutdown', + }); + shutdownService.shutdown(); + await shutdownService.waitForShutdown(); + + expect(mockErrorReporterProxy).toHaveBeenCalledTimes(1); + const error = mockErrorReporterProxy.mock.calls[0][0]; + expect(error).toBeInstanceOf(ApplicationError); + expect(error.message).toBe('Failed to shutdown gracefully'); + expect(error.extra).toEqual({ + component: 'MockComponent.onShutdown()', + }); + expect(error.cause).toBe(componentError); + }); + }); + + describe('waitForShutdown', () => { + it('should wait for shutdown', async () => { + shutdownService.register(10, { + serviceClass: MockComponent as unknown as ServiceClass, + methodName: 'onShutdown', + }); + shutdownService.shutdown(); + await expect(shutdownService.waitForShutdown()).resolves.toBeUndefined(); + }); + + it('should throw error if app is not shutting down', async () => { + await expect(async () => shutdownService.waitForShutdown()).rejects.toThrow( + 'App is not shutting down', + ); + }); + }); + + describe('isShuttingDown', () => { + it('should return true if app is shutting down', () => { + shutdownService.register(10, { + serviceClass: MockComponent as unknown as ServiceClass, + methodName: 'onShutdown', + }); + shutdownService.shutdown(); + expect(shutdownService.isShuttingDown()).toBe(true); + }); + + it('should return false if app is not shutting down', () => { + expect(shutdownService.isShuttingDown()).toBe(false); + }); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 857bf0d9b5818..b1662464aa41f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.21.0", + "version": "1.22.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/core/src/ActiveWorkflows.ts b/packages/core/src/ActiveWorkflows.ts index 9b6c4c60783c1..d42729b1f2693 100644 --- a/packages/core/src/ActiveWorkflows.ts +++ b/packages/core/src/ActiveWorkflows.ts @@ -1,3 +1,4 @@ +import { Service } from 'typedi'; import { CronJob } from 'cron'; import type { @@ -22,10 +23,9 @@ import { import type { IWorkflowData } from './Interfaces'; +@Service() export class ActiveWorkflows { - private activeWorkflows: { - [workflowId: string]: IWorkflowData; - } = {}; + private activeWorkflows: { [workflowId: string]: IWorkflowData } = {}; /** * Returns if the workflow is active in memory. diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 659dbcae8e934..df6f7865821d7 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -37,6 +37,7 @@ import pick from 'lodash/pick'; import { extension, lookup } from 'mime-types'; import type { BinaryHelperFunctions, + CloseFunction, ConnectionTypes, ContextType, FieldType, @@ -162,6 +163,13 @@ axios.defaults.paramsSerializer = (params) => { } return stringify(params, { arrayFormat: 'indices' }); }; +axios.interceptors.request.use((config) => { + // If no content-type is set by us, prevent axios from force-setting the content-type to `application/x-www-form-urlencoded` + if (config.data === undefined) { + config.headers.setContentType(false, false); + } + return config; +}); const pushFormDataValue = (form: FormData, key: string, value: any) => { if (value?.hasOwnProperty('value') && value.hasOwnProperty('options')) { @@ -3111,6 +3119,7 @@ export function getExecuteFunctions( additionalData: IWorkflowExecuteAdditionalData, executeData: IExecuteData, mode: WorkflowExecuteMode, + closeFunctions: CloseFunction[], abortSignal?: AbortSignal, ): IExecuteFunctions { return ((workflow, runExecutionData, connectionInputData, inputData, node) => { @@ -3287,7 +3296,11 @@ export function getExecuteFunctions( }; try { - return await nodeType.supplyData.call(context, itemIndex); + const response = await nodeType.supplyData.call(context, itemIndex); + if (response.closeFunction) { + closeFunctions.push(response.closeFunction); + } + return response; } catch (error) { // Propagate errors from sub-nodes if (error.functionality === 'configuration-node') throw error; diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index d4f733cd0d886..781aea9fdbf5d 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -32,6 +32,7 @@ import type { IRunExecutionData, IWorkflowExecuteAdditionalData, WorkflowExecuteMode, + CloseFunction, } from 'n8n-workflow'; import { LoggerProxy as Logger, @@ -1074,7 +1075,7 @@ export class WorkflowExecute { const errorItems: INodeExecutionData[] = []; const successItems: INodeExecutionData[] = []; - + const closeFunctions: CloseFunction[] = []; // Create a WorkflowDataProxy instance that we can get the data of the // item which did error const executeFunctions = NodeExecuteFunctions.getExecuteFunctions( @@ -1087,6 +1088,7 @@ export class WorkflowExecute { this.additionalData, executionData, this.mode, + closeFunctions, this.abortController.signal, ); const dataProxy = executeFunctions.getWorkflowDataProxy(0); diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 3f3b2097b632b..02492eb5251be 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,6 +1,6 @@ { "name": "n8n-design-system", - "version": "1.15.0", + "version": "1.16.0", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", "author": { diff --git a/packages/design-system/src/components/N8nInfoTip/InfoTip.vue b/packages/design-system/src/components/N8nInfoTip/InfoTip.vue index 7a81cbb0fd003..394b994c8e7c8 100644 --- a/packages/design-system/src/components/N8nInfoTip/InfoTip.vue +++ b/packages/design-system/src/components/N8nInfoTip/InfoTip.vue @@ -2,6 +2,7 @@
- - + + - + @@ -48,7 +49,7 @@ export default defineComponent({ type: String, default: 'info', validator: (value: string): boolean => - ['info', 'info-light', 'warning', 'danger'].includes(value), + ['info', 'info-light', 'warning', 'danger', 'success'].includes(value), }, type: { type: String, @@ -64,10 +65,50 @@ export default defineComponent({ default: 'top', }, }, + computed: { + iconData(): { icon: string; color: string } { + switch (this.theme) { + case 'info': + return { + icon: 'info-circle', + color: '--color-text-light)', + }; + case 'info-light': + return { + icon: 'info-circle', + color: 'var(--color-foreground-dark)', + }; + case 'warning': + return { + icon: 'exclamation-triangle', + color: 'var(--color-warning)', + }; + case 'danger': + return { + icon: 'exclamation-triangle', + color: 'var(--color-danger)', + }; + case 'success': + return { + icon: 'check-circle', + color: 'var(--color-success)', + }; + default: + return { + icon: 'info-circle', + color: '--color-text-light)', + }; + } + }, + }, }); diff --git a/packages/design-system/src/components/N8nInfoTip/__tests__/__snapshots__/InfoTip.spec.ts.snap b/packages/design-system/src/components/N8nInfoTip/__tests__/__snapshots__/InfoTip.spec.ts.snap index dc755e961d5ee..3a49e215d5349 100644 --- a/packages/design-system/src/components/N8nInfoTip/__tests__/__snapshots__/InfoTip.spec.ts.snap +++ b/packages/design-system/src/components/N8nInfoTip/__tests__/__snapshots__/InfoTip.spec.ts.snap @@ -1,9 +1,9 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`N8nInfoTip > should render correctly as note 1`] = `"
Need help doing something?Open docs
"`; +exports[`N8nInfoTip > should render correctly as note 1`] = `"
Need help doing something?Open docs
"`; exports[`N8nInfoTip > should render correctly as tooltip 1`] = ` -"
+"
" `; diff --git a/packages/design-system/src/components/N8nSticky/Sticky.vue b/packages/design-system/src/components/N8nSticky/Sticky.vue index fc83e4d9db610..70cf5983dbe26 100644 --- a/packages/design-system/src/components/N8nSticky/Sticky.vue +++ b/packages/design-system/src/components/N8nSticky/Sticky.vue @@ -36,7 +36,6 @@ @mouseup.stop @keydown.esc="onInputBlur" @keydown.stop - @wheel.stop :class="{ 'full-height': !shouldShowFooter, 'sticky-textarea': true }" >
@@ -180,6 +180,12 @@ export default defineComponent({ this.isResizing = true; this.$emit('resizestart'); }, + onInputScroll(event: WheelEvent) { + // Pass through zoom events but hold regular scrolling + if (!event.ctrlKey && !event.metaKey) { + event.stopPropagation(); + } + }, }, watch: { editMode(newMode, prevMode) { diff --git a/packages/design-system/src/components/N8nTooltip/Tooltip.vue b/packages/design-system/src/components/N8nTooltip/Tooltip.vue index ee8c53683a4c6..c49df4118457b 100644 --- a/packages/design-system/src/components/N8nTooltip/Tooltip.vue +++ b/packages/design-system/src/components/N8nTooltip/Tooltip.vue @@ -1,5 +1,5 @@