Merge branch 'master' of github.com:n8n-io/n8n into node-374-openai-node-change-max_tokens-default

This commit is contained in:
Jonathan Bennetts 2023-03-29 11:05:26 +01:00
commit 024867a774
984 changed files with 17272 additions and 9768 deletions

View file

@ -28,7 +28,10 @@ for (let { name, path, version, private: isPrivate, dependencies } of packages)
packageMap[name] = { path, isDirty, version };
}
assert.ok(packageMap['n8n'].isDirty, 'No changes found since the last release');
assert.ok(
Object.values(packageMap).some(({ isDirty }) => isDirty),
'No changes found since the last release',
);
// Keep the monorepo version up to date with the released version
packageMap['monorepo-root'].version = packageMap['n8n'].version;

View file

@ -35,6 +35,11 @@ jobs:
- name: Test
run: pnpm test
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: packages/cli/coverage/cobertura-coverage.xml,packages/core/coverage/cobertura-coverage.xml,packages/design-system/coverage/cobertura-coverage.xml,packages/editor-ui/coverage/cobertura-coverage.xml,packages/nodes-base/coverage/cobertura-coverage.xml,packages/workflow/coverage/cobertura-coverage.xml
- name: Lint
env:
CI_LINT_MASTER: true

View file

@ -67,6 +67,11 @@ jobs:
- name: Test
run: pnpm test
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: packages/cli/coverage/cobertura-coverage.xml,packages/core/coverage/cobertura-coverage.xml,packages/design-system/coverage/cobertura-coverage.xml,packages/editor-ui/coverage/cobertura-coverage.xml,packages/nodes-base/coverage/cobertura-coverage.xml,packages/workflow/coverage/cobertura-coverage.xml
lint:
name: Lint changes
runs-on: ubuntu-latest
@ -108,7 +113,7 @@ jobs:
uses: ./.github/workflows/e2e-reusable.yml
if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-e2e') }}
with:
branch: ${{ github.event.pull_request.base.ref }}
branch: ${{ github.event.pull_request.head.ref }}
user: ${{ github.event.inputs.user || 'PR User' }}
spec: ${{ github.event.inputs.spec || 'e2e/0-smoke.cy.ts' }}
run-env: base:16.18.1
@ -117,3 +122,15 @@ jobs:
containers: '[1]'
secrets:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
checklist_job:
runs-on: ubuntu-latest
name: Checklist job
steps:
- name: Checkout
uses: actions/checkout@v1
- name: Checklist
uses: wyozi/contextual-qa-checklist-action@master
with:
gh-token: ${{ secrets.GITHUB_TOKEN }}
comment-footer: Make sure to check off this list before asking for review.

1
.gitignore vendored
View file

@ -3,6 +3,7 @@ node_modules
.tmp
tmp
dist
coverage
npm-debug.log*
yarn.lock
google-generated-credentials.json

View file

