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 }; 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 // Keep the monorepo version up to date with the released version
packageMap['monorepo-root'].version = packageMap['n8n'].version; packageMap['monorepo-root'].version = packageMap['n8n'].version;

View file

@ -35,6 +35,11 @@ jobs:
- name: Test - name: Test
run: pnpm 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 - name: Lint
env: env:
CI_LINT_MASTER: true CI_LINT_MASTER: true

View file

@ -67,6 +67,11 @@ jobs:
- name: Test - name: Test
run: pnpm 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: lint:
name: Lint changes name: Lint changes
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -108,7 +113,7 @@ jobs:
uses: ./.github/workflows/e2e-reusable.yml uses: ./.github/workflows/e2e-reusable.yml
if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-e2e') }} if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-e2e') }}
with: with:
branch: ${{ github.event.pull_request.base.ref }} branch: ${{ github.event.pull_request.head.ref }}
user: ${{ github.event.inputs.user || 'PR User' }} user: ${{ github.event.inputs.user || 'PR User' }}
spec: ${{ github.event.inputs.spec || 'e2e/0-smoke.cy.ts' }} spec: ${{ github.event.inputs.spec || 'e2e/0-smoke.cy.ts' }}
run-env: base:16.18.1 run-env: base:16.18.1
@ -117,3 +122,15 @@ jobs:
containers: '[1]' containers: '[1]'
secrets: secrets:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} 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
tmp tmp
dist dist
coverage
npm-debug.log* npm-debug.log*
yarn.lock yarn.lock
google-generated-credentials.json 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) # [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, defaultCommandTimeout: 10000,
requestTimeout: 12000, requestTimeout: 12000,
numTestsKeptInMemory: 0,
experimentalMemoryManagement: true,
e2e: { e2e: {
baseUrl: BASE_URL, baseUrl: BASE_URL,
video: true, 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 { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
import { v4 as uuid } from 'uuid'; 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 WorkflowsPage = new WorkflowsPageClass();
const WorkflowPage = new WorkflowPageClass(); const WorkflowPage = new WorkflowPageClass();
@ -16,23 +10,17 @@ const multipleWorkflowsCount = 5;
describe('Workflows', () => { describe('Workflows', () => {
before(() => { before(() => {
cy.resetAll(); cy.resetAll();
cy.setup({ email, firstName, lastName, password }); cy.skipSetup();
}); });
beforeEach(() => { beforeEach(() => {
cy.on('uncaught:exception', (err, runnable) => { cy.visit(WorkflowsPage.url);
expect(err.message).to.include('Not logged in');
return false;
}); });
cy.signin({ email, password }); it('should create a new workflow using empty state card', () => {
cy.visit('/'); WorkflowsPage.getters.newWorkflowButtonCard().should('be.visible');
cy.waitForLoad(); 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()}`); cy.createFixtureWorkflow('Test_workflow_1.json', `Empty State Card Workflow ${uuid()}`);
WorkflowPage.getters.workflowTags().should('contain.text', 'some-tag-1'); WorkflowPage.getters.workflowTags().should('contain.text', 'some-tag-1');
@ -89,13 +77,5 @@ describe('Workflows', () => {
}); });
WorkflowsPage.getters.newWorkflowButtonCard().should('be.visible'); 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 ZOOM_OUT_X2_FACTOR = 0.64;
const RENAME_NODE_NAME = 'Something else'; const RENAME_NODE_NAME = 'Something else';
describe('Canvas Actions', () => { describe('Canvas Node Manipulation and Navigation', () => {
before(() => { before(() => {
cy.resetAll(); cy.resetAll();
cy.skipSetup(); cy.skipSetup();
@ -30,56 +30,6 @@ describe('Canvas Actions', () => {
WorkflowPage.actions.visit(); 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', () => { it('should add switch node and test connections', () => {
WorkflowPage.actions.addNodeToCanvas(SWITCH_NODE_NAME, true); 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); cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 4);
WorkflowPage.actions.executeWorkflow(); 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 // 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'); cy.get('[data-label="2 items"]').should('be.visible');
}); });
@ -167,41 +119,6 @@ describe('Canvas Actions', () => {
cy.get('.jtk-connector').should('have.length', 4); 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', () => { it('should delete node using node action button', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
@ -319,50 +236,6 @@ describe('Canvas Actions', () => {
WorkflowPage.getters.canvasNodes().last().should('be.visible'); 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', () => { it('should disable node by pressing the disable button', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); 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.canvasNodes().should('have.length', 3);
WorkflowPage.getters.nodeConnections().should('have.length', 1); 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(() => { beforeEach(() => {
wf.actions.visit(); wf.actions.visit();
cy.waitForLoad();
cy.window() cy.window()
// @ts-ignore // @ts-ignore

View file

@ -16,6 +16,9 @@ import {
} from '../constants'; } from '../constants';
import { randFirstName, randLastName } from '@ngneat/falso'; import { randFirstName, randLastName } from '@ngneat/falso';
import { CredentialsPage, CredentialsModal, WorkflowPage, NDV } from '../pages'; 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 email = DEFAULT_USER_EMAIL;
const password = DEFAULT_USER_PASSWORD; const password = DEFAULT_USER_PASSWORD;
@ -31,25 +34,10 @@ const NEW_CREDENTIAL_NAME = 'Something else';
describe('Credentials', () => { describe('Credentials', () => {
before(() => { before(() => {
cy.resetAll(); cy.resetAll();
cy.setup({ email, firstName, lastName, password }); cy.skipSetup();
// 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' },
}
});
}); });
beforeEach(() => { 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); cy.visit(credentialsPage.url);
}); });
@ -250,24 +238,4 @@ describe('Credentials', () => {
credentialsModal.actions.fillCredentialsForm(); credentialsModal.actions.fillCredentialsForm();
workflowPage.getters.nodeCredentialsSelect().should('contain', NEW_QUERY_AUTH_ACCOUNT_NAME); 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 WorkflowPage = new WorkflowPageClass();
const NDVModal = new NDV(); const NDVModal = new NDV();
describe('Node Creator', () => { describe('Node Creator', () => {
before(() => { before(() => {
cy.resetAll(); cy.resetAll();
@ -104,44 +105,75 @@ describe('Node Creator', () => {
NDVModal.getters.parameterInput('operation').should('contain.text', 'Rename'); NDVModal.getters.parameterInput('operation').should('contain.text', 'Rename');
}) })
it('should render and select community node', () => { it('should not show actions for single action nodes', () => {
const customNode = 'E2E Node'; const singleActionNodes = [
'DHL',
'iCalendar',
'LingvaNex',
'Mailcheck',
'MSG91',
'OpenThesaurus',
'Spontit',
'Vonage',
'Send Email',
'Toggl Trigger'
]
const doubleActionNode = 'OpenWeatherMap'
nodeCreatorFeature.actions.openNodeCreator(); 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 describe('should correctly append manual trigger for regular actions', () => {
.getCreatorItem(customNode) // For these sources, manual node should be added
.findChildByTestId('node-creator-item-tooltip') const sourcesWithAppend = [
.should('exist'); {
nodeCreatorFeature.actions.selectNode(customNode); name: 'canvas add button',
handler: () => nodeCreatorFeature.getters.canvasAddButton().click(),
// TODO: Replace once we have canvas feature utils }, {
cy.get('.data-display .node-name').contains(customNode).should('exist'); name: 'plus button',
handler: () => nodeCreatorFeature.getters.plusButton().click(),
const nodeParameters = () => cy.getByTestId('node-parameters'); },
const firstParameter = () => nodeParameters().find('.parameter-item').eq(0); // We can't test this one because it's not possible to trigger tab key in Cypress
const secondParameter = () => nodeParameters().find('.parameter-item').eq(1); // only way is to use `realPress` which is hanging the tests in Electron for some reason
// {
// Check correct fields are rendered // name: 'tab key',
nodeParameters().should('exist'); // handler: () => cy.realPress('Tab'),
// Test property text input // },
firstParameter().contains('Test property').should('exist'); ]
firstParameter().find('input.el-input__inner').should('have.value', 'Some default'); sourcesWithAppend.forEach((source) => {
// Resource select input it(`should append manual trigger when source is ${source.name}`, () => {
secondParameter().find('label').contains('Resource').should('exist'); source.handler()
secondParameter().find('input.el-input__inner').should('have.value', 'option2'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n');
secondParameter().find('.el-select').click(); nodeCreatorFeature.getters.getCreatorItem('n8n').click();
secondParameter().find('.el-select-dropdown__list').should('exist'); nodeCreatorFeature.getters.getCreatorItem('Create a credential').click();
// Check if all options are rendered and select the fourth one NDVModal.actions.close();
secondParameter().find('.el-select-dropdown__list').children().should('have.length', 4); WorkflowPage.getters.canvasNodes().should('have.length', 2);
secondParameter() });
.find('.el-select-dropdown__list') });
.children()
.eq(3) it('should not append manual trigger when source is canvas related', () => {
.contains('option4') nodeCreatorFeature.getters.canvasAddButton().click();
.should('exist') nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n');
.click(); nodeCreatorFeature.getters.getCreatorItem('n8n').click();
secondParameter().find('input.el-input__inner').should('have.value', 'option4'); 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); 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'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
const NEW_WORKFLOW_NAME = 'Something else'; 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_NAME = 'Duplicated workflow';
const DUPLICATE_WORKFLOW_TAG = 'Duplicate'; 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').should('be.visible');
cy.get('.el-message-box').find('input').type(IMPORT_WORKFLOW_URL); cy.get('.el-message-box').find('input').type(IMPORT_WORKFLOW_URL);
cy.get('body').type('{enter}'); cy.get('body').type('{enter}');
cy.waitForLoad(); cy.waitForLoad(false)
WorkflowPage.actions.zoomToFit(); WorkflowPage.actions.zoomToFit();
WorkflowPage.getters.canvasNodes().should('have.length', 2); WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 1); WorkflowPage.getters.nodeConnections().should('have.length', 1);
@ -104,7 +104,7 @@ describe('Workflow Actions', () => {
WorkflowPage.getters WorkflowPage.getters
.workflowImportInput() .workflowImportInput()
.selectFile('cypress/fixtures/Test_workflow-actions_paste-data.json', { force: true }); .selectFile('cypress/fixtures/Test_workflow-actions_paste-data.json', { force: true });
cy.waitForLoad(); cy.waitForLoad(false)
WorkflowPage.actions.zoomToFit(); WorkflowPage.actions.zoomToFit();
WorkflowPage.getters.canvasNodes().should('have.length', 2); WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 1); 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 = { actions = {
openNodeCreator: () => { openNodeCreator: () => {
cy.waitForLoad();
this.getters.plusButton().click(); this.getters.plusButton().click();
this.getters.nodeCreator().should('be.visible'); this.getters.nodeCreator().should('be.visible');
}, },

View file

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

View file

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

View file

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

View file

@ -26,7 +26,6 @@
import 'cypress-real-events'; import 'cypress-real-events';
import { WorkflowsPage, SigninPage, SignupPage, SettingsUsersPage, WorkflowPage } from '../pages'; import { WorkflowsPage, SigninPage, SignupPage, SettingsUsersPage, WorkflowPage } from '../pages';
import { N8N_AUTH_COOKIE } from '../constants'; import { N8N_AUTH_COOKIE } from '../constants';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
import { MessageBox } from '../pages/modals/message-box'; import { MessageBox } from '../pages/modals/message-box';
Cypress.Commands.add('getByTestId', (selector, ...args) => { Cypress.Commands.add('getByTestId', (selector, ...args) => {
@ -34,15 +33,17 @@ Cypress.Commands.add('getByTestId', (selector, ...args) => {
}); });
Cypress.Commands.add('createFixtureWorkflow', (fixtureKey, workflowName) => { 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 // We need to force the click because the input is hidden
WorkflowPage.getters workflowPage.getters
.workflowImportInput() .workflowImportInput()
.selectFile(`cypress/fixtures/${fixtureKey}`, { force: true }); .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( 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.getByTestId('node-view-loader', { timeout: 20000 }).should('not.exist');
cy.get('.el-loading-mask', { timeout: 20000 }).should('not.exist'); cy.get('.el-loading-mask', { timeout: 20000 }).should('not.exist');
}); });
Cypress.Commands.add('signin', ({ email, password }) => { Cypress.Commands.add('signin', ({ email, password }) => {
const signinPage = new SigninPage(); const signinPage = new SigninPage();
const workflowPage = new WorkflowPage(); const workflowsPage = new WorkflowsPage();
cy.session( cy.session(
[email, password], [email, password],
@ -74,10 +81,7 @@ Cypress.Commands.add('signin', ({ email, password }) => {
}); });
// we should be redirected to /workflows // we should be redirected to /workflows
cy.visit(workflowPage.url); cy.url().should('include', workflowsPage.url);
cy.url().should('include', workflowPage.url);
cy.intercept('GET', '/rest/workflows/new').as('loading');
cy.wait('@loading');
}, },
{ {
validate() { 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) => { Cypress.Commands.add('paste', { prevSubject: true }, (selector, pastePayload) => {
// https://developer.mozilla.org/en-US/docs/Web/API/Element/paste_event // https://developer.mozilla.org/en-US/docs/Web/API/Element/paste_event

View file

@ -14,28 +14,17 @@
// *********************************************************** // ***********************************************************
import './commands'; 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 // Load custom nodes and credentials fixtures
beforeEach(() => { beforeEach(() => {
cy.intercept('GET', '/types/nodes.json', (req) => { cy.intercept('GET', '/rest/settings').as('loadSettings');
req.on('response', (res) => { cy.intercept('GET', '/rest/login').as('loadLogin');
const nodes = res.body || [];
res.headers['cache-control'] = 'no-cache, no-store'; // Always intercept the request to test credentials and return a success
nodes.push(CustomNodeFixture, CustomNodeWithN8nCredentialFixture, CustomNodeWithCustomCredentialFixture); 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; skipSetup(): void;
resetAll(): void; resetAll(): void;
enableFeature(feature: string): void; enableFeature(feature: string): void;
waitForLoad(): void; waitForLoad(waitForIntercepts?: boolean): void;
grantBrowserPermissions(...permissions: string[]): void; grantBrowserPermissions(...permissions: string[]): void;
readClipboard(): Chainable<string>; readClipboard(): Chainable<string>;
paste(pastePayload: string): void; paste(pastePayload: string): void;

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,7 @@ ARG NODE_VERSION=16
# 1. Create an image to build n8n # 1. Create an image to build n8n
FROM n8nio/base:${NODE_VERSION} as builder 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 scripts ./scripts
COPY packages ./packages COPY packages ./packages
COPY patches ./patches 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 ## Start n8n in Docker
``` ```bash
docker run -it --rm \ docker run -it --rm \
--name n8n \ --name n8n \
-p 5678:5678 \ -p 5678:5678 \
-v ~/.n8n:/home/node/.n8n \ -v ~/.n8n:/home/node/.n8n \
n8nio/n8n docker.n8n.io/n8nio/n8n
``` ```
You can then access n8n by opening: You can then access n8n by opening:
@ -62,12 +62,12 @@ n8n instance.
To use it simply start n8n with `--tunnel` To use it simply start n8n with `--tunnel`
``` ```bash
docker run -it --rm \ docker run -it --rm \
--name n8n \ --name n8n \
-p 5678:5678 \ -p 5678:5678 \
-v ~/.n8n:/home/node/.n8n \ -v ~/.n8n:/home/node/.n8n \
n8nio/n8n \ docker.n8n.io/n8nio/n8n \
n8n start --tunnel 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 Right now we have very basic protection via basic-auth in place. It can be activated
by setting the following environment variables: by setting the following environment variables:
``` ```text
N8N_BASIC_AUTH_ACTIVE=true N8N_BASIC_AUTH_ACTIVE=true
N8N_BASIC_AUTH_USER=<USER> N8N_BASIC_AUTH_USER=<USER>
N8N_BASIC_AUTH_PASSWORD=<PASSWORD> 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 folder (`/home/node/.n8n`). That folder also additionally contains the
settings like webhook URL and encryption key. settings like webhook URL and encryption key.
``` ```bash
docker run -it --rm \ docker run -it --rm \
--name n8n \ --name n8n \
-p 5678:5678 \ -p 5678:5678 \
-v ~/.n8n:/home/node/.n8n \ -v ~/.n8n:/home/node/.n8n \
n8nio/n8n docker.n8n.io/n8nio/n8n
``` ```
### Start with other Database ### Start with other Database
@ -123,7 +123,7 @@ Replace the following placeholders with the actual data:
- POSTGRES_USER - POSTGRES_USER
- POSTGRES_SCHEMA - POSTGRES_SCHEMA
``` ```bash
docker run -it --rm \ docker run -it --rm \
--name n8n \ --name n8n \
-p 5678:5678 \ -p 5678:5678 \
@ -135,7 +135,7 @@ docker run -it --rm \
-e DB_POSTGRESDB_SCHEMA=<POSTGRES_SCHEMA> \ -e DB_POSTGRESDB_SCHEMA=<POSTGRES_SCHEMA> \
-e DB_POSTGRESDB_PASSWORD=<POSTGRES_PASSWORD> \ -e DB_POSTGRESDB_PASSWORD=<POSTGRES_PASSWORD> \
-v ~/.n8n:/home/node/.n8n \ -v ~/.n8n:/home/node/.n8n \
n8nio/n8n \ docker.n8n.io/n8nio/n8n \
n8n start n8n start
``` ```
@ -151,7 +151,7 @@ Replace the following placeholders with the actual data:
- MYSQLDB_PORT - MYSQLDB_PORT
- MYSQLDB_USER - MYSQLDB_USER
``` ```bash
docker run -it --rm \ docker run -it --rm \
--name n8n \ --name n8n \
-p 5678:5678 \ -p 5678:5678 \
@ -162,7 +162,7 @@ docker run -it --rm \
-e DB_MYSQLDB_USER=<MYSQLDB_USER> \ -e DB_MYSQLDB_USER=<MYSQLDB_USER> \
-e DB_MYSQLDB_PASSWORD=<MYSQLDB_PASSWORD> \ -e DB_MYSQLDB_PASSWORD=<MYSQLDB_PASSWORD> \
-v ~/.n8n:/home/node/.n8n \ -v ~/.n8n:/home/node/.n8n \
n8nio/n8n \ docker.n8n.io/n8nio/n8n \
n8n start 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 ## Updating a running docker-compose instance
``` 1. Pull the latest version from the registry
# Pull down the latest version from dockerhub
docker pull n8nio/n8n `docker pull docker.n8n.io/n8nio/n8n`
# Stop current setup
sudo docker-compose stop 2. Stop the current setup
# Delete it (will only delete the docker-containers, data is stored separately)
sudo docker-compose rm `sudo docker-compose stop`
# Then start it again
sudo docker-compose up -d 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 ## Setting Timezone
@ -212,22 +217,22 @@ the environment variable `TZ`.
Example to use the same timezone for both: Example to use the same timezone for both:
``` ```bash
docker run -it --rm \ docker run -it --rm \
--name n8n \ --name n8n \
-p 5678:5678 \ -p 5678:5678 \
-e GENERIC_TIMEZONE="Europe/Berlin" \ -e GENERIC_TIMEZONE="Europe/Berlin" \
-e TZ="Europe/Berlin" \ -e TZ="Europe/Berlin" \
n8nio/n8n docker.n8n.io/n8nio/n8n
``` ```
## Build Docker-Image ## Build Docker-Image
``` ```bash
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --build-arg N8N_VERSION=<VERSION> -t n8nio/n8n:<VERSION> . docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --build-arg N8N_VERSION=<VERSION> -t n8n:<VERSION> .
# For example: # 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? ## What does n8n mean and how do you pronounce it?

View file

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

View file

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

View file

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

View file

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

View file

@ -302,7 +302,7 @@ export abstract class AbstractServer {
// ---------------------------------------- // ----------------------------------------
protected setupWaitingWebhookEndpoint() { protected setupWaitingWebhookEndpoint() {
const endpoint = this.endpointWebhookWaiting; const endpoint = this.endpointWebhookWaiting;
const waitingWebhooks = new WaitingWebhooks(); const waitingWebhooks = Container.get(WaitingWebhooks);
// Register all webhook-waiting requests // Register all webhook-waiting requests
this.app.all(`/${endpoint}/*`, async (req, res) => { 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 @typescript-eslint/no-unnecessary-type-assertion */
/* eslint-disable no-param-reassign */
/* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
@ -25,7 +22,7 @@ import type {
IWorkflowExecutionDataProcess, IWorkflowExecutionDataProcess,
} from '@/Interfaces'; } from '@/Interfaces';
import * as ResponseHelper from '@/ResponseHelper'; import * as ResponseHelper from '@/ResponseHelper';
import * as WorkflowHelpers from '@/WorkflowHelpers'; import { isWorkflowIdValid } from '@/utils';
import { Service } from 'typedi'; import { Service } from 'typedi';
@Service() @Service()
@ -60,7 +57,7 @@ export class ActiveExecutions {
} }
const workflowId = executionData.workflowData.id; const workflowId = executionData.workflowData.id;
if (workflowId !== undefined && WorkflowHelpers.isWorkflowIdValid(workflowId)) { if (workflowId !== undefined && isWorkflowIdValid(workflowId)) {
fullExecutionData.workflowId = workflowId; fullExecutionData.workflowId = workflowId;
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -284,9 +284,19 @@ export class InternalHooks implements IInternalHooksClass {
properties.user_id = userId; 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) { if (runData !== undefined) {
properties.execution_mode = runData.mode; properties.execution_mode = runData.mode;
properties.success = !!runData.finished;
properties.is_manual = runData.mode === 'manual'; properties.is_manual = runData.mode === 'manual';
let nodeGraphResult: INodesGraphResult | null = null; let nodeGraphResult: INodesGraphResult | null = null;
@ -342,7 +352,7 @@ export class InternalHooks implements IInternalHooksClass {
const manualExecEventProperties: ITelemetryTrackProperties = { const manualExecEventProperties: ITelemetryTrackProperties = {
user_id: userId, user_id: userId,
workflow_id: workflow.id, workflow_id: workflow.id,
status: properties.success ? 'success' : 'failed', status: executionStatus,
executionStatus: runData?.status ?? 'unknown', executionStatus: runData?.status ?? 'unknown',
error_message: properties.error_message as string, error_message: properties.error_message as string,
error_node_type: properties.error_node_type, 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( promises.push(
Db.collections.Execution.update(executionId, { Db.collections.Execution.update(executionId, {
status: executionStatus, status: executionStatus,

View file

@ -2,8 +2,6 @@ import type { LdapConfig } from './types';
export const LDAP_FEATURE_NAME = 'features.ldap'; 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_LABEL = 'sso.ldap.loginLabel';
export const LDAP_LOGIN_ENABLED = 'sso.ldap.loginEnabled'; export const LDAP_LOGIN_ENABLED = 'sso.ldap.loginEnabled';

View file

@ -2,6 +2,7 @@
import { AES, enc } from 'crypto-js'; import { AES, enc } from 'crypto-js';
import type { Entry as LdapUser } from 'ldapts'; import type { Entry as LdapUser } from 'ldapts';
import { Filter } from 'ldapts/filters/Filter'; import { Filter } from 'ldapts/filters/Filter';
import { Container } from 'typedi';
import { UserSettings } from 'n8n-core'; import { UserSettings } from 'n8n-core';
import { validate } from 'jsonschema'; import { validate } from 'jsonschema';
import * as Db from '@/Db'; import * as Db from '@/Db';
@ -16,23 +17,26 @@ import { LdapManager } from './LdapManager.ee';
import { import {
BINARY_AD_ATTRIBUTES, BINARY_AD_ATTRIBUTES,
LDAP_CONFIG_SCHEMA, LDAP_CONFIG_SCHEMA,
LDAP_ENABLED,
LDAP_FEATURE_NAME, LDAP_FEATURE_NAME,
LDAP_LOGIN_ENABLED, LDAP_LOGIN_ENABLED,
LDAP_LOGIN_LABEL, LDAP_LOGIN_LABEL,
} from './constants'; } from './constants';
import type { ConnectionSecurity, LdapConfig } from './types'; import type { ConnectionSecurity, LdapConfig } from './types';
import { jsonParse, LoggerProxy as Logger } from 'n8n-workflow'; import { jsonParse, LoggerProxy as Logger } from 'n8n-workflow';
import { getLicense } from '@/License'; import { License } from '@/License';
import { Container } from 'typedi';
import { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
import {
isEmailCurrentAuthenticationMethod,
isLdapCurrentAuthenticationMethod,
setCurrentAuthenticationMethod,
} from '@/sso/ssoHelpers';
/** /**
* Check whether the LDAP feature is disabled in the instance * Check whether the LDAP feature is disabled in the instance
*/ */
export const isLdapEnabled = (): boolean => { export const isLdapEnabled = (): boolean => {
const license = getLicense(); const license = Container.get(License);
return isUserManagementEnabled() && (config.getEnv(LDAP_ENABLED) || license.isLdapEnabled()); return isUserManagementEnabled() && license.isLdapEnabled();
}; };
/** /**
@ -50,8 +54,24 @@ export const setLdapLoginLabel = (value: string): void => {
/** /**
* Set the LDAP login enabled to the configuration object * Set the LDAP login enabled to the configuration object
*/ */
export const setLdapLoginEnabled = (value: boolean): void => { export const setLdapLoginEnabled = async (value: boolean): Promise<void> => {
config.set(LDAP_LOGIN_ENABLED, value); 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 * Take the LDAP configuration and set login enabled and login label to the config object
*/ */
export const setGlobalLdapConfigVariables = (ldapConfig: LdapConfig): void => { export const setGlobalLdapConfigVariables = async (ldapConfig: LdapConfig): Promise<void> => {
setLdapLoginEnabled(ldapConfig.loginEnabled); await setLdapLoginEnabled(ldapConfig.loginEnabled);
setLdapLoginLabel(ldapConfig.loginLabel); setLdapLoginLabel(ldapConfig.loginLabel);
}; };
@ -175,7 +195,7 @@ export const updateLdapConfig = async (ldapConfig: LdapConfig): Promise<void> =>
{ key: LDAP_FEATURE_NAME }, { key: LDAP_FEATURE_NAME },
{ value: JSON.stringify(ldapConfig), loadOnStartup: true }, { 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(); const ldapConfig = await getLdapConfig();
setGlobalLdapConfigVariables(ldapConfig); await setGlobalLdapConfigVariables(ldapConfig);
// init LDAP manager with the current // init LDAP manager with the current
// configuration // 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 config from '@/config';
import * as Db from '@/Db'; import * as Db from '@/Db';
import { LICENSE_FEATURES, N8N_VERSION, SETTINGS_LICENSE_CERT_KEY } from './constants'; import { LICENSE_FEATURES, N8N_VERSION, SETTINGS_LICENSE_CERT_KEY } from './constants';
import { Service } from 'typedi';
async function loadCertStr(): Promise<TLicenseContainerStr> { 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({ const databaseSettings = await Db.collections.Settings.findOne({
where: { where: {
key: SETTINGS_LICENSE_CERT_KEY, key: SETTINGS_LICENSE_CERT_KEY,
@ -17,6 +23,8 @@ async function loadCertStr(): Promise<TLicenseContainerStr> {
} }
async function saveCertStr(value: TLicenseContainerStr): Promise<void> { 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( await Db.collections.Settings.upsert(
{ {
key: SETTINGS_LICENSE_CERT_KEY, key: SETTINGS_LICENSE_CERT_KEY,
@ -27,6 +35,7 @@ async function saveCertStr(value: TLicenseContainerStr): Promise<void> {
); );
} }
@Service()
export class License { export class License {
private logger: ILogger; private logger: ILogger;
@ -160,13 +169,3 @@ export class License {
return (this.getFeatureValue('planName') ?? 'Community') as string; 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 uniq from 'lodash.uniq';
import glob from 'fast-glob';
import type { DirectoryLoader, Types } from 'n8n-core'; import type { DirectoryLoader, Types } from 'n8n-core';
import { import {
CUSTOM_EXTENSION_ENV, CUSTOM_EXTENSION_ENV,
@ -18,18 +19,18 @@ import type {
import { LoggerProxy, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; import { LoggerProxy, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
import { createWriteStream } from 'fs'; 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 path from 'path';
import config from '@/config'; import config from '@/config';
import type { InstalledPackages } from '@db/entities/InstalledPackages'; import type { InstalledPackages } from '@db/entities/InstalledPackages';
import { executeCommand } from '@/CommunityNodes/helpers'; import { executeCommand } from '@/CommunityNodes/helpers';
import { import {
CLI_DIR,
GENERATED_STATIC_DIR, GENERATED_STATIC_DIR,
RESPONSE_ERROR_MESSAGES, RESPONSE_ERROR_MESSAGES,
CUSTOM_API_CALL_KEY, CUSTOM_API_CALL_KEY,
CUSTOM_API_CALL_NAME, CUSTOM_API_CALL_NAME,
inTest, inTest,
CLI_DIR,
} from '@/constants'; } from '@/constants';
import { CredentialsOverwrites } from '@/CredentialsOverwrites'; import { CredentialsOverwrites } from '@/CredentialsOverwrites';
import { Service } from 'typedi'; import { Service } from 'typedi';
@ -52,6 +53,8 @@ export class LoadNodesAndCredentials implements INodesAndCredentials {
logger: ILogger; logger: ILogger;
private downloadFolder: string;
async init() { async init() {
// Make sure the imported modules can resolve dependencies fine. // Make sure the imported modules can resolve dependencies fine.
const delimiter = process.platform === 'win32' ? ';' : ':'; const delimiter = process.platform === 'win32' ? ';' : ':';
@ -61,8 +64,13 @@ export class LoadNodesAndCredentials implements INodesAndCredentials {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call // eslint-disable-next-line @typescript-eslint/no-unsafe-call
if (!inTest) module.constructor._initPaths(); if (!inTest) module.constructor._initPaths();
await this.loadNodesFromBasePackages(); this.downloadFolder = UserSettings.getUserN8nFolderDownloadedNodesPath();
await this.loadNodesFromDownloadedPackages();
// 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.loadNodesFromCustomDirectories();
await this.postProcessLoaders(); await this.postProcessLoaders();
this.injectCustomApiCallOptions(); this.injectCustomApiCallOptions();
@ -109,32 +117,20 @@ export class LoadNodesAndCredentials implements INodesAndCredentials {
await writeStaticJSON('credentials', this.types.credentials); await writeStaticJSON('credentials', this.types.credentials);
} }
private async loadNodesFromBasePackages() { private async loadNodesFromNodeModules(scanDir: string): Promise<void> {
const nodeModulesPath = await this.getNodeModulesPath(); const nodeModulesDir = path.join(scanDir, 'node_modules');
const nodePackagePaths = await this.getN8nNodePackages(nodeModulesPath); 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) { for (const packagePath of installedPackagePaths) {
await this.runDirectoryLoader(LazyPackageDirectoryLoader, packagePath);
}
}
private async loadNodesFromDownloadedPackages(): Promise<void> {
const nodePackages = [];
try { try {
// Read downloaded nodes and credentials await this.runDirectoryLoader(
const downloadedNodesDir = UserSettings.getUserN8nFolderDownloadedNodesPath(); LazyPackageDirectoryLoader,
const downloadedNodesDirModules = path.join(downloadedNodesDir, 'node_modules'); path.join(nodeModulesDir, packagePath),
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);
} catch (error) { } catch (error) {
ErrorReporter.error(error); ErrorReporter.error(error);
} }
@ -158,49 +154,45 @@ export class LoadNodesAndCredentials implements INodesAndCredentials {
} }
} }
/** private async installOrUpdateNpmModule(
* Returns all the names of the packages which could contain n8n nodes packageName: string,
*/ options: { version?: string } | { installedPackage: InstalledPackages },
private async getN8nNodePackages(baseModulesPath: string): Promise<string[]> { ) {
const getN8nNodePackagesRecursive = async (relativePath: string): Promise<string[]> => { const isUpdate = 'installedPackage' in options;
const results: string[] = []; const command = isUpdate
const nodeModulesPath = `${baseModulesPath}/${relativePath}`; ? `npm update ${packageName}`
const nodeModules = await fsReaddir(nodeModulesPath); : `npm install ${packageName}${options.version ? `@${options.version}` : ''}`;
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}` : ''}`;
try {
await executeCommand(command); 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) { if (loader.loadedNodes.length > 0) {
// Save info to DB // Save info to DB
try { 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); const installedPackage = await persistInstalledPackageData(loader);
await this.postProcessLoaders(); await this.postProcessLoaders();
await this.generateTypesForFrontend(); 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> { async removeNpmModule(packageName: string, installedPackage: InstalledPackages): Promise<void> {
const command = `npm remove ${packageName}`; const command = `npm remove ${packageName}`;
@ -244,49 +240,7 @@ export class LoadNodesAndCredentials implements INodesAndCredentials {
packageName: string, packageName: string,
installedPackage: InstalledPackages, installedPackage: InstalledPackages,
): Promise<InstalledPackages> { ): Promise<InstalledPackages> {
const downloadFolder = UserSettings.getUserN8nFolderDownloadedNodesPath(); return this.installOrUpdateNpmModule(packageName, { installedPackage });
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);
}
} }
/** /**
@ -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 { 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'; import type { Risk } from '@/audit/types';
@ -26,10 +26,10 @@ export type AuthenticatedRequest<
> = express.Request<RouteParams, ResponseBody, RequestBody, RequestQuery> & { > = express.Request<RouteParams, ResponseBody, RequestBody, RequestQuery> & {
user: User; user: User;
globalMemberRole?: Role; 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 type express from 'express';
import { Container } from 'typedi'; import { Container } from 'typedi';
import type { FindManyOptions, FindOptionsWhere } from 'typeorm'; import type { FindOptionsWhere } from 'typeorm';
import { In } from 'typeorm'; import { In } from 'typeorm';
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
@ -20,12 +20,11 @@ import {
updateWorkflow, updateWorkflow,
hasStartNode, hasStartNode,
getStartNode, getStartNode,
getWorkflows,
getSharedWorkflows, getSharedWorkflows,
getWorkflowsCount,
createWorkflow, createWorkflow,
getWorkflowIdsViaTags, getWorkflowIdsViaTags,
parseTagNames, parseTagNames,
getWorkflowsAndCount,
} from './workflows.service'; } from './workflows.service';
import { WorkflowsService } from '@/workflows/workflows.services'; import { WorkflowsService } from '@/workflows/workflows.services';
import { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
@ -98,28 +97,15 @@ export = {
async (req: WorkflowRequest.GetAll, res: express.Response): Promise<express.Response> => { async (req: WorkflowRequest.GetAll, res: express.Response): Promise<express.Response> => {
const { offset = 0, limit = 100, active = undefined, tags = undefined } = req.query; const { offset = 0, limit = 100, active = undefined, tags = undefined } = req.query;
let workflows: WorkflowEntity[];
let count: number;
const where: FindOptionsWhere<WorkflowEntity> = { const where: FindOptionsWhere<WorkflowEntity> = {
...(active !== undefined && { active }), ...(active !== undefined && { active }),
}; };
const query: FindManyOptions<WorkflowEntity> = {
skip: offset,
take: limit,
where,
...(!config.getEnv('workflowTagsDisabled') && { relations: ['tags'] }),
};
if (isInstanceOwner(req.user)) { if (isInstanceOwner(req.user)) {
if (tags) { if (tags) {
const workflowIds = await getWorkflowIdsViaTags(parseTagNames(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 { } else {
const options: { workflowIds?: string[] } = {}; const options: { workflowIds?: string[] } = {};
@ -137,14 +123,16 @@ export = {
} }
const workflowsIds = sharedWorkflows.map((shareWorkflow) => shareWorkflow.workflowId); const workflowsIds = sharedWorkflows.map((shareWorkflow) => shareWorkflow.workflowId);
where.id = In(workflowsIds);
Object.assign(where, { id: In(workflowsIds) });
workflows = await getWorkflows(query);
count = await getWorkflowsCount(query);
} }
const [workflows, count] = await getWorkflowsAndCount({
skip: offset,
take: limit,
where,
...(!config.getEnv('workflowTagsDisabled') && { relations: ['tags'] }),
});
void Container.get(InternalHooks).onUserRetrievedAllWorkflows({ void Container.get(InternalHooks).onUserRetrievedAllWorkflows({
user_id: req.user.id, user_id: req.user.id,
public_api: true, public_api: true,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,9 +3,9 @@ import type { Transporter } from 'nodemailer';
import { createTransport } from 'nodemailer'; import { createTransport } from 'nodemailer';
import { ErrorReporterProxy as ErrorReporter, LoggerProxy as Logger } from 'n8n-workflow'; import { ErrorReporterProxy as ErrorReporter, LoggerProxy as Logger } from 'n8n-workflow';
import config from '@/config'; 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; private transport?: Transporter;
async init(): Promise<void> { async init(): Promise<void> {

View file

@ -2,13 +2,9 @@ import { existsSync } from 'fs';
import { readFile } from 'fs/promises'; import { readFile } from 'fs/promises';
import Handlebars from 'handlebars'; import Handlebars from 'handlebars';
import { join as pathJoin } from 'path'; import { join as pathJoin } from 'path';
import { Service } from 'typedi';
import config from '@/config'; import config from '@/config';
import type { import type { InviteEmailData, PasswordResetData, SendEmailResult } from './Interfaces';
InviteEmailData,
PasswordResetData,
SendEmailResult,
UserManagementMailerImplementation,
} from './Interfaces';
import { NodeMailer } from './NodeMailer'; import { NodeMailer } from './NodeMailer';
type Template = HandlebarsTemplateDelegate<unknown>; type Template = HandlebarsTemplateDelegate<unknown>;
@ -36,8 +32,9 @@ async function getTemplate(
return template; return template;
} }
@Service()
export class UserManagementMailer { export class UserManagementMailer {
private mailer: UserManagementMailerImplementation | undefined; private mailer: NodeMailer | undefined;
constructor() { constructor() {
// Other implementations can be used in the future. // Other implementations can be used in the future.
@ -81,12 +78,3 @@ export class UserManagementMailer {
return result ?? { emailSent: false }; 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, LoggerProxy as Logger,
WorkflowOperationError, WorkflowOperationError,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { Service } from 'typedi';
import type { FindManyOptions, ObjectLiteral } from 'typeorm'; import type { FindManyOptions, ObjectLiteral } from 'typeorm';
import { Not, LessThanOrEqual } from 'typeorm'; import { Not, LessThanOrEqual } from 'typeorm';
import { DateUtils } from 'typeorm/util/DateUtils'; import { DateUtils } from 'typeorm/util/DateUtils';
@ -17,7 +18,6 @@ import { DateUtils } from 'typeorm/util/DateUtils';
import config from '@/config'; import config from '@/config';
import * as Db from '@/Db'; import * as Db from '@/Db';
import * as ResponseHelper from '@/ResponseHelper'; import * as ResponseHelper from '@/ResponseHelper';
import { ActiveExecutions } from '@/ActiveExecutions';
import type { import type {
IExecutionFlattedDb, IExecutionFlattedDb,
IExecutionsStopData, IExecutionsStopData,
@ -25,12 +25,9 @@ import type {
} from '@/Interfaces'; } from '@/Interfaces';
import { WorkflowRunner } from '@/WorkflowRunner'; import { WorkflowRunner } from '@/WorkflowRunner';
import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper'; import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper';
import { Container, Service } from 'typedi';
@Service() @Service()
export class WaitTracker { export class WaitTracker {
activeExecutionsInstance: ActiveExecutions;
private waitingExecutions: { private waitingExecutions: {
[key: string]: { [key: string]: {
executionId: string; executionId: string;
@ -41,8 +38,6 @@ export class WaitTracker {
mainTimer: NodeJS.Timeout; mainTimer: NodeJS.Timeout;
constructor() { constructor() {
this.activeExecutionsInstance = Container.get(ActiveExecutions);
// Poll every 60 seconds a list of upcoming executions // Poll every 60 seconds a list of upcoming executions
this.mainTimer = setInterval(() => { this.mainTimer = setInterval(() => {
this.getWaitingExecutions(); this.getWaitingExecutions();

View file

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

View file

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

View file

@ -66,11 +66,12 @@ import * as ResponseHelper from '@/ResponseHelper';
import * as WebhookHelpers from '@/WebhookHelpers'; import * as WebhookHelpers from '@/WebhookHelpers';
import * as WorkflowHelpers from '@/WorkflowHelpers'; import * as WorkflowHelpers from '@/WorkflowHelpers';
import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper'; import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper';
import { findSubworkflowStart } from '@/utils'; import { findSubworkflowStart, isWorkflowIdValid } from '@/utils';
import { PermissionChecker } from './UserManagement/PermissionChecker'; import { PermissionChecker } from './UserManagement/PermissionChecker';
import { WorkflowsService } from './workflows/workflows.services'; import { WorkflowsService } from './workflows/workflows.services';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
import type { ExecutionMetadata } from './databases/entities/ExecutionMetadata';
const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType'); const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType');
@ -135,17 +136,11 @@ export function executeErrorWorkflow(
// Run the error workflow // 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. // 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 ( const { errorWorkflow } = workflowData.settings ?? {};
workflowData.settings?.errorWorkflow && if (errorWorkflow && !(mode === 'error' && workflowId && errorWorkflow === workflowId)) {
!(
mode === 'error' &&
workflowId &&
workflowData.settings.errorWorkflow.toString() === workflowId
)
) {
Logger.verbose('Start external error workflow', { Logger.verbose('Start external error workflow', {
executionId, executionId,
errorWorkflowId: workflowData.settings.errorWorkflow.toString(), errorWorkflowId: errorWorkflow,
workflowId, workflowId,
}); });
// If a specific error workflow is set run only that one // If a specific error workflow is set run only that one
@ -159,11 +154,7 @@ export function executeErrorWorkflow(
} }
getWorkflowOwner(workflowId) getWorkflowOwner(workflowId)
.then((user) => { .then((user) => {
void WorkflowHelpers.executeErrorWorkflow( void WorkflowHelpers.executeErrorWorkflow(errorWorkflow, workflowErrorData, user);
workflowData.settings!.errorWorkflow as string,
workflowErrorData,
user,
);
}) })
.catch((error: Error) => { .catch((error: Error) => {
ErrorReporter.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`, `Could not execute ErrorWorkflow for execution ID ${this.executionId} because of error querying the workflow owner`,
{ {
executionId, executionId,
errorWorkflowId: workflowData.settings!.errorWorkflow!.toString(), errorWorkflowId: errorWorkflow,
workflowId, workflowId,
error, error,
workflowErrorData, 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 * Returns hook functions to push data to Editor-UI
* *
@ -404,21 +411,21 @@ export function hookFunctionsPreExecute(parentProcessMode?: string): IWorkflowEx
], ],
nodeExecuteAfter: [ nodeExecuteAfter: [
async function ( async function (
this: WorkflowHooks,
nodeName: string, nodeName: string,
data: ITaskData, data: ITaskData,
executionData: IRunExecutionData, executionData: IRunExecutionData,
): Promise<void> { ): Promise<void> {
if (this.workflowData.settings !== undefined) { const saveExecutionProgress = config.getEnv('executions.saveExecutionProgress');
if (this.workflowData.settings.saveExecutionProgress === false) { const workflowSettings = this.workflowData.settings;
if (workflowSettings !== undefined) {
if (workflowSettings.saveExecutionProgress === false) {
return; return;
} }
if ( if (workflowSettings.saveExecutionProgress !== true && !saveExecutionProgress) {
this.workflowData.settings.saveExecutionProgress !== true &&
!config.getEnv('executions.saveExecutionProgress')
) {
return; return;
} }
} else if (!config.getEnv('executions.saveExecutionProgress')) { } else if (!saveExecutionProgress) {
return; return;
} }
@ -530,11 +537,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
const isManualMode = [this.mode, parentProcessMode].includes('manual'); const isManualMode = [this.mode, parentProcessMode].includes('manual');
try { try {
if ( if (!isManualMode && isWorkflowIdValid(this.workflowData.id) && newStaticData) {
!isManualMode &&
WorkflowHelpers.isWorkflowIdValid(this.workflowData.id) &&
newStaticData
) {
// Workflow is saved so update in database // Workflow is saved so update in database
try { try {
await WorkflowHelpers.saveStaticDataById( await WorkflowHelpers.saveStaticDataById(
@ -550,13 +553,11 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
} }
} }
const workflowSettings = this.workflowData.settings;
let saveManualExecutions = config.getEnv('executions.saveDataManualExecutions'); let saveManualExecutions = config.getEnv('executions.saveDataManualExecutions');
if ( if (workflowSettings?.saveManualExecutions !== undefined) {
this.workflowData.settings !== undefined &&
this.workflowData.settings.saveManualExecutions !== undefined
) {
// Apply to workflow override // Apply to workflow override
saveManualExecutions = this.workflowData.settings.saveManualExecutions as boolean; saveManualExecutions = workflowSettings.saveManualExecutions as boolean;
} }
if (isManualMode && !saveManualExecutions && !fullRunData.waitTill) { if (isManualMode && !saveManualExecutions && !fullRunData.waitTill) {
@ -641,7 +642,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
} }
const workflowId = this.workflowData.id; const workflowId = this.workflowData.id;
if (WorkflowHelpers.isWorkflowIdValid(workflowId)) { if (isWorkflowIdValid(workflowId)) {
fullExecutionData.workflowId = workflowId; fullExecutionData.workflowId = workflowId;
} }
@ -661,6 +662,14 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
executionData as IExecutionFlattedDb, 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 (fullRunData.finished === true && this.retryOf !== undefined) {
// If the retry was successful save the reference it on the original execution // If the retry was successful save the reference it on the original execution
// await Db.collections.Execution.save(executionData as IExecutionFlattedDb); // await Db.collections.Execution.save(executionData as IExecutionFlattedDb);
@ -729,7 +738,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
newStaticData: IDataObject, newStaticData: IDataObject,
): Promise<void> { ): Promise<void> {
try { try {
if (WorkflowHelpers.isWorkflowIdValid(this.workflowData.id) && newStaticData) { if (isWorkflowIdValid(this.workflowData.id) && newStaticData) {
// Workflow is saved so update in database // Workflow is saved so update in database
try { try {
await WorkflowHelpers.saveStaticDataById( await WorkflowHelpers.saveStaticDataById(
@ -776,7 +785,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
} }
const workflowId = this.workflowData.id; const workflowId = this.workflowData.id;
if (WorkflowHelpers.isWorkflowIdValid(workflowId)) { if (isWorkflowIdValid(workflowId)) {
fullExecutionData.workflowId = workflowId; fullExecutionData.workflowId = workflowId;
} }
@ -793,6 +802,14 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
status: executionData.status, 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 (fullRunData.finished === true && this.retryOf !== undefined) {
// If the retry was successful save the reference it on the original execution // If the retry was successful save the reference it on the original execution
await Db.collections.Execution.update(this.retryOf, { await Db.collections.Execution.update(this.retryOf, {
@ -995,16 +1012,14 @@ async function executeWorkflow(
additionalDataIntegrated.executeWorkflow = additionalData.executeWorkflow; additionalDataIntegrated.executeWorkflow = additionalData.executeWorkflow;
let subworkflowTimeout = additionalData.executionTimeoutTimestamp; let subworkflowTimeout = additionalData.executionTimeoutTimestamp;
if ( const workflowSettings = workflowData.settings;
workflowData.settings?.executionTimeout !== undefined && if (workflowSettings?.executionTimeout !== undefined && workflowSettings.executionTimeout > 0) {
workflowData.settings.executionTimeout > 0
) {
// We might have received a max timeout timestamp from the parent workflow // 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 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. // If no timeout was given from the parent, then we use our timeout.
subworkflowTimeout = Math.min( subworkflowTimeout = Math.min(
additionalData.executionTimeoutTimestamp || Number.MAX_SAFE_INTEGER, 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 { In } from 'typeorm';
import { Container } from 'typedi';
import type { import type {
IDataObject, IDataObject,
IExecuteData, IExecuteData,
@ -32,7 +33,7 @@ import type { User } from '@db/entities/User';
import { whereClause } from '@/UserManagement/UserManagementHelper'; import { whereClause } from '@/UserManagement/UserManagementHelper';
import omit from 'lodash.omit'; import omit from 'lodash.omit';
import { PermissionChecker } from './UserManagement/PermissionChecker'; import { PermissionChecker } from './UserManagement/PermissionChecker';
import { Container } from 'typedi'; import { isWorkflowIdValid } from './utils';
const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType'); const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType');
@ -74,15 +75,6 @@ export function getDataLastExecutedNodeData(inputData: IRun): ITaskData | undefi
return lastNodeRunData; 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 * Executes the error workflow
* *

View file

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

View file

@ -54,9 +54,9 @@ import config from '@/config';
import { generateFailedExecutionFromError } from '@/WorkflowHelpers'; import { generateFailedExecutionFromError } from '@/WorkflowHelpers';
import { initErrorHandling } from '@/ErrorReporting'; import { initErrorHandling } from '@/ErrorReporting';
import { PermissionChecker } from '@/UserManagement/PermissionChecker'; import { PermissionChecker } from '@/UserManagement/PermissionChecker';
import { getLicense } from './License'; import { License } from '@/License';
import { InternalHooks } from './InternalHooks'; import { InternalHooks } from '@/InternalHooks';
import { PostHogClient } from './posthog'; import { PostHogClient } from '@/posthog';
class WorkflowRunnerProcess { class WorkflowRunnerProcess {
data: IWorkflowExecutionDataProcessWithExecution | undefined; data: IWorkflowExecutionDataProcessWithExecution | undefined;
@ -127,16 +127,13 @@ class WorkflowRunnerProcess {
// Init db since we need to read the license. // Init db since we need to read the license.
await Db.init(); await Db.init();
const license = getLicense(); const license = Container.get(License);
await license.init(instanceId); await license.init(instanceId);
// Start timeout for the execution const workflowSettings = this.data.workflowData.settings ?? {};
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
}
// Start timeout for the execution
let workflowTimeout = workflowSettings.executionTimeout ?? config.getEnv('executions.timeout'); // initialize with default
if (workflowTimeout > 0) { if (workflowTimeout > 0) {
workflowTimeout = Math.min(workflowTimeout, config.getEnv('executions.maxTimeout')); 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/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
import { Router } from 'express'; import { Router } from 'express';
import type { Request } from 'express';
import bodyParser from 'body-parser'; import bodyParser from 'body-parser';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import config from '@/config'; import config from '@/config';
@ -12,12 +13,26 @@ import * as Db from '@/Db';
import type { Role } from '@db/entities/Role'; import type { Role } from '@db/entities/Role';
import { hashPassword } from '@/UserManagement/UserManagementHelper'; import { hashPassword } from '@/UserManagement/UserManagementHelper';
import { eventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; import { eventBus } from '@/eventbus/MessageEventBus/MessageEventBus';
import Container from 'typedi';
import { License } from '../License';
if (process.env.E2E_TESTS !== 'true') { if (process.env.E2E_TESTS !== 'true') {
console.error('E2E endpoints only allowed during E2E tests'); console.error('E2E endpoints only allowed during E2E tests');
process.exit(1); 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 = [ const tablesToTruncate = [
'auth_identity', 'auth_identity',
'auth_provider_sync_history', 'auth_provider_sync_history',
@ -78,7 +93,7 @@ const setupUserManagement = async () => {
}; };
const resetLogStreaming = async () => { const resetLogStreaming = async () => {
config.set('enterprise.features.logStreaming', false); enabledFeatures.logStreaming = false;
for (const id in eventBus.destinations) { for (const id in eventBus.destinations) {
await eventBus.removeDestination(id); await eventBus.removeDestination(id);
} }
@ -127,7 +142,8 @@ e2eController.post('/db/setup-owner', bodyParser.json(), async (req, res) => {
res.writeHead(204).end(); res.writeHead(204).end();
}); });
e2eController.post('/enable-feature/:feature', async (req, res) => { e2eController.post('/enable-feature/:feature', async (req: Request<{ feature: Feature }>, res) => {
config.set(`enterprise.features.${req.params.feature}`, true); const { feature } = req.params;
enabledFeatures[feature] = true;
res.writeHead(204).end(); 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 userSettings: IUserSettings;
protected instanceId: string;
async init(): Promise<void> { async init(): Promise<void> {
await initErrorHandling(); await initErrorHandling();
@ -49,9 +51,9 @@ export abstract class BaseCommand extends Command {
const credentialTypes = Container.get(CredentialTypes); const credentialTypes = Container.get(CredentialTypes);
CredentialsOverwrites(credentialTypes); CredentialsOverwrites(credentialTypes);
const instanceId = this.userSettings.instanceId ?? ''; this.instanceId = this.userSettings.instanceId ?? '';
await Container.get(PostHogClient).init(instanceId); await Container.get(PostHogClient).init(this.instanceId);
await Container.get(InternalHooks).init(instanceId); await Container.get(InternalHooks).init(this.instanceId);
await Db.init().catch(async (error: Error) => await Db.init().catch(async (error: Error) =>
this.exitWithCrash('There was an error initializing DB', 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 { ActiveExecutions } from '@/ActiveExecutions';
import * as Db from '@/Db'; import * as Db from '@/Db';
import * as WorkflowHelpers from '@/WorkflowHelpers';
import { WorkflowRunner } from '@/WorkflowRunner'; import { WorkflowRunner } from '@/WorkflowRunner';
import type { IWorkflowExecutionDataProcess } from '@/Interfaces'; import type { IWorkflowExecutionDataProcess } from '@/Interfaces';
import { getInstanceOwner } from '@/UserManagement/UserManagementHelper'; import { getInstanceOwner } from '@/UserManagement/UserManagementHelper';
import { findCliWorkflowStart } from '@/utils'; import { findCliWorkflowStart, isWorkflowIdValid } from '@/utils';
import { initEvents } from '@/events'; import { initEvents } from '@/events';
import { BaseCommand } from './BaseCommand'; import { BaseCommand } from './BaseCommand';
import { Container } from 'typedi'; import { Container } from 'typedi';
@ -101,7 +100,7 @@ export class Execute extends BaseCommand {
throw new Error('Failed to retrieve workflow data for requested workflow'); throw new Error('Failed to retrieve workflow data for requested workflow');
} }
if (!WorkflowHelpers.isWorkflowIdValid(workflowId)) { if (!isWorkflowIdValid(workflowId)) {
workflowId = undefined; workflowId = undefined;
} }

View file

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

View file

@ -1,5 +1,5 @@
import * as Db from '@/Db'; 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 { In } from 'typeorm';
import { BaseCommand } from '../BaseCommand'; import { BaseCommand } from '../BaseCommand';
@ -17,6 +17,11 @@ export class Reset extends BaseCommand {
await AuthIdentity.delete({ providerType: 'ldap' }); await AuthIdentity.delete({ providerType: 'ldap' });
await User.delete({ id: In(ldapIdentities.map((i) => i.userId)) }); await User.delete({ id: In(ldapIdentities.map((i) => i.userId)) });
await Settings.delete({ key: LDAP_FEATURE_NAME }); 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.'); 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 * as Server from '@/Server';
import { TestWebhooks } from '@/TestWebhooks'; import { TestWebhooks } from '@/TestWebhooks';
import { getAllInstalledPackages } from '@/CommunityNodes/packageModel'; import { getAllInstalledPackages } from '@/CommunityNodes/packageModel';
import { handleLdapInit } from '@/Ldap/helpers';
import { EDITOR_UI_DIST_DIR, GENERATED_STATIC_DIR } from '@/constants'; import { EDITOR_UI_DIST_DIR, GENERATED_STATIC_DIR } from '@/constants';
import { eventBus } from '@/eventbus'; import { eventBus } from '@/eventbus';
import { BaseCommand } from './BaseCommand'; import { BaseCommand } from './BaseCommand';
import { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
import { License } from '@/License';
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires
const open = require('open'); 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 * Opens the UI in browser
@ -182,11 +182,27 @@ export class Start extends BaseCommand {
await Promise.all(files.map(compileFile)); 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() { async init() {
await this.initCrashJournal(); await this.initCrashJournal();
await super.init(); await super.init();
this.logger.info('Initializing n8n process'); this.logger.info('Initializing n8n process');
this.activeWorkflowRunner = Container.get(ActiveWorkflowRunner);
await this.initLicense();
await this.initBinaryManager(); await this.initBinaryManager();
await this.initExternalHooks(); await this.initExternalHooks();
@ -252,11 +268,10 @@ export class Start extends BaseCommand {
// Optimistic approach - stop if any installation fails // Optimistic approach - stop if any installation fails
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
for (const missingPackage of missingPackages) { for (const missingPackage of missingPackages) {
// eslint-disable-next-line no-await-in-loop await this.loadNodesAndCredentials.installNpmModule(
void (await this.loadNodesAndCredentials.loadNpmModule(
missingPackage.packageName, missingPackage.packageName,
missingPackage.version, missingPackage.version,
)); );
missingPackages.delete(missingPackage); missingPackages.delete(missingPackage);
} }
LoggerProxy.info('Packages reinstalled successfully. Resuming regular initialization.'); 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 // Start to get active workflows and run their triggers
await this.activeWorkflowRunner.init(); await this.activeWorkflowRunner.init();
await handleLdapInit();
const editorUrl = GenericHelpers.getBaseUrl(); const editorUrl = GenericHelpers.getBaseUrl();
this.log(`\nEditor is now accessible via:\n${editorUrl}`); 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 config from '@/config';
import { ActiveExecutions } from '@/ActiveExecutions'; import { ActiveExecutions } from '@/ActiveExecutions';
import { WebhookServer } from '@/WebhookServer'; import { WebhookServer } from '@/WebhookServer';
import { Queue } from '@/Queue';
import { BaseCommand } from './BaseCommand'; import { BaseCommand } from './BaseCommand';
import { Container } from 'typedi'; import { Container } from 'typedi';
@ -79,6 +80,7 @@ export class Webhook extends BaseCommand {
} }
async run() { async run() {
await Container.get(Queue).init();
await new WebhookServer().start(); await new WebhookServer().start();
this.logger.info('Webhook listener waiting for requests.'); this.logger.info('Webhook listener waiting for requests.');

View file

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

View file

@ -24,11 +24,7 @@ if (inE2ETests) {
dotenv.config(); dotenv.config();
} }
const config = convict(schema); const config = convict(schema, { args: [] });
if (inE2ETests) {
config.set('enterprise.features.sharing', true);
}
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
config.getEnv = config.get; 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: { sso: {
justInTimeProvisioning: { justInTimeProvisioning: {
format: Boolean, format: Boolean,
@ -1166,6 +1141,12 @@ export const schema = {
env: 'N8N_LICENSE_TENANT_ID', env: 'N8N_LICENSE_TENANT_ID',
doc: 'Tenant id used by the license manager', doc: 'Tenant id used by the license manager',
}, },
cert: {
format: String,
default: '',
env: 'N8N_LICENSE_CERT',
doc: 'Ephemeral license certificate',
},
}, },
hideUsagePage: { hideUsagePage: {

View file

@ -18,7 +18,7 @@ export const CUSTOM_API_CALL_KEY = '__CUSTOM_API_CALL__';
export const CLI_DIR = resolve(__dirname, '..'); export const CLI_DIR = resolve(__dirname, '..');
export const TEMPLATES_DIR = join(CLI_DIR, 'templates'); 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 GENERATED_STATIC_DIR = join(UserSettings.getUserHome(), '.cache/n8n/public');
export const EDITOR_UI_DIST_DIR = join(dirname(require.resolve('n8n-editor-ui')), 'dist'); 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_NOT_FOUND: 'Package not found in npm',
PACKAGE_VERSION_NOT_FOUND: 'The specified package version was not found', 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_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', DISK_IS_FULL: 'There appears to be insufficient disk space',
}; };

View file

@ -19,8 +19,10 @@ import type {
} from '@/Interfaces'; } from '@/Interfaces';
import { handleEmailLogin, handleLdapLogin } from '@/auth'; import { handleEmailLogin, handleLdapLogin } from '@/auth';
import type { PostHogClient } from '@/posthog'; import type { PostHogClient } from '@/posthog';
import { isSamlCurrentAuthenticationMethod } from '../sso/ssoHelpers'; import {
import { SamlUrls } from '../sso/saml/constants'; isLdapCurrentAuthenticationMethod,
isSamlCurrentAuthenticationMethod,
} from '@/sso/ssoHelpers';
@RestController() @RestController()
export class AuthController { export class AuthController {
@ -73,19 +75,12 @@ export class AuthController {
if (preliminaryUser?.globalRole?.name === 'owner') { if (preliminaryUser?.globalRole?.name === 'owner') {
user = preliminaryUser; user = preliminaryUser;
} else { } else {
// TODO:SAML - uncomment this block when we have a way to redirect users to the SSO flow throw new AuthError('SAML is enabled, please log in with SAML');
// 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.',
// );
// }
} }
} else if (isLdapCurrentAuthenticationMethod()) {
user = await handleLdapLogin(email, password);
} else { } else {
user = (await handleLdapLogin(email, password)) ?? (await handleEmailLogin(email, password)); user = await handleEmailLogin(email, password);
} }
if (user) { if (user) {
await issueCookie(res, user); await issueCookie(res, user);

View file

@ -1,6 +1,10 @@
export { AuthController } from './auth.controller'; export { AuthController } from './auth.controller';
export { LdapController } from './ldap.controller';
export { MeController } from './me.controller'; export { MeController } from './me.controller';
export { NodesController } from './nodes.controller';
export { NodeTypesController } from './nodeTypes.controller';
export { OwnerController } from './owner.controller'; export { OwnerController } from './owner.controller';
export { PasswordResetController } from './passwordReset.controller'; export { PasswordResetController } from './passwordReset.controller';
export { TagsController } from './tags.controller';
export { TranslationController } from './translation.controller'; export { TranslationController } from './translation.controller';
export { UsersController } from './users.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 { readFile } from 'fs/promises';
import get from 'lodash.get'; import get from 'lodash.get';
import { Request } from 'express';
import type { INodeTypeDescription, INodeTypeNameVersion } from 'n8n-workflow'; import type { INodeTypeDescription, INodeTypeNameVersion } from 'n8n-workflow';
import { Post, RestController } from '@/decorators';
import config from '@/config';
import { NodeTypes } from '@/NodeTypes';
import * as ResponseHelper from '@/ResponseHelper';
import { getNodeTranslationPath } from '@/TranslationHelpers'; 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 private readonly nodeTypes: NodeTypes;
nodeTypesController.post(
'/', constructor({ config, nodeTypes }: { config: Config; nodeTypes: NodeTypes }) {
ResponseHelper.send(async (req: express.Request): Promise<INodeTypeDescription[]> => { this.config = config;
this.nodeTypes = nodeTypes;
}
@Post('/')
async getNodeInfo(req: Request) {
const nodeInfos = get(req, 'body.nodeInfos', []) as INodeTypeNameVersion[]; const nodeInfos = get(req, 'body.nodeInfos', []) as INodeTypeNameVersion[];
const defaultLocale = config.getEnv('defaultLocale'); const defaultLocale = this.config.getEnv('defaultLocale');
if (defaultLocale === 'en') { if (defaultLocale === 'en') {
return nodeInfos.reduce<INodeTypeDescription[]>((acc, { name, version }) => { 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); acc.push(description);
return acc; return acc;
}, []); }, []);
} }
async function populateTranslation( const populateTranslation = async (
name: string, name: string,
version: number, version: number,
nodeTypes: INodeTypeDescription[], nodeTypes: INodeTypeDescription[],
) { ) => {
const { description, sourcePath } = Container.get(NodeTypes).getWithSourcePath(name, version); const { description, sourcePath } = this.nodeTypes.getWithSourcePath(name, version);
const translationPath = await getNodeTranslationPath({ const translationPath = await getNodeTranslationPath({
nodeSourcePath: sourcePath, nodeSourcePath: sourcePath,
longNodeType: description.name, longNodeType: description.name,
@ -44,12 +48,12 @@ nodeTypesController.post(
const translation = await readFile(translationPath, 'utf8'); const translation = await readFile(translationPath, 'utf8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
description.translation = JSON.parse(translation); description.translation = JSON.parse(translation);
} catch (error) { } catch {
// ignore - no translation exists at path // ignore - no translation exists at path
} }
nodeTypes.push(description); nodeTypes.push(description);
} };
const nodeTypes: INodeTypeDescription[] = []; const nodeTypes: INodeTypeDescription[] = [];
@ -60,5 +64,5 @@ nodeTypesController.post(
await Promise.all(promises); await Promise.all(promises);
return nodeTypes; return nodeTypes;
}), }
); }

View file

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

View file

@ -14,7 +14,7 @@ import {
hashPassword, hashPassword,
validatePassword, validatePassword,
} from '@/UserManagement/UserManagementHelper'; } from '@/UserManagement/UserManagementHelper';
import * as UserManagementMailer from '@/UserManagement/email'; import type { UserManagementMailer } from '@/UserManagement/email';
import { Response } from 'express'; import { Response } from 'express';
import type { ILogger } from 'n8n-workflow'; import type { ILogger } from 'n8n-workflow';
@ -35,6 +35,8 @@ export class PasswordResetController {
private readonly internalHooks: IInternalHooksClass; private readonly internalHooks: IInternalHooksClass;
private readonly mailer: UserManagementMailer;
private readonly userRepository: Repository<User>; private readonly userRepository: Repository<User>;
constructor({ constructor({
@ -42,18 +44,21 @@ export class PasswordResetController {
logger, logger,
externalHooks, externalHooks,
internalHooks, internalHooks,
mailer,
repositories, repositories,
}: { }: {
config: Config; config: Config;
logger: ILogger; logger: ILogger;
externalHooks: IExternalHooksClass; externalHooks: IExternalHooksClass;
internalHooks: IInternalHooksClass; internalHooks: IInternalHooksClass;
mailer: UserManagementMailer;
repositories: Pick<IDatabaseCollections, 'User'>; repositories: Pick<IDatabaseCollections, 'User'>;
}) { }) {
this.config = config; this.config = config;
this.logger = logger; this.logger = logger;
this.externalHooks = externalHooks; this.externalHooks = externalHooks;
this.internalHooks = internalHooks; this.internalHooks = internalHooks;
this.mailer = mailer;
this.userRepository = repositories.User; this.userRepository = repositories.User;
} }
@ -126,8 +131,7 @@ export class PasswordResetController {
url.searchParams.append('token', resetPasswordToken); url.searchParams.append('token', resetPasswordToken);
try { try {
const mailer = UserManagementMailer.getInstance(); await this.mailer.passwordReset({
await mailer.passwordReset({
email, email,
firstName, firstName,
lastName, 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, getInstanceBaseUrl,
hashPassword, hashPassword,
isEmailSetUp, isEmailSetUp,
isUserManagementEnabled,
sanitizeUser, sanitizeUser,
validatePassword, validatePassword,
withFeatureFlags, withFeatureFlags,
@ -35,6 +34,8 @@ import type {
import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import { AuthIdentity } from '@db/entities/AuthIdentity'; import { AuthIdentity } from '@db/entities/AuthIdentity';
import type { PostHogClient } from '@/posthog'; import type { PostHogClient } from '@/posthog';
import { userManagementEnabledMiddleware } from '../middlewares/userManagementEnabled';
import { isSamlLicensedAndEnabled } from '../sso/saml/samlHelpers';
@RestController('/users') @RestController('/users')
export class UsersController { export class UsersController {
@ -98,14 +99,15 @@ export class UsersController {
/** /**
* Send email invite(s) to one or multiple users and create user shell(s). * Send email invite(s) to one or multiple users and create user shell(s).
*/ */
@Post('/') @Post('/', { middlewares: [userManagementEnabledMiddleware] })
async sendEmailInvites(req: UserRequest.Invite) { async sendEmailInvites(req: UserRequest.Invite) {
// TODO: this should be checked in the middleware rather than here if (isSamlLicensedAndEnabled()) {
if (!isUserManagementEnabled()) {
this.logger.debug( 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')) { if (!this.config.getEnv('userManagement.isInstanceOwnerSetUp')) {

View file

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

View file

@ -1,9 +1,10 @@
import { ExecutionStatus, WorkflowExecuteMode } from 'n8n-workflow'; 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 { datetimeColumnType, jsonColumnType } from './AbstractEntity';
import { IWorkflowDb } from '@/Interfaces'; import { IWorkflowDb } from '@/Interfaces';
import type { IExecutionFlattedDb } from '@/Interfaces'; import type { IExecutionFlattedDb } from '@/Interfaces';
import { idStringifier } from '../utils/transformers'; import { idStringifier } from '../utils/transformers';
import type { ExecutionMetadata } from './ExecutionMetadata';
@Entity() @Entity()
@Index(['workflowId', 'id']) @Index(['workflowId', 'id'])
@ -49,4 +50,7 @@ export class ExecutionEntity implements IExecutionFlattedDb {
@Column({ type: datetimeColumnType, nullable: true }) @Column({ type: datetimeColumnType, nullable: true })
waitTill: Date; 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 { WebhookEntity } from './WebhookEntity';
import { WorkflowEntity } from './WorkflowEntity'; import { WorkflowEntity } from './WorkflowEntity';
import { WorkflowStatistics } from './WorkflowStatistics'; import { WorkflowStatistics } from './WorkflowStatistics';
import { ExecutionMetadata } from './ExecutionMetadata';
export const entities = { export const entities = {
AuthIdentity, AuthIdentity,
@ -33,4 +34,5 @@ export const entities = {
WebhookEntity, WebhookEntity,
WorkflowEntity, WorkflowEntity,
WorkflowStatistics, 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 { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToExecutions';
import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus'; import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus';
import { UpdateRunningExecutionStatus1677236788851 } from './1677236788851-UpdateRunningExecutionStatus'; import { UpdateRunningExecutionStatus1677236788851 } from './1677236788851-UpdateRunningExecutionStatus';
import { CreateExecutionMetadataTable1679416281779 } from './1679416281779-CreateExecutionMetadataTable';
export const mysqlMigrations = [ export const mysqlMigrations = [
InitialMigration1588157391238, InitialMigration1588157391238,
@ -72,4 +73,5 @@ export const mysqlMigrations = [
AddStatusToExecutions1674138566000, AddStatusToExecutions1674138566000,
MigrateExecutionStatus1676996103000, MigrateExecutionStatus1676996103000,
UpdateRunningExecutionStatus1677236788851, 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 { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToExecutions';
import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus'; import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus';
import { UpdateRunningExecutionStatus1677236854063 } from './1677236854063-UpdateRunningExecutionStatus'; import { UpdateRunningExecutionStatus1677236854063 } from './1677236854063-UpdateRunningExecutionStatus';
import { CreateExecutionMetadataTable1679416281778 } from './1679416281778-CreateExecutionMetadataTable';
export const postgresMigrations = [ export const postgresMigrations = [
InitialMigration1587669153312, InitialMigration1587669153312,
@ -68,4 +69,5 @@ export const postgresMigrations = [
AddStatusToExecutions1674138566000, AddStatusToExecutions1674138566000,
MigrateExecutionStatus1676996103000, MigrateExecutionStatus1676996103000,
UpdateRunningExecutionStatus1677236854063, 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 { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToExecutions';
import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus'; import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus';
import { UpdateRunningExecutionStatus1677237073720 } from './1677237073720-UpdateRunningExecutionStatus'; import { UpdateRunningExecutionStatus1677237073720 } from './1677237073720-UpdateRunningExecutionStatus';
import { CreateExecutionMetadataTable1679416281777 } from './1679416281777-CreateExecutionMetadataTable';
const sqliteMigrations = [ const sqliteMigrations = [
InitialMigration1588102412422, InitialMigration1588102412422,
@ -66,6 +67,7 @@ const sqliteMigrations = [
AddStatusToExecutions1674138566000, AddStatusToExecutions1674138566000,
MigrateExecutionStatus1676996103000, MigrateExecutionStatus1676996103000,
UpdateRunningExecutionStatus1677237073720, UpdateRunningExecutionStatus1677237073720,
CreateExecutionMetadataTable1679416281777,
]; ];
export { sqliteMigrations }; 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 { CONTROLLER_ROUTES } from './constants';
import type { Method, RouteMetadata } from './types'; import type { Method, RouteMetadata } from './types';
interface RouteOptions {
middlewares?: RequestHandler[];
}
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
const RouteFactory = const RouteFactory =
(method: Method) => (method: Method) =>
(path: `/${string}`): MethodDecorator => (path: `/${string}`, options: RouteOptions = {}): MethodDecorator =>
(target, handlerName) => { (target, handlerName) => {
const controllerClass = target.constructor; const controllerClass = target.constructor;
const routes = (Reflect.getMetadata(CONTROLLER_ROUTES, controllerClass) ?? const routes = (Reflect.getMetadata(CONTROLLER_ROUTES, controllerClass) ??
[]) as RouteMetadata[]; []) 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); Reflect.defineMetadata(CONTROLLER_ROUTES, routes, controllerClass);
}; };
export const Get = RouteFactory('get'); export const Get = RouteFactory('get');
export const Post = RouteFactory('post'); export const Post = RouteFactory('post');
export const Put = RouteFactory('put');
export const Patch = RouteFactory('patch'); export const Patch = RouteFactory('patch');
export const Delete = RouteFactory('delete'); export const Delete = RouteFactory('delete');

View file

@ -1,2 +1,3 @@
export const CONTROLLER_ROUTES = 'CONTROLLER_ROUTES'; export const CONTROLLER_ROUTES = 'CONTROLLER_ROUTES';
export const CONTROLLER_BASE_PATH = 'CONTROLLER_BASE_PATH'; 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