@ -1,3 +1,151 @@
## [0.221.2](https://github.com/n8n-io/n8n/compare/n8n@0.221.1...n8n@0.221.2) (2023-03-24)
### Bug Fixes
* **core:** Assign properties.success earlier to set executionStatus correctly ([6c7772a](https://github.com/n8n-io/n8n/commit/6c7772a0b3276ffe9e5ab8029e17a0ed743ee5a7))
## [0.221.1](https://github.com/n8n-io/n8n/compare/n8n@0.221.0...n8n@0.221.1) (2023-03-23)
### Bug Fixes
* **core:** Initialize queue in the webhook server as well ([163859b](https://github.com/n8n-io/n8n/commit/163859b87a19f3795e2689ce8eba2e2ce6766684))
# [0.221.0](https://github.com/n8n-io/n8n/compare/n8n@0.220.0...n8n@0.221.0) (2023-03-23)
### Bug Fixes
* **core:** Fix calling error workflows in main mode recovery ([#5698](https://github.com/n8n-io/n8n/issues/5698)) ([e0ea97a](https://github.com/n8n-io/n8n/commit/e0ea97af8d7aaa014680f5f9d5702d1cafd49757))
* **core:** Fix telemetry execution status for manual workflows executions ([#5712](https://github.com/n8n-io/n8n/issues/5712)) ([a28396e](https://github.com/n8n-io/n8n/commit/a28396ee91bfbccc6596812606c237a8e2c34088))
* **core:** Return saml attributes after connection test ([#5717](https://github.com/n8n-io/n8n/issues/5717)) ([be172cb](https://github.com/n8n-io/n8n/commit/be172cb720c8a44ebd1f0b86dddab321e1e3c9fd))
* **editor:** Disable tooltip for display modes that don't support mapping ([#5715](https://github.com/n8n-io/n8n/issues/5715)) ([fb8755e](https://github.com/n8n-io/n8n/commit/fb8755ea3c720c98f002a6756c39b8fed11482c0))
* **editor:** Fix execution list item selection ([#5606](https://github.com/n8n-io/n8n/issues/5606)) ([7a352ef](https://github.com/n8n-io/n8n/commit/7a352efff944c52062412e53ea2c1a034a25f908))
* **editor:** Fix for large notifications being cut off ([#5705](https://github.com/n8n-io/n8n/issues/5705)) ([c07f838](https://github.com/n8n-io/n8n/commit/c07f838ce60dc33261fe3e1b5e7dd6fe05f1d63b))
* **editor:** Fix redo in code and expression editor ([#5708](https://github.com/n8n-io/n8n/issues/5708)) ([cd7a55b](https://github.com/n8n-io/n8n/commit/cd7a55ba5aeb83d1e540a65b5c6b2c74fd742461))
* **editor:** Fix the canvas node distance when automatically injecting manual trigger ([#5716](https://github.com/n8n-io/n8n/issues/5716)) ([cb2ba97](https://github.com/n8n-io/n8n/commit/cb2ba97f3837b572e237da1256b9f2ee376767a9))
* **HTTP Request Node:** Fix AWS credentials to automatically deconstruct the url ([#5751](https://github.com/n8n-io/n8n/issues/5751)) ([4ac944a](https://github.com/n8n-io/n8n/commit/4ac944af3028b70ae600000300c16de77c1af1d5))
* **Split In Batches Node:** Roll back changes in v1 and create v2 ([#5747](https://github.com/n8n-io/n8n/issues/5747)) ([cefec77](https://github.com/n8n-io/n8n/commit/cefec7739b6da820d64f9476476e1901d4f386bf))
### Features
* **core:** Augment data instead of copying it ([#5487](https://github.com/n8n-io/n8n/issues/5487)) ([0876c38](https://github.com/n8n-io/n8n/commit/0876c38aaeb8355141fecbc14e84cdda0b2c737b))
* **editor:** Move canvas by holding Space or Middle mouse button ([#5719](https://github.com/n8n-io/n8n/issues/5719)) ([19dded1](https://github.com/n8n-io/n8n/commit/19dded18c9a588a30b9ac1fc274dcd967e9b7b6b))
* **editor:** Recommend and pre-select auth type with overrides ([#5684](https://github.com/n8n-io/n8n/issues/5684)) ([f59b591](https://github.com/n8n-io/n8n/commit/f59b591c93ecd7cbd279668abe6494ef2b88c831))
* **editor:** SSO login button ([#5615](https://github.com/n8n-io/n8n/issues/5615)) ([6916628](https://github.com/n8n-io/n8n/commit/6916628a9f11e07cbcdf390f747f396fb0ef9e3c))
* **QuickChart Node:** Add QuickChart node ([#3572](https://github.com/n8n-io/n8n/issues/3572)) ([233f1fa](https://github.com/n8n-io/n8n/commit/233f1fa7ec230e92e868de0247e315aa6a705ead))
## [0.220.1](https://github.com/n8n-io/n8n/compare/n8n@0.220.0...n8n@0.220.1) (2023-03-22)
### Bug Fixes
* **Split In Batches Node:** Roll back changes in v1 and create v2 ([#5747](https://github.com/n8n-io/n8n/issues/5747)) ([6d1c88e](https://github.com/n8n-io/n8n/commit/6d1c88ea8c2e5dc72c6e6edeeeef52dc1fba4075))
# [0.220.0](https://github.com/n8n-io/n8n/compare/n8n@0.219.1...n8n@0.220.0) (2023-03-16)
### Bug Fixes
* **core:** Initialize License and LDAP in the correct order ([#5673](https://github.com/n8n-io/n8n/issues/5673)) ([90afa5e](https://github.com/n8n-io/n8n/commit/90afa5e55f96fbe46417f4be8f764795fb5c2225))
* **editor:** Display correct error message for env access ([#5634](https://github.com/n8n-io/n8n/issues/5634)) ([5f238ea](https://github.com/n8n-io/n8n/commit/5f238ea6413d25704a5865d339401117e81dbbab))
* **editor:** Fix autocomplete for complex expresions ([#5695](https://github.com/n8n-io/n8n/issues/5695)) ([11bf260](https://github.com/n8n-io/n8n/commit/11bf260bf164c6c9dffe71b875fde139c93f905d))
* **editor:** Fix owner set-up checkbox wording ([#5697](https://github.com/n8n-io/n8n/issues/5697)) ([58232be](https://github.com/n8n-io/n8n/commit/58232bec618edd403f18527913c489bfa11f570b))
* **editor:** Properly handle mapping of dragged expression if it contains hyphen ([#5703](https://github.com/n8n-io/n8n/issues/5703)) ([7025efe](https://github.com/n8n-io/n8n/commit/7025efe8654a8a55ff10e2105ddc6ce2dc5a89d6))
* **Metabase Node:** Fix issue with question results not correctly being returned ([#5665](https://github.com/n8n-io/n8n/issues/5665)) ([d1e3c19](https://github.com/n8n-io/n8n/commit/d1e3c192ba9e2dfd852e570e88f6135d42d2ed45))
### Features
* **core:** Improve SAML connection test ([#5680](https://github.com/n8n-io/n8n/issues/5680)) ([ef07528](https://github.com/n8n-io/n8n/commit/ef07528cc21f06ee52f93bafb34ac54a244609f9))
* **editor:** Add basic Datatable and Pagination components ([#5652](https://github.com/n8n-io/n8n/issues/5652)) ([29f2629](https://github.com/n8n-io/n8n/commit/29f2629716e3693372ec9a4572113a3f3721ff5e))
* **editor:** Add support for schema view in the NDV output ([#5688](https://github.com/n8n-io/n8n/issues/5688)) ([541850f](https://github.com/n8n-io/n8n/commit/541850f95f1c42fc16d9aeee3a3fef68a4b77082))
* **editor:** Do not show actions panel for single-action nodes ([#5683](https://github.com/n8n-io/n8n/issues/5683)) ([de1db92](https://github.com/n8n-io/n8n/commit/de1db927cbdc5fc8ef7d697cccbd8603f66391ea))
* **Item Lists Node:** Update actions ([#5648](https://github.com/n8n-io/n8n/issues/5648)) ([332d50c](https://github.com/n8n-io/n8n/commit/332d50c5f1f8ba63b87325799360adecdbaa06bf))
* **OpenAI Node:** Add support for gpt4 on chat completion ([#5692](https://github.com/n8n-io/n8n/issues/5692)) ([ba73fff](https://github.com/n8n-io/n8n/commit/ba73fff27d2972093746acc3f7016c7420e23459))
* **Split In Batches Node:** Make it easy to combine processed data ([#5655](https://github.com/n8n-io/n8n/issues/5655)) ([2f7639e](https://github.com/n8n-io/n8n/commit/2f7639e9e4b10f08c5cb1c4916fc6ae031375cf3))
## [0.215.4](https://github.com/n8n-io/n8n/compare/n8n@0.215.3...n8n@0.215.4) (2023-03-14)
### Bug Fixes
* **core:** Revert `isPending` check on the user entity ([#5571](https://github.com/n8n-io/n8n/issues/5571)) ([6d2c50d](https://github.com/n8n-io/n8n/commit/6d2c50dfed0aeffa2afdb09f5aac80c0e25a6a06))
## [0.214.5](https://github.com/n8n-io/n8n/compare/n8n@0.214.4...n8n@0.214.5) (2023-03-14)
### Bug Fixes
* **core:** Revert `isPending` check on the user entity ([#5571](https://github.com/n8n-io/n8n/issues/5571)) ([b94af03](https://github.com/n8n-io/n8n/commit/b94af0384243d634683212d5199316067956f628))
## [0.219.1](https://github.com/n8n-io/n8n/compare/n8n@0.219.0...n8n@0.219.1) (2023-03-10)
### Bug Fixes
* **HTTP Request Node:** Remove streaming response ([#5663](https://github.com/n8n-io/n8n/issues/5663)) ([974d57d](https://github.com/n8n-io/n8n/commit/974d57dfed78489d3f22c8c63e0ea624c637bfe0))
# [0.219.0](https://github.com/n8n-io/n8n/compare/n8n@0.218.0...n8n@0.219.0) (2023-03-09)
### Bug Fixes
* **core:** Allow serving icons for custom nodes with npm scoped names ([#5626](https://github.com/n8n-io/n8n/issues/5626)) ([45ccdd3](https://github.com/n8n-io/n8n/commit/45ccdd3bb5d5601ccc60438d96aadb40cb87588b))
* **core:** Rename advancedFilters to advancedExecutionFilters ([#5643](https://github.com/n8n-io/n8n/issues/5643)) ([419969c](https://github.com/n8n-io/n8n/commit/419969c0d761b1ac7870e7821c0398ecfca1f0ce))
* **editor:** Fix ElButton overrides ([#5605](https://github.com/n8n-io/n8n/issues/5605)) ([2eba050](https://github.com/n8n-io/n8n/commit/2eba05046141bd13145f95c6a1ec1e6fb95b37c2))
* **editor:** Only fetch new versions at app launch ([#5647](https://github.com/n8n-io/n8n/issues/5647)) ([5b9c521](https://github.com/n8n-io/n8n/commit/5b9c521d04bc34a9f84be966a4646f23c56ca3da))
* Fetch credentials on workflows view to include in duplicated workflows ([#5532](https://github.com/n8n-io/n8n/issues/5532)) ([493f7a1](https://github.com/n8n-io/n8n/commit/493f7a1c92d77d3c75fc311892e53f43e1fb367f))
* Fix color discrepancies for executions list items ([#5640](https://github.com/n8n-io/n8n/issues/5640)) ([c81656d](https://github.com/n8n-io/n8n/commit/c81656d149764dc398b93d3eb8626a402eddb0ef))
* **OpenAI Node:** Fix issue with expressions not working with chat complete ([#5609](https://github.com/n8n-io/n8n/issues/5609)) ([e949db3](https://github.com/n8n-io/n8n/commit/e949db352526033394083476077519598dd8061c))
* **OpenAI Node:** Simplify code ([#5618](https://github.com/n8n-io/n8n/issues/5618)) ([1c65bff](https://github.com/n8n-io/n8n/commit/1c65bff31d86ea76ff5fca10341e71389d4de7b5))
### Features
* **Cal Trigger Node:** Update to support v2 webhooks ([#5331](https://github.com/n8n-io/n8n/issues/5331)) ([2889e53](https://github.com/n8n-io/n8n/commit/2889e53b3767f2a61ee7fb3ea9fe1db7c65aaf70))
* **core:** Add advancedFilters feature flag ([#5638](https://github.com/n8n-io/n8n/issues/5638)) ([0b5ef09](https://github.com/n8n-io/n8n/commit/0b5ef09e7cbd0c80a4b79311976b5c06c2be8747))
* **core:** Add SAML post and test endpoints ([#5595](https://github.com/n8n-io/n8n/issues/5595)) ([523fa71](https://github.com/n8n-io/n8n/commit/523fa71705c0408c6c60c7cb5135323e3488e8c9))
* **core:** Add SAML XML validation ([#5600](https://github.com/n8n-io/n8n/issues/5600)) ([ca66ec8](https://github.com/n8n-io/n8n/commit/ca66ec8f4d5ab0e427390b1f1874fb668bc53479))
* **core:** Limit user changes when saml is enabled ([#5577](https://github.com/n8n-io/n8n/issues/5577)) ([b517959](https://github.com/n8n-io/n8n/commit/b5179597f3ec4ae468b2eb91969fa56322088e6f))
* **core:** Refactor and add SAML preferences for service provider instance ([#5637](https://github.com/n8n-io/n8n/issues/5637)) ([6f27b44](https://github.com/n8n-io/n8n/commit/6f27b445ca2307b94ffc7d4eeb24e76d63516a19))
* **editor:** Do not automatically add manual trigger on node plus ([#5644](https://github.com/n8n-io/n8n/issues/5644)) ([ac2f89a](https://github.com/n8n-io/n8n/commit/ac2f89a18a4c25ef1b39bcacc624884de9197fdf))
* **editor:** Redirect users to canvas if they don't have any workflows ([#5629](https://github.com/n8n-io/n8n/issues/5629)) ([354edf6](https://github.com/n8n-io/n8n/commit/354edf6886fa3cc5a59d317dcab59cc75e62dc2d))
* **HTTP Request Node:** Move from Binary Buffer to Binary streaming ([#5610](https://github.com/n8n-io/n8n/issues/5610)) ([ce0d9d2](https://github.com/n8n-io/n8n/commit/ce0d9d2bede7d87b97e18c45b63ea31ecf592255))
* **Mattermost Node:** Add self signed certificate support ([#5630](https://github.com/n8n-io/n8n/issues/5630)) ([01a2160](https://github.com/n8n-io/n8n/commit/01a2160b3b8d36509f4b2871249a3f45358cf692))
* **Microsoft SQL Node:** Add support for self signed certificates ([#5160](https://github.com/n8n-io/n8n/issues/5160)) ([971d5ae](https://github.com/n8n-io/n8n/commit/971d5ae8f5d2bfe319fc700fee2bcf597ea4c07e))
* **Mindee Node:** Add support for v4 API ([#5559](https://github.com/n8n-io/n8n/issues/5559)) ([e56fbfe](https://github.com/n8n-io/n8n/commit/e56fbfef3ebb50706f24ab07505a0031f361d9b1))
* **Slack Node:** Move from Binary Buffer to Binary streaming ([#5612](https://github.com/n8n-io/n8n/issues/5612)) ([9420b0f](https://github.com/n8n-io/n8n/commit/9420b0fdf8ccccb95780c8c97e2b5d14cc4d513e))
## [0.217.1](https://github.com/n8n-io/n8n/compare/n8n@0.217.0...n8n@0.217.1) (2023-02-24)
### Bug Fixes
* **core:** Revert `isPending` check on the user entity ([#5571](https://github.com/n8n-io/n8n/issues/5571)) ([5282fd2](https://github.com/n8n-io/n8n/commit/5282fd266c26e7053ceb887addceed26b741f995))
# [0.218.0](https://github.com/n8n-io/n8n/compare/n8n@0.217.2...n8n@0.218.0) (2023-03-02)

48
CHECKLIST.yml Normal file
View file

@ -0,0 +1,48 @@
paths:
"packages/**":
- If fixing bug, added test to cover scenario.
- If addressing forum or Github issue, added link to description.
"packages/**/*.ts":
- Added unit tests to cover new or updated functionality.
"**/*.vue":
- Used composition API for all new components.
- Added component or unit tests to cover functionality.
# cli
"packages/cli/src/databases/migrations/**":
- Requested review from at least two engineers on migration.
- Avoided irreversible data migrations.
- Avoided deleting or updating data keys.
- Wrote 'down' migration if possible.
"n8n/packages/cli/src/api/**":
- Added integration tests for new endpoints.
# editor ui
"packages/editor-ui/**/*.vue":
- Added E2E if adding new features.
- Used design system tokens (colors, spacings...) where possible.
"packages/editor-ui/src/mixins/restApi.ts":
- Avoided adding new methods. Only deleted from here.
"packages/editor-ui/src/mixins/**":
- Avoided adding new mixins (use composables instead). Only removed code from here.
"packages/editor-ui/src/views/NodeView.vue":
- Avoided adding code here. Only refactored to make it smaller.
"packages/editor-ui/src/hooks/**":
- Avoided adding new hooks. Only refactored to move hooks to relevant store instead.
# nodes-base
"packages/nodes-base/nodes/**":
- Added workflow tests for nodes if possible.
# design-system
"packages/design-system/**/*.vue":
- Used design system tokens (colors, spacings...) where possible.
- Updated Storybook with new component or updated functionality.
# e2e
"cypress/e2e/**":
- Avoided chaining commands more than two or three times (to avoid flakiness because only last one will be retried).
- Spoofed endpoints that are not critical for the test (to avoid flakiness).
- Picked most efficient path to start the test (for example skipped account setup and starting at /workflow/new for a canvas test).
- Avoided adding waits on time (use request intercepts instead).
- Ensured each spec does not depend on any another spec to pass.

View file

@ -11,6 +11,8 @@ module.exports = defineConfig({
},
defaultCommandTimeout: 10000,
requestTimeout: 12000,
numTestsKeptInMemory: 0,
experimentalMemoryManagement: true,
e2e: {
baseUrl: BASE_URL,
video: true,

View file

@ -1,13 +1,7 @@
import { DEFAULT_USER_EMAIL, DEFAULT_USER_PASSWORD } from '../constants';
import { randFirstName, randLastName } from '@ngneat/falso';
import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
import { v4 as uuid } from 'uuid';
const email = DEFAULT_USER_EMAIL;
const password = DEFAULT_USER_PASSWORD;
const firstName = randFirstName();
const lastName = randLastName();
const WorkflowsPage = new WorkflowsPageClass();
const WorkflowPage = new WorkflowPageClass();
@ -16,23 +10,17 @@ const multipleWorkflowsCount = 5;
describe('Workflows', () => {
before(() => {
cy.resetAll();
cy.setup({ email, firstName, lastName, password });
cy.skipSetup();
});
beforeEach(() => {
cy.on('uncaught:exception', (err, runnable) => {
expect(err.message).to.include('Not logged in');
return false;
cy.visit(WorkflowsPage.url);
});
cy.signin({ email, password });
cy.visit('/');
cy.waitForLoad();
});
it('should create a new workflow using empty state card', () => {
WorkflowsPage.getters.newWorkflowButtonCard().should('be.visible');
WorkflowsPage.getters.newWorkflowButtonCard().click();
it('should land on empty canvas after registration', () => {
cy.url().should('include', WorkflowPage.url);
cy.createFixtureWorkflow('Test_workflow_1.json', `Empty State Card Workflow ${uuid()}`);
WorkflowPage.getters.workflowTags().should('contain.text', 'some-tag-1');
@ -89,13 +77,5 @@ describe('Workflows', () => {
});
WorkflowsPage.getters.newWorkflowButtonCard().should('be.visible');
WorkflowsPage.getters.newWorkflowTemplateCard().should('be.visible');
});
it('should redirect to new canvas if no workflows', () => {
cy.wait(1000);
cy.visit(WorkflowsPage.url);
cy.wait(1000);
cy.url().should('include', WorkflowPage.url);
});
});

View file

@ -0,0 +1,172 @@
import {
MANUAL_TRIGGER_NODE_NAME,
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
CODE_NODE_NAME,
SCHEDULE_TRIGGER_NODE_NAME,
SET_NODE_NAME,
IF_NODE_NAME,
HTTP_REQUEST_NODE_NAME,
} from './../constants';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
const WorkflowPage = new WorkflowPageClass();
describe('Canvas Actions', () => {
before(() => {
cy.resetAll();
cy.skipSetup();
});
beforeEach(() => {
WorkflowPage.actions.visit();
});
it('should render canvas', () => {
WorkflowPage.getters.nodeViewRoot().should('be.visible');
WorkflowPage.getters.canvasPlusButton().should('be.visible');
WorkflowPage.getters.zoomToFitButton().should('be.visible');
WorkflowPage.getters.zoomInButton().should('be.visible');
WorkflowPage.getters.zoomOutButton().should('be.visible');
WorkflowPage.getters.executeWorkflowButton().should('be.visible');
});
it('should connect and disconnect a simple node', () => {
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME);
WorkflowPage.getters.nodeViewBackground().click(600, 200, { force: true });
cy.get('.jtk-connector').should('have.length', 1);
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME);
// Change connection from Set to Set1
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('input', SET_NODE_NAME),
WorkflowPage.getters.getEndpointSelector('input', `${SET_NODE_NAME}1`),
);
WorkflowPage.getters
.canvasNodeInputEndpointByName(`${SET_NODE_NAME}1`)
.should('have.class', 'jtk-endpoint-connected');
cy.get('.jtk-connector').should('have.length', 1);
// Disconnect Set1
cy.drag(WorkflowPage.getters.getEndpointSelector('input', `${SET_NODE_NAME}1`), [-200, 100]);
cy.get('.jtk-connector').should('have.length', 0);
});
it('should add first step', () => {
WorkflowPage.getters.canvasPlusButton().should('be.visible');
WorkflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodes().should('have.length', 1);
});
it('should add a node via plus endpoint drag', () => {
WorkflowPage.getters.canvasPlusButton().should('be.visible');
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME, true);
cy.drag(
WorkflowPage.getters.getEndpointSelector('plus', SCHEDULE_TRIGGER_NODE_NAME),
[100, 100],
);
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
WorkflowPage.actions.addNodeToCanvas(IF_NODE_NAME, false);
WorkflowPage.getters.nodeViewBackground().click({ force: true });
});
it('should add a connected node using plus endpoint', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
cy.get('.plus-endpoint').should('be.visible').click();
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
WorkflowPage.getters.nodeCreatorSearchBar().type(CODE_NODE_NAME);
WorkflowPage.getters.nodeCreatorSearchBar().type('{enter}');
cy.get('body').type('{esc}');
WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
});
it('should add disconnected node if nothing is selected', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
// Deselect nodes
WorkflowPage.getters.nodeViewBackground().click({ force: true });
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
it('should add node between two connected nodes', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME);
WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.addNodeBetweenNodes(CODE_NODE_NAME, SET_NODE_NAME, HTTP_REQUEST_NODE_NAME);
WorkflowPage.getters.canvasNodes().should('have.length', 4);
WorkflowPage.getters.nodeConnections().should('have.length', 3);
// And last node should be pushed to the right
WorkflowPage.getters
.canvasNodes()
.last()
.should('have.attr', 'style', 'left: 860px; top: 260px;');
});
it('should delete connections by pressing the delete button', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.nodeConnections().first().realHover();
cy.get('.connection-actions .delete').first().click({ force: true });
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
it('should delete a connection by moving it away from endpoint', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.drag(WorkflowPage.getters.getEndpointSelector('input', CODE_NODE_NAME), [0, -100]);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
it('should execute node', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters
.canvasNodes()
.last()
.find('[data-test-id="execute-node-button"]')
.click({ force: true });
WorkflowPage.getters.successToast().should('contain', 'Node executed successfully');
});
it('should copy selected nodes', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.selectAll();
WorkflowPage.actions.hitCopy();
WorkflowPage.getters.successToast().should('contain', 'Copied!');
});
it('should select all nodes', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.selectAll();
WorkflowPage.getters.selectedNodes().should('have.length', 2);
});
it('should select nodes using arrow keys', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.wait(500);
cy.get('body').type('{leftArrow}');
WorkflowPage.getters.canvasNodes().first().should('have.class', 'jtk-drag-selected');
cy.get('body').type('{rightArrow}');
WorkflowPage.getters.canvasNodes().last().should('have.class', 'jtk-drag-selected');
});
it('should select nodes using shift and arrow keys', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.wait(500);
cy.get('body').type('{shift}', { release: false }).type('{leftArrow}');
WorkflowPage.getters.selectedNodes().should('have.length', 2);
});
});

View file

@ -20,7 +20,7 @@ const ZOOM_OUT_X1_FACTOR = 0.8;
const ZOOM_OUT_X2_FACTOR = 0.64;
const RENAME_NODE_NAME = 'Something else';
describe('Canvas Actions', () => {
describe('Canvas Node Manipulation and Navigation', () => {
before(() => {
cy.resetAll();
cy.skipSetup();
@ -30,56 +30,6 @@ describe('Canvas Actions', () => {
WorkflowPage.actions.visit();
});
it('should render canvas', () => {
WorkflowPage.getters.nodeViewRoot().should('be.visible');
WorkflowPage.getters.canvasPlusButton().should('be.visible');
WorkflowPage.getters.zoomToFitButton().should('be.visible');
WorkflowPage.getters.zoomInButton().should('be.visible');
WorkflowPage.getters.zoomOutButton().should('be.visible');
WorkflowPage.getters.executeWorkflowButton().should('be.visible');
});
it('should connect and disconnect a simple node', () => {
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME);
WorkflowPage.getters.nodeViewBackground().click(600, 200, { force: true });
cy.get('.jtk-connector').should('have.length', 1);
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME);
// Change connection from Set to Set1
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('input', SET_NODE_NAME),
WorkflowPage.getters.getEndpointSelector('input', `${SET_NODE_NAME}1`),
);
WorkflowPage.getters
.canvasNodeInputEndpointByName(`${SET_NODE_NAME}1`)
.should('have.class', 'jtk-endpoint-connected');
cy.get('.jtk-connector').should('have.length', 1);
// Disconnect Set1
cy.drag(WorkflowPage.getters.getEndpointSelector('input', `${SET_NODE_NAME}1`), [-200, 100]);
cy.get('.jtk-connector').should('have.length', 0);
});
it('should add first step', () => {
WorkflowPage.getters.canvasPlusButton().should('be.visible');
WorkflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodes().should('have.length', 1);
});
it('should add a node via plus endpoint drag', () => {
WorkflowPage.getters.canvasPlusButton().should('be.visible');
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME, true);
cy.drag(
WorkflowPage.getters.getEndpointSelector('plus', SCHEDULE_TRIGGER_NODE_NAME),
[100, 100],
);
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
WorkflowPage.actions.addNodeToCanvas(IF_NODE_NAME, false);
WorkflowPage.getters.nodeViewBackground().click({ force: true });
});
it('should add switch node and test connections', () => {
WorkflowPage.actions.addNodeToCanvas(SWITCH_NODE_NAME, true);
@ -142,6 +92,8 @@ describe('Canvas Actions', () => {
cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 4);
WorkflowPage.actions.executeWorkflow();
WorkflowPage.getters.stopExecutionButton().should('not.exist');
// If the merged set nodes are connected and executed correctly, there should be 2 items in the output of merge node
cy.get('[data-label="2 items"]').should('be.visible');
});
@ -167,41 +119,6 @@ describe('Canvas Actions', () => {
cy.get('.jtk-connector').should('have.length', 4);
});
it('should add a connected node using plus endpoint', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
cy.get('.plus-endpoint').should('be.visible').click();
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
WorkflowPage.getters.nodeCreatorSearchBar().type(CODE_NODE_NAME);
WorkflowPage.getters.nodeCreatorSearchBar().type('{enter}');
cy.get('body').type('{esc}');
WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
});
it('should add disconnected node if nothing is selected', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
// Deselect nodes
WorkflowPage.getters.nodeViewBackground().click({ force: true });
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
it('should add note between two connected nodes', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME);
WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.addNodeBetweenNodes(CODE_NODE_NAME, SET_NODE_NAME, HTTP_REQUEST_NODE_NAME);
WorkflowPage.getters.canvasNodes().should('have.length', 4);
WorkflowPage.getters.nodeConnections().should('have.length', 3);
// And last node should be pushed to the right
WorkflowPage.getters
.canvasNodes()
.last()
.should('have.attr', 'style', 'left: 860px; top: 260px;');
});
it('should delete node using node action button', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
@ -319,50 +236,6 @@ describe('Canvas Actions', () => {
WorkflowPage.getters.canvasNodes().last().should('be.visible');
});
it('should select all nodes', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.selectAll();
WorkflowPage.getters.selectedNodes().should('have.length', 2);
});
it('should select nodes using arrow keys', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.wait(500);
cy.get('body').type('{leftArrow}');
WorkflowPage.getters.canvasNodes().first().should('have.class', 'jtk-drag-selected');
cy.get('body').type('{rightArrow}');
WorkflowPage.getters.canvasNodes().last().should('have.class', 'jtk-drag-selected');
});
it('should select nodes using shift and arrow keys', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.wait(500);
cy.get('body').type('{shift}', { release: false }).type('{leftArrow}');
WorkflowPage.getters.selectedNodes().should('have.length', 2);
});
it('should delete connections by pressing the delete button', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.nodeConnections().first().realHover();
cy.get('.connection-actions .delete').first().click({ force: true });
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
it('should delete a connection by moving it away from endpoint', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.drag(WorkflowPage.getters.getEndpointSelector('input', CODE_NODE_NAME), [0, -100]);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
it('should disable node by pressing the disable button', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
@ -418,23 +291,4 @@ describe('Canvas Actions', () => {
WorkflowPage.getters.canvasNodes().should('have.length', 3);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
});
it('should execute node', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters
.canvasNodes()
.last()
.find('[data-test-id="execute-node-button"]')
.click({ force: true });
WorkflowPage.getters.successToast().should('contain', 'Node executed successfully');
});
it('should copy selected nodes', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.selectAll();
WorkflowPage.actions.hitCopy();
WorkflowPage.getters.successToast().should('contain', 'Copied!');
});
});

View file

@ -11,7 +11,6 @@ describe('Data transformation expressions', () => {
beforeEach(() => {
wf.actions.visit();
cy.waitForLoad();
cy.window()
// @ts-ignore

View file

@ -16,6 +16,9 @@ import {
} from '../constants';
import { randFirstName, randLastName } from '@ngneat/falso';
import { CredentialsPage, CredentialsModal, WorkflowPage, NDV } from '../pages';
import CustomNodeWithN8nCredentialFixture from '../fixtures/Custom_node_n8n_credential.json';
import CustomNodeWithCustomCredentialFixture from '../fixtures/Custom_node_custom_credential.json';
import CustomCredential from '../fixtures/Custom_credential.json';
const email = DEFAULT_USER_EMAIL;
const password = DEFAULT_USER_PASSWORD;
@ -31,25 +34,10 @@ const NEW_CREDENTIAL_NAME = 'Something else';
describe('Credentials', () => {
before(() => {
cy.resetAll();
cy.setup({ email, firstName, lastName, password });
// Always intercept the request to test credentials and return a success
cy.intercept('POST', '/rest/credentials/test', {
statusCode: 200,
body: {
data: { status: 'success', message: 'Tested successfully' },
}
});
cy.skipSetup();
});
beforeEach(() => {
cy.on('uncaught:exception', (err, runnable) => {
expect(err.message).to.include('Not logged in');
return false;
});
cy.signin({ email, password });
cy.visit(credentialsPage.url);
});
@ -250,24 +238,4 @@ describe('Credentials', () => {
credentialsModal.actions.fillCredentialsForm();
workflowPage.getters.nodeCredentialsSelect().should('contain', NEW_QUERY_AUTH_ACCOUNT_NAME);
});
it('should render custom node with n8n credential', () => {
workflowPage.actions.visit();
workflowPage.actions.addNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('E2E Node with native n8n credential', true, true);
workflowPage.getters.nodeCredentialsLabel().click();
cy.contains('Create New Credential').click();
credentialsModal.getters.editCredentialModal().should('be.visible');
credentialsModal.getters.editCredentialModal().should('contain.text', 'Notion API');
});
it('should render custom node with custom credential', () => {
workflowPage.actions.visit();
workflowPage.actions.addNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('E2E Node with custom credential', true, true);
workflowPage.getters.nodeCredentialsLabel().click();
cy.contains('Create New Credential').click();
credentialsModal.getters.editCredentialModal().should('be.visible');
credentialsModal.getters.editCredentialModal().should('contain.text', 'Custom E2E Credential');
});
});

View file

@ -0,0 +1,101 @@
import { NodeCreator } from '../pages/features/node-creator';
import CustomNodeFixture from '../fixtures/Custom_node.json';
import { CredentialsModal, WorkflowPage } from '../pages';
import CustomNodeWithN8nCredentialFixture from '../fixtures/Custom_node_n8n_credential.json';
import CustomNodeWithCustomCredentialFixture from '../fixtures/Custom_node_custom_credential.json';
import CustomCredential from '../fixtures/Custom_credential.json';
const credentialsModal = new CredentialsModal();
const nodeCreatorFeature = new NodeCreator();
const workflowPage = new WorkflowPage();
// We separate-out the custom nodes because they require injecting nodes and credentials
// so the /nodes and /credentials endpoints are intercepted and non-cached.
// We want to keep the other tests as fast as possible so we don't want to break the cache in those.
describe('Community Nodes', () => {
before(() => {
cy.resetAll();
cy.skipSetup();
})
beforeEach(() => {
cy.intercept('/types/nodes.json', { middleware: true }, (req) => {
req.headers['cache-control'] = 'no-cache, no-store';
req.on('response', (res) => {
const nodes = res.body || [];
nodes.push(CustomNodeFixture, CustomNodeWithN8nCredentialFixture, CustomNodeWithCustomCredentialFixture);
});
})
cy.intercept('/types/credentials.json', { middleware: true }, (req) => {
req.headers['cache-control'] = 'no-cache, no-store';
req.on('response', (res) => {
const credentials = res.body || [];
credentials.push(CustomCredential);
})
})
workflowPage.actions.visit();
});
it('should render and select community node', () => {
const customNode = 'E2E Node';
nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.searchBar().find('input').clear().type(customNode);
nodeCreatorFeature.getters
.getCreatorItem(customNode)
.findChildByTestId('node-creator-item-tooltip')
.should('exist');
nodeCreatorFeature.actions.selectNode(customNode);
// TODO: Replace once we have canvas feature utils
cy.get('.data-display .node-name').contains(customNode).should('exist');
const nodeParameters = () => cy.getByTestId('node-parameters');
const firstParameter = () => nodeParameters().find('.parameter-item').eq(0);
const secondParameter = () => nodeParameters().find('.parameter-item').eq(1);
// Check correct fields are rendered
nodeParameters().should('exist');
// Test property text input
firstParameter().contains('Test property').should('exist');
firstParameter().find('input.el-input__inner').should('have.value', 'Some default');
// Resource select input
secondParameter().find('label').contains('Resource').should('exist');
secondParameter().find('input.el-input__inner').should('have.value', 'option2');
secondParameter().find('.el-select').click();
secondParameter().find('.el-select-dropdown__list').should('exist');
// Check if all options are rendered and select the fourth one
secondParameter().find('.el-select-dropdown__list').children().should('have.length', 4);
secondParameter()
.find('.el-select-dropdown__list')
.children()
.eq(3)
.contains('option4')
.should('exist')
.click();
secondParameter().find('input.el-input__inner').should('have.value', 'option4');
});
it('should render custom node with n8n credential', () => {
workflowPage.actions.addNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('E2E Node with native n8n credential', true, true);
workflowPage.getters.nodeCredentialsLabel().click();
cy.contains('Create New Credential').click();
credentialsModal.getters.editCredentialModal().should('be.visible');
credentialsModal.getters.editCredentialModal().should('contain.text', 'Notion API');
});
it('should render custom node with custom credential', () => {
workflowPage.actions.addNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('E2E Node with custom credential', true, true);
workflowPage.getters.nodeCredentialsLabel().click();
cy.contains('Create New Credential').click();
credentialsModal.getters.editCredentialModal().should('be.visible');
credentialsModal.getters.editCredentialModal().should('contain.text', 'Custom E2E Credential');
});
});

View file

@ -6,6 +6,7 @@ const nodeCreatorFeature = new NodeCreator();
const WorkflowPage = new WorkflowPageClass();
const NDVModal = new NDV();
describe('Node Creator', () => {
before(() => {
cy.resetAll();
@ -104,44 +105,75 @@ describe('Node Creator', () => {
NDVModal.getters.parameterInput('operation').should('contain.text', 'Rename');
})
it('should render and select community node', () => {
const customNode = 'E2E Node';
it('should not show actions for single action nodes', () => {
const singleActionNodes = [
'DHL',
'iCalendar',
'LingvaNex',
'Mailcheck',
'MSG91',
'OpenThesaurus',
'Spontit',
'Vonage',
'Send Email',
'Toggl Trigger'
]
const doubleActionNode = 'OpenWeatherMap'
nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.searchBar().find('input').clear().type(customNode);
singleActionNodes.forEach((node) => {
nodeCreatorFeature.getters.searchBar().find('input').clear().type(node);
nodeCreatorFeature.getters.getCreatorItem(node).find('button[class*="panelIcon"]').should('not.exist');
})
nodeCreatorFeature.getters.searchBar().find('input').clear().type(doubleActionNode);
nodeCreatorFeature.getters.getCreatorItem(doubleActionNode).click();
nodeCreatorFeature.getters.creatorItem().should('have.length', 2);
})
nodeCreatorFeature.getters
.getCreatorItem(customNode)
.findChildByTestId('node-creator-item-tooltip')
.should('exist');
nodeCreatorFeature.actions.selectNode(customNode);
// TODO: Replace once we have canvas feature utils
cy.get('.data-display .node-name').contains(customNode).should('exist');
const nodeParameters = () => cy.getByTestId('node-parameters');
const firstParameter = () => nodeParameters().find('.parameter-item').eq(0);
const secondParameter = () => nodeParameters().find('.parameter-item').eq(1);
// Check correct fields are rendered
nodeParameters().should('exist');
// Test property text input
firstParameter().contains('Test property').should('exist');
firstParameter().find('input.el-input__inner').should('have.value', 'Some default');
// Resource select input
secondParameter().find('label').contains('Resource').should('exist');
secondParameter().find('input.el-input__inner').should('have.value', 'option2');
secondParameter().find('.el-select').click();
secondParameter().find('.el-select-dropdown__list').should('exist');
// Check if all options are rendered and select the fourth one
secondParameter().find('.el-select-dropdown__list').children().should('have.length', 4);
secondParameter()
.find('.el-select-dropdown__list')
.children()
.eq(3)
.contains('option4')
.should('exist')
.click();
secondParameter().find('input.el-input__inner').should('have.value', 'option4');
describe('should correctly append manual trigger for regular actions', () => {
// For these sources, manual node should be added
const sourcesWithAppend = [
{
name: 'canvas add button',
handler: () => nodeCreatorFeature.getters.canvasAddButton().click(),
}, {
name: 'plus button',
handler: () => nodeCreatorFeature.getters.plusButton().click(),
},
// We can't test this one because it's not possible to trigger tab key in Cypress
// only way is to use `realPress` which is hanging the tests in Electron for some reason
// {
// name: 'tab key',
// handler: () => cy.realPress('Tab'),
// },
]
sourcesWithAppend.forEach((source) => {
it(`should append manual trigger when source is ${source.name}`, () => {
source.handler()
nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n');
nodeCreatorFeature.getters.getCreatorItem('n8n').click();
nodeCreatorFeature.getters.getCreatorItem('Create a credential').click();
NDVModal.actions.close();
WorkflowPage.getters.canvasNodes().should('have.length', 2);
});
});
it('should not append manual trigger when source is canvas related', () => {
nodeCreatorFeature.getters.canvasAddButton().click();
nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n');
nodeCreatorFeature.getters.getCreatorItem('n8n').click();
nodeCreatorFeature.getters.getCreatorItem('Create a credential').click();
NDVModal.actions.close();
WorkflowPage.actions.deleteNode('When clicking "Execute Workflow"')
WorkflowPage.getters.canvasNodePlusEndpointByName('n8n').click()
nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n');
nodeCreatorFeature.getters.getCreatorItem('n8n').click();
nodeCreatorFeature.getters.getCreatorItem('Create a credential').click();
NDVModal.actions.close();
WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.addNodeBetweenNodes('n8n', 'n8n1', 'Item Lists')
WorkflowPage.getters.canvasNodes().should('have.length', 3);
})
});
});

View file

@ -89,4 +89,42 @@ describe('NDV', () => {
cy.get('[class*=hasIssues]').should('have.length', 1);
});
});
describe('test output schema view', () => {
const schemaKeys = ['id', 'name', 'email', 'notes', 'country', 'created', 'objectValue', 'prop1', 'prop2'];
beforeEach(() => {
cy.createFixtureWorkflow('Test_workflow_schema_test.json', `NDV test schema view ${uuid()}`);
workflowPage.actions.zoomToFit();
workflowPage.actions.openNode('Set');
ndv.actions.execute();
});
it('should switch to output schema view and validate it', () => {
ndv.getters.outputDisplayMode().children().should('have.length', 3);
ndv.getters.outputDisplayMode().find('[class*=active]').should('contain', 'Table');
ndv.getters.outputDisplayMode().contains('Schema').click();
ndv.getters.outputDisplayMode().find('[class*=active]').should('contain', 'Schema');
schemaKeys.forEach((key) => {
ndv.getters.outputPanel().find('[data-test-id=run-data-schema-item]').contains(key).should('exist');
});
});
it('should preserve schema view after execution', () => {
ndv.getters.outputDisplayMode().contains('Schema').click();
ndv.actions.execute();
ndv.getters.outputDisplayMode().find('[class*=active]').should('contain', 'Schema');
})
it('should collapse and expand nested schema object', () => {
const expandedObjectProps = ['prop1', 'prop2'];;
const getObjectValueItem = () => ndv.getters.outputPanel().find('[data-test-id=run-data-schema-item]').filter(':contains("objectValue")');
ndv.getters.outputDisplayMode().contains('Schema').click();
expandedObjectProps.forEach((key) => {
ndv.getters.outputPanel().find('[data-test-id=run-data-schema-item]').contains(key).should('be.visible');
});
getObjectValueItem().find('label').click();
expandedObjectProps.forEach((key) => {
ndv.getters.outputPanel().find('[data-test-id=run-data-schema-item]').contains(key).should('not.be.visible');
});
})
})
});

View file

@ -7,7 +7,7 @@ import {
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
const NEW_WORKFLOW_NAME = 'Something else';
const IMPORT_WORKFLOW_URL = 'https://www.jsonkeeper.com/b/FNB0#.json';
const IMPORT_WORKFLOW_URL = 'https://gist.githubusercontent.com/OlegIvaniv/010bd3f45c8a94f8eb7012e663a8b671/raw/3afea1aec15573cc168d9af7e79395bd76082906/test-workflow.json';
const DUPLICATE_WORKFLOW_NAME = 'Duplicated workflow';
const DUPLICATE_WORKFLOW_TAG = 'Duplicate';
@ -94,7 +94,7 @@ describe('Workflow Actions', () => {
cy.get('.el-message-box').should('be.visible');
cy.get('.el-message-box').find('input').type(IMPORT_WORKFLOW_URL);
cy.get('body').type('{enter}');
cy.waitForLoad();
cy.waitForLoad(false)
WorkflowPage.actions.zoomToFit();
WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
@ -104,7 +104,7 @@ describe('Workflow Actions', () => {
WorkflowPage.getters
.workflowImportInput()
.selectFile('cypress/fixtures/Test_workflow-actions_paste-data.json', { force: true });
cy.waitForLoad();
cy.waitForLoad(false)
WorkflowPage.actions.zoomToFit();
WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 1);

View file

@ -0,0 +1,92 @@
{
"name": "My workflow 8",
"nodes": [
{
"parameters": {
"operation": "getAllPeople",
"limit": 10
},
"id": "39cd80ce-5a8f-4339-b3d5-c4af969dd330",
"name": "Customer Datastore (n8n training)",
"type": "n8n-nodes-base.n8nTrainingCustomerDatastore",
"typeVersion": 1,
"position": [
940,
680
]
},
{
"parameters": {
"values": {
"number": [
{
"name": "objectValue.prop1",
"value": 123
}
],
"string": [
{
"name": "objectValue.prop2",
"value": "someText"
}
]
},
"options": {
"dotNotation": true
}
},
"id": "6e4490f6-ba95-4400-beec-2caefdd4895a",
"name": "Set",
"type": "n8n-nodes-base.set",
"typeVersion": 1,
"position": [
1300,
680
]
},
{
"parameters": {},
"id": "58512a93-dabf-4584-817f-27c608c1bdd5",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
720,
680
]
}
],
"pinData": {},
"connections": {
"Customer Datastore (n8n training)": {
"main": [
[
{
"node": "Set",
"type": "main",
"index": 0
}
]
]
},
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Customer Datastore (n8n training)",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {},
"versionId": "4a4f292a-92be-427c-848a-9582527f5ed3",
"id": "8",
"meta": {
"instanceId": "032eceae7493054b723340499be69ecbf4cbe28a7ec6df676b759000750b968d"
},
"tags": []
}

View file

@ -24,7 +24,6 @@ export class NodeCreator extends BasePage {
};
actions = {
openNodeCreator: () => {
cy.waitForLoad();
this.getters.plusButton().click();
this.getters.nodeCreator().should('be.visible');
},

View file

@ -13,10 +13,9 @@ export class NDV extends BasePage {
outputPanel: () => cy.getByTestId('output-panel'),
executingLoader: () => cy.getByTestId('ndv-executing'),
inputDataContainer: () => this.getters.inputPanel().findChildByTestId('ndv-data-container'),
inputDisplayMode: () => this.getters.inputPanel().getByTestId('ndv-run-data-display-mode'),
inputDisplayMode: () => this.getters.inputPanel().findChildByTestId('ndv-run-data-display-mode').first(),
outputDataContainer: () => this.getters.outputPanel().findChildByTestId('ndv-data-container'),
outputDisplayMode: () => this.getters.outputPanel().getByTestId('ndv-run-data-display-mode'),
digital: () => cy.getByTestId('ndv-run-data-display-mode'),
outputDisplayMode: () => this.getters.outputPanel().findChildByTestId('ndv-run-data-display-mode').first(),
pinDataButton: () => cy.getByTestId('ndv-pin-data'),
editPinnedDataButton: () => cy.getByTestId('ndv-edit-pinned-data'),
pinnedDataEditor: () => this.getters.outputPanel().find('.monaco-editor[role=code]'),

View file

@ -1,9 +1,11 @@
import { SettingsSidebar } from './sidebar/settings-sidebar';
import { MainSidebar } from './sidebar/main-sidebar';
import { WorkflowPage } from './workflow';
import { WorkflowsPage } from './workflows';
import { BasePage } from './base';
const workflowPage = new WorkflowPage();
const workflowsPage = new WorkflowsPage();
const mainSidebar = new MainSidebar();
const settingsSidebar = new SettingsSidebar();
@ -39,7 +41,7 @@ export class SettingsUsersPage extends BasePage {
settingsSidebar.getters.menuItem('Users').should('not.exist');
// Should be redirected to workflows page if trying to access UM url
cy.visit('/settings/users');
cy.url().should('match', new RegExp(workflowPage.url));
cy.url().should('match', new RegExp(workflowsPage.url));
}
},
opedDeleteDialog: (email: string) => {

View file

@ -174,6 +174,10 @@ export class WorkflowPage extends BasePage {
saveWorkflowUsingKeyboardShortcut: () => {
cy.get('body').type('{meta}', { release: false }).type('s');
},
deleteNode: (name: string) => {
this.getters.canvasNodeByName(name).first().click();
cy.get('body').type('{del}');
},
setWorkflowName: (name: string) => {
this.getters.workflowNameInput().should('be.disabled');
this.getters.workflowNameInput().parent().click();

View file

@ -26,7 +26,6 @@
import 'cypress-real-events';
import { WorkflowsPage, SigninPage, SignupPage, SettingsUsersPage, WorkflowPage } from '../pages';
import { N8N_AUTH_COOKIE } from '../constants';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
import { MessageBox } from '../pages/modals/message-box';
Cypress.Commands.add('getByTestId', (selector, ...args) => {
@ -34,15 +33,17 @@ Cypress.Commands.add('getByTestId', (selector, ...args) => {
});
Cypress.Commands.add('createFixtureWorkflow', (fixtureKey, workflowName) => {
const WorkflowPage = new WorkflowPageClass();
const workflowPage = new WorkflowPage();
// We need to force the click because the input is hidden
WorkflowPage.getters
workflowPage.getters
.workflowImportInput()
.selectFile(`cypress/fixtures/${fixtureKey}`, { force: true });
WorkflowPage.actions.setWorkflowName(workflowName);
WorkflowPage.getters.saveButton().should('contain', 'Saved');
cy.waitForLoad(false);
workflowPage.actions.setWorkflowName(workflowName);
workflowPage.getters.saveButton().should('contain', 'Saved');
});
Cypress.Commands.add(
@ -53,14 +54,20 @@ Cypress.Commands.add(
},
);
Cypress.Commands.add('waitForLoad', () => {
Cypress.Commands.add('waitForLoad', (waitForIntercepts = true) => {
// These aliases are set-up before each test in cypress/support/e2e.ts
// we can't set them up here because at this point it would be too late
// and the requests would already have been made
if(waitForIntercepts) {
cy.wait(['@loadSettings', '@loadLogin'])
}
cy.getByTestId('node-view-loader', { timeout: 20000 }).should('not.exist');
cy.get('.el-loading-mask', { timeout: 20000 }).should('not.exist');
});
Cypress.Commands.add('signin', ({ email, password }) => {
const signinPage = new SigninPage();
const workflowPage = new WorkflowPage();
const workflowsPage = new WorkflowsPage();
cy.session(
[email, password],
@ -74,10 +81,7 @@ Cypress.Commands.add('signin', ({ email, password }) => {
});
// we should be redirected to /workflows
cy.visit(workflowPage.url);
cy.url().should('include', workflowPage.url);
cy.intercept('GET', '/rest/workflows/new').as('loading');
cy.wait('@loading');
cy.url().should('include', workflowsPage.url);
},
{
validate() {
@ -212,7 +216,9 @@ Cypress.Commands.add('grantBrowserPermissions', (...permissions: string[]) => {
}
});
Cypress.Commands.add('readClipboard', () => cy.window().then(win => win.navigator.clipboard.readText()))
Cypress.Commands.add('readClipboard', () =>
cy.window().then((win) => win.navigator.clipboard.readText()),
);
Cypress.Commands.add('paste', { prevSubject: true }, (selector, pastePayload) => {
// https://developer.mozilla.org/en-US/docs/Web/API/Element/paste_event

View file

@ -14,28 +14,17 @@
// ***********************************************************
import './commands';
import CustomNodeFixture from '../fixtures/Custom_node.json';
import CustomNodeWithN8nCredentialFixture from '../fixtures/Custom_node_n8n_credential.json';
import CustomNodeWithCustomCredentialFixture from '../fixtures/Custom_node_custom_credential.json';
import CustomCredential from '../fixtures/Custom_credential.json';
// Load custom nodes and credentials fixtures
beforeEach(() => {
cy.intercept('GET', '/types/nodes.json', (req) => {
req.on('response', (res) => {
const nodes = res.body || [];
cy.intercept('GET', '/rest/settings').as('loadSettings');
cy.intercept('GET', '/rest/login').as('loadLogin');
res.headers['cache-control'] = 'no-cache, no-store';
nodes.push(CustomNodeFixture, CustomNodeWithN8nCredentialFixture, CustomNodeWithCustomCredentialFixture);
// Always intercept the request to test credentials and return a success
cy.intercept('POST', '/rest/credentials/test', {
statusCode: 200,
body: {
data: { status: 'success', message: 'Tested successfully' },
}
});
}).as('nodesIntercept');
cy.intercept('GET', '/types/credentials.json', (req) => {
req.on('response', (res) => {
const credentials = res.body || [];
res.headers['cache-control'] = 'no-cache, no-store';
credentials.push(CustomCredential);
})
}).as('credentialsIntercept');
})

View file

@ -43,7 +43,7 @@ declare global {
skipSetup(): void;
resetAll(): void;
enableFeature(feature: string): void;
waitForLoad(): void;
waitForLoad(waitForIntercepts?: boolean): void;
grantBrowserPermissions(...permissions: string[]): void;
readClipboard(): Chainable<string>;
paste(pastePayload: string): void;

View file

@ -20,7 +20,7 @@ services:
- ${DATA_FOLDER}/letsencrypt:/letsencrypt
- /var/run/docker.sock:/var/run/docker.sock:ro
n8n:
image: n8nio/n8n
image: docker.n8n.io/n8nio/n8n
ports:
- '127.0.0.1:5678:5678'
labels:

View file

@ -23,7 +23,7 @@ services:
retries: 10
n8n:
image: n8nio/n8n
image: docker.n8n.io/n8nio/n8n
restart: always
environment:
- DB_TYPE=mariadb

View file

@ -24,7 +24,7 @@ services:
retries: 10
n8n:
image: n8nio/n8n
image: docker.n8n.io/n8nio/n8n
restart: always
environment:
- DB_TYPE=postgresdb

View file

@ -63,14 +63,14 @@ services:
n8n:
<<: *shared
image: n8nio/n8n
image: docker.n8n.io/n8nio/n8n
command: /bin/sh -c "n8n start --tunnel"
ports:
- 5678:5678
n8n-worker:
<<: *shared
image: n8nio/n8n
image: docker.n8n.io/n8nio/n8n
command: /bin/sh -c "sleep 5; n8n worker"
depends_on:
- n8n

View file

@ -3,7 +3,7 @@ ARG NODE_VERSION=16
# 1. Create an image to build n8n
FROM n8nio/base:${NODE_VERSION} as builder
COPY turbo.json package.json .npmrc pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json ./
COPY turbo.json package.json .npmrc pnpm-lock.yaml pnpm-workspace.yaml jest.config.js tsconfig.json ./
COPY scripts ./scripts
COPY packages ./packages
COPY patches ./patches

View file

@ -40,12 +40,12 @@ Additional information and example workflows on the n8n.io website: [https://n8n
## Start n8n in Docker
```
```bash
docker run -it --rm \
--name n8n \
-p 5678:5678 \
-v ~/.n8n:/home/node/.n8n \
n8nio/n8n
docker.n8n.io/n8nio/n8n
```
You can then access n8n by opening:
@ -62,12 +62,12 @@ n8n instance.
To use it simply start n8n with `--tunnel`
```
```bash
docker run -it --rm \
--name n8n \
-p 5678:5678 \
-v ~/.n8n:/home/node/.n8n \
n8nio/n8n \
docker.n8n.io/n8nio/n8n \
n8n start --tunnel
```
@ -79,7 +79,7 @@ to make sure that n8n is protected!
Right now we have very basic protection via basic-auth in place. It can be activated
by setting the following environment variables:
```
```text
N8N_BASIC_AUTH_ACTIVE=true
N8N_BASIC_AUTH_USER=<USER>
N8N_BASIC_AUTH_PASSWORD=<PASSWORD>
@ -91,12 +91,12 @@ The workflow data gets by default saved in an SQLite database in the user
folder (`/home/node/.n8n`). That folder also additionally contains the
settings like webhook URL and encryption key.
```
```bash
docker run -it --rm \
--name n8n \
-p 5678:5678 \
-v ~/.n8n:/home/node/.n8n \
n8nio/n8n
docker.n8n.io/n8nio/n8n
```
### Start with other Database
@ -123,7 +123,7 @@ Replace the following placeholders with the actual data:
- POSTGRES_USER
- POSTGRES_SCHEMA
```
```bash
docker run -it --rm \
--name n8n \
-p 5678:5678 \
@ -135,7 +135,7 @@ docker run -it --rm \
-e DB_POSTGRESDB_SCHEMA=<POSTGRES_SCHEMA> \
-e DB_POSTGRESDB_PASSWORD=<POSTGRES_PASSWORD> \
-v ~/.n8n:/home/node/.n8n \
n8nio/n8n \
docker.n8n.io/n8nio/n8n \
n8n start
```
@ -151,7 +151,7 @@ Replace the following placeholders with the actual data:
- MYSQLDB_PORT
- MYSQLDB_USER
```
```bash
docker run -it --rm \
--name n8n \
-p 5678:5678 \
@ -162,7 +162,7 @@ docker run -it --rm \
-e DB_MYSQLDB_USER=<MYSQLDB_USER> \
-e DB_MYSQLDB_PASSWORD=<MYSQLDB_PASSWORD> \
-v ~/.n8n:/home/node/.n8n \
n8nio/n8n \
docker.n8n.io/n8nio/n8n \
n8n start
```
@ -191,16 +191,21 @@ A basic step by step example setup of n8n with docker-compose and Lets Encrypt i
## Updating a running docker-compose instance
```
# Pull down the latest version from dockerhub
docker pull n8nio/n8n
# Stop current setup
sudo docker-compose stop
# Delete it (will only delete the docker-containers, data is stored separately)
sudo docker-compose rm
# Then start it again
sudo docker-compose up -d
```
1. Pull the latest version from the registry
`docker pull docker.n8n.io/n8nio/n8n`
2. Stop the current setup
`sudo docker-compose stop`
3. Delete it (will only delete the docker-containers, data is stored separately)
`sudo docker-compose rm`
4. Then start it again
`sudo docker-compose up -d`
## Setting Timezone
@ -212,22 +217,22 @@ the environment variable `TZ`.
Example to use the same timezone for both:
```
```bash
docker run -it --rm \
--name n8n \
-p 5678:5678 \
-e GENERIC_TIMEZONE="Europe/Berlin" \
-e TZ="Europe/Berlin" \
n8nio/n8n
docker.n8n.io/n8nio/n8n
```
## Build Docker-Image
```
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --build-arg N8N_VERSION=<VERSION> -t n8nio/n8n:<VERSION> .
```bash
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --build-arg N8N_VERSION=<VERSION> -t n8n:<VERSION> .
# For example:
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --build-arg N8N_VERSION=0.114.0 -t n8nio/n8n:0.114.0 .
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --build-arg N8N_VERSION=0.114.0 -t n8n:0.114.0 .
```
## What does n8n mean and how do you pronounce it?

View file

@ -22,11 +22,14 @@ const config = {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverage: true,
coverageReporters: [process.env.COVERAGE_REPORT === 'true' ? 'text' : 'text-summary'],
collectCoverageFrom: ['src/**/*.ts'],
};
if (process.env.CI === 'true') {
config.maxWorkers = 2;
config.workerIdleMemoryLimit = 2048;
config.workerIdleMemoryLimit = 1024;
config.coverageReporters = ['cobertura'];
}
module.exports = config;

View file

@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "0.218.0",
"version": "0.221.2",
"private": true,
"homepage": "https://n8n.io",
"engines": {
@ -39,15 +39,15 @@
"devDependencies": {
"@n8n_io/eslint-config": "workspace:*",
"@ngneat/falso": "^6.1.0",
"@types/jest": "^29.2.2",
"@types/jest": "^29.5.0",
"@types/supertest": "^2.0.12",
"cross-env": "^7.0.3",
"cypress": "^12.7.0",
"cypress": "^12.8.1",
"cypress-real-events": "^1.7.6",
"jest": "^29.4.2",
"jest-environment-jsdom": "^29.4.2",
"jest-mock": "^29.4.2",
"jest-mock-extended": "^3.0.1",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"jest-mock": "^29.5.0",
"jest-mock-extended": "^3.0.3",
"nock": "^13.2.9",
"node-fetch": "^2.6.7",
"p-limit": "^3.1.0",
@ -69,8 +69,10 @@
"@types/node": "^16.18.12",
"browserslist": "^4.21.4",
"chokidar": "3.5.2",
"decode-uri-component": "0.2.2",
"ejs": "^3.1.8",
"fork-ts-checker-webpack-plugin": "^6.0.4",
"http-cache-semantics": "4.1.1",
"jsonwebtoken": "9.0.0",
"prettier": "^2.8.3",
"ts-node": "^10.9.1",

View file

@ -58,7 +58,7 @@ To play around with n8n, you can also start it using Docker:
docker run -it --rm \
--name n8n \
-p 5678:5678 \
n8nio/n8n
docker.n8n.io/n8nio/n8n
```
Be aware that all the data will be lost once the Docker container gets removed. To persist the data mount the `~/.n8n` folder:
@ -68,11 +68,9 @@ docker run -it --rm \
--name n8n \
-p 5678:5678 \
-v ~/.n8n:/home/node/.n8n \
n8nio/n8n
docker.n8n.io/n8nio/n8n
```
n8n also offers a Docker image for Raspberry Pi: `n8nio/n8n:latest-rpi`.
Refer to the [documentation](https://github.com/n8n-io/n8n/blob/master/docker/images/n8n/README.md) for more information on the Docker setup.
### Install with npm

View file

@ -11,4 +11,5 @@ module.exports = {
'^@/(.*)$': '<rootDir>/src/$1',
'^@db/(.*)$': '<rootDir>/src/databases/$1',
},
coveragePathIgnorePatterns: ['/src/databases/migrations/'],
};

View file

@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "0.218.0",
"version": "0.221.2",
"description": "n8n Workflow Automation Tool",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -70,7 +70,7 @@
"@types/body-parser-xml": "^2.0.2",
"@types/compression": "1.0.1",
"@types/connect-history-api-fallback": "^1.3.1",
"@types/convict": "^4.2.1",
"@types/convict": "^6.1.1",
"@types/cookie-parser": "^1.4.2",
"@types/express": "^4.17.6",
"@types/json-diff": "^0.5.1",
@ -114,7 +114,7 @@
"tsconfig-paths": "^4.1.2"
},
"dependencies": {
"@n8n_io/license-sdk": "^1.8.0",
"@n8n_io/license-sdk": "^1.9.1",
"@oclif/command": "^1.8.16",
"@oclif/core": "^1.16.4",
"@oclif/errors": "^1.3.6",
@ -134,13 +134,14 @@
"client-oauth2": "^4.2.5",
"compression": "^1.7.4",
"connect-history-api-fallback": "^1.6.0",
"convict": "^6.0.1",
"convict": "^6.2.4",
"cookie-parser": "^1.4.6",
"crypto-js": "~4.1.1",
"csrf": "^3.1.0",
"curlconverter": "^3.0.0",
"dotenv": "^8.0.0",
"express": "^4.18.2",
"express-handlebars": "^7.0.2",
"express-async-errors": "^3.1.1",
"express-openapi-validator": "^4.13.6",
"express-prom-bundle": "^6.6.0",
@ -169,7 +170,7 @@
"lodash.uniq": "^4.5.0",
"lodash.uniqby": "^4.7.0",
"lodash.unset": "^4.5.2",
"luxon": "^3.1.0",
"luxon": "^3.3.0",
"mysql2": "~2.3.3",
"n8n-core": "workspace:*",
"n8n-editor-ui": "workspace:*",
@ -195,7 +196,7 @@
"semver": "^7.3.8",
"shelljs": "^0.8.5",
"source-map-support": "^0.5.21",
"sqlite3": "^5.1.4",
"sqlite3": "^5.1.6",
"sse-channel": "^4.0.0",
"swagger-ui-express": "^4.3.0",
"syslog-client": "^1.1.1",

View file

@ -302,7 +302,7 @@ export abstract class AbstractServer {
// ----------------------------------------
protected setupWaitingWebhookEndpoint() {
const endpoint = this.endpointWebhookWaiting;
const waitingWebhooks = new WaitingWebhooks();
const waitingWebhooks = Container.get(WaitingWebhooks);
// Register all webhook-waiting requests
this.app.all(`/${endpoint}/*`, async (req, res) => {

View file

@ -1,7 +1,4 @@
/* eslint-disable prefer-template */
/* eslint-disable @typescript-eslint/restrict-plus-operands */
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
/* eslint-disable no-param-reassign */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
@ -25,7 +22,7 @@ import type {
IWorkflowExecutionDataProcess,
} from '@/Interfaces';
import * as ResponseHelper from '@/ResponseHelper';
import * as WorkflowHelpers from '@/WorkflowHelpers';
import { isWorkflowIdValid } from '@/utils';
import { Service } from 'typedi';
@Service()
@ -60,7 +57,7 @@ export class ActiveExecutions {
}
const workflowId = executionData.workflowData.id;
if (workflowId !== undefined && WorkflowHelpers.isWorkflowIdValid(workflowId)) {
if (workflowId !== undefined && isWorkflowIdValid(workflowId)) {
fullExecutionData.workflowId = workflowId;
}

View file

@ -9,7 +9,7 @@
/* 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 {
@ -82,7 +82,11 @@ export class ActiveWorkflowRunner {
[key: string]: IQueuedWorkflowActivations;
} = {};
constructor(private externalHooks: ExternalHooks) {}
constructor(
private activeExecutions: ActiveExecutions,
private externalHooks: ExternalHooks,
private nodeTypes: NodeTypes,
) {}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async init() {
@ -271,14 +275,13 @@ export class ActiveWorkflowRunner {
);
}
const nodeTypes = Container.get(NodeTypes);
const workflow = new Workflow({
id: webhook.workflowId,
name: workflowData.name,
nodes: workflowData.nodes,
connections: workflowData.connections,
active: workflowData.active,
nodeTypes,
nodeTypes: this.nodeTypes,
staticData: workflowData.staticData,
settings: workflowData.settings,
});
@ -514,14 +517,13 @@ export class ActiveWorkflowRunner {
throw new Error(`Could not find workflow with id "${workflowId}"`);
}
const nodeTypes = Container.get(NodeTypes);
const workflow = new Workflow({
id: workflowId,
name: workflowData.name,
nodes: workflowData.nodes,
connections: workflowData.connections,
active: workflowData.active,
nodeTypes,
nodeTypes: this.nodeTypes,
staticData: workflowData.staticData,
settings: workflowData.settings,
});
@ -638,7 +640,7 @@ export class ActiveWorkflowRunner {
if (donePromise) {
executePromise.then((executionId) => {
Container.get(ActiveExecutions)
this.activeExecutions
.getPostExecutePromise(executionId)
.then(donePromise.resolve)
.catch(donePromise.reject);
@ -695,7 +697,7 @@ export class ActiveWorkflowRunner {
if (donePromise) {
executePromise.then((executionId) => {
Container.get(ActiveExecutions)
this.activeExecutions
.getPostExecutePromise(executionId)
.then(donePromise.resolve)
.catch(donePromise.reject);
@ -782,14 +784,13 @@ export class ActiveWorkflowRunner {
if (!workflowData) {
throw new Error(`Could not find workflow with id "${workflowId}".`);
}
const nodeTypes = Container.get(NodeTypes);
workflowInstance = new Workflow({
id: workflowId,
name: workflowData.name,
nodes: workflowData.nodes,
connections: workflowData.connections,
active: workflowData.active,
nodeTypes,
nodeTypes: this.nodeTypes,
staticData: workflowData.staticData,
settings: workflowData.settings,
});

View file

@ -207,7 +207,7 @@ export function hasPackageLoaded(packageName: string): boolean {
export function removePackageFromMissingList(packageName: string): void {
try {
const failedPackages = (config.get('nodes.packagesMissing') as string).split(' ');
const failedPackages = config.get('nodes.packagesMissing').split(' ');
const packageFailedToLoad = failedPackages.filter(
(packageNameAndVersion) =>

View file

@ -393,8 +393,7 @@ export class CredentialsHelper extends ICredentialsHelper {
}
if (expressionResolveValues) {
const timezone =
(expressionResolveValues.workflow.settings.timezone as string) || defaultTimezone;
const timezone = expressionResolveValues.workflow.settings.timezone ?? defaultTimezone;
try {
decryptedData = expressionResolveValues.workflow.expression.getParameterValue(
@ -452,7 +451,6 @@ export class CredentialsHelper extends ICredentialsHelper {
type: string,
data: ICredentialDataDecryptedObject,
): Promise<void> {
// eslint-disable-next-line @typescript-eslint/await-thenable
const credentials = await this.getCredentials(nodeCredentials, type);
if (!Db.isInitialized) {

View file

@ -169,6 +169,8 @@ export async function init(
collections.InstalledPackages = linkRepository(entities.InstalledPackages);
collections.InstalledNodes = linkRepository(entities.InstalledNodes);
collections.WorkflowStatistics = linkRepository(entities.WorkflowStatistics);
collections.ExecutionMetadata = linkRepository(entities.ExecutionMetadata);
collections.EventDestinations = linkRepository(entities.EventDestinations);
isInitialized = true;

View file

@ -23,6 +23,7 @@ import type {
ExecutionStatus,
IExecutionsSummary,
FeatureFlags,
WorkflowSettings,
} from 'n8n-workflow';
import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
@ -48,6 +49,7 @@ import type { WebhookEntity } from '@db/entities/WebhookEntity';
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { WorkflowStatistics } from '@db/entities/WorkflowStatistics';
import type { EventDestinations } from '@db/entities/MessageEventBusDestinationEntity';
import type { ExecutionMetadata } from './databases/entities/ExecutionMetadata';
export interface IActivationError {
time: number;
@ -88,6 +90,7 @@ export interface IDatabaseCollections {
InstalledNodes: Repository<InstalledNodes>;
WorkflowStatistics: Repository<WorkflowStatistics>;
EventDestinations: Repository<EventDestinations>;
ExecutionMetadata: Repository<ExecutionMetadata>;
}
// ----------------------------------
@ -453,16 +456,12 @@ export interface IVersionNotificationSettings {
export interface IN8nUISettings {
endpointWebhook: string;
endpointWebhookTest: string;
saveDataErrorExecution: 'all' | 'none';
saveDataSuccessExecution: 'all' | 'none';
saveDataErrorExecution: WorkflowSettings.SaveDataExecution;
saveDataSuccessExecution: WorkflowSettings.SaveDataExecution;
saveManualExecutions: boolean;
executionTimeout: number;
maxExecutionTimeout: number;
workflowCallerPolicyDefaultOption:
| 'any'
| 'none'
| 'workflowsFromAList'
| 'workflowsFromSameOwner';
workflowCallerPolicyDefaultOption: WorkflowSettings.CallerPolicy;
oauthCallbackUrls: {
oauth1: string;
oauth2: string;

View file

@ -284,9 +284,19 @@ export class InternalHooks implements IInternalHooksClass {
properties.user_id = userId;
}
properties.success = !!runData?.finished;
let executionStatus: ExecutionStatus;
if (runData?.status === 'crashed') {
executionStatus = 'crashed';
} else if (runData?.status === 'waiting' || runData?.data?.waitTill) {
executionStatus = 'waiting';
} else {
executionStatus = properties.success ? 'success' : 'failed';
}
if (runData !== undefined) {
properties.execution_mode = runData.mode;
properties.success = !!runData.finished;
properties.is_manual = runData.mode === 'manual';
let nodeGraphResult: INodesGraphResult | null = null;
@ -342,7 +352,7 @@ export class InternalHooks implements IInternalHooksClass {
const manualExecEventProperties: ITelemetryTrackProperties = {
user_id: userId,
workflow_id: workflow.id,
status: properties.success ? 'success' : 'failed',
status: executionStatus,
executionStatus: runData?.status ?? 'unknown',
error_message: properties.error_message as string,
error_node_type: properties.error_node_type,
@ -392,15 +402,6 @@ export class InternalHooks implements IInternalHooksClass {
}
}
let executionStatus: ExecutionStatus;
if (runData?.status === 'crashed') {
executionStatus = 'crashed';
} else if (runData?.status === 'waiting' || runData?.data?.waitTill) {
executionStatus = 'waiting';
} else {
executionStatus = properties.success ? 'success' : 'failed';
}
promises.push(
Db.collections.Execution.update(executionId, {
status: executionStatus,

View file

@ -2,8 +2,6 @@ import type { LdapConfig } from './types';
export const LDAP_FEATURE_NAME = 'features.ldap';
export const LDAP_ENABLED = 'enterprise.features.ldap';
export const LDAP_LOGIN_LABEL = 'sso.ldap.loginLabel';
export const LDAP_LOGIN_ENABLED = 'sso.ldap.loginEnabled';

View file

@ -2,6 +2,7 @@
import { AES, enc } from 'crypto-js';
import type { Entry as LdapUser } from 'ldapts';
import { Filter } from 'ldapts/filters/Filter';
import { Container } from 'typedi';
import { UserSettings } from 'n8n-core';
import { validate } from 'jsonschema';
import * as Db from '@/Db';
@ -16,23 +17,26 @@ import { LdapManager } from './LdapManager.ee';
import {
BINARY_AD_ATTRIBUTES,
LDAP_CONFIG_SCHEMA,
LDAP_ENABLED,
LDAP_FEATURE_NAME,
LDAP_LOGIN_ENABLED,
LDAP_LOGIN_LABEL,
} from './constants';
import type { ConnectionSecurity, LdapConfig } from './types';
import { jsonParse, LoggerProxy as Logger } from 'n8n-workflow';
import { getLicense } from '@/License';
import { Container } from 'typedi';
import { License } from '@/License';
import { InternalHooks } from '@/InternalHooks';
import {
isEmailCurrentAuthenticationMethod,
isLdapCurrentAuthenticationMethod,
setCurrentAuthenticationMethod,
} from '@/sso/ssoHelpers';
/**
* Check whether the LDAP feature is disabled in the instance
*/
export const isLdapEnabled = (): boolean => {
const license = getLicense();
return isUserManagementEnabled() && (config.getEnv(LDAP_ENABLED) || license.isLdapEnabled());
const license = Container.get(License);
return isUserManagementEnabled() && license.isLdapEnabled();
};
/**
@ -50,8 +54,24 @@ export const setLdapLoginLabel = (value: string): void => {
/**
* Set the LDAP login enabled to the configuration object
*/
export const setLdapLoginEnabled = (value: boolean): void => {
config.set(LDAP_LOGIN_ENABLED, value);
export const setLdapLoginEnabled = async (value: boolean): Promise<void> => {
if (config.get(LDAP_LOGIN_ENABLED) === value) {
return;
}
// only one auth method can be active at a time, with email being the default
if (value && isEmailCurrentAuthenticationMethod()) {
// enable ldap login and disable email login, but only if email is the current auth method
config.set(LDAP_LOGIN_ENABLED, true);
await setCurrentAuthenticationMethod('ldap');
} else if (!value && isLdapCurrentAuthenticationMethod()) {
// disable ldap login, but only if ldap is the current auth method
config.set(LDAP_LOGIN_ENABLED, false);
await setCurrentAuthenticationMethod('email');
} else {
Logger.warn(
'Cannot switch LDAP login enabled state when an authentication method other than email is active',
);
}
};
/**
@ -126,8 +146,8 @@ export const getLdapConfig = async (): Promise<LdapConfig> => {
/**
* Take the LDAP configuration and set login enabled and login label to the config object
*/
export const setGlobalLdapConfigVariables = (ldapConfig: LdapConfig): void => {
setLdapLoginEnabled(ldapConfig.loginEnabled);
export const setGlobalLdapConfigVariables = async (ldapConfig: LdapConfig): Promise<void> => {
await setLdapLoginEnabled(ldapConfig.loginEnabled);
setLdapLoginLabel(ldapConfig.loginLabel);
};
@ -175,7 +195,7 @@ export const updateLdapConfig = async (ldapConfig: LdapConfig): Promise<void> =>
{ key: LDAP_FEATURE_NAME },
{ value: JSON.stringify(ldapConfig), loadOnStartup: true },
);
setGlobalLdapConfigVariables(ldapConfig);
await setGlobalLdapConfigVariables(ldapConfig);
};
/**
@ -197,7 +217,7 @@ export const handleLdapInit = async (): Promise<void> => {
const ldapConfig = await getLdapConfig();
setGlobalLdapConfigVariables(ldapConfig);
await setGlobalLdapConfigVariables(ldapConfig);
// init LDAP manager with the current
// configuration

View file

@ -1,77 +0,0 @@
import express from 'express';
import { LdapManager } from '../LdapManager.ee';
import { getLdapConfig, getLdapSynchronizations, updateLdapConfig } from '../helpers';
import type { LdapConfiguration } from '../types';
import pick from 'lodash.pick';
import { NON_SENSIBLE_LDAP_CONFIG_PROPERTIES } from '../constants';
import { InternalHooks } from '@/InternalHooks';
import { Container } from 'typedi';
export const ldapController = express.Router();
/**
* GET /ldap/config
*/
ldapController.get('/config', async (req: express.Request, res: express.Response) => {
const data = await getLdapConfig();
return res.status(200).json({ data });
});
/**
* POST /ldap/test-connection
*/
ldapController.post('/test-connection', async (req: express.Request, res: express.Response) => {
try {
await LdapManager.getInstance().service.testConnection();
} catch (error) {
const errorObject = error as { message: string };
return res.status(400).json({ message: errorObject.message });
}
return res.status(200).json();
});
/**
* PUT /ldap/config
*/
ldapController.put('/config', async (req: LdapConfiguration.Update, res: express.Response) => {
try {
await updateLdapConfig(req.body);
} catch (e) {
if (e instanceof Error) {
return res.status(400).json({ message: e.message });
}
}
const data = await getLdapConfig();
void Container.get(InternalHooks).onUserUpdatedLdapSettings({
user_id: req.user.id,
...pick(data, NON_SENSIBLE_LDAP_CONFIG_PROPERTIES),
});
return res.status(200).json({ data });
});
/**
* POST /ldap/sync
*/
ldapController.post('/sync', async (req: LdapConfiguration.Sync, res: express.Response) => {
const runType = req.body.type;
try {
await LdapManager.getInstance().sync.run(runType);
} catch (e) {
if (e instanceof Error) {
return res.status(400).json({ message: e.message });
}
}
return res.status(200).json({});
});
/**
* GET /ldap/sync
*/
ldapController.get('/sync', async (req: LdapConfiguration.GetSync, res: express.Response) => {
const { page = '0', perPage = '20' } = req.query;
const data = await getLdapSynchronizations(parseInt(page, 10), parseInt(perPage, 10));
return res.status(200).json({ data });
});

View file

@ -5,8 +5,14 @@ import { getLogger } from './Logger';
import config from '@/config';
import * as Db from '@/Db';
import { LICENSE_FEATURES, N8N_VERSION, SETTINGS_LICENSE_CERT_KEY } from './constants';
import { Service } from 'typedi';
async function loadCertStr(): Promise<TLicenseContainerStr> {
// if we have an ephemeral license, we don't want to load it from the database
const ephemeralLicense = config.get('license.cert');
if (ephemeralLicense) {
return ephemeralLicense;
}
const databaseSettings = await Db.collections.Settings.findOne({
where: {
key: SETTINGS_LICENSE_CERT_KEY,
@ -17,6 +23,8 @@ async function loadCertStr(): Promise<TLicenseContainerStr> {
}
async function saveCertStr(value: TLicenseContainerStr): Promise<void> {
// if we have an ephemeral license, we don't want to save it to the database
if (config.get('license.cert')) return;
await Db.collections.Settings.upsert(
{
key: SETTINGS_LICENSE_CERT_KEY,
@ -27,6 +35,7 @@ async function saveCertStr(value: TLicenseContainerStr): Promise<void> {
);
}
@Service()
export class License {
private logger: ILogger;
@ -160,13 +169,3 @@ export class License {
return (this.getFeatureValue('planName') ?? 'Community') as string;
}
}
let licenseInstance: License | undefined;
export function getLicense(): License {
if (licenseInstance === undefined) {
licenseInstance = new License();
}
return licenseInstance;
}

View file

@ -1,4 +1,5 @@
import uniq from 'lodash.uniq';
import glob from 'fast-glob';
import type { DirectoryLoader, Types } from 'n8n-core';
import {
CUSTOM_EXTENSION_ENV,
@ -18,18 +19,18 @@ import type {
import { LoggerProxy, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
import { createWriteStream } from 'fs';
import { access as fsAccess, mkdir, readdir as fsReaddir, stat as fsStat } from 'fs/promises';
import { mkdir } from 'fs/promises';
import path from 'path';
import config from '@/config';
import type { InstalledPackages } from '@db/entities/InstalledPackages';
import { executeCommand } from '@/CommunityNodes/helpers';
import {
CLI_DIR,
GENERATED_STATIC_DIR,
RESPONSE_ERROR_MESSAGES,
CUSTOM_API_CALL_KEY,
CUSTOM_API_CALL_NAME,
inTest,
CLI_DIR,
} from '@/constants';
import { CredentialsOverwrites } from '@/CredentialsOverwrites';
import { Service } from 'typedi';
@ -52,6 +53,8 @@ export class LoadNodesAndCredentials implements INodesAndCredentials {
logger: ILogger;
private downloadFolder: string;
async init() {
// Make sure the imported modules can resolve dependencies fine.
const delimiter = process.platform === 'win32' ? ';' : ':';
@ -61,8 +64,13 @@ export class LoadNodesAndCredentials implements INodesAndCredentials {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
if (!inTest) module.constructor._initPaths();
await this.loadNodesFromBasePackages();
await this.loadNodesFromDownloadedPackages();
this.downloadFolder = UserSettings.getUserN8nFolderDownloadedNodesPath();
// Load nodes from `n8n-nodes-base` and any other `n8n-nodes-*` package in the main `node_modules`
await this.loadNodesFromNodeModules(CLI_DIR);
// Load nodes from installed community packages
await this.loadNodesFromNodeModules(this.downloadFolder);
await this.loadNodesFromCustomDirectories();
await this.postProcessLoaders();
this.injectCustomApiCallOptions();
@ -109,32 +117,20 @@ export class LoadNodesAndCredentials implements INodesAndCredentials {
await writeStaticJSON('credentials', this.types.credentials);
}
private async loadNodesFromBasePackages() {
const nodeModulesPath = await this.getNodeModulesPath();
const nodePackagePaths = await this.getN8nNodePackages(nodeModulesPath);
private async loadNodesFromNodeModules(scanDir: string): Promise<void> {
const nodeModulesDir = path.join(scanDir, 'node_modules');
const globOptions = { cwd: nodeModulesDir, onlyDirectories: true };
const installedPackagePaths = [
...(await glob('n8n-nodes-*', { ...globOptions, deep: 1 })),
...(await glob('@*/n8n-nodes-*', { ...globOptions, deep: 2 })),
];
for (const packagePath of nodePackagePaths) {
await this.runDirectoryLoader(LazyPackageDirectoryLoader, packagePath);
}
}
private async loadNodesFromDownloadedPackages(): Promise<void> {
const nodePackages = [];
for (const packagePath of installedPackagePaths) {
try {
// Read downloaded nodes and credentials
const downloadedNodesDir = UserSettings.getUserN8nFolderDownloadedNodesPath();
const downloadedNodesDirModules = path.join(downloadedNodesDir, 'node_modules');
await fsAccess(downloadedNodesDirModules);
const downloadedPackages = await this.getN8nNodePackages(downloadedNodesDirModules);
nodePackages.push(...downloadedPackages);
} catch (error) {
// Folder does not exist so ignore and return
return;
}
for (const packagePath of nodePackages) {
try {
await this.runDirectoryLoader(PackageDirectoryLoader, packagePath);
await this.runDirectoryLoader(
LazyPackageDirectoryLoader,
path.join(nodeModulesDir, packagePath),
);
} catch (error) {
ErrorReporter.error(error);
}
@ -158,49 +154,45 @@ export class LoadNodesAndCredentials implements INodesAndCredentials {
}
}
/**
* Returns all the names of the packages which could contain n8n nodes
*/
private async getN8nNodePackages(baseModulesPath: string): Promise<string[]> {
const getN8nNodePackagesRecursive = async (relativePath: string): Promise<string[]> => {
const results: string[] = [];
const nodeModulesPath = `${baseModulesPath}/${relativePath}`;
const nodeModules = await fsReaddir(nodeModulesPath);
for (const nodeModule of nodeModules) {
const isN8nNodesPackage = nodeModule.indexOf('n8n-nodes-') === 0;
const isNpmScopedPackage = nodeModule.indexOf('@') === 0;
if (!isN8nNodesPackage && !isNpmScopedPackage) {
continue;
}
if (!(await fsStat(nodeModulesPath)).isDirectory()) {
continue;
}
if (isN8nNodesPackage) {
results.push(`${baseModulesPath}/${relativePath}${nodeModule}`);
}
if (isNpmScopedPackage) {
results.push(...(await getN8nNodePackagesRecursive(`${relativePath}${nodeModule}/`)));
}
}
return results;
};
return getN8nNodePackagesRecursive('');
}
async loadNpmModule(packageName: string, version?: string): Promise<InstalledPackages> {
const downloadFolder = UserSettings.getUserN8nFolderDownloadedNodesPath();
const command = `npm install ${packageName}${version ? `@${version}` : ''}`;
private async installOrUpdateNpmModule(
packageName: string,
options: { version?: string } | { installedPackage: InstalledPackages },
) {
const isUpdate = 'installedPackage' in options;
const command = isUpdate
? `npm update ${packageName}`
: `npm install ${packageName}${options.version ? `@${options.version}` : ''}`;
try {
await executeCommand(command);
} catch (error) {
if (error instanceof Error && error.message === RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND) {
throw new Error(`The npm package "${packageName}" could not be found.`);
}
throw error;
}
const finalNodeUnpackedPath = path.join(downloadFolder, 'node_modules', packageName);
const finalNodeUnpackedPath = path.join(this.downloadFolder, 'node_modules', packageName);
const loader = await this.runDirectoryLoader(PackageDirectoryLoader, finalNodeUnpackedPath);
let loader: PackageDirectoryLoader;
try {
loader = await this.runDirectoryLoader(PackageDirectoryLoader, finalNodeUnpackedPath);
} catch (error) {
// Remove this package since loading it failed
const removeCommand = `npm remove ${packageName}`;
try {
await executeCommand(removeCommand);
} catch {}
throw new Error(RESPONSE_ERROR_MESSAGES.PACKAGE_LOADING_FAILED, { cause: error });
}
if (loader.loadedNodes.length > 0) {
// Save info to DB
try {
const { persistInstalledPackageData } = await import('@/CommunityNodes/packageModel');
const { persistInstalledPackageData, removePackageFromDatabase } = await import(
'@/CommunityNodes/packageModel'
);
if (isUpdate) await removePackageFromDatabase(options.installedPackage);
const installedPackage = await persistInstalledPackageData(loader);
await this.postProcessLoaders();
await this.generateTypesForFrontend();
@ -223,6 +215,10 @@ export class LoadNodesAndCredentials implements INodesAndCredentials {
}
}
async installNpmModule(packageName: string, version?: string): Promise<InstalledPackages> {
return this.installOrUpdateNpmModule(packageName, { version });
}
async removeNpmModule(packageName: string, installedPackage: InstalledPackages): Promise<void> {
const command = `npm remove ${packageName}`;
@ -244,49 +240,7 @@ export class LoadNodesAndCredentials implements INodesAndCredentials {
packageName: string,
installedPackage: InstalledPackages,
): Promise<InstalledPackages> {
const downloadFolder = UserSettings.getUserN8nFolderDownloadedNodesPath();
const command = `npm i ${packageName}@latest`;
try {
await executeCommand(command);
} catch (error) {
if (error instanceof Error && error.message === RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND) {
throw new Error(`The npm package "${packageName}" could not be found.`);
}
throw error;
}
const finalNodeUnpackedPath = path.join(downloadFolder, 'node_modules', packageName);
const loader = await this.runDirectoryLoader(PackageDirectoryLoader, finalNodeUnpackedPath);
if (loader.loadedNodes.length > 0) {
// Save info to DB
try {
const { persistInstalledPackageData, removePackageFromDatabase } = await import(
'@/CommunityNodes/packageModel'
);
await removePackageFromDatabase(installedPackage);
const newlyInstalledPackage = await persistInstalledPackageData(loader);
await this.postProcessLoaders();
await this.generateTypesForFrontend();
return newlyInstalledPackage;
} catch (error) {
LoggerProxy.error('Failed to save installed packages and nodes', {
error: error as Error,
packageName,
});
throw error;
}
} else {
// Remove this package since it contains no loadable nodes
const removeCommand = `npm remove ${packageName}`;
try {
await executeCommand(removeCommand);
} catch {}
throw new Error(RESPONSE_ERROR_MESSAGES.PACKAGE_DOES_NOT_CONTAIN_NODES);
}
return this.installOrUpdateNpmModule(packageName, { installedPackage });
}
/**
@ -399,27 +353,4 @@ export class LoadNodesAndCredentials implements INodesAndCredentials {
}
}
}
private async getNodeModulesPath(): Promise<string> {
// Get the path to the node-modules folder to be later able
// to load the credentials and nodes
const checkPaths = [
// In case "n8n" package is in same node_modules folder.
path.join(CLI_DIR, '..', 'n8n-workflow'),
// In case "n8n" package is the root and the packages are
// in the "node_modules" folder underneath it.
path.join(CLI_DIR, 'node_modules', 'n8n-workflow'),
// In case "n8n" package is installed using npm/yarn workspaces
// the node_modules folder is in the root of the workspace.
path.join(CLI_DIR, '..', '..', 'node_modules', 'n8n-workflow'),
];
for (const checkPath of checkPaths) {
try {
await fsAccess(checkPath);
// Folder exists, so use it.
return path.dirname(checkPath);
} catch {} // Folder does not exist so get next one
}
throw new Error('Could not find "node_modules" folder!');
}
}

View file

@ -7,7 +7,7 @@ import type { Role } from '@db/entities/Role';
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type * as UserManagementMailer from '@/UserManagement/email/UserManagementMailer';
import type { UserManagementMailer } from '@/UserManagement/email';
import type { Risk } from '@/audit/types';
@ -26,10 +26,10 @@ export type AuthenticatedRequest<
> = express.Request<RouteParams, ResponseBody, RequestBody, RequestQuery> & {
user: User;
globalMemberRole?: Role;
mailer?: UserManagementMailer.UserManagementMailer;
mailer?: UserManagementMailer;
};
export type PaginatatedRequest = AuthenticatedRequest<
export type PaginatedRequest = AuthenticatedRequest<
{},
{},
{},

View file

@ -1,6 +1,6 @@
import type express from 'express';
import { Container } from 'typedi';
import type { FindManyOptions, FindOptionsWhere } from 'typeorm';
import type { FindOptionsWhere } from 'typeorm';
import { In } from 'typeorm';
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
@ -20,12 +20,11 @@ import {
updateWorkflow,
hasStartNode,
getStartNode,
getWorkflows,
getSharedWorkflows,
getWorkflowsCount,
createWorkflow,
getWorkflowIdsViaTags,
parseTagNames,
getWorkflowsAndCount,
} from './workflows.service';
import { WorkflowsService } from '@/workflows/workflows.services';
import { InternalHooks } from '@/InternalHooks';
@ -98,28 +97,15 @@ export = {
async (req: WorkflowRequest.GetAll, res: express.Response): Promise<express.Response> => {
const { offset = 0, limit = 100, active = undefined, tags = undefined } = req.query;
let workflows: WorkflowEntity[];
let count: number;
const where: FindOptionsWhere<WorkflowEntity> = {
...(active !== undefined && { active }),
};
const query: FindManyOptions<WorkflowEntity> = {
skip: offset,
take: limit,
where,
...(!config.getEnv('workflowTagsDisabled') && { relations: ['tags'] }),
};
if (isInstanceOwner(req.user)) {
if (tags) {
const workflowIds = await getWorkflowIdsViaTags(parseTagNames(tags));
Object.assign(where, { id: In(workflowIds) });
where.id = In(workflowIds);
}
workflows = await getWorkflows(query);
count = await getWorkflowsCount(query);
} else {
const options: { workflowIds?: string[] } = {};
@ -137,14 +123,16 @@ export = {
}
const workflowsIds = sharedWorkflows.map((shareWorkflow) => shareWorkflow.workflowId);
Object.assign(where, { id: In(workflowsIds) });
workflows = await getWorkflows(query);
count = await getWorkflowsCount(query);
where.id = In(workflowsIds);
}
const [workflows, count] = await getWorkflowsAndCount({
skip: offset,
take: limit,
where,
...(!config.getEnv('workflowTagsDisabled') && { relations: ['tags'] }),
});
void Container.get(InternalHooks).onUserRetrievedAllWorkflows({
user_id: req.user.id,
public_api: true,

View file

@ -109,14 +109,10 @@ export async function deleteWorkflow(workflow: WorkflowEntity): Promise<Workflow
return Db.collections.Workflow.remove(workflow);
}
export async function getWorkflows(
export async function getWorkflowsAndCount(
options: FindManyOptions<WorkflowEntity>,
): Promise<WorkflowEntity[]> {
return Db.collections.Workflow.find(options);
}
export async function getWorkflowsCount(options: FindManyOptions<WorkflowEntity>): Promise<number> {
return Db.collections.Workflow.count(options);
): Promise<[WorkflowEntity[], number]> {
return Db.collections.Workflow.findAndCount(options);
}
export async function updateWorkflow(

View file

@ -2,7 +2,7 @@
import type express from 'express';
import type { AuthenticatedRequest, PaginatatedRequest } from '../../../types';
import type { AuthenticatedRequest, PaginatedRequest } from '../../../types';
import { decodeCursor } from '../services/pagination.service';
export const authorize =
@ -22,7 +22,7 @@ export const authorize =
};
export const validCursor = (
req: PaginatatedRequest,
req: PaginatedRequest,
res: express.Response,
next: express.NextFunction,
): express.Response | void => {

View file

@ -1,10 +1,10 @@
import type Bull from 'bull';
import type { RedisOptions } from 'ioredis';
import { Service } from 'typedi';
import type { IExecuteResponsePromiseData } from 'n8n-workflow';
import config from '@/config';
import { ActiveExecutions } from '@/ActiveExecutions';
import * as WebhookHelpers from '@/WebhookHelpers';
import { Container } from 'typedi';
export type JobId = Bull.JobId;
export type Job = Bull.Job<JobData>;
@ -24,6 +24,7 @@ export interface WebhookResponse {
response: IExecuteResponsePromiseData;
}
@Service()
export class Queue {
private jobQueue: JobQueue;
@ -91,14 +92,3 @@ export class Queue {
return false;
}
}
let activeQueueInstance: Queue | undefined;
export async function getInstance(): Promise<Queue> {
if (activeQueueInstance === undefined) {
activeQueueInstance = new Queue(Container.get(ActiveExecutions));
await activeQueueInstance.init();
}
return activeQueueInstance;
}

View file

@ -19,6 +19,7 @@ import { createHmac } from 'crypto';
import { promisify } from 'util';
import cookieParser from 'cookie-parser';
import express from 'express';
import { engine as expressHandlebars } from 'express-handlebars';
import type { ServeStaticOptions } from 'serve-static';
import type { FindManyOptions } from 'typeorm';
import { Not, In } from 'typeorm';
@ -56,10 +57,9 @@ import timezones from 'google-timezones-json';
import history from 'connect-history-api-fallback';
import config from '@/config';
import * as Queue from '@/Queue';
import { Queue } from '@/Queue';
import { getSharedWorkflowIds } from '@/WorkflowHelpers';
import { nodesController } from '@/api/nodes.api';
import { workflowsController } from '@/workflows/workflows.controller';
import {
EDITOR_UI_DIST_DIR,
@ -83,16 +83,18 @@ import type {
import { registerController } from '@/decorators';
import {
AuthController,
LdapController,
MeController,
NodesController,
NodeTypesController,
OwnerController,
PasswordResetController,
TagsController,
TranslationController,
UsersController,
} from '@/controllers';
import { executionsController } from '@/executions/executions.controller';
import { nodeTypesController } from '@/api/nodeTypes.api';
import { tagsController } from '@/api/tags.api';
import { workflowStatsController } from '@/api/workflowStats.api';
import { loadPublicApiVersions } from '@/PublicApi';
import {
@ -102,7 +104,7 @@ import {
isUserManagementEnabled,
whereClause,
} from '@/UserManagement/UserManagementHelper';
import { getInstance as getMailerInstance } from '@/UserManagement/email';
import { UserManagementMailer } from '@/UserManagement/email';
import * as Db from '@/Db';
import type {
ICredentialsDb,
@ -127,15 +129,18 @@ import { WaitTracker } from '@/WaitTracker';
import * as WebhookHelpers from '@/WebhookHelpers';
import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData';
import { toHttpNodeParameters } from '@/CurlConverterHelper';
import { eventBusRouter } from '@/eventbus/eventBusRoutes';
import { EventBusController } from '@/eventbus/eventBus.controller';
import { isLogStreamingEnabled } from '@/eventbus/MessageEventBus/MessageEventBusHelper';
import { getLicense } from '@/License';
import { licenseController } from './license/license.controller';
import { Push, setupPushServer, setupPushHandler } from '@/push';
import { setupAuthMiddlewares } from './middlewares';
import { initEvents } from './events';
import { ldapController } from './Ldap/routes/ldap.controller.ee';
import { getLdapLoginLabel, isLdapEnabled, isLdapLoginEnabled } from './Ldap/helpers';
import {
getLdapLoginLabel,
handleLdapInit,
isLdapEnabled,
isLdapLoginEnabled,
} from './Ldap/helpers';
import { AbstractServer } from './AbstractServer';
import { configureMetrics } from './metrics';
import { setupBasicAuth } from './middlewares/basicAuth';
@ -149,9 +154,9 @@ import {
isAdvancedExecutionFiltersEnabled,
} from './executions/executionHelpers';
import { getSamlLoginLabel, isSamlLoginEnabled, isSamlLicensed } from './sso/saml/samlHelpers';
import { samlControllerPublic } from './sso/saml/routes/saml.controller.public.ee';
import { SamlController } from './sso/saml/routes/saml.controller.ee';
import { SamlService } from './sso/saml/saml.service.ee';
import { samlControllerProtected } from './sso/saml/routes/saml.controller.protected.ee';
import { LdapManager } from './Ldap/LdapManager.ee';
const exec = promisify(callbackExec);
@ -179,6 +184,10 @@ class Server extends AbstractServer {
constructor() {
super();
this.app.engine('handlebars', expressHandlebars({ defaultLayout: false }));
this.app.set('view engine', 'handlebars');
this.app.set('views', TEMPLATES_DIR);
this.loadNodesAndCredentials = Container.get(LoadNodesAndCredentials);
this.credentialTypes = Container.get(CredentialTypes);
this.nodeTypes = Container.get(NodeTypes);
@ -302,8 +311,8 @@ class Server extends AbstractServer {
sharing: false,
ldap: false,
saml: false,
logStreaming: config.getEnv('enterprise.features.logStreaming'),
advancedExecutionFilters: config.getEnv('enterprise.features.advancedExecutionFilters'),
logStreaming: false,
advancedExecutionFilters: false,
},
hideUsagePage: config.getEnv('hideUsagePage'),
license: {
@ -356,35 +365,32 @@ class Server extends AbstractServer {
return this.frontendSettings;
}
async initLicense(): Promise<void> {
const license = getLicense();
await license.init(this.frontendSettings.instanceId);
const activationKey = config.getEnv('license.activationKey');
if (activationKey) {
try {
await license.activate(activationKey);
} catch (e) {
LoggerProxy.error('Could not activate license', e);
}
}
}
private registerControllers(ignoredEndpoints: Readonly<string[]>) {
const { app, externalHooks, activeWorkflowRunner } = this;
const { app, externalHooks, activeWorkflowRunner, nodeTypes } = this;
const repositories = Db.collections;
setupAuthMiddlewares(app, ignoredEndpoints, this.restEndpoint, repositories.User);
const logger = LoggerProxy;
const internalHooks = Container.get(InternalHooks);
const mailer = getMailerInstance();
const mailer = Container.get(UserManagementMailer);
const postHog = this.postHog;
const samlService = Container.get(SamlService);
const controllers = [
const controllers: object[] = [
new EventBusController(),
new AuthController({ config, internalHooks, repositories, logger, postHog }),
new OwnerController({ config, internalHooks, repositories, logger }),
new MeController({ externalHooks, internalHooks, repositories, logger }),
new PasswordResetController({ config, externalHooks, internalHooks, repositories, logger }),
new NodeTypesController({ config, nodeTypes }),
new PasswordResetController({
config,
externalHooks,
internalHooks,
mailer,
repositories,
logger,
}),
new TagsController({ config, repositories, externalHooks }),
new TranslationController(config, this.credentialTypes),
new UsersController({
config,
@ -396,7 +402,20 @@ class Server extends AbstractServer {
logger,
postHog,
}),
new SamlController(samlService),
];
if (isLdapEnabled()) {
const { service, sync } = LdapManager.getInstance();
controllers.push(new LdapController(service, sync, internalHooks));
}
if (config.getEnv('nodes.communityPackages.enabled')) {
controllers.push(
new NodesController(config, this.loadNodesAndCredentials, this.push, internalHooks),
);
}
controllers.forEach((controller) => registerController(app, config, controller));
}
@ -413,7 +432,6 @@ class Server extends AbstractServer {
await this.externalHooks.run('frontend.settings', [this.frontendSettings]);
await this.initLicense();
await this.postHog.init(this.frontendSettings.instanceId);
const publicApiEndpoint = config.getEnv('publicApi.path');
@ -423,8 +441,6 @@ class Server extends AbstractServer {
'assets',
'healthz',
'metrics',
'icons',
'types',
'e2e',
this.endpointWebhook,
this.endpointWebhookTest,
@ -475,20 +491,16 @@ class Server extends AbstractServer {
}),
);
// ----------------------------------------
// User Management
// ----------------------------------------
if (config.getEnv('executions.mode') === 'queue') {
await Container.get(Queue).init();
}
await handleLdapInit();
this.registerControllers(ignoredEndpoints);
this.app.use(`/${this.restEndpoint}/credentials`, credentialsController);
// ----------------------------------------
// Packages and nodes management
// ----------------------------------------
if (config.getEnv('nodes.communityPackages.enabled')) {
this.app.use(`/${this.restEndpoint}/nodes`, nodesController);
}
// ----------------------------------------
// Workflow
// ----------------------------------------
@ -504,18 +516,6 @@ class Server extends AbstractServer {
// ----------------------------------------
this.app.use(`/${this.restEndpoint}/workflow-stats`, workflowStatsController);
// ----------------------------------------
// Tags
// ----------------------------------------
this.app.use(`/${this.restEndpoint}/tags`, tagsController);
// ----------------------------------------
// LDAP
// ----------------------------------------
if (isLdapEnabled()) {
this.app.use(`/${this.restEndpoint}/ldap`, ldapController);
}
// ----------------------------------------
// SAML
// ----------------------------------------
@ -524,17 +524,13 @@ class Server extends AbstractServer {
// set up the initial environment
if (isSamlLicensed()) {
try {
await SamlService.getInstance().init();
await Container.get(SamlService).init();
} catch (error) {
LoggerProxy.error(`SAML initialization failed: ${error.message}`);
}
}
this.app.use(`/${this.restEndpoint}/sso/saml`, samlControllerPublic);
this.app.use(`/${this.restEndpoint}/sso/saml`, samlControllerProtected);
// ----------------------------------------
// Returns parameter values which normally get loaded from an external API or
// get generated dynamically
this.app.get(
@ -645,12 +641,6 @@ class Server extends AbstractServer {
),
);
// ----------------------------------------
// Node-Types
// ----------------------------------------
this.app.use(`/${this.restEndpoint}/node-types`, nodeTypesController);
// ----------------------------------------
// Active Workflows
// ----------------------------------------
@ -978,7 +968,7 @@ class Server extends AbstractServer {
ResponseHelper.send(
async (req: ExecutionRequest.GetAllCurrent): Promise<IExecutionsSummary[]> => {
if (config.getEnv('executions.mode') === 'queue') {
const queue = await Queue.getInstance();
const queue = Container.get(Queue);
const currentJobs = await queue.getJobs(['active', 'waiting']);
const currentlyRunningQueueIds = currentJobs.map((job) => job.data.executionId);
@ -1121,7 +1111,7 @@ class Server extends AbstractServer {
} as IExecutionsStopData;
}
const queue = await Queue.getInstance();
const queue = Container.get(Queue);
const currentJobs = await queue.getJobs(['active', 'waiting']);
const job = currentJobs.find((job) => job.data.executionId === req.params.id);
@ -1240,8 +1230,6 @@ class Server extends AbstractServer {
if (!eventBus.isInitialized) {
await eventBus.initialize();
}
// add Event Bus REST endpoints
this.app.use(`/${this.restEndpoint}/eventbus`, eventBusRouter);
// ----------------------------------------
// Webhooks

View file

@ -116,7 +116,7 @@ export class PermissionChecker {
if (parentWorkflowId === undefined) {
throw errorToThrow;
}
const allowedCallerIds = (subworkflow.settings.callerIds as string | undefined)
const allowedCallerIds = subworkflow.settings.callerIds
?.split(',')
.map((id) => id.trim())
.filter((id) => id !== '');

View file

@ -3,6 +3,7 @@
import { In } from 'typeorm';
import type express from 'express';
import { compare, genSaltSync, hash } from 'bcryptjs';
import Container from 'typedi';
import * as Db from '@/Db';
import * as ResponseHelper from '@/ResponseHelper';
@ -13,7 +14,7 @@ import type { Role } from '@db/entities/Role';
import type { AuthenticatedRequest } from '@/requests';
import config from '@/config';
import { getWebhookBaseUrl } from '@/WebhookHelpers';
import { getLicense } from '@/License';
import { License } from '@/License';
import { RoleService } from '@/role/role.service';
import type { PostHogClient } from '@/posthog';
@ -55,11 +56,8 @@ export function isUserManagementEnabled(): boolean {
}
export function isSharingEnabled(): boolean {
const license = getLicense();
return (
isUserManagementEnabled() &&
(config.getEnv('enterprise.features.sharing') || license.isSharingEnabled())
);
const license = Container.get(License);
return isUserManagementEnabled() && license.isSharingEnabled();
}
export async function getRoleId(scope: Role['scope'], name: Role['name']): Promise<Role['id']> {

View file

@ -1,9 +1,3 @@
export interface UserManagementMailerImplementation {
init: () => Promise<void>;
sendMail: (mailData: MailData) => Promise<SendEmailResult>;
verifyConnection: () => Promise<void>;
}
export type InviteEmailData = {
email: string;
firstName?: string;

View file

@ -3,9 +3,9 @@ import type { Transporter } from 'nodemailer';
import { createTransport } from 'nodemailer';
import { ErrorReporterProxy as ErrorReporter, LoggerProxy as Logger } from 'n8n-workflow';
import config from '@/config';
import type { MailData, SendEmailResult, UserManagementMailerImplementation } from './Interfaces';
import type { MailData, SendEmailResult } from './Interfaces';
export class NodeMailer implements UserManagementMailerImplementation {
export class NodeMailer {
private transport?: Transporter;
async init(): Promise<void> {

View file

@ -2,13 +2,9 @@ import { existsSync } from 'fs';
import { readFile } from 'fs/promises';
import Handlebars from 'handlebars';
import { join as pathJoin } from 'path';
import { Service } from 'typedi';
import config from '@/config';
import type {
InviteEmailData,
PasswordResetData,
SendEmailResult,
UserManagementMailerImplementation,
} from './Interfaces';
import type { InviteEmailData, PasswordResetData, SendEmailResult } from './Interfaces';
import { NodeMailer } from './NodeMailer';
type Template = HandlebarsTemplateDelegate<unknown>;
@ -36,8 +32,9 @@ async function getTemplate(
return template;
}
@Service()
export class UserManagementMailer {
private mailer: UserManagementMailerImplementation | undefined;
private mailer: NodeMailer | undefined;
constructor() {
// Other implementations can be used in the future.
@ -81,12 +78,3 @@ export class UserManagementMailer {
return result ?? { emailSent: false };
}
}
let mailerInstance: UserManagementMailer | undefined;
export function getInstance(): UserManagementMailer {
if (mailerInstance === undefined) {
mailerInstance = new UserManagementMailer();
}
return mailerInstance;
}

View file

@ -1,3 +1,3 @@
import { getInstance, UserManagementMailer } from './UserManagementMailer';
import { UserManagementMailer } from './UserManagementMailer';
export { getInstance, UserManagementMailer };
export { UserManagementMailer };

View file

@ -10,6 +10,7 @@ import {
LoggerProxy as Logger,
WorkflowOperationError,
} from 'n8n-workflow';
import { Service } from 'typedi';
import type { FindManyOptions, ObjectLiteral } from 'typeorm';
import { Not, LessThanOrEqual } from 'typeorm';
import { DateUtils } from 'typeorm/util/DateUtils';
@ -17,7 +18,6 @@ import { DateUtils } from 'typeorm/util/DateUtils';
import config from '@/config';
import * as Db from '@/Db';
import * as ResponseHelper from '@/ResponseHelper';
import { ActiveExecutions } from '@/ActiveExecutions';
import type {
IExecutionFlattedDb,
IExecutionsStopData,
@ -25,12 +25,9 @@ import type {
} from '@/Interfaces';
import { WorkflowRunner } from '@/WorkflowRunner';
import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper';
import { Container, Service } from 'typedi';
@Service()
export class WaitTracker {
activeExecutionsInstance: ActiveExecutions;
private waitingExecutions: {
[key: string]: {
executionId: string;
@ -41,8 +38,6 @@ export class WaitTracker {
mainTimer: NodeJS.Timeout;
constructor() {
this.activeExecutionsInstance = Container.get(ActiveExecutions);
// Poll every 60 seconds a list of upcoming executions
this.mainTimer = setInterval(() => {
this.getWaitingExecutions();

View file

@ -3,7 +3,7 @@
/* eslint-disable no-param-reassign */
import type { INode, WebhookHttpMethod } from 'n8n-workflow';
import { NodeHelpers, Workflow, LoggerProxy as Logger } from 'n8n-workflow';
import { Service } from 'typedi';
import type express from 'express';
import * as Db from '@/Db';
@ -13,9 +13,11 @@ import { NodeTypes } from '@/NodeTypes';
import type { IExecutionResponse, IResponseCallbackData, IWorkflowDb } from '@/Interfaces';
import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData';
import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper';
import { Container } from 'typedi';
@Service()
export class WaitingWebhooks {
constructor(private nodeTypes: NodeTypes) {}
async executeWebhook(
httpMethod: WebhookHttpMethod,
fullPath: string,
@ -79,14 +81,13 @@ export class WaitingWebhooks {
const { workflowData } = fullExecutionData;
const nodeTypes = Container.get(NodeTypes);
const workflow = new Workflow({
id: workflowData.id!.toString(),
name: workflowData.name,
nodes: workflowData.nodes,
connections: workflowData.connections,
active: workflowData.active,
nodeTypes,
nodeTypes: this.nodeTypes,
staticData: workflowData.staticData,
settings: workflowData.settings,
});

View file

@ -16,7 +16,7 @@
import type express from 'express';
import get from 'lodash.get';
import { BINARY_ENCODING, BinaryDataManager, NodeExecuteFunctions, eventEmitter } from 'n8n-core';
import { BinaryDataManager, NodeExecuteFunctions, eventEmitter } from 'n8n-core';
import type {
IBinaryKeyData,
@ -35,6 +35,7 @@ import type {
WorkflowExecuteMode,
} from 'n8n-workflow';
import {
BINARY_ENCODING,
createDeferredPromise,
ErrorReporterProxy as ErrorReporter,
LoggerProxy as Logger,

View file

@ -66,11 +66,12 @@ import * as ResponseHelper from '@/ResponseHelper';
import * as WebhookHelpers from '@/WebhookHelpers';
import * as WorkflowHelpers from '@/WorkflowHelpers';
import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper';
import { findSubworkflowStart } from '@/utils';
import { findSubworkflowStart, isWorkflowIdValid } from '@/utils';
import { PermissionChecker } from './UserManagement/PermissionChecker';
import { WorkflowsService } from './workflows/workflows.services';
import { Container } from 'typedi';
import { InternalHooks } from '@/InternalHooks';
import type { ExecutionMetadata } from './databases/entities/ExecutionMetadata';
const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType');
@ -135,17 +136,11 @@ export function executeErrorWorkflow(
// Run the error workflow
// To avoid an infinite loop do not run the error workflow again if the error-workflow itself failed and it is its own error-workflow.
if (
workflowData.settings?.errorWorkflow &&
!(
mode === 'error' &&
workflowId &&
workflowData.settings.errorWorkflow.toString() === workflowId
)
) {
const { errorWorkflow } = workflowData.settings ?? {};
if (errorWorkflow && !(mode === 'error' && workflowId && errorWorkflow === workflowId)) {
Logger.verbose('Start external error workflow', {
executionId,
errorWorkflowId: workflowData.settings.errorWorkflow.toString(),
errorWorkflowId: errorWorkflow,
workflowId,
});
// If a specific error workflow is set run only that one
@ -159,11 +154,7 @@ export function executeErrorWorkflow(
}
getWorkflowOwner(workflowId)
.then((user) => {
void WorkflowHelpers.executeErrorWorkflow(
workflowData.settings!.errorWorkflow as string,
workflowErrorData,
user,
);
void WorkflowHelpers.executeErrorWorkflow(errorWorkflow, workflowErrorData, user);
})
.catch((error: Error) => {
ErrorReporter.error(error);
@ -171,7 +162,7 @@ export function executeErrorWorkflow(
`Could not execute ErrorWorkflow for execution ID ${this.executionId} because of error querying the workflow owner`,
{
executionId,
errorWorkflowId: workflowData.settings!.errorWorkflow!.toString(),
errorWorkflowId: errorWorkflow,
workflowId,
error,
workflowErrorData,
@ -264,6 +255,22 @@ async function pruneExecutionData(this: WorkflowHooks): Promise<void> {
}
}
export async function saveExecutionMetadata(
executionId: string,
executionMetadata: Record<string, string>,
): Promise<ExecutionMetadata[]> {
const metadataRows = [];
for (const [key, value] of Object.entries(executionMetadata)) {
metadataRows.push({
execution: { id: executionId },
key,
value,
});
}
return Db.collections.ExecutionMetadata.save(metadataRows);
}
/**
* Returns hook functions to push data to Editor-UI
*
@ -404,21 +411,21 @@ export function hookFunctionsPreExecute(parentProcessMode?: string): IWorkflowEx
],
nodeExecuteAfter: [
async function (
this: WorkflowHooks,
nodeName: string,
data: ITaskData,
executionData: IRunExecutionData,
): Promise<void> {
if (this.workflowData.settings !== undefined) {
if (this.workflowData.settings.saveExecutionProgress === false) {
const saveExecutionProgress = config.getEnv('executions.saveExecutionProgress');
const workflowSettings = this.workflowData.settings;
if (workflowSettings !== undefined) {
if (workflowSettings.saveExecutionProgress === false) {
return;
}
if (
this.workflowData.settings.saveExecutionProgress !== true &&
!config.getEnv('executions.saveExecutionProgress')
) {
if (workflowSettings.saveExecutionProgress !== true && !saveExecutionProgress) {
return;
}
} else if (!config.getEnv('executions.saveExecutionProgress')) {
} else if (!saveExecutionProgress) {
return;
}
@ -530,11 +537,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
const isManualMode = [this.mode, parentProcessMode].includes('manual');
try {
if (
!isManualMode &&
WorkflowHelpers.isWorkflowIdValid(this.workflowData.id) &&
newStaticData
) {
if (!isManualMode && isWorkflowIdValid(this.workflowData.id) && newStaticData) {
// Workflow is saved so update in database
try {
await WorkflowHelpers.saveStaticDataById(
@ -550,13 +553,11 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
}
}
const workflowSettings = this.workflowData.settings;
let saveManualExecutions = config.getEnv('executions.saveDataManualExecutions');
if (
this.workflowData.settings !== undefined &&
this.workflowData.settings.saveManualExecutions !== undefined
) {
if (workflowSettings?.saveManualExecutions !== undefined) {
// Apply to workflow override
saveManualExecutions = this.workflowData.settings.saveManualExecutions as boolean;
saveManualExecutions = workflowSettings.saveManualExecutions as boolean;
}
if (isManualMode && !saveManualExecutions && !fullRunData.waitTill) {
@ -641,7 +642,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
}
const workflowId = this.workflowData.id;
if (WorkflowHelpers.isWorkflowIdValid(workflowId)) {
if (isWorkflowIdValid(workflowId)) {
fullExecutionData.workflowId = workflowId;
}
@ -661,6 +662,14 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
executionData as IExecutionFlattedDb,
);
try {
if (fullRunData.data.resultData.metadata) {
await saveExecutionMetadata(this.executionId, fullRunData.data.resultData.metadata);
}
} catch (e) {
Logger.error(`Failed to save metadata for execution ID ${this.executionId}`, e);
}
if (fullRunData.finished === true && this.retryOf !== undefined) {
// If the retry was successful save the reference it on the original execution
// await Db.collections.Execution.save(executionData as IExecutionFlattedDb);
@ -729,7 +738,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
newStaticData: IDataObject,
): Promise<void> {
try {
if (WorkflowHelpers.isWorkflowIdValid(this.workflowData.id) && newStaticData) {
if (isWorkflowIdValid(this.workflowData.id) && newStaticData) {
// Workflow is saved so update in database
try {
await WorkflowHelpers.saveStaticDataById(
@ -776,7 +785,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
}
const workflowId = this.workflowData.id;
if (WorkflowHelpers.isWorkflowIdValid(workflowId)) {
if (isWorkflowIdValid(workflowId)) {
fullExecutionData.workflowId = workflowId;
}
@ -793,6 +802,14 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
status: executionData.status,
});
try {
if (fullRunData.data.resultData.metadata) {
await saveExecutionMetadata(this.executionId, fullRunData.data.resultData.metadata);
}
} catch (e) {
Logger.error(`Failed to save metadata for execution ID ${this.executionId}`, e);
}
if (fullRunData.finished === true && this.retryOf !== undefined) {
// If the retry was successful save the reference it on the original execution
await Db.collections.Execution.update(this.retryOf, {
@ -995,16 +1012,14 @@ async function executeWorkflow(
additionalDataIntegrated.executeWorkflow = additionalData.executeWorkflow;
let subworkflowTimeout = additionalData.executionTimeoutTimestamp;
if (
workflowData.settings?.executionTimeout !== undefined &&
workflowData.settings.executionTimeout > 0
) {
const workflowSettings = workflowData.settings;
if (workflowSettings?.executionTimeout !== undefined && workflowSettings.executionTimeout > 0) {
// We might have received a max timeout timestamp from the parent workflow
// If we did, then we get the minimum time between the two timeouts
// If no timeout was given from the parent, then we use our timeout.
subworkflowTimeout = Math.min(
additionalData.executionTimeoutTimestamp || Number.MAX_SAFE_INTEGER,
Date.now() + (workflowData.settings.executionTimeout as number) * 1000,
Date.now() + workflowSettings.executionTimeout * 1000,
);
}

View file

@ -1,4 +1,5 @@
import { In } from 'typeorm';
import { Container } from 'typedi';
import type {
IDataObject,
IExecuteData,
@ -32,7 +33,7 @@ import type { User } from '@db/entities/User';
import { whereClause } from '@/UserManagement/UserManagementHelper';
import omit from 'lodash.omit';
import { PermissionChecker } from './UserManagement/PermissionChecker';
import { Container } from 'typedi';
import { isWorkflowIdValid } from './utils';
const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType');
@ -74,15 +75,6 @@ export function getDataLastExecutedNodeData(inputData: IRun): ITaskData | undefi
return lastNodeRunData;
}
/**
* Returns if the given id is a valid workflow id
*
* @param {(string | null | undefined)} id The id to check
*/
export function isWorkflowIdValid(id: string | null | undefined): boolean {
return !(typeof id === 'string' && isNaN(parseInt(id, 10)));
}
/**
* Executes the error workflow
*

View file

@ -21,6 +21,7 @@ import type {
IRun,
WorkflowExecuteMode,
WorkflowHooks,
WorkflowSettings,
} from 'n8n-workflow';
import {
ErrorReporterProxy as ErrorReporter,
@ -44,7 +45,8 @@ import type {
IWorkflowExecutionDataProcessWithExecution,
} from '@/Interfaces';
import { NodeTypes } from '@/NodeTypes';
import * as Queue from '@/Queue';
import type { Job, JobData, JobQueue, JobResponse } from '@/Queue';
import { Queue } from '@/Queue';
import * as ResponseHelper from '@/ResponseHelper';
import * as WebhookHelpers from '@/WebhookHelpers';
import * as WorkflowHelpers from '@/WorkflowHelpers';
@ -63,7 +65,7 @@ export class WorkflowRunner {
push: Push;
jobQueue: Queue.JobQueue;
jobQueue: JobQueue;
constructor() {
this.push = Container.get(Push);
@ -167,7 +169,7 @@ export class WorkflowRunner {
await initErrorHandling();
if (executionsMode === 'queue') {
const queue = await Queue.getInstance();
const queue = Container.get(Queue);
this.jobQueue = queue.getBullObjectInstance();
}
@ -247,11 +249,9 @@ export class WorkflowRunner {
// Changes were made by adding the `workflowTimeout` to the `additionalData`
// So that the timeout will also work for executions with nested workflows.
let executionTimeout: NodeJS.Timeout;
let workflowTimeout = config.getEnv('executions.timeout'); // initialize with default
if (data.workflowData.settings && data.workflowData.settings.executionTimeout) {
workflowTimeout = data.workflowData.settings.executionTimeout as number; // preference on workflow setting
}
const workflowSettings = data.workflowData.settings ?? {};
let workflowTimeout = workflowSettings.executionTimeout ?? config.getEnv('executions.timeout'); // initialize with default
if (workflowTimeout > 0) {
workflowTimeout = Math.min(workflowTimeout, config.getEnv('executions.maxTimeout'));
}
@ -264,7 +264,7 @@ export class WorkflowRunner {
active: data.workflowData.active,
nodeTypes,
staticData: data.workflowData.staticData,
settings: data.workflowData.settings,
settings: workflowSettings,
});
const additionalData = await WorkflowExecuteAdditionalData.getBase(
data.userId,
@ -434,7 +434,7 @@ export class WorkflowRunner {
this.activeExecutions.attachResponsePromise(executionId, responsePromise);
}
const jobData: Queue.JobData = {
const jobData: JobData = {
executionId,
loadStaticData: !!loadStaticData,
};
@ -451,7 +451,7 @@ export class WorkflowRunner {
removeOnComplete: true,
removeOnFail: true,
};
let job: Queue.Job;
let job: Job;
let hooks: WorkflowHooks;
try {
job = await this.jobQueue.add(jobData, jobOptions);
@ -485,7 +485,7 @@ export class WorkflowRunner {
async (resolve, reject, onCancel) => {
onCancel.shouldReject = false;
onCancel(async () => {
const queue = await Queue.getInstance();
const queue = Container.get(Queue);
await queue.stopJob(job);
// We use "getWorkflowHooksWorkerExecuter" as "getWorkflowHooksWorkerMain" does not contain the
@ -503,11 +503,11 @@ export class WorkflowRunner {
reject(error);
});
const jobData: Promise<Queue.JobResponse> = job.finished();
const jobData: Promise<JobResponse> = job.finished();
const queueRecoveryInterval = config.getEnv('queue.bull.queueRecoveryInterval');
const racingPromises: Array<Promise<Queue.JobResponse | object>> = [jobData];
const racingPromises: Array<Promise<JobResponse | object>> = [jobData];
let clearWatchdogInterval;
if (queueRecoveryInterval > 0) {
@ -589,16 +589,12 @@ export class WorkflowRunner {
try {
// Check if this execution data has to be removed from database
// based on workflow settings.
let saveDataErrorExecution = config.getEnv('executions.saveDataOnError') as string;
let saveDataSuccessExecution = config.getEnv('executions.saveDataOnSuccess') as string;
if (data.workflowData.settings !== undefined) {
saveDataErrorExecution =
(data.workflowData.settings.saveDataErrorExecution as string) ||
saveDataErrorExecution;
saveDataSuccessExecution =
(data.workflowData.settings.saveDataSuccessExecution as string) ||
saveDataSuccessExecution;
}
const workflowSettings = data.workflowData.settings ?? {};
const saveDataErrorExecution =
workflowSettings.saveDataErrorExecution ?? config.getEnv('executions.saveDataOnError');
const saveDataSuccessExecution =
workflowSettings.saveDataSuccessExecution ??
config.getEnv('executions.saveDataOnSuccess');
const workflowDidSucceed = !runData.data.resultData.error;
if (
@ -665,10 +661,9 @@ export class WorkflowRunner {
// Start timeout for the execution
let executionTimeout: NodeJS.Timeout;
let workflowTimeout = config.getEnv('executions.timeout'); // initialize with default
if (data.workflowData.settings && data.workflowData.settings.executionTimeout) {
workflowTimeout = data.workflowData.settings.executionTimeout as number; // preference on workflow setting
}
const workflowSettings = data.workflowData.settings ?? {};
let workflowTimeout = workflowSettings.executionTimeout ?? config.getEnv('executions.timeout'); // initialize with default
const processTimeoutFunction = (timeout: number) => {
this.activeExecutions.stopExecution(executionId, 'timeout');

View file

@ -54,9 +54,9 @@ import config from '@/config';
import { generateFailedExecutionFromError } from '@/WorkflowHelpers';
import { initErrorHandling } from '@/ErrorReporting';
import { PermissionChecker } from '@/UserManagement/PermissionChecker';
import { getLicense } from './License';
import { InternalHooks } from './InternalHooks';
import { PostHogClient } from './posthog';
import { License } from '@/License';
import { InternalHooks } from '@/InternalHooks';
import { PostHogClient } from '@/posthog';
class WorkflowRunnerProcess {
data: IWorkflowExecutionDataProcessWithExecution | undefined;
@ -127,16 +127,13 @@ class WorkflowRunnerProcess {
// Init db since we need to read the license.
await Db.init();
const license = getLicense();
const license = Container.get(License);
await license.init(instanceId);
// Start timeout for the execution
let workflowTimeout = config.getEnv('executions.timeout'); // initialize with default
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
if (this.data.workflowData.settings && this.data.workflowData.settings.executionTimeout) {
workflowTimeout = this.data.workflowData.settings.executionTimeout as number; // preference on workflow setting
}
const workflowSettings = this.data.workflowData.settings ?? {};
// Start timeout for the execution
let workflowTimeout = workflowSettings.executionTimeout ?? config.getEnv('executions.timeout'); // initialize with default
if (workflowTimeout > 0) {
workflowTimeout = Math.min(workflowTimeout, config.getEnv('executions.maxTimeout'));
}

View file

@ -5,6 +5,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/naming-convention */
import { Router } from 'express';
import type { Request } from 'express';
import bodyParser from 'body-parser';
import { v4 as uuid } from 'uuid';
import config from '@/config';
@ -12,12 +13,26 @@ import * as Db from '@/Db';
import type { Role } from '@db/entities/Role';
import { hashPassword } from '@/UserManagement/UserManagementHelper';
import { eventBus } from '@/eventbus/MessageEventBus/MessageEventBus';
import Container from 'typedi';
import { License } from '../License';
if (process.env.E2E_TESTS !== 'true') {
console.error('E2E endpoints only allowed during E2E tests');
process.exit(1);
}
const enabledFeatures = {
sharing: true, //default to true here instead of setting it in config/index.ts for e2e
ldap: false,
saml: false,
logStreaming: false,
advancedExecutionFilters: false,
};
type Feature = keyof typeof enabledFeatures;
Container.get(License).isFeatureEnabled = (feature: Feature) => enabledFeatures[feature] ?? false;
const tablesToTruncate = [
'auth_identity',
'auth_provider_sync_history',
@ -78,7 +93,7 @@ const setupUserManagement = async () => {
};
const resetLogStreaming = async () => {
config.set('enterprise.features.logStreaming', false);
enabledFeatures.logStreaming = false;
for (const id in eventBus.destinations) {
await eventBus.removeDestination(id);
}
@ -127,7 +142,8 @@ e2eController.post('/db/setup-owner', bodyParser.json(), async (req, res) => {
res.writeHead(204).end();
});
e2eController.post('/enable-feature/:feature', async (req, res) => {
config.set(`enterprise.features.${req.params.feature}`, true);
e2eController.post('/enable-feature/:feature', async (req: Request<{ feature: Feature }>, res) => {
const { feature } = req.params;
enabledFeatures[feature] = true;
res.writeHead(204).end();
});

View file

@ -1,110 +0,0 @@
/* eslint-disable @typescript-eslint/no-unnecessary-boolean-literal-compare */
/* eslint-disable @typescript-eslint/no-invalid-void-type */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable no-param-reassign */
import express from 'express';
import * as Db from '@/Db';
import { ExternalHooks } from '@/ExternalHooks';
import type { ITagWithCountDb } from '@/Interfaces';
import * as ResponseHelper from '@/ResponseHelper';
import config from '@/config';
import * as TagHelpers from '@/TagHelpers';
import { validateEntity } from '@/GenericHelpers';
import { TagEntity } from '@db/entities/TagEntity';
import type { TagsRequest } from '@/requests';
import { Container } from 'typedi';
export const tagsController = express.Router();
const workflowsEnabledMiddleware: express.RequestHandler = (req, res, next) => {
if (config.getEnv('workflowTagsDisabled')) {
throw new ResponseHelper.BadRequestError('Workflow tags are disabled');
}
next();
};
// Retrieves all tags, with or without usage count
tagsController.get(
'/',
workflowsEnabledMiddleware,
ResponseHelper.send(async (req: express.Request): Promise<TagEntity[] | ITagWithCountDb[]> => {
if (req.query.withUsageCount === 'true') {
const tablePrefix = config.getEnv('database.tablePrefix');
return TagHelpers.getTagsWithCountDb(tablePrefix);
}
return Db.collections.Tag.find({ select: ['id', 'name', 'createdAt', 'updatedAt'] });
}),
);
// Creates a tag
tagsController.post(
'/',
workflowsEnabledMiddleware,
ResponseHelper.send(async (req: express.Request): Promise<TagEntity | void> => {
const newTag = new TagEntity();
newTag.name = req.body.name.trim();
await Container.get(ExternalHooks).run('tag.beforeCreate', [newTag]);
await validateEntity(newTag);
const tag = await Db.collections.Tag.save(newTag);
await Container.get(ExternalHooks).run('tag.afterCreate', [tag]);
return tag;
}),
);
// Updates a tag
tagsController.patch(
'/:id(\\d+)',
workflowsEnabledMiddleware,
ResponseHelper.send(async (req: express.Request): Promise<TagEntity | void> => {
const { name } = req.body;
const { id } = req.params;
const newTag = new TagEntity();
// @ts-ignore
newTag.id = id;
newTag.name = name.trim();
await Container.get(ExternalHooks).run('tag.beforeUpdate', [newTag]);
await validateEntity(newTag);
const tag = await Db.collections.Tag.save(newTag);
await Container.get(ExternalHooks).run('tag.afterUpdate', [tag]);
return tag;
}),
);
tagsController.delete(
'/:id(\\d+)',
workflowsEnabledMiddleware,
ResponseHelper.send(async (req: TagsRequest.Delete): Promise<boolean> => {
if (
config.getEnv('userManagement.isInstanceOwnerSetUp') === true &&
req.user.globalRole.name !== 'owner'
) {
throw new ResponseHelper.UnauthorizedError(
'You are not allowed to perform this action',
'Only owners can remove tags',
);
}
const id = req.params.id;
await Container.get(ExternalHooks).run('tag.beforeDelete', [id]);
await Db.collections.Tag.delete({ id });
await Container.get(ExternalHooks).run('tag.afterDelete', [id]);
return true;
}),
);

View file

@ -34,6 +34,8 @@ export abstract class BaseCommand extends Command {
protected userSettings: IUserSettings;
protected instanceId: string;
async init(): Promise<void> {
await initErrorHandling();
@ -49,9 +51,9 @@ export abstract class BaseCommand extends Command {
const credentialTypes = Container.get(CredentialTypes);
CredentialsOverwrites(credentialTypes);
const instanceId = this.userSettings.instanceId ?? '';
await Container.get(PostHogClient).init(instanceId);
await Container.get(InternalHooks).init(instanceId);
this.instanceId = this.userSettings.instanceId ?? '';
await Container.get(PostHogClient).init(this.instanceId);
await Container.get(InternalHooks).init(this.instanceId);
await Db.init().catch(async (error: Error) =>
this.exitWithCrash('There was an error initializing DB', error),

View file

@ -6,11 +6,10 @@ import { ExecutionBaseError } from 'n8n-workflow';
import { ActiveExecutions } from '@/ActiveExecutions';
import * as Db from '@/Db';
import * as WorkflowHelpers from '@/WorkflowHelpers';
import { WorkflowRunner } from '@/WorkflowRunner';
import type { IWorkflowExecutionDataProcess } from '@/Interfaces';
import { getInstanceOwner } from '@/UserManagement/UserManagementHelper';
import { findCliWorkflowStart } from '@/utils';
import { findCliWorkflowStart, isWorkflowIdValid } from '@/utils';
import { initEvents } from '@/events';
import { BaseCommand } from './BaseCommand';
import { Container } from 'typedi';
@ -101,7 +100,7 @@ export class Execute extends BaseCommand {
throw new Error('Failed to retrieve workflow data for requested workflow');
}
if (!WorkflowHelpers.isWorkflowIdValid(workflowId)) {
if (!isWorkflowIdValid(workflowId)) {
workflowId = undefined;
}

View file

@ -144,12 +144,16 @@ export class ExecuteBatch extends BaseCommand {
'econnrefused',
'missing a required parameter',
'insufficient credit balance',
'internal server error',
'503',
'502',
'504',
'insufficient balance',
'request timed out',
'status code 401',
];
errorMessage = errorMessage.toLowerCase();
for (let i = 0; i < warningStrings.length; i++) {
if (errorMessage.includes(warningStrings[i])) {
return true;

View file

@ -1,5 +1,5 @@
import * as Db from '@/Db';
import { LDAP_FEATURE_NAME } from '@/Ldap/constants';
import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/Ldap/constants';
import { In } from 'typeorm';
import { BaseCommand } from '../BaseCommand';
@ -17,6 +17,11 @@ export class Reset extends BaseCommand {
await AuthIdentity.delete({ providerType: 'ldap' });
await User.delete({ id: In(ldapIdentities.map((i) => i.userId)) });
await Settings.delete({ key: LDAP_FEATURE_NAME });
await Settings.insert({
key: LDAP_FEATURE_NAME,
value: JSON.stringify(LDAP_DEFAULT_CONFIGURATION),
loadOnStartup: true,
});
this.logger.info('Successfully reset the database to default ldap state.');
}

View file

@ -24,11 +24,11 @@ import * as GenericHelpers from '@/GenericHelpers';
import * as Server from '@/Server';
import { TestWebhooks } from '@/TestWebhooks';
import { getAllInstalledPackages } from '@/CommunityNodes/packageModel';
import { handleLdapInit } from '@/Ldap/helpers';
import { EDITOR_UI_DIST_DIR, GENERATED_STATIC_DIR } from '@/constants';
import { eventBus } from '@/eventbus';
import { BaseCommand } from './BaseCommand';
import { InternalHooks } from '@/InternalHooks';
import { License } from '@/License';
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires
const open = require('open');
@ -60,7 +60,7 @@ export class Start extends BaseCommand {
}),
};
protected activeWorkflowRunner = Container.get(ActiveWorkflowRunner);
protected activeWorkflowRunner: ActiveWorkflowRunner;
/**
* Opens the UI in browser
@ -182,11 +182,27 @@ export class Start extends BaseCommand {
await Promise.all(files.map(compileFile));
}
async initLicense(): Promise<void> {
const license = Container.get(License);
await license.init(this.instanceId);
const activationKey = config.getEnv('license.activationKey');
if (activationKey) {
try {
await license.activate(activationKey);
} catch (e) {
LoggerProxy.error('Could not activate license', e as Error);
}
}
}
async init() {
await this.initCrashJournal();
await super.init();
this.logger.info('Initializing n8n process');
this.activeWorkflowRunner = Container.get(ActiveWorkflowRunner);
await this.initLicense();
await this.initBinaryManager();
await this.initExternalHooks();
@ -252,11 +268,10 @@ export class Start extends BaseCommand {
// Optimistic approach - stop if any installation fails
// eslint-disable-next-line no-restricted-syntax
for (const missingPackage of missingPackages) {
// eslint-disable-next-line no-await-in-loop
void (await this.loadNodesAndCredentials.loadNpmModule(
await this.loadNodesAndCredentials.installNpmModule(
missingPackage.packageName,
missingPackage.version,
));
);
missingPackages.delete(missingPackage);
}
LoggerProxy.info('Packages reinstalled successfully. Resuming regular initialization.');
@ -331,8 +346,6 @@ export class Start extends BaseCommand {
// Start to get active workflows and run their triggers
await this.activeWorkflowRunner.init();
await handleLdapInit();
const editorUrl = GenericHelpers.getBaseUrl();
this.log(`\nEditor is now accessible via:\n${editorUrl}`);

View file

@ -3,6 +3,7 @@ import { LoggerProxy, sleep } from 'n8n-workflow';
import config from '@/config';
import { ActiveExecutions } from '@/ActiveExecutions';
import { WebhookServer } from '@/WebhookServer';
import { Queue } from '@/Queue';
import { BaseCommand } from './BaseCommand';
import { Container } from 'typedi';
@ -79,6 +80,7 @@ export class Webhook extends BaseCommand {
}
async run() {
await Container.get(Queue).init();
await new WebhookServer().start();
this.logger.info('Webhook listener waiting for requests.');

View file

@ -1,6 +1,7 @@
import express from 'express';
import http from 'http';
import type PCancelable from 'p-cancelable';
import { Container } from 'typedi';
import { flags } from '@oclif/command';
import { WorkflowExecute } from 'n8n-core';
@ -15,7 +16,8 @@ import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'
import { PermissionChecker } from '@/UserManagement/PermissionChecker';
import config from '@/config';
import * as Queue from '@/Queue';
import type { Job, JobId, JobQueue, JobResponse, WebhookResponse } from '@/Queue';
import { Queue } from '@/Queue';
import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper';
import { generateFailedExecutionFromError } from '@/WorkflowHelpers';
import { N8N_VERSION } from '@/constants';
@ -38,7 +40,7 @@ export class Worker extends BaseCommand {
[key: string]: PCancelable<IRun>;
} = {};
static jobQueue: Queue.JobQueue;
static jobQueue: JobQueue;
/**
* Stop n8n in a graceful way.
@ -86,7 +88,7 @@ export class Worker extends BaseCommand {
await this.exitSuccessFully();
}
async runJob(job: Queue.Job, nodeTypes: INodeTypes): Promise<Queue.JobResponse> {
async runJob(job: Job, nodeTypes: INodeTypes): Promise<JobResponse> {
const { executionId, loadStaticData } = job.data;
const executionDb = await Db.collections.Execution.findOneBy({ id: executionId });
@ -125,14 +127,9 @@ export class Worker extends BaseCommand {
staticData = workflowData.staticData;
}
let workflowTimeout = config.getEnv('executions.timeout'); // initialize with default
if (
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
currentExecutionDb.workflowData.settings &&
currentExecutionDb.workflowData.settings.executionTimeout
) {
workflowTimeout = currentExecutionDb.workflowData.settings.executionTimeout as number; // preference on workflow setting
}
const workflowSettings = currentExecutionDb.workflowData.settings ?? {};
let workflowTimeout = workflowSettings.executionTimeout ?? config.getEnv('executions.timeout'); // initialize with default
let executionTimeoutTimestamp: number | undefined;
if (workflowTimeout > 0) {
@ -179,7 +176,7 @@ export class Worker extends BaseCommand {
additionalData.hooks.hookFunctions.sendResponse = [
async (response: IExecuteResponsePromiseData): Promise<void> => {
const progress: Queue.WebhookResponse = {
const progress: WebhookResponse = {
executionId,
response: WebhookHelpers.encodeWebhookResponse(response),
};
@ -238,7 +235,8 @@ export class Worker extends BaseCommand {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const redisConnectionTimeoutLimit = config.getEnv('queue.bull.redis.timeoutThreshold');
const queue = await Queue.getInstance();
const queue = Container.get(Queue);
await queue.init();
Worker.jobQueue = queue.getBullObjectInstance();
// eslint-disable-next-line @typescript-eslint/no-floating-promises
Worker.jobQueue.process(flags.concurrency, async (job) => this.runJob(job, this.nodeTypes));
@ -248,7 +246,7 @@ export class Worker extends BaseCommand {
this.logger.info(` * Concurrency: ${flags.concurrency}`);
this.logger.info('');
Worker.jobQueue.on('global:progress', (jobId: Queue.JobId, progress) => {
Worker.jobQueue.on('global:progress', (jobId: JobId, progress) => {
// Progress of a job got updated which does get used
// to communicate that a job got canceled.

View file

@ -24,11 +24,7 @@ if (inE2ETests) {
dotenv.config();
}
const config = convict(schema);
if (inE2ETests) {
config.set('enterprise.features.sharing', true);
}
const config = convict(schema, { args: [] });
// eslint-disable-next-line @typescript-eslint/unbound-method
config.getEnv = config.get;

View file

@ -990,31 +990,6 @@ export const schema = {
},
},
enterprise: {
features: {
sharing: {
format: Boolean,
default: false,
},
ldap: {
format: Boolean,
default: false,
},
saml: {
format: Boolean,
default: false,
},
logStreaming: {
format: Boolean,
default: false,
},
advancedExecutionFilters: {
format: Boolean,
default: false,
},
},
},
sso: {
justInTimeProvisioning: {
format: Boolean,
@ -1166,6 +1141,12 @@ export const schema = {
env: 'N8N_LICENSE_TENANT_ID',
doc: 'Tenant id used by the license manager',
},
cert: {
format: String,
default: '',
env: 'N8N_LICENSE_CERT',
doc: 'Ephemeral license certificate',
},
},
hideUsagePage: {

View file

@ -18,7 +18,7 @@ export const CUSTOM_API_CALL_KEY = '__CUSTOM_API_CALL__';
export const CLI_DIR = resolve(__dirname, '..');
export const TEMPLATES_DIR = join(CLI_DIR, 'templates');
export const NODES_BASE_DIR = join(CLI_DIR, '..', 'nodes-base');
export const NODES_BASE_DIR = dirname(require.resolve('n8n-nodes-base'));
export const GENERATED_STATIC_DIR = join(UserSettings.getUserHome(), '.cache/n8n/public');
export const EDITOR_UI_DIST_DIR = join(dirname(require.resolve('n8n-editor-ui')), 'dist');
@ -45,6 +45,7 @@ export const RESPONSE_ERROR_MESSAGES = {
PACKAGE_NOT_FOUND: 'Package not found in npm',
PACKAGE_VERSION_NOT_FOUND: 'The specified package version was not found',
PACKAGE_DOES_NOT_CONTAIN_NODES: 'The specified package does not contain any nodes',
PACKAGE_LOADING_FAILED: 'The specified package could not be loaded',
DISK_IS_FULL: 'There appears to be insufficient disk space',
};

View file

@ -19,8 +19,10 @@ import type {
} from '@/Interfaces';
import { handleEmailLogin, handleLdapLogin } from '@/auth';
import type { PostHogClient } from '@/posthog';
import { isSamlCurrentAuthenticationMethod } from '../sso/ssoHelpers';
import { SamlUrls } from '../sso/saml/constants';
import {
isLdapCurrentAuthenticationMethod,
isSamlCurrentAuthenticationMethod,
} from '@/sso/ssoHelpers';
@RestController()
export class AuthController {
@ -73,19 +75,12 @@ export class AuthController {
if (preliminaryUser?.globalRole?.name === 'owner') {
user = preliminaryUser;
} else {
// TODO:SAML - uncomment this block when we have a way to redirect users to the SSO flow
// if (doRedirectUsersFromLoginToSsoFlow()) {
res.redirect(SamlUrls.restInitSSO);
return;
// return withFeatureFlags(this.postHog, sanitizeUser(preliminaryUser));
// } else {
// throw new AuthError(
// 'Login with username and password is disabled due to SAML being the default authentication method. Please use SAML to log in.',
// );
// }
throw new AuthError('SAML is enabled, please log in with SAML');
}
} else if (isLdapCurrentAuthenticationMethod()) {
user = await handleLdapLogin(email, password);
} else {
user = (await handleLdapLogin(email, password)) ?? (await handleEmailLogin(email, password));
user = await handleEmailLogin(email, password);
}
if (user) {
await issueCookie(res, user);

View file

@ -1,6 +1,10 @@
export { AuthController } from './auth.controller';
export { LdapController } from './ldap.controller';
export { MeController } from './me.controller';
export { NodesController } from './nodes.controller';
export { NodeTypesController } from './nodeTypes.controller';
export { OwnerController } from './owner.controller';
export { PasswordResetController } from './passwordReset.controller';
export { TagsController } from './tags.controller';
export { TranslationController } from './translation.controller';
export { UsersController } from './users.controller';

View file

@ -0,0 +1,65 @@
import pick from 'lodash.pick';
import { Get, Post, Put, RestController } from '@/decorators';
import { getLdapConfig, getLdapSynchronizations, updateLdapConfig } from '@/Ldap/helpers';
import { LdapService } from '@/Ldap/LdapService.ee';
import { LdapSync } from '@/Ldap/LdapSync.ee';
import { LdapConfiguration } from '@/Ldap/types';
import { BadRequestError } from '@/ResponseHelper';
import { NON_SENSIBLE_LDAP_CONFIG_PROPERTIES } from '@/Ldap/constants';
import { InternalHooks } from '@/InternalHooks';
@RestController('/ldap')
export class LdapController {
constructor(
private ldapService: LdapService,
private ldapSync: LdapSync,
private internalHooks: InternalHooks,
) {}
@Get('/config')
async getConfig() {
return getLdapConfig();
}
@Post('/test-connection')
async testConnection() {
try {
await this.ldapService.testConnection();
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
}
@Put('/config')
async updateConfig(req: LdapConfiguration.Update) {
try {
await updateLdapConfig(req.body);
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
const data = await getLdapConfig();
void this.internalHooks.onUserUpdatedLdapSettings({
user_id: req.user.id,
...pick(data, NON_SENSIBLE_LDAP_CONFIG_PROPERTIES),
});
return data;
}
@Get('/sync')
async getLdapSync(req: LdapConfiguration.GetSync) {
const { page = '0', perPage = '20' } = req.query;
return getLdapSynchronizations(parseInt(page, 10), parseInt(perPage, 10));
}
@Post('/sync')
async syncLdap(req: LdapConfiguration.Sync) {
try {
await this.ldapSync.run(req.body.type);
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
}
}

View file

@ -1,39 +1,43 @@
import express from 'express';
import { readFile } from 'fs/promises';
import get from 'lodash.get';
import { Request } from 'express';
import type { INodeTypeDescription, INodeTypeNameVersion } from 'n8n-workflow';
import config from '@/config';
import { NodeTypes } from '@/NodeTypes';
import * as ResponseHelper from '@/ResponseHelper';
import { Post, RestController } from '@/decorators';
import { getNodeTranslationPath } from '@/TranslationHelpers';
import { Container } from 'typedi';
import type { Config } from '@/config';
import type { NodeTypes } from '@/NodeTypes';
export const nodeTypesController = express.Router();
@RestController('/node-types')
export class NodeTypesController {
private readonly config: Config;
// Returns node information based on node names and versions
nodeTypesController.post(
'/',
ResponseHelper.send(async (req: express.Request): Promise<INodeTypeDescription[]> => {
private readonly nodeTypes: NodeTypes;
constructor({ config, nodeTypes }: { config: Config; nodeTypes: NodeTypes }) {
this.config = config;
this.nodeTypes = nodeTypes;
}
@Post('/')
async getNodeInfo(req: Request) {
const nodeInfos = get(req, 'body.nodeInfos', []) as INodeTypeNameVersion[];
const defaultLocale = config.getEnv('defaultLocale');
const defaultLocale = this.config.getEnv('defaultLocale');
if (defaultLocale === 'en') {
return nodeInfos.reduce<INodeTypeDescription[]>((acc, { name, version }) => {
const { description } = Container.get(NodeTypes).getByNameAndVersion(name, version);
const { description } = this.nodeTypes.getByNameAndVersion(name, version);
acc.push(description);
return acc;
}, []);
}
async function populateTranslation(
const populateTranslation = async (
name: string,
version: number,
nodeTypes: INodeTypeDescription[],
) {
const { description, sourcePath } = Container.get(NodeTypes).getWithSourcePath(name, version);
) => {
const { description, sourcePath } = this.nodeTypes.getWithSourcePath(name, version);
const translationPath = await getNodeTranslationPath({
nodeSourcePath: sourcePath,
longNodeType: description.name,
@ -44,12 +48,12 @@ nodeTypesController.post(
const translation = await readFile(translationPath, 'utf8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
description.translation = JSON.parse(translation);
} catch (error) {
} catch {
// ignore - no translation exists at path
}
nodeTypes.push(description);
}
};
const nodeTypes: INodeTypeDescription[] = [];
@ -60,5 +64,5 @@ nodeTypesController.post(
await Promise.all(promises);
return nodeTypes;
}),
);
}
}

View file

@ -1,10 +1,12 @@
import express from 'express';
import type { PublicInstalledPackage } from 'n8n-workflow';
import config from '@/config';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import * as ResponseHelper from '@/ResponseHelper';
import { Request, Response, NextFunction } from 'express';
import {
RESPONSE_ERROR_MESSAGES,
STARTER_TEMPLATE_NAME,
UNKNOWN_FAILURE_REASON,
} from '@/constants';
import { Delete, Get, Middleware, Patch, Post, RestController } from '@/decorators';
import { NodeRequest } from '@/requests';
import { BadRequestError, InternalServerError } from '@/ResponseHelper';
import {
checkNpmPackageStatus,
executeCommand,
@ -22,57 +24,50 @@ import {
getAllInstalledPackages,
isPackageInstalled,
} from '@/CommunityNodes/packageModel';
import {
RESPONSE_ERROR_MESSAGES,
STARTER_TEMPLATE_NAME,
UNKNOWN_FAILURE_REASON,
} from '@/constants';
import { isAuthenticatedRequest } from '@/UserManagement/UserManagementHelper';
import type { InstalledPackages } from '@db/entities/InstalledPackages';
import type { CommunityPackages } from '@/Interfaces';
import type { NodeRequest } from '@/requests';
import { Push } from '@/push';
import { Container } from 'typedi';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import { InternalHooks } from '@/InternalHooks';
import { Push } from '@/push';
import { Config } from '@/config';
import { isAuthenticatedRequest } from '@/UserManagement/UserManagementHelper';
const { PACKAGE_NOT_INSTALLED, PACKAGE_NAME_NOT_PROVIDED } = RESPONSE_ERROR_MESSAGES;
export const nodesController = express.Router();
@RestController('/nodes')
export class NodesController {
constructor(
private config: Config,
private loadNodesAndCredentials: LoadNodesAndCredentials,
private push: Push,
private internalHooks: InternalHooks,
) {}
nodesController.use((req, res, next) => {
if (!isAuthenticatedRequest(req) || req.user.globalRole.name !== 'owner') {
// TODO: move this into a new decorator `@Authorized`
@Middleware()
checkIfOwner(req: Request, res: Response, next: NextFunction) {
if (!isAuthenticatedRequest(req) || req.user.globalRole.name !== 'owner')
res.status(403).json({ status: 'error', message: 'Unauthorized' });
return;
else next();
}
next();
});
nodesController.use((req, res, next) => {
if (config.getEnv('executions.mode') === 'queue' && req.method !== 'GET') {
// TODO: move this into a new decorator `@IfConfig('executions.mode', 'queue')`
@Middleware()
checkIfCommunityNodesEnabled(req: Request, res: Response, next: NextFunction) {
if (this.config.getEnv('executions.mode') === 'queue' && req.method !== 'GET')
res.status(400).json({
status: 'error',
message: 'Package management is disabled when running in "queue" mode',
});
return;
else next();
}
next();
});
/**
* POST /nodes
*
* Install an n8n community package
*/
nodesController.post(
'/',
ResponseHelper.send(async (req: NodeRequest.Post) => {
@Post('/')
async installPackage(req: NodeRequest.Post) {
const { name } = req.body;
if (!name) {
throw new ResponseHelper.BadRequestError(PACKAGE_NAME_NOT_PROVIDED);
throw new BadRequestError(PACKAGE_NAME_NOT_PROVIDED);
}
let parsed: CommunityPackages.ParsedPackageName;
@ -80,13 +75,13 @@ nodesController.post(
try {
parsed = parseNpmPackageName(name);
} catch (error) {
throw new ResponseHelper.BadRequestError(
throw new BadRequestError(
error instanceof Error ? error.message : 'Failed to parse package name',
);
}
if (parsed.packageName === STARTER_TEMPLATE_NAME) {
throw new ResponseHelper.BadRequestError(
throw new BadRequestError(
[
`Package "${parsed.packageName}" is only a template`,
'Please enter an actual package to install',
@ -98,7 +93,7 @@ nodesController.post(
const hasLoaded = hasPackageLoaded(name);
if (isInstalled && hasLoaded) {
throw new ResponseHelper.BadRequestError(
throw new BadRequestError(
[
`Package "${parsed.packageName}" is already installed`,
'To update it, click the corresponding button in the UI',
@ -109,22 +104,19 @@ nodesController.post(
const packageStatus = await checkNpmPackageStatus(name);
if (packageStatus.status !== 'OK') {
throw new ResponseHelper.BadRequestError(
`Package "${name}" is banned so it cannot be installed`,
);
throw new BadRequestError(`Package "${name}" is banned so it cannot be installed`);
}
let installedPackage: InstalledPackages;
try {
installedPackage = await Container.get(LoadNodesAndCredentials).loadNpmModule(
installedPackage = await this.loadNodesAndCredentials.installNpmModule(
parsed.packageName,
parsed.version,
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON;
void Container.get(InternalHooks).onCommunityPackageInstallFinished({
void this.internalHooks.onCommunityPackageInstallFinished({
user: req.user,
input_string: name,
package_name: parsed.packageName,
@ -133,26 +125,26 @@ nodesController.post(
failure_reason: errorMessage,
});
const message = [`Error loading package "${name}"`, errorMessage].join(':');
let message = [`Error loading package "${name}" `, errorMessage].join(':');
if (error instanceof Error && error.cause instanceof Error) {
message += `\nCause: ${error.cause.message}`;
}
const clientError = error instanceof Error ? isClientError(error) : false;
throw new ResponseHelper[clientError ? 'BadRequestError' : 'InternalServerError'](message);
throw new (clientError ? BadRequestError : InternalServerError)(message);
}
if (!hasLoaded) removePackageFromMissingList(name);
const pushInstance = Container.get(Push);
// broadcast to connected frontends that node list has been updated
installedPackage.installedNodes.forEach((node) => {
pushInstance.send('reloadNodeType', {
this.push.send('reloadNodeType', {
name: node.type,
version: node.latestVersion,
});
});
void Container.get(InternalHooks).onCommunityPackageInstallFinished({
void this.internalHooks.onCommunityPackageInstallFinished({
user: req.user,
input_string: name,
package_name: parsed.packageName,
@ -164,17 +156,10 @@ nodesController.post(
});
return installedPackage;
}),
);
}
/**
* GET /nodes
*
* Retrieve list of installed n8n community packages
*/
nodesController.get(
'/',
ResponseHelper.send(async (): Promise<PublicInstalledPackage[]> => {
@Get('/')
async getInstalledPackages() {
const installedPackages = await getAllInstalledPackages();
if (installedPackages.length === 0) return [];
@ -188,7 +173,6 @@ nodesController.get(
// when there are updates, npm exits with code 1
// when there are no updates, command succeeds
// https://github.com/npm/rfcs/issues/473
if (isNpmError(error) && error.code === 1) {
pendingUpdates = JSON.parse(error.stdout) as CommunityPackages.AvailableUpdates;
}
@ -197,31 +181,21 @@ nodesController.get(
let hydratedPackages = matchPackagesWithUpdates(installedPackages, pendingUpdates);
try {
const missingPackages = config.get('nodes.packagesMissing') as string | undefined;
const missingPackages = this.config.get('nodes.packagesMissing') as string | undefined;
if (missingPackages) {
hydratedPackages = matchMissingPackages(hydratedPackages, missingPackages);
}
} catch {
// Do nothing if setting is missing
}
} catch {}
return hydratedPackages;
}),
);
}
/**
* DELETE /nodes
*
* Uninstall an installed n8n community package
*/
nodesController.delete(
'/',
ResponseHelper.send(async (req: NodeRequest.Delete) => {
@Delete('/')
async uninstallPackage(req: NodeRequest.Delete) {
const { name } = req.query;
if (!name) {
throw new ResponseHelper.BadRequestError(PACKAGE_NAME_NOT_PROVIDED);
throw new BadRequestError(PACKAGE_NAME_NOT_PROVIDED);
}
try {
@ -229,37 +203,35 @@ nodesController.delete(
} catch (error) {
const message = error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON;
throw new ResponseHelper.BadRequestError(message);
throw new BadRequestError(message);
}
const installedPackage = await findInstalledPackage(name);
if (!installedPackage) {
throw new ResponseHelper.BadRequestError(PACKAGE_NOT_INSTALLED);
throw new BadRequestError(PACKAGE_NOT_INSTALLED);
}
try {
await Container.get(LoadNodesAndCredentials).removeNpmModule(name, installedPackage);
await this.loadNodesAndCredentials.removeNpmModule(name, installedPackage);
} catch (error) {
const message = [
`Error removing package "${name}"`,
error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON,
].join(':');
throw new ResponseHelper.InternalServerError(message);
throw new InternalServerError(message);
}
const pushInstance = Container.get(Push);
// broadcast to connected frontends that node list has been updated
installedPackage.installedNodes.forEach((node) => {
pushInstance.send('removeNodeType', {
this.push.send('removeNodeType', {
name: node.type,
version: node.latestVersion,
});
});
void Container.get(InternalHooks).onCommunityPackageDeleteFinished({
void this.internalHooks.onCommunityPackageDeleteFinished({
user: req.user,
package_name: name,
package_version: installedPackage.installedVersion,
@ -267,53 +239,44 @@ nodesController.delete(
package_author: installedPackage.authorName,
package_author_email: installedPackage.authorEmail,
});
}),
);
}
/**
* PATCH /nodes
*
* Update an installed n8n community package
*/
nodesController.patch(
'/',
ResponseHelper.send(async (req: NodeRequest.Update) => {
@Patch('/')
async updatePackage(req: NodeRequest.Update) {
const { name } = req.body;
if (!name) {
throw new ResponseHelper.BadRequestError(PACKAGE_NAME_NOT_PROVIDED);
throw new BadRequestError(PACKAGE_NAME_NOT_PROVIDED);
}
const previouslyInstalledPackage = await findInstalledPackage(name);
if (!previouslyInstalledPackage) {
throw new ResponseHelper.BadRequestError(PACKAGE_NOT_INSTALLED);
throw new BadRequestError(PACKAGE_NOT_INSTALLED);
}
try {
const newInstalledPackage = await Container.get(LoadNodesAndCredentials).updateNpmModule(
const newInstalledPackage = await this.loadNodesAndCredentials.updateNpmModule(
parseNpmPackageName(name).packageName,
previouslyInstalledPackage,
);
const pushInstance = Container.get(Push);
// broadcast to connected frontends that node list has been updated
previouslyInstalledPackage.installedNodes.forEach((node) => {
pushInstance.send('removeNodeType', {
this.push.send('removeNodeType', {
name: node.type,
version: node.latestVersion,
});
});
newInstalledPackage.installedNodes.forEach((node) => {
pushInstance.send('reloadNodeType', {
this.push.send('reloadNodeType', {
name: node.name,
version: node.latestVersion,
});
});
void Container.get(InternalHooks).onCommunityPackageUpdateFinished({
void this.internalHooks.onCommunityPackageUpdateFinished({
user: req.user,
package_name: name,
package_version_current: previouslyInstalledPackage.installedVersion,
@ -326,8 +289,7 @@ nodesController.patch(
return newInstalledPackage;
} catch (error) {
previouslyInstalledPackage.installedNodes.forEach((node) => {
const pushInstance = Container.get(Push);
pushInstance.send('removeNodeType', {
this.push.send('removeNodeType', {
name: node.type,
version: node.latestVersion,
});
@ -338,7 +300,7 @@ nodesController.patch(
error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON,
].join(':');
throw new ResponseHelper.InternalServerError(message);
throw new InternalServerError(message);
}
}
}
}),
);

View file

@ -14,7 +14,7 @@ import {
hashPassword,
validatePassword,
} from '@/UserManagement/UserManagementHelper';
import * as UserManagementMailer from '@/UserManagement/email';
import type { UserManagementMailer } from '@/UserManagement/email';
import { Response } from 'express';
import type { ILogger } from 'n8n-workflow';
@ -35,6 +35,8 @@ export class PasswordResetController {
private readonly internalHooks: IInternalHooksClass;
private readonly mailer: UserManagementMailer;
private readonly userRepository: Repository<User>;
constructor({
@ -42,18 +44,21 @@ export class PasswordResetController {
logger,
externalHooks,
internalHooks,
mailer,
repositories,
}: {
config: Config;
logger: ILogger;
externalHooks: IExternalHooksClass;
internalHooks: IInternalHooksClass;
mailer: UserManagementMailer;
repositories: Pick<IDatabaseCollections, 'User'>;
}) {
this.config = config;
this.logger = logger;
this.externalHooks = externalHooks;
this.internalHooks = internalHooks;
this.mailer = mailer;
this.userRepository = repositories.User;
}
@ -126,8 +131,7 @@ export class PasswordResetController {
url.searchParams.append('token', resetPasswordToken);
try {
const mailer = UserManagementMailer.getInstance();
await mailer.passwordReset({
await this.mailer.passwordReset({
email,
firstName,
lastName,

View file

@ -0,0 +1,102 @@
import { Request, Response, NextFunction } from 'express';
import type { Repository } from 'typeorm';
import type { Config } from '@/config';
import { Delete, Get, Middleware, Patch, Post, RestController } from '@/decorators';
import type { IDatabaseCollections, IExternalHooksClass, ITagWithCountDb } from '@/Interfaces';
import { TagEntity } from '@db/entities/TagEntity';
import { getTagsWithCountDb } from '@/TagHelpers';
import { validateEntity } from '@/GenericHelpers';
import { BadRequestError, UnauthorizedError } from '@/ResponseHelper';
import { TagsRequest } from '@/requests';
@RestController('/tags')
export class TagsController {
private config: Config;
private externalHooks: IExternalHooksClass;
private tagsRepository: Repository<TagEntity>;
constructor({
config,
externalHooks,
repositories,
}: {
config: Config;
externalHooks: IExternalHooksClass;
repositories: Pick<IDatabaseCollections, 'Tag'>;
}) {
this.config = config;
this.externalHooks = externalHooks;
this.tagsRepository = repositories.Tag;
}
// TODO: move this into a new decorator `@IfEnabled('workflowTagsDisabled')`
@Middleware()
workflowsEnabledMiddleware(req: Request, res: Response, next: NextFunction) {
if (this.config.getEnv('workflowTagsDisabled'))
throw new BadRequestError('Workflow tags are disabled');
next();
}
// Retrieves all tags, with or without usage count
@Get('/')
async getAll(req: TagsRequest.GetAll): Promise<TagEntity[] | ITagWithCountDb[]> {
const { withUsageCount } = req.query;
if (withUsageCount === 'true') {
const tablePrefix = this.config.getEnv('database.tablePrefix');
return getTagsWithCountDb(tablePrefix);
}
return this.tagsRepository.find({ select: ['id', 'name', 'createdAt', 'updatedAt'] });
}
// Creates a tag
@Post('/')
async createTag(req: TagsRequest.Create): Promise<TagEntity> {
const newTag = new TagEntity();
newTag.name = req.body.name.trim();
await this.externalHooks.run('tag.beforeCreate', [newTag]);
await validateEntity(newTag);
const tag = await this.tagsRepository.save(newTag);
await this.externalHooks.run('tag.afterCreate', [tag]);
return tag;
}
// Updates a tag
@Patch('/:id(\\d+)')
async updateTag(req: TagsRequest.Update): Promise<TagEntity> {
const { name } = req.body;
const { id } = req.params;
const newTag = new TagEntity();
newTag.id = id;
newTag.name = name.trim();
await this.externalHooks.run('tag.beforeUpdate', [newTag]);
await validateEntity(newTag);
const tag = await this.tagsRepository.save(newTag);
await this.externalHooks.run('tag.afterUpdate', [tag]);
return tag;
}
@Delete('/:id(\\d+)')
async deleteTag(req: TagsRequest.Delete) {
const isInstanceOwnerSetUp = this.config.getEnv('userManagement.isInstanceOwnerSetUp');
if (isInstanceOwnerSetUp && req.user.globalRole.name !== 'owner') {
throw new UnauthorizedError(
'You are not allowed to perform this action',
'Only owners can remove tags',
);
}
const { id } = req.params;
await this.externalHooks.run('tag.beforeDelete', [id]);
await this.tagsRepository.delete({ id });
await this.externalHooks.run('tag.afterDelete', [id]);
return true;
}
}

View file

@ -13,7 +13,6 @@ import {
getInstanceBaseUrl,
hashPassword,
isEmailSetUp,
isUserManagementEnabled,
sanitizeUser,
validatePassword,
withFeatureFlags,
@ -35,6 +34,8 @@ import type {
import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import { AuthIdentity } from '@db/entities/AuthIdentity';
import type { PostHogClient } from '@/posthog';
import { userManagementEnabledMiddleware } from '../middlewares/userManagementEnabled';
import { isSamlLicensedAndEnabled } from '../sso/saml/samlHelpers';
@RestController('/users')
export class UsersController {
@ -98,14 +99,15 @@ export class UsersController {
/**
* Send email invite(s) to one or multiple users and create user shell(s).
*/
@Post('/')
@Post('/', { middlewares: [userManagementEnabledMiddleware] })
async sendEmailInvites(req: UserRequest.Invite) {
// TODO: this should be checked in the middleware rather than here
if (!isUserManagementEnabled()) {
if (isSamlLicensedAndEnabled()) {
this.logger.debug(
'Request to send email invite(s) to user(s) failed because user management is disabled',
'SAML is enabled, so users are managed by the Identity Provider and cannot be added through invites',
);
throw new BadRequestError(
'SAML is enabled, so users are managed by the Identity Provider and cannot be added through invites',
);
throw new BadRequestError('User management is disabled');
}
if (!this.config.getEnv('userManagement.isInstanceOwnerSetUp')) {

View file

@ -11,7 +11,6 @@ import type {
WorkflowExecuteMode,
INodeCredentialsDetails,
ICredentialsEncrypted,
IDataObject,
} from 'n8n-workflow';
import { LoggerProxy } from 'n8n-workflow';
import { resolve as pathResolve } from 'path';
@ -112,7 +111,7 @@ oauth2CredentialController.get(
);
const token = new Csrf();
// Generate a CSRF prevention token and send it as a OAuth2 state stringma/ERR
// Generate a CSRF prevention token and send it as an OAuth2 state string
const csrfSecret = token.secretSync();
const state = {
token: token.create(csrfSecret),
@ -174,6 +173,9 @@ oauth2CredentialController.get(
}),
);
const renderCallbackError = (res: express.Response, errorMessage: string) =>
res.render('oauth-error-callback', { error: { message: errorMessage } });
/**
* GET /oauth2-credential/callback
*
@ -188,12 +190,12 @@ oauth2CredentialController.get(
const { code, state: stateEncoded } = req.query;
if (!code || !stateEncoded) {
const errorResponse = new ResponseHelper.ServiceUnavailableError(
return renderCallbackError(
res,
`Insufficient parameters for OAuth2 callback. Received following query parameters: ${JSON.stringify(
req.query,
)}`,
);
return ResponseHelper.sendErrorResponse(res, errorResponse);
}
let state;
@ -203,31 +205,21 @@ oauth2CredentialController.get(
token: string;
};
} catch (error) {
const errorResponse = new ResponseHelper.ServiceUnavailableError(
'Invalid state format returned',
);
return ResponseHelper.sendErrorResponse(res, errorResponse);
return renderCallbackError(res, 'Invalid state format returned');
}
const credential = await getCredentialWithoutUser(state.cid);
if (!credential) {
LoggerProxy.error('OAuth2 callback failed because of insufficient permissions', {
const errorMessage = 'OAuth2 callback failed because of insufficient permissions';
LoggerProxy.error(errorMessage, {
userId: req.user?.id,
credentialId: state.cid,
});
const errorResponse = new ResponseHelper.NotFoundError(
RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL,
);
return ResponseHelper.sendErrorResponse(res, errorResponse);
return renderCallbackError(res, errorMessage);
}
let encryptionKey: string;
try {
encryptionKey = await UserSettings.getEncryptionKey();
} catch (error) {
throw new ResponseHelper.InternalServerError((error as IDataObject).message as string);
}
const encryptionKey = await UserSettings.getEncryptionKey();
const mode: WorkflowExecuteMode = 'internal';
const timezone = config.getEnv('generic.timezone');
@ -251,14 +243,12 @@ oauth2CredentialController.get(
decryptedDataOriginal.csrfSecret === undefined ||
!token.verify(decryptedDataOriginal.csrfSecret as string, state.token)
) {
LoggerProxy.debug('OAuth2 callback state is invalid', {
const errorMessage = 'The OAuth2 callback state is invalid!';
LoggerProxy.debug(errorMessage, {
userId: req.user?.id,
credentialId: state.cid,
});
const errorResponse = new ResponseHelper.NotFoundError(
'The OAuth2 callback state is invalid!',
);
return ResponseHelper.sendErrorResponse(res, errorResponse);
return renderCallbackError(res, errorMessage);
}
let options = {};
@ -298,12 +288,12 @@ oauth2CredentialController.get(
}
if (oauthToken === undefined) {
LoggerProxy.error('OAuth2 callback failed: unable to get access tokens', {
const errorMessage = 'Unable to get OAuth2 access tokens!';
LoggerProxy.error(errorMessage, {
userId: req.user?.id,
credentialId: state.cid,
});
const errorResponse = new ResponseHelper.NotFoundError('Unable to get access tokens!');
return ResponseHelper.sendErrorResponse(res, errorResponse);
return renderCallbackError(res, errorMessage);
}
if (decryptedDataOriginal.oauthTokenData) {
@ -336,9 +326,7 @@ oauth2CredentialController.get(
return res.sendFile(pathResolve(TEMPLATES_DIR, 'oauth-callback.html'));
} catch (error) {
// Error response
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
return ResponseHelper.sendErrorResponse(res, error);
return renderCallbackError(res, (error as Error).message);
}
},
);

View file

@ -1,9 +1,10 @@
import { ExecutionStatus, WorkflowExecuteMode } from 'n8n-workflow';
import { Column, Entity, Generated, Index, PrimaryColumn } from 'typeorm';
import { Column, Entity, Generated, Index, OneToMany, PrimaryColumn } from 'typeorm';
import { datetimeColumnType, jsonColumnType } from './AbstractEntity';
import { IWorkflowDb } from '@/Interfaces';
import type { IExecutionFlattedDb } from '@/Interfaces';
import { idStringifier } from '../utils/transformers';
import type { ExecutionMetadata } from './ExecutionMetadata';
@Entity()
@Index(['workflowId', 'id'])
@ -49,4 +50,7 @@ export class ExecutionEntity implements IExecutionFlattedDb {
@Column({ type: datetimeColumnType, nullable: true })
waitTill: Date;
@OneToMany('ExecutionMetadata', 'execution')
metadata: ExecutionMetadata[];
}

View file

@ -0,0 +1,22 @@
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn, RelationId } from 'typeorm';
import { ExecutionEntity } from './ExecutionEntity';
@Entity()
export class ExecutionMetadata {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne('ExecutionEntity', 'metadata', {
onDelete: 'CASCADE',
})
execution: ExecutionEntity;
@RelationId((executionMetadata: ExecutionMetadata) => executionMetadata.execution)
executionId: number;
@Column('text')
key: string;
@Column('text')
value: string;
}

View file

@ -15,6 +15,7 @@ import { User } from './User';
import { WebhookEntity } from './WebhookEntity';
import { WorkflowEntity } from './WorkflowEntity';
import { WorkflowStatistics } from './WorkflowStatistics';
import { ExecutionMetadata } from './ExecutionMetadata';
export const entities = {
AuthIdentity,
@ -33,4 +34,5 @@ export const entities = {
WebhookEntity,
WorkflowEntity,
WorkflowStatistics,
ExecutionMetadata,
};

View file

@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
export class CreateExecutionMetadataTable1679416281779 implements MigrationInterface {
name = 'CreateExecutionMetadataTable1679416281779';
public async up(queryRunner: QueryRunner): Promise<void> {
logMigrationStart(this.name);
const tablePrefix = getTablePrefix();
await queryRunner.query(
`CREATE TABLE ${tablePrefix}execution_metadata (
id int(11) auto_increment NOT NULL PRIMARY KEY,
executionId int(11) NOT NULL,
\`key\` TEXT NOT NULL,
value TEXT NOT NULL,
CONSTRAINT \`${tablePrefix}execution_metadata_FK\` FOREIGN KEY (\`executionId\`) REFERENCES \`${tablePrefix}execution_entity\` (\`id\`) ON DELETE CASCADE,
INDEX \`IDX_${tablePrefix}6d44376da6c1058b5e81ed8a154e1fee106046eb\` (\`executionId\` ASC)
)
ENGINE=InnoDB`,
);
// Remove indices that are no longer needed since the addition of the status column
await queryRunner.query(
`DROP INDEX \`IDX_${tablePrefix}06da892aaf92a48e7d3e400003\` ON \`${tablePrefix}execution_entity\``,
);
await queryRunner.query(
`DROP INDEX \`IDX_${tablePrefix}78d62b89dc1433192b86dce18a\` ON \`${tablePrefix}execution_entity\``,
);
await queryRunner.query(
`DROP INDEX \`IDX_${tablePrefix}1688846335d274033e15c846a4\` ON \`${tablePrefix}execution_entity\``,
);
await queryRunner.query(
`DROP INDEX \`IDX_${tablePrefix}cefb067df2402f6aed0638a6c1\` ON \`${tablePrefix}execution_entity\``,
);
// Add index to the new status column
await queryRunner.query(
`CREATE INDEX \`IDX_${tablePrefix}8b6f3f9ae234f137d707b98f3bf43584\` ON \`${tablePrefix}execution_entity\` (\`status\`, \`workflowId\`)`,
);
logMigrationEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = getTablePrefix();
await queryRunner.query(`DROP TABLE "${tablePrefix}execution_metadata"`);
await queryRunner.query(
`CREATE INDEX \`IDX_${tablePrefix}06da892aaf92a48e7d3e400003\` ON \`${tablePrefix}execution_entity\` (\`workflowId\`, \`waitTill\`, \`id\`)`,
);
await queryRunner.query(
`CREATE INDEX \`IDX_${tablePrefix}78d62b89dc1433192b86dce18a\` ON \`${tablePrefix}execution_entity\` (\`workflowId\`, \`finished\`, \`id\`)`,
);
await queryRunner.query(
`CREATE INDEX \`IDX_${tablePrefix}1688846335d274033e15c846a4\` ON \`${tablePrefix}execution_entity\` (\`finished\`, \`id\`)`,
);
await queryRunner.query(
'CREATE INDEX `IDX_' +
tablePrefix +
'cefb067df2402f6aed0638a6c1` ON `' +
tablePrefix +
'execution_entity` (`stoppedAt`)',
);
await queryRunner.query(
`DROP INDEX \`IDX_${tablePrefix}8b6f3f9ae234f137d707b98f3bf43584\` ON \`${tablePrefix}execution_entity\``,
);
}
}

View file

@ -34,6 +34,7 @@ import { PurgeInvalidWorkflowConnections1675940580449 } from './1675940580449-Pu
import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToExecutions';
import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus';
import { UpdateRunningExecutionStatus1677236788851 } from './1677236788851-UpdateRunningExecutionStatus';
import { CreateExecutionMetadataTable1679416281779 } from './1679416281779-CreateExecutionMetadataTable';
export const mysqlMigrations = [
InitialMigration1588157391238,
@ -72,4 +73,5 @@ export const mysqlMigrations = [
AddStatusToExecutions1674138566000,
MigrateExecutionStatus1676996103000,
UpdateRunningExecutionStatus1677236788851,
CreateExecutionMetadataTable1679416281779,
];

View file

@ -0,0 +1,62 @@
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
export class CreateExecutionMetadataTable1679416281778 implements MigrationInterface {
name = 'CreateExecutionMetadataTable1679416281778';
public async up(queryRunner: QueryRunner): Promise<void> {
logMigrationStart(this.name);
const tablePrefix = getTablePrefix();
await queryRunner.query(
`CREATE TABLE ${tablePrefix}execution_metadata (
"id" serial4 NOT NULL PRIMARY KEY,
"executionId" int4 NOT NULL,
"key" text NOT NULL,
"value" text NOT NULL,
CONSTRAINT ${tablePrefix}execution_metadata_fk FOREIGN KEY ("executionId") REFERENCES ${tablePrefix}execution_entity(id) ON DELETE CASCADE
)`,
);
await queryRunner.query(
`CREATE INDEX "IDX_${tablePrefix}6d44376da6c1058b5e81ed8a154e1fee106046eb" ON "${tablePrefix}execution_metadata" ("executionId");`,
);
// Remove indices that are no longer needed since the addition of the status column
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_${tablePrefix}33228da131bb1112247cf52a42"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_${tablePrefix}72ffaaab9f04c2c1f1ea86e662"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_${tablePrefix}58154df94c686818c99fb754ce"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_${tablePrefix}4f474ac92be81610439aaad61e"`);
// Create new index for status
await queryRunner.query(
`CREATE INDEX "IDX_${tablePrefix}8b6f3f9ae234f137d707b98f3bf43584" ON "${tablePrefix}execution_entity" ("status", "workflowId");`,
);
logMigrationEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = getTablePrefix();
// Re-add removed indices
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}33228da131bb1112247cf52a42" ON ${tablePrefix}execution_entity ("stoppedAt") `,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}72ffaaab9f04c2c1f1ea86e662" ON ${tablePrefix}execution_entity ("finished", "id") `,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}58154df94c686818c99fb754ce" ON ${tablePrefix}execution_entity ("workflowId", "waitTill", "id") `,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}4f474ac92be81610439aaad61e" ON ${tablePrefix}execution_entity ("workflowId", "finished", "id") `,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "IDX_${tablePrefix}8b6f3f9ae234f137d707b98f3bf43584"`,
);
await queryRunner.query(`DROP TABLE "${tablePrefix}execution_metadata"`);
}
}

View file

@ -32,6 +32,7 @@ import { PurgeInvalidWorkflowConnections1675940580449 } from './1675940580449-Pu
import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToExecutions';
import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus';
import { UpdateRunningExecutionStatus1677236854063 } from './1677236854063-UpdateRunningExecutionStatus';
import { CreateExecutionMetadataTable1679416281778 } from './1679416281778-CreateExecutionMetadataTable';
export const postgresMigrations = [
InitialMigration1587669153312,
@ -68,4 +69,5 @@ export const postgresMigrations = [
AddStatusToExecutions1674138566000,
MigrateExecutionStatus1676996103000,
UpdateRunningExecutionStatus1677236854063,
CreateExecutionMetadataTable1679416281778,
];

View file

@ -0,0 +1,64 @@
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
export class CreateExecutionMetadataTable1679416281777 implements MigrationInterface {
name = 'CreateExecutionMetadataTable1679416281777';
public async up(queryRunner: QueryRunner): Promise<void> {
logMigrationStart(this.name);
const tablePrefix = getTablePrefix();
await queryRunner.query(
`CREATE TABLE "${tablePrefix}execution_metadata" (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
executionId INTEGER NOT NULL,
"key" TEXT NOT NULL,
value TEXT NOT NULL,
CONSTRAINT ${tablePrefix}execution_metadata_entity_FK FOREIGN KEY (executionId) REFERENCES ${tablePrefix}execution_entity(id) ON DELETE CASCADE
)`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}6d44376da6c1058b5e81ed8a154e1fee106046eb" ON "${tablePrefix}execution_metadata" ("executionId");`,
);
// Re add some lost indices from migration DeleteExecutionsWithWorkflows.ts
// that were part of AddExecutionEntityIndexes.ts
// not all were needed since we added the `status` column to execution_entity
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}b94b45ce2c73ce46c54f20b5f9' ON '${tablePrefix}execution_entity' ('waitTill', 'id') `,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}81fc04c8a17de15835713505e4' ON '${tablePrefix}execution_entity' ('workflowId', 'id') `,
);
// Also add index to the new status column
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}8b6f3f9ae234f137d707b98f3bf43584' ON '${tablePrefix}execution_entity' ('status', 'workflowId') `,
);
// Remove no longer needed index to waitTill since it's already covered by the index b94b45ce2c73ce46c54f20b5f9 above
await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}ca4a71b47f28ac6ea88293a8e2'`);
// Remove index for stoppedAt since it's not used anymore
await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}cefb067df2402f6aed0638a6c1'`);
logMigrationEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = getTablePrefix();
await queryRunner.query(`DROP TABLE "${tablePrefix}execution_metadata"`);
await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}b94b45ce2c73ce46c54f20b5f9'`);
await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}81fc04c8a17de15835713505e4'`);
await queryRunner.query(
`DROP INDEX IF EXISTS 'IDX_${tablePrefix}8b6f3f9ae234f137d707b98f3bf43584'`,
);
await queryRunner.query(
`CREATE INDEX "IDX_${tablePrefix}ca4a71b47f28ac6ea88293a8e2" ON "${tablePrefix}execution_entity" ("waitTill")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_${tablePrefix}cefb067df2402f6aed0638a6c1" ON "${tablePrefix}execution_entity" ("stoppedAt")`,
);
}
}

View file

@ -31,6 +31,7 @@ import { PurgeInvalidWorkflowConnections1675940580449 } from './1675940580449-Pu
import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToExecutions';
import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus';
import { UpdateRunningExecutionStatus1677237073720 } from './1677237073720-UpdateRunningExecutionStatus';
import { CreateExecutionMetadataTable1679416281777 } from './1679416281777-CreateExecutionMetadataTable';
const sqliteMigrations = [
InitialMigration1588102412422,
@ -66,6 +67,7 @@ const sqliteMigrations = [
AddStatusToExecutions1674138566000,
MigrateExecutionStatus1676996103000,
UpdateRunningExecutionStatus1677237073720,
CreateExecutionMetadataTable1679416281777,
];
export { sqliteMigrations };

View file

@ -0,0 +1,11 @@
import { CONTROLLER_MIDDLEWARES } from './constants';
import type { MiddlewareMetadata } from './types';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const Middleware = (): MethodDecorator => (target, handlerName) => {
const controllerClass = target.constructor;
const middlewares = (Reflect.getMetadata(CONTROLLER_MIDDLEWARES, controllerClass) ??
[]) as MiddlewareMetadata[];
middlewares.push({ handlerName: String(handlerName) });
Reflect.defineMetadata(CONTROLLER_MIDDLEWARES, middlewares, controllerClass);
};

View file

@ -1,19 +1,30 @@
import type { RequestHandler } from 'express';
import { CONTROLLER_ROUTES } from './constants';
import type { Method, RouteMetadata } from './types';
interface RouteOptions {
middlewares?: RequestHandler[];
}
/* eslint-disable @typescript-eslint/naming-convention */
const RouteFactory =
(method: Method) =>
(path: `/${string}`): MethodDecorator =>
(path: `/${string}`, options: RouteOptions = {}): MethodDecorator =>
(target, handlerName) => {
const controllerClass = target.constructor;
const routes = (Reflect.getMetadata(CONTROLLER_ROUTES, controllerClass) ??
[]) as RouteMetadata[];
routes.push({ method, path, handlerName: String(handlerName) });
routes.push({
method,
path,
middlewares: options.middlewares ?? [],
handlerName: String(handlerName),
});
Reflect.defineMetadata(CONTROLLER_ROUTES, routes, controllerClass);
};
export const Get = RouteFactory('get');
export const Post = RouteFactory('post');
export const Put = RouteFactory('put');
export const Patch = RouteFactory('patch');
export const Delete = RouteFactory('delete');

View file

@ -1,2 +1,3 @@
export const CONTROLLER_ROUTES = 'CONTROLLER_ROUTES';
export const CONTROLLER_BASE_PATH = 'CONTROLLER_BASE_PATH';
export const CONTROLLER_MIDDLEWARES = 'CONTROLLER_MIDDLEWARES';

Some files were not shown because too many files have changed in this diff Show more