n8n/packages/cli/src/Server.ts
Ben Hesseldieck 7264239b83
feat: Add User Management (#2636)
*  adjust tests

* 🛠 refactor user invites to be indempotent (#2791)

* 🔐 Encrypt SMTP pass for user management backend (#2793)

* 📦 Add crypto-js to /cli

* 📦 Update package-lock.json

*  Create type for SMTP config

*  Encrypt SMTP pass

*  Update format for `userManagement.emails.mode`

*  Update format for `binaryDataManager.mode`

*  Update format for `logs.level`

* 🔥 Remove logging

* 👕 Fix lint

* 👰  n8n 2826 um wedding FE<>BE (#2789)

* remove mocks

* update authorization func

* lock down default role

* 🐛 fix requiring authentication for OPTIONS requests

* 🐛 fix cors and cookie issues in dev

* update setup route

Co-authored-by: Ben Hesseldieck <b.hesseldieck@gmail.com>

* update telemetry

* 🐛 preload role for users

* 🐛 remove auth for password reset routes

* 🐛 fix forgot-password flow

*  allow workflow tag disabling

* update telemetry init

* add reset

* clear error notifications on signin

* remove load settings from node view

* remove user id from user state

* inherit existing user props

* go back in history on button click

* use replace to force redirect

* update stories

*  add env check for tag create

* 🧪 Add `/users` tests for user management backend (#2790)

*  Refactor users namespace

*  Adjust fillout endpoint

*  Refactor initTestServer arg

* ✏️ Specify agent type

* ✏️ Specify role type

*  Tighten `/users/:id` check

*  Add initial tests

* 🚚 Reposition init server map

*  Set constants in `validatePassword()`

*  Tighten `/users/:id` check

*  Improve checks in `/users/:id`

*  Add tests for `/users/:id`

* 📦 Update package-lock.json

*  Simplify expectation

*  Reuse util for authless agent

* 🚚 Make role names consistent

* 📘 Tighten namespaces map type

* 🔥 Remove unneeded default arg

*  Add tests for `POST /users`

* 📘 Create test SMTP account type

* ✏️ Improve wording

* 🎨 Formatting

* 🔥 Remove temp fix

*  Replace helper with config call

*  Fix failing tests

* 🔥 Remove outdated test

* 🔥 Remove unused helper

*  Increase readability of domain fetcher

*  Refactor payload validation

* 🔥 Remove repetition

*  Restore logging

*  Initialize logger in tests

* 🔥 Remove redundancy from check

* 🚚 Move `globalOwnerRole` fetching to global scope

* 🔥 Remove unused imports

* 🚚 Move random utils to own module

* 🚚 Move test types to own module

* ✏️ Add dividers to utils

* ✏️ Reorder `initTestServer` param docstring

* ✏️ Add TODO comment

*  Dry up member creation

*  Tighten search criteria

* 🧪 Add expectation to `GET /users`

*  Create role fetcher utils

*  Create one more role fetch util

* 🔥 Remove unneeded DB query

* 🧪 Add expectation to `POST /users`

* 🧪 Add expectation to `DELETE /users/:id`

* 🧪 Add another expectation to `DELETE /users/:id`

* 🧪 Add expectations to `DELETE /users/:id`

* 🧪 Adjust expectations in `POST /users/:id`

* 🧪 Add expectations to `DELETE /users/:id`

* 👕 Fix build

*  Update method

* 📘 Fix `userToDelete` type

*  Refactor `createAgent()`

*  Make role fetching global

*  Optimize roles fetching

*  Centralize member creation

*  Refactor truncation helper

* 🧪 Add teardown to `DELETE /users/:id`

* 🧪 Add DB expectations to users tests

* 🔥 Remove pass validation due to hash

* ✏️ Improve pass validation error message

*  Improve owner pass validation

*  Create logger initialization helper

*  Optimize helpers

*  Restructure `getAllRoles` helper

* 🧪 Add password reset flow tests for user management backend (#2807)

*  Refactor users namespace

*  Adjust fillout endpoint

*  Refactor initTestServer arg

* ✏️ Specify agent type

* ✏️ Specify role type

*  Tighten `/users/:id` check

*  Add initial tests

* 🚚 Reposition init server map

*  Set constants in `validatePassword()`

*  Tighten `/users/:id` check

*  Improve checks in `/users/:id`

*  Add tests for `/users/:id`

* 📦 Update package-lock.json

*  Simplify expectation

*  Reuse util for authless agent

* 🚚 Make role names consistent

* 📘 Tighten namespaces map type

* 🔥 Remove unneeded default arg

*  Add tests for `POST /users`

* 📘 Create test SMTP account type

* ✏️ Improve wording

* 🎨 Formatting

* 🔥 Remove temp fix

*  Replace helper with config call

*  Fix failing tests

* 🔥 Remove outdated test

*  Add tests for password reset flow

* ✏️ Fix test wording

*  Set password reset namespace

* 🔥 Remove unused helper

*  Increase readability of domain fetcher

*  Refactor payload validation

* 🔥 Remove repetition

*  Restore logging

*  Initialize logger in tests

* 🔥 Remove redundancy from check

* 🚚 Move `globalOwnerRole` fetching to global scope

* 🔥 Remove unused imports

* 🚚 Move random utils to own module

* 🚚 Move test types to own module

* ✏️ Add dividers to utils

* ✏️ Reorder `initTestServer` param docstring

* ✏️ Add TODO comment

*  Dry up member creation

*  Tighten search criteria

* 🧪 Add expectation to `GET /users`

*  Create role fetcher utils

*  Create one more role fetch util

* 🔥 Remove unneeded DB query

* 🧪 Add expectation to `POST /users`

* 🧪 Add expectation to `DELETE /users/:id`

* 🧪 Add another expectation to `DELETE /users/:id`

* 🧪 Add expectations to `DELETE /users/:id`

* 🧪 Adjust expectations in `POST /users/:id`

* 🧪 Add expectations to `DELETE /users/:id`

* 📘 Add namespace name to type

* 🚚 Adjust imports

*  Optimize `globalOwnerRole` fetching

* 🧪 Add expectations

* 👕 Fix build

* 👕 Fix build

*  Update method

*  Update method

* 🧪 Fix `POST /change-password` test

* 📘 Fix `userToDelete` type

*  Refactor `createAgent()`

*  Make role fetching global

*  Optimize roles fetching

*  Centralize member creation

*  Refactor truncation helper

* 🧪 Add teardown to `DELETE /users/:id`

* 🧪 Add DB expectations to users tests

*  Refactor as in users namespace

* 🧪 Add expectation to `POST /change-password`

* 🔥 Remove pass validation due to hash

* ✏️ Improve pass validation error message

*  Improve owner pass validation

*  Create logger initialization helper

*  Optimize helpers

*  Restructure `getAllRoles` helper

*  Update `truncate` calls

* 🐛 return 200 for non-existing user

*  fix tests for forgot-password and user creation

* Update packages/editor-ui/src/components/MainSidebar.vue

Co-authored-by: Ahsan Virani <ahsan.virani@gmail.com>

* Update packages/editor-ui/src/components/Telemetry.vue

Co-authored-by: Ahsan Virani <ahsan.virani@gmail.com>

* Update packages/editor-ui/src/plugins/telemetry/index.ts

Co-authored-by: Ahsan Virani <ahsan.virani@gmail.com>

* Update packages/editor-ui/src/plugins/telemetry/index.ts

Co-authored-by: Ahsan Virani <ahsan.virani@gmail.com>

* Update packages/editor-ui/src/plugins/telemetry/index.ts

Co-authored-by: Ahsan Virani <ahsan.virani@gmail.com>

* 🚚 Fix imports

*  reset password just if password exists

* Fix validation at `PATCH /workfows/:id` (#2819)

* 🐛 Validate entity only if workflow

* 👕 Fix build

* 🔨 refactor response from user creation

* 🐛 um email invite fix (#2833)

* update users invite

* fix notificaitons stacking on top of each other

* remove unnessary check

* fix type issues

* update structure

* fix types

* 🐘  database migrations UM + password reset expiration (#2710)

* Add table prefix and assign existing workflows and credentials to owner for sqlite

* Added user management migration to MySQL

* Fixed some missing table prefixes and removed unnecessary user id

* Created migration for postgres and applies minor fixes

* Fixed migration for sqlite by removing the unnecessary index and for mysql by removing unnecessary user data

* Added password reset token expiration

* Addressing comments made by Ben

* ️ add missing tablePrefix

*  fix tests + add tests for expiring pw-reset-token

Co-authored-by: Ben Hesseldieck <b.hesseldieck@gmail.com>

*  treat skipped personalizationSurvey as not answered

* 🐛 removing active workflows when deleting user, 🐛 fix reinvite, 🐛 fix resolve-signup-token, 🐘 remove workflowname uniqueness

*  Add DB state check tests (#2841)

* 🔥 Remove unneeded import

* 🔥 Remove unneeded vars

* ✏️ Improve naming

* 🧪 Add expectations to `POST /owner`

* 🧪 Add expectations to `PATCH /me`

* 🧪 Add expectation to `PATCH /me/password`

* ✏️ Clarify when owner is owner shell

* 🧪 Add more expectations

*  Restore package-lock to parent branch state

* Add logging to user management endpoints v2 (#2836)

*  Initialize logger in tests

*  Add logs to mailer

*  Add logs to middleware

*  Add logs to me endpoints

*  Add logs to owner endpoints

*  Add logs to pass flow endpoints

*  Add logs to users endpoints

* 📘 Improve typings

*  Merge two logs into one

*  Adjust log type

*  Add password reset email log

* ✏️ Reword log message

*  Adjust log meta object

*  Add total to log

* ✏️ Add detail to log message

* ✏️ Reword log message

* ✏️ Reword log message

* 🐛 Make total users to set up accurate

* ✏️ Reword `Logger.debug()` messages

* ✏️ Phrasing change for consistency

* 🐛 Fix ID overridden in range query

* 🔨 small refactoring

* 🔐 add auth to push-connection

* 🛠   Create credentials namespace and add tests (#2831)

* 🧪 Fix failing test

* 📘 Improve `createAgent` signature

* 🚚 Fix `LoggerProxy` import

*  Create credentials endpoints namespace

* 🧪 Set up initial tests

*  Add validation to model

*  Adjust validation

* 🧪 Add test

* 🚚 Sort creds endpoints

* ✏️ Plan out pending tests

* 🧪 Add deletion tests

* 🧪 Add patch tests

* 🧪 Add get cred tests

* 🚚 Hoist import

* ✏️ Make test descriptions consistent

* ✏️ Adjust description

* 🧪 Add missing test

* ✏️ Make get descriptions consistent

*  Undo line break

*  Refactor to simplify `saveCredential`

* 🧪 Add non-owned tests for owner

* ✏️ Improve naming

* ✏️ Add clarifying comments

* 🚚 Improve imports

*  Initialize config file

* 🔥 Remove unneeded import

* 🚚 Rename dir

*  Adjust deletion call

*  Adjust error code

* ✏️ Touch up comment

*  Optimize fetching with `@RelationId`

* 🧪 Add expectations

*  Simplify mock calls

* 📘 Set deep readonly to object constants

* 🔥 Remove unused param and encryption key

*  Add more `@RelationId` calls in models

*  Restore

* 🐛 no auth for .svg

* 🛠 move auth cookie name to constant; 🐛 fix auth for push-connection

*  Add auth middleware tests (#2853)

*  Simplify existing suite

* 🧪 Validate that auth cookie exists

* ✏️ Move comment

* 🔥 Remove unneeded imports

* ✏️ Add clarifying comments

* ✏️ Document auth endpoints

* 🧪 Add middleware tests

* ✏️ Fix typos

Co-authored-by: Ben Hesseldieck <1849459+BHesseldieck@users.noreply.github.com>

* 🔥 Remove test description wrappers (#2874)

* 🔥 Remove /owner test wrappers

* 🔥 Remove auth middleware test wrappers

* 🔥 Remove auth endpoints test wrappers

* 🔥 Remove overlooked middleware wrappers

* 🔥 Remove me namespace test wrappers

Co-authored-by: Ben Hesseldieck <b.hesseldieck@gmail.com>

*  Runtime checks for credentials load and execute workflows (#2697)

* Runtime checks for credentials load and execute workflows

* Fixed from reviewers

* Changed runtime validation for credentials to be on start instead of on demand

* Refactored validations to use user id instead of whole User instance

* Removed user entity from workflow project because it is no longer needed

* General fixes and improvements to runtime checks

* Remove query builder and improve styling

* Fix lint issues

*  remove personalizationAnswers when fetching all users

*  fix failing get all users test

*  check authorization routes also for authentication

* 🐛 fix defaults in reset command

* 🛠 refactorings from walkthrough (#2856)

*  Make `getTemplate` async

*  Remove query builder from `getCredentials`

*  Add save manual executions log message

*  Restore and hide migrations logs

*  Centralize ignore paths check

* 👕 Fix build

* 🚚 Rename `hasOwner` to `isInstanceOwnerSetUp`

*  Add `isSetUp` flag to `User`

*  Add `isSetUp` to FE interface

*  Adjust `isSetUp` checks on FE

* 👕 Fix build

*  Adjust `isPendingUser()` check

* 🚚 Shorten helper name

*  Refactor as `isPending` per feedback

* ✏️ Update log message

*  Broaden check

* 🔥 Remove unneeded relation

*  Refactor query

* 🔥 Re-remove logs from migrations

* 🛠 set up credentials router (#2882)

*  Refactor creds endpoints into router

* 🧪 Refactor creds tests to use router

* 🚚 Rename arg for consistency

* 🚚 Move `credentials.api.ts` outside /public

* 🚚 Rename constant for consistency

* 📘 Simplify types

* 🔥 Remove unneeded arg

* 🚚 Rename router to controller

*  Shorten endpoint

*  Update `initTestServer()` arg

*  Mutate response body in GET /credentials

* 🏎 improve performance of type cast for FE

Co-authored-by: Ben Hesseldieck <b.hesseldieck@gmail.com>

* 🐛 remove GET /login from auth

* 🔀 merge master + FE update (#2905)

*  Add Templates (#2720)

* Templates Bugs / Fixed Various Bugs / Multiply Api Request, Carousel Gradient, Core Nodes Filters ...

* Updated MainSidebar Paddings

* N8N-Templates Bugfixing - Remove Unnecesairy Icon (Shape), Refatctor infiniteScrollEnabled Prop + updated infiniterScroll functinality

* N8N-2853 Fixed Carousel Arrows Bug after Cleaning the SearchBar

* fix telemetry init

* fix search tracking issues

* N8N-2853 Created FilterTemplateNode Constant Array, Filter PlayButton and WebhookRespond from Nodes, Added Box for showing more nodes inside TemplateList, Updated NewWorkflowButton to primary, Fixed Markdown issue with Code

* N8N-2853 Removed Placeholder if Workflows Or Collections are not found, Updated the Logic

* fix telemetry events

* clean up session id

* update user inserted event

* N8N-2853 Fixed Categories to Moving if the names are long

* Add todos

* Update Routes on loading

* fix spacing

* Update Border Color

* Update Border Readius

* fix filter fn

* fix constant, console error

* N8N-2853 PR Fixes, Refactoring, Removing unnecesairy code ..

* N8N-2853 PR Fixes - Editor-ui Fixes, Refactoring, Removing Dead Code ...

* N8N-2853 Refactor Card to LongCard

* clean up spacing, replace css var

* clean up spacing

* set categories as optional in node

* replace vars

* refactor store

* remove unnesssary import

* fix error

* fix templates view to start

* add to cache

* fix coll view data

* fix categories

* fix category event

* fix collections carousel

* fix initial load and search

* fix infinite load

* fix query param

* fix scrolling issues

* fix scroll to top

* fix search

* fix collections search

* fix navigation bug

* rename view

* update package lock

* rename workflow view

* rename coll view

* update routes

* add wrapper component

* set session id

* fix search tracking

* fix session tracking

* remove deleted mutation

* remove check for unsupported nodes

* refactor filters

* lazy load template

* clean up types

* refactor infinte scroll

* fix end of search

* Fix spacing

* fix coll loading

* fix types

* fix coll view list

* fix navigation

* rename types

* rename state

* fix search responsiveness

* fix coll view spacing

* fix search view spacing

* clean up views

* set background color

* center page not vert

* fix workflow view

* remove import

* fix background color

* fix background

* clean props

* clean up imports

* refactor button

* update background color

* fix spacing issue

* rename event

* update telemetry event

* update endpoints, add loading view, check for endpoint health

* remove conolse log

* N8N-2853 Fixed Menu Items Padding

* replace endpoints

* fix type issues

* fix categories

* N8N-2853 Fixed ParameterInput Placeholder after ElementUI Upgrade

* update createdAt

*  Fix placeholder in creds config modal

* ✏️ Adjust docstring to `credText` placeholder version

* N8N-2853 Optimized

* N8N-2853 Optimized code

*  Add deployment type to FE settings

*  Add deployment type to interfaces

* N8N-2853 Removed Animated prop from components

*  Add deployment type to store module

*  Create hiring banner

*  Display hiring banner

*  Undo unrelated change

* N8N-2853 Refactor TemplateFilters

*  Fix indentation

* N8N-2853 Reorder items / TemplateList

* 👕 Fix lint

* N8N-2853 Refactor TemplateFilters Component

* N8N-2853 Reorder TemplateList

* refactor template card

* update timeout

* fix removelistener

* fix spacing

* split enabled from offline

* add spacing to go back

* N8N-2853 Fixed Screens for Tablet & Mobile

* N8N-2853 Update Stores Order

* remove image componet

* remove placeholder changes

* N8N-2853 Fixed Chinnese Placeholders for El Select Component that comes from the Library Upgrade

* N8N-2853 Fixed Vue Agile Console Warnings

* N8N-2853 Update Collection Route

* ✏️ Update jobs URL

* 🚚 Move logging to root component

*  Refactor `deploymentType` to `isInternalUser`

*  Improve syntax

* fix cut bug in readonly view

* N8N-3012 Fixed Details section in templates with lots of description, Fixed Mardown Block with overflox-x

* N8N-3012 Increased Font-size, Spacing and Line-height of the Categories Items

* N8N-3012 Fixed Vue-agile client width error on resize

* only delay redirect for root path

* N8N-3012 Fixed Carousel Arrows that Disappear

* N8N-3012 Make Loading Screen same color as Templates

* N8N-3012 Markdown renders inline block as block code

* add offline warning

* hide log from workflow iframe

* update text

* make search button larger

* N8N-3012 Categories / Tags extended all the way in details section

* load data in cred modals

* remove deleted message

* add external hook

* remove import

* update env variable description

* fix markdown width issue

* disable telemetry for demo, add session id to template pages

* fix telemetery bugs

* N8N-3012 Not found Collections/Wokrkflow

* N8N-3012 Checkboxes change order when categories are changed

* N8N-3012 Refactor SortedCategories inside TemplateFilters component

* fix firefox bug

* add telemetry requirements

* add error check

* N8N-3012 Update GoBackButton to check if Route History is present

* N8N-3012 Fixed WF Nodes Icons

* hide workflow screenshots

* remove unnessary mixins

* rename prop

* fix design a bit

* rename data

* clear workspace on destroy

* fix copy paste bug

* fix disabled state

* N8N-3012 Fixed Saving/Leave without saving Modal

* fix telemetry issue

* fix telemetry issues, error bug

* fix error notification

* disable workflow menu items on templates

* fix i18n elementui issue

* Remove Emit - NodeType from HoverableNodeIcon component

* TechnicalFixes: NavigateTo passed down as function should be helper

* TechnicalFixes: Update NavigateTo function

* TechnicalFixes: Add FilterCoreNodes directly as function

* check for empty connecitions

* fix titles

* respect new lines

* increase categories to be sliced

* rename prop

* onUseWorkflow

* refactor click event

* fix bug, refactor

* fix loading story

* add default

* fix styles at right level of abstraction

* add wrapper with width

* remove loading blocks component

* add story

* rename prop

* fix spacing

* refactor tag, add story

* move margin to container

* fix tag redirect, remove unnessary check

* make version optional

* rename view

* move from workflows to templates store

* remove unnessary change

* remove unnessary css

* rename component

* refactor collection card

* add boolean to prevent shrink

* clean up carousel

* fix redirection bug on save

* remove listeners to fix multiple listeners bug

* remove unnessary types

* clean up boolean set

* fix node select bug

* rename component

* remove unnessary class

* fix redirection bug

* remove unnessary error

* fix typo

* fix blockquotes, pre

* refactor markdown rendering

* remove console log

* escape markdown

* fix safari bug

* load active workflows to fix modal bug

* ⬆️ Update package-lock.json file

*  Add n8n version as header

Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com>
Co-authored-by: Mutasem <mutdmour@gmail.com>
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>

* 🔖 Release n8n-workflow@0.88.0

* ⬆️ Set n8n-workflow@0.88.0 on n8n-core

* 🔖 Release n8n-core@0.106.0

* ⬆️ Set n8n-core@0.106.0 and n8n-workflow@0.88.0 on n8n-node-dev

* 🔖 Release n8n-node-dev@0.45.0

* ⬆️ Set n8n-core@0.106.0 and n8n-workflow@0.88.0 on n8n-nodes-base

* 🔖 Release n8n-nodes-base@0.163.0

* 🔖 Release n8n-design-system@0.12.0

* ⬆️ Set n8n-design-system@0.12.0 and n8n-workflow@0.88.0 on n8n-editor-ui

* 🔖 Release n8n-editor-ui@0.132.0

* ⬆️ Set n8n-core@0.106.0, n8n-editor-ui@0.132.0, n8n-nodes-base@0.163.0 and n8n-workflow@0.88.0 on n8n

* 🔖 Release n8n@0.165.0

* fix default user bug

* fix bug

* update package lock

* fix duplicate import

* fix settings

* fix templates access

Co-authored-by: Oliver Trajceski <olivertrajceski@yahoo.com>
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>

*  n8n 2952 personalisation (#2911)

* refactor/update survey

* update customers

* Fix up personalization survey

* fix recommendation logic

* set to false

* hide suggested nodes when empty

* use keys

* add missing logic

* switch types

* Fix logic

* remove unused constants

* add back constant

* refactor filtering inputs

* hide last input on personal

* fix other

*  add current pw check for change password (#2912)

* fix back button

* Add current password input

* add to modal

* update package.json

* delete mock file

* delete mock file

* get settings func

* update router

* update package lock

* update package lock

* Fix invite text

* update error i18n

* open personalization on search if not set

* update error view i18n

* update change password

* update settings sidebar

* remove import

* fix sidebar

* 🥅 fix error for credential/workflow not found

* update invite modal

*  persist skipping owner setup (#2894)

* 🚧 added skipInstanceOwnerSetup to DB + route to save skipping

*  skipping owner setup persists

*  add tests for authorization and /owner/skip-setup

* 🛠 refactor FE settings getter

* 🛠 move setting setup stop to owner creation

* 🐛 fix wrong setting of User.isPending

* 🐛 fix isPending

* 🏷 add isPending to PublicUser

* 🐛 fix unused import

* update delete modal

* change password modal

* remove _label

* sort keys

* remove key

* update key names

* fix test endpoint

* 🥅 Handle error workflows permissions (#2908)

* Handle error workflows permissions

* Fixed wrong query format

* 🛠 refactor query

Co-authored-by: Ben Hesseldieck <1849459+BHesseldieck@users.noreply.github.com>

* fix ts issue

* fix list after ispending changes

* fix error page bugs

* fix error redirect

* fix notification

* 🐛 fix survey import in migration

* fix up spacing

* update keys spacing

* update keys

* add space

* update key

* fix up more spacing

* 🔐 add current password (#2919)

* add curr pass

* update key names

* 🐛 stringify tag ids

* 🔐 check current password before update

* add package lock

* fix dep version

* update version

* 🐛 fix access for instance owner to credentials (#2927)

* 🛠 stringify tag id on entity

* 🔐 Update password requirements (#2920)

*  Update password requirements

*  Adjust random helpers

*  fix tests for currentPassword check

* change redirection, add homepage

* fix error view redirection

* updated wording

* fix setup redirection

* update validator

* remove successfully

* update consumers

* update settings redirect

* on signup, redirect to homepage

* update empty state

* add space to emails

* remove brackets

* add opacity

* update spacing

* remove border from last user

* personal details updated

* update redirect on sign up

* prevent text wrap

* fix notification title line height

* remove console log

* 🐘 Support testing with Postgres and MySQL (#2886)

* 🗃️ Fix Postgres migrations

*  Add DB-specific scripts

*  Set up test connections

*  Add Postgres UUID check

* 🧪 Make test adjustments for Postgres

*  Refactor connection logic

*  Set up double init for Postgres

* ✏️ Add TODOs

*  Refactor DB dropping logic

*  Implement global teardown

*  Create TypeORM wrappers

*  Initial MySQL setup

*  Clean up Postgres connection options

*  Simplify by sharing bootstrap connection name

* 🗃️ Fix MySQL migrations

* 🔥 Remove comments

*  Use ES6 imports

* 🔥 Remove outdated comments

*  Centralize bootstrap connection name handles

*  Centralize database types

* ✏️ Update comment

* 🚚 Rename `findRepository`

* 🚧 Attempt to truncate MySQL

*  Implement creds router

* 🐛 Fix duplicated MySQL bootstrap

* 🐛 Fix misresolved merge conflict

* 🗃️ Fix tags migration

* 🗃️ Fix MySQL UM migration

* 🐛 Fix MySQL parallelization issues

* 📘 Augment TypeORM to prevent error

* 🔥 Remove comments

*  Support one sqlite DB per suite run

* 🚚 Move `testDb` to own module

* 🔥 Deduplicate bootstrap Postgres logic

* 🔥 Remove unneeded comment

*  Make logger init calls consistent

* ✏️ Improve comment

* ✏️ Add dividers

* 🎨 Improve formatting

* 🔥 Remove duplicate MySQL global setting

* 🚚 Move comment

*  Update default test script

* 🔥 Remove unneeded helper

*  Unmarshal answers from Postgres

* 🐛 Phase out `isTestRun`

*  Refactor `isEmailSetup`

* 🔥 Remove unneeded imports

*  Handle bootstrap connection errors

* 🔥 Remove unneeded imports

* 🔥 Remove outdated comments

* ✏️ Fix typos

* 🚚 Relocate `answersFormatter`

*  Undo package.json miscommit

* 🔥 Remove unneeded import

*  Refactor test DB prefixing

*  Add no-leftover check to MySQL

* 📦 Update package.json

*  Autoincrement on simulated MySQL truncation

* 🔥 Remove debugging queries

* ✏️ fix email template link expiry

* 🔥 remove unused import

*  fix testing email not sent error

* fix duplicate import

* add package lock

* fix export

* change opacity

* fix text issue

* update action box

* update error title

* update forgot password

* update survey

* update product text

* remove unset fields

* add category to page events

* remove duplicate import

* update key

* update key

* update label type

* 🎨 um/fe review (#2946)

* 🐳 Update Node.js versions of Docker images to 16

* 🐛 Fix that some keyboard shortcuts did no longer work

* N8N-3057 Fixed Keyboard shortcuts no longer working on / Fixed callDebounced function

* N8N-3057 Update Debounce Function

* N8N-3057 Refactor callDebounce function

* N8N-3057 Update Dobounce Function

* 🐛 Fix issue with tooltips getting displayed behind node details view

* fix tooltips z-index

* move all element ui components

* update package lock

* 🐛 Fix credentials list load issue (#2931)

* always fetch credentials

* only fetch credentials once

*  Allow to disable hiring banner (#2902)

*  Add flag

*  Adjust interfaces

*  Adjust store module

*  Adjust frontend settings

*  Adjust frontend display

* 🐛 Fix issue that ctrl + o did behave wrong on workflow templates page (#2934)

* N8N-3094 Workflow Templates cmd-o acts on the Preview/Iframe

* N8N-3094 Workflow Templates cmd-o acts on the Preview/Iframe

* disable shortcuts for preview

Co-authored-by: Mutasem <mutdmour@gmail.com>

* ⬆️ Update package-lock.json file

* 🐛 Fix sorting by field in Baserow Node (#2942)

This fixes a bug which currently leads to the "Sorting" option of the node to be ignored.

* 🐛 Fix some i18n line break issues

*  Add Odoo Node (#2601)

* added odoo scaffolding

* update getting data from odoo instance

* added scaffolding for main loop and request functions

* added functions for CRUD opperations

* improoved error handling for odooJSONRPCRequest

* updated odoo node and fixing nodelinter issues

* fixed alpabetical order

* fixed types in odoo node

* fixing linter errors

* fixing linter errors

* fixed data shape returned from man loop

* updated node input types, added fields list to models

* update when custom resource is selected options for fields list will be populated dynamicly

* minor fixes

* 🔨 fixed credential test, updating CRUD methods

* 🔨 added additional fields to crm resource

* 🔨 added descriptions, fixed credentials test bug

* 🔨 standardize node and descriptions design

* 🔨 removed comments

* 🔨 added pagination to getAll operation

*  removed leftover function from previous implementation, removed required from optional fields

*  fixed id field, added indication of type and if required to field description, replaced string input in filters to fetched list of fields

* 🔨 fetching list of models from odoo, added selection of fields to be returned to predefined models, fixes accordingly to review

*  Small improvements

* 🔨 extracted adress fields into collection, changed fields to include in descriptions, minor tweaks

*  Improvements

* 🔨 working on review

* 🔨 fixed linter errors

* 🔨 review wip

* 🔨 review wip

* 🔨 review wip

*  updated display name for URL in credentials

* 🔨 added checks for valid id to delete and update

*  Minor improvements

Co-authored-by: ricardo <ricardoespinoza105@gmail.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>

* 🐛 Handle Wise SCA requests (#2734)

*  Improve Wise error message after previous change

* fix duplicate import

* add package lock

* fix export

* change opacity

* fix text issue

* update action box

* update error title

* update forgot password

* update survey

* update product text

* remove unset fields

* add category to page events

* remove duplicate import

* update key

* update key

Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
Co-authored-by: Oliver Trajceski <olivertrajceski@yahoo.com>
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: Tom <19203795+that-one-tom@users.noreply.github.com>
Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com>
Co-authored-by: ricardo <ricardoespinoza105@gmail.com>
Co-authored-by: pemontto <939704+pemontto@users.noreply.github.com>

* Move owner skip from settings

* 🐛 SMTP fixes (#2937)

* 🔥 Remove `UM_` from SMTP env vars

* 🔥 Remove SMTP host default value

*  Update sender value

*  Update invite template

*  Update password reset template

*  Update `N8N_EMAIL_MODE` default value

* 🔥 Remove `EMAIL` from all SMTP vars

*  Implement `verifyConnection()`

* 🚚 Reposition comment

* ✏️ Fix typo

* ✏️ Minor env var documentation improvements

* 🎨 Fix spacing

* 🎨 Fix spacing

* 🗃️ Remove SMTP settings cache

*  Adjust log message

*  Update error message

* ✏️ Fix template typo

* ✏️ Adjust wording

*  Interpolate email into success toast

* ✏️ Adjust base message in `verifyConnection()`

*  Verify connection on password reset

*  Bring up POST /users SMTP check

* 🐛 remove cookie if cookie is not valid

*  verify connection on instantiation

Co-authored-by: Ben Hesseldieck <b.hesseldieck@gmail.com>

* 🔊 create logger helper for migrations (#2944)

* 🔥 remove unused database

* 🔊 add migration logging for sqlite

* 🔥 remove unnecessary index creation

* ️ change log level to warn

* 🐛 Fix issue with workflow process to initialize db connection correctly (#2948)

* ✏️ update error messages for webhhook run/activation

* 📈 Implement telemetry events (#2868)

* Implement basic telemetry events

* Fixing user id as part of the telemetry data

* Added user id to be part of the tracked data

*  Create telemetry mock

* 🧪 Fix tests with telemetry mock

* 🧪 Fix missing key in authless endpoint

* 📘 Create authless request type

* 🔥 Remove log

* 🐛 Fix `migration_strategy` assignment

* 📘 Remove `instance_id` from `ITelemetryUserDeletionData`

*  Simplify concatenation

*  Simplify `track()` call signature

* Fixed payload of telemetry to always include user_id

* Fixing minor issues

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>

* 🔊 Added logs to credentials, executions and workflows (#2915)

* Added logs to credentials, executions and workflows

* Some updates according to ivov's feedback

*  update log levels

*  fix tests

Co-authored-by: Ben Hesseldieck <b.hesseldieck@gmail.com>

* 🐛 fix telemetry error

* fix conflicts with master

* fix duplicate

* add package-lock

* 🐛 Um/fixes (#2952)

* add initials to avatar

* redirect to signin if invalid token

* update pluralization

* add auth page category

* data transferred

* touch up setup page

* update button to add cursor

* fix personalization modal not closing

* ✏️ fix environment name

* 🐛 fix disabling UM

* 🐛 fix email setup flag

* 🐛 FE fixes 1 (#2953)

* add initials to avatar

* redirect to signin if invalid token

* update pluralization

* add auth page category

* data transferred

* touch up setup page

* update button to add cursor

* fix personalization modal not closing

* capitalize labels, refactor text

* Fixed the issue with telemetry data missing for personalization survey

* Changed invite email text

* 🐛 Fix quotes issue with postgres migration (#2958)

* Changed text for invite link

* 🐛 fix reset command for mysql

*  fix race condition in test DB creation

* 🔐 block user creation if UM is disabled

* 🥅 improve smtp setup issue error

*  update error message

* refactor route rules

* set package lock

* fix access

* remove capitalize

* update input labels

* refactor heading

* change span to fragment

* add route types

* refactor views

*  fix increase timeout for mysql

*  correct logic of error message

* refactor view names

*  update randomString

* 📈 Added missing event regarding failed emails (#2964)

* replace label with info

* 🛠 refactor JWT-secret creation

* remove duplicate key

* remove unused part

* remove semicolon

* fix up i18n pattern

* update translation keys

* update urls

* support i18n in nds

* fix how external keys are handled

* add source

* 💥 update timestamp of UM migration

* ✏️ small message updates

* fix tracking

* update notification line-height

* fix avatar opacity

* fix up empty state

* shift focus to input

* 🔐 Disable basic auth after owner has been set up (#2973)

* Disable basic auth after owner has been set up

* Remove unnecessary comparison

* rename modal title

* 🐛 use pgcrypto extension for uuid creation (#2977)

* 📧 Added public url variable for emails (#2967)

* Added public url variable for emails

* Fixed base url for reset password - the current implementation overrides possibly existing path

* Change variable name to editorUrl

* Using correct name editorUrl for emails

* Changed variable description

* Improved base url naming and appending path so it remains consistent

* Removed trailing slash from editor base url

* 🌐 fix i18n pattern (#2970)

* fix up i18n pattern

* update translation keys

* update urls

* support i18n in nds

* fix how external keys are handled

* add source

* Um/fixes 1000 (#2980)

* fix select issue

* 😫 hacky solution to circumvent pgcrypto (#2979)

* fix owner bug after transfer. always fetch latest credentials

* add confirmation modal to setup

* Use webhook url as fallback when editor url is not defined

* fix enter bug

* update modal

* update modal

* update modal text, fix bug in settings view

* Updating editor url to not append path

* rename keys

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com>
Co-authored-by: Mutasem <mutdmour@gmail.com>
Co-authored-by: Ahsan Virani <ahsan.virani@gmail.com>
Co-authored-by: Omar Ajoue <krynble@gmail.com>
Co-authored-by: Oliver Trajceski <olivertrajceski@yahoo.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
Co-authored-by: Tom <19203795+that-one-tom@users.noreply.github.com>
Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com>
Co-authored-by: ricardo <ricardoespinoza105@gmail.com>
Co-authored-by: pemontto <939704+pemontto@users.noreply.github.com>
2022-03-14 14:46:32 +01:00

3113 lines
93 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
/* eslint-disable @typescript-eslint/no-use-before-define */
/* eslint-disable @typescript-eslint/await-thenable */
/* eslint-disable new-cap */
/* eslint-disable prefer-const */
/* eslint-disable @typescript-eslint/no-invalid-void-type */
/* eslint-disable no-return-assign */
/* eslint-disable no-param-reassign */
/* eslint-disable consistent-return */
/* eslint-disable import/no-cycle */
/* eslint-disable import/no-extraneous-dependencies */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable id-denylist */
/* eslint-disable no-console */
/* eslint-disable global-require */
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable @typescript-eslint/no-shadow */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/return-await */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable no-continue */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-restricted-syntax */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable import/no-dynamic-require */
/* eslint-disable no-await-in-loop */
import * as express from 'express';
import { readFileSync } from 'fs';
import { readFile } from 'fs/promises';
import { cloneDeep } from 'lodash';
import { dirname as pathDirname, join as pathJoin, resolve as pathResolve } from 'path';
import {
FindConditions,
FindManyOptions,
getConnection,
getConnectionManager,
In,
IsNull,
LessThan,
LessThanOrEqual,
MoreThan,
Not,
Raw,
} from 'typeorm';
import * as bodyParser from 'body-parser';
import * as cookieParser from 'cookie-parser';
import * as history from 'connect-history-api-fallback';
import * as os from 'os';
// eslint-disable-next-line import/no-extraneous-dependencies
import * as _ from 'lodash';
import * as clientOAuth2 from 'client-oauth2';
import * as clientOAuth1 from 'oauth-1.0a';
import { RequestOptions } from 'oauth-1.0a';
import * as csrf from 'csrf';
import * as requestPromise from 'request-promise-native';
import { createHmac } from 'crypto';
// IMPORTANT! Do not switch to anther bcrypt library unless really necessary and
// tested with all possible systems like Windows, Alpine on ARM, FreeBSD, ...
import { compare } from 'bcryptjs';
import * as promClient from 'prom-client';
import {
BinaryDataManager,
Credentials,
IBinaryDataConfig,
LoadNodeParameterOptions,
UserSettings,
} from 'n8n-core';
import {
ICredentialType,
IDataObject,
INodeCredentials,
INodeCredentialsDetails,
INodeParameters,
INodePropertyOptions,
INodeType,
INodeTypeDescription,
INodeTypeNameVersion,
ITelemetrySettings,
IWorkflowBase,
LoggerProxy,
NodeHelpers,
WebhookHttpMethod,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
import * as basicAuth from 'basic-auth';
import * as compression from 'compression';
import * as jwt from 'jsonwebtoken';
import * as jwks from 'jwks-rsa';
// @ts-ignore
import * as timezones from 'google-timezones-json';
import * as parseUrl from 'parseurl';
import * as querystring from 'querystring';
import { OptionsWithUrl } from 'request-promise-native';
import { Registry } from 'prom-client';
import * as Queue from './Queue';
import {
ActiveExecutions,
ActiveWorkflowRunner,
CredentialsHelper,
CredentialsOverwrites,
CredentialTypes,
DatabaseType,
Db,
ExternalHooks,
GenericHelpers,
ICredentialsDb,
ICredentialsOverwrite,
ICustomRequest,
IExecutionFlattedDb,
IExecutionFlattedResponse,
IExecutionPushResponse,
IExecutionResponse,
IExecutionsListResponse,
IExecutionsStopData,
IExecutionsSummary,
IExternalHooksClass,
IDiagnosticInfo,
IN8nUISettings,
IPackageVersions,
ITagWithCountDb,
IWorkflowExecutionDataProcess,
IWorkflowResponse,
NodeTypes,
Push,
ResponseHelper,
TestWebhooks,
WaitTracker,
WaitTrackerClass,
WebhookHelpers,
WebhookServer,
WorkflowExecuteAdditionalData,
WorkflowHelpers,
WorkflowRunner,
getCredentialForUser,
} from '.';
import * as config from '../config';
import * as TagHelpers from './TagHelpers';
import { InternalHooksManager } from './InternalHooksManager';
import { TagEntity } from './databases/entities/TagEntity';
import { WorkflowEntity } from './databases/entities/WorkflowEntity';
import { getSharedWorkflowIds, whereClause } from './WorkflowHelpers';
import { getCredentialTranslationPath, getNodeTranslationPath } from './TranslationHelpers';
import { WEBHOOK_METHODS } from './WebhookHelpers';
import { userManagementRouter } from './UserManagement';
import { resolveJwt } from './UserManagement/auth/jwt';
import { User } from './databases/entities/User';
import { CredentialsEntity } from './databases/entities/CredentialsEntity';
import type {
CredentialRequest,
ExecutionRequest,
WorkflowRequest,
NodeParameterOptionsRequest,
OAuthRequest,
AuthenticatedRequest,
TagsRequest,
} from './requests';
import { DEFAULT_EXECUTIONS_GET_ALL_LIMIT, validateEntity } from './GenericHelpers';
import { ExecutionEntity } from './databases/entities/ExecutionEntity';
import { SharedWorkflow } from './databases/entities/SharedWorkflow';
import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from './constants';
import { credentialsController } from './api/credentials.api';
import { getInstanceBaseUrl, isEmailSetUp } from './UserManagement/UserManagementHelper';
require('body-parser-xml')(bodyParser);
export const externalHooks: IExternalHooksClass = ExternalHooks();
class App {
app: express.Application;
activeWorkflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner;
testWebhooks: TestWebhooks.TestWebhooks;
endpointWebhook: string;
endpointWebhookWaiting: string;
endpointWebhookTest: string;
endpointPresetCredentials: string;
externalHooks: IExternalHooksClass;
waitTracker: WaitTrackerClass;
defaultWorkflowName: string;
defaultCredentialsName: string;
saveDataErrorExecution: string;
saveDataSuccessExecution: string;
saveManualExecutions: boolean;
executionTimeout: number;
maxExecutionTimeout: number;
timezone: string;
activeExecutionsInstance: ActiveExecutions.ActiveExecutions;
push: Push.Push;
versions: IPackageVersions | undefined;
restEndpoint: string;
frontendSettings: IN8nUISettings;
protocol: string;
sslKey: string;
sslCert: string;
payloadSizeMax: number;
presetCredentialsLoaded: boolean;
webhookMethods: WebhookHttpMethod[];
constructor() {
this.app = express();
this.endpointWebhook = config.get('endpoints.webhook') as string;
this.endpointWebhookWaiting = config.get('endpoints.webhookWaiting') as string;
this.endpointWebhookTest = config.get('endpoints.webhookTest') as string;
this.defaultWorkflowName = config.get('workflows.defaultName') as string;
this.defaultCredentialsName = config.get('credentials.defaultName') as string;
this.saveDataErrorExecution = config.get('executions.saveDataOnError') as string;
this.saveDataSuccessExecution = config.get('executions.saveDataOnSuccess') as string;
this.saveManualExecutions = config.get('executions.saveDataManualExecutions') as boolean;
this.executionTimeout = config.get('executions.timeout') as number;
this.maxExecutionTimeout = config.get('executions.maxTimeout') as number;
this.payloadSizeMax = config.get('endpoints.payloadSizeMax') as number;
this.timezone = config.get('generic.timezone') as string;
this.restEndpoint = config.get('endpoints.rest') as string;
this.activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
this.testWebhooks = TestWebhooks.getInstance();
this.push = Push.getInstance();
this.activeExecutionsInstance = ActiveExecutions.getInstance();
this.waitTracker = WaitTracker();
this.protocol = config.get('protocol');
this.sslKey = config.get('ssl_key');
this.sslCert = config.get('ssl_cert');
this.externalHooks = externalHooks;
this.presetCredentialsLoaded = false;
this.endpointPresetCredentials = config.get('credentials.overwrite.endpoint') as string;
const urlBaseWebhook = WebhookHelpers.getWebhookBaseUrl();
const telemetrySettings: ITelemetrySettings = {
enabled: config.get('diagnostics.enabled') as boolean,
};
if (telemetrySettings.enabled) {
const conf = config.get('diagnostics.config.frontend') as string;
const [key, url] = conf.split(';');
if (!key || !url) {
LoggerProxy.warn('Diagnostics frontend config is invalid');
telemetrySettings.enabled = false;
}
telemetrySettings.config = { key, url };
}
this.frontendSettings = {
endpointWebhook: this.endpointWebhook,
endpointWebhookTest: this.endpointWebhookTest,
saveDataErrorExecution: this.saveDataErrorExecution,
saveDataSuccessExecution: this.saveDataSuccessExecution,
saveManualExecutions: this.saveManualExecutions,
executionTimeout: this.executionTimeout,
maxExecutionTimeout: this.maxExecutionTimeout,
timezone: this.timezone,
urlBaseWebhook,
urlBaseEditor: getInstanceBaseUrl(),
versionCli: '',
oauthCallbackUrls: {
oauth1: `${urlBaseWebhook}${this.restEndpoint}/oauth1-credential/callback`,
oauth2: `${urlBaseWebhook}${this.restEndpoint}/oauth2-credential/callback`,
},
versionNotifications: {
enabled: config.get('versionNotifications.enabled'),
endpoint: config.get('versionNotifications.endpoint'),
infoUrl: config.get('versionNotifications.infoUrl'),
},
instanceId: '',
telemetry: telemetrySettings,
personalizationSurveyEnabled:
config.get('personalization.enabled') && config.get('diagnostics.enabled'),
defaultLocale: config.get('defaultLocale'),
userManagement: {
enabled:
config.get('userManagement.disabled') === false ||
config.get('userManagement.isInstanceOwnerSetUp') === true,
showSetupOnFirstLoad:
config.get('userManagement.disabled') === false &&
config.get('userManagement.isInstanceOwnerSetUp') === false &&
config.get('userManagement.skipInstanceOwnerSetup') === false,
smtpSetup: isEmailSetUp(),
},
workflowTagsDisabled: config.get('workflowTagsDisabled'),
logLevel: config.get('logs.level'),
hiringBannerEnabled: config.get('hiringBanner.enabled'),
templates: {
enabled: config.get('templates.enabled'),
host: config.get('templates.host'),
},
};
}
/**
* Returns the current epoch time
*
* @returns {number}
* @memberof App
*/
getCurrentDate(): Date {
return new Date();
}
/**
* Returns the current settings for the frontend
*/
getSettingsForFrontend(): IN8nUISettings {
// refresh user management status
Object.assign(this.frontendSettings.userManagement, {
enabled:
config.get('userManagement.disabled') === false ||
config.get('userManagement.isInstanceOwnerSetUp') === true,
showSetupOnFirstLoad:
config.get('userManagement.disabled') === false &&
config.get('userManagement.isInstanceOwnerSetUp') === false &&
config.get('userManagement.skipInstanceOwnerSetup') === false,
});
return this.frontendSettings;
}
async config(): Promise<void> {
const enableMetrics = config.get('endpoints.metrics.enable') as boolean;
let register: Registry;
if (enableMetrics) {
const prefix = config.get('endpoints.metrics.prefix') as string;
register = new promClient.Registry();
register.setDefaultLabels({ prefix });
promClient.collectDefaultMetrics({ register });
}
this.versions = await GenericHelpers.getVersions();
this.frontendSettings.versionCli = this.versions.cli;
this.frontendSettings.instanceId = await UserSettings.getInstanceId();
await this.externalHooks.run('frontend.settings', [this.frontendSettings]);
const excludeEndpoints = config.get('security.excludeEndpoints') as string;
const ignoredEndpoints = [
'healthz',
'metrics',
this.endpointWebhook,
this.endpointWebhookTest,
this.endpointPresetCredentials,
];
// eslint-disable-next-line prefer-spread
ignoredEndpoints.push.apply(ignoredEndpoints, excludeEndpoints.split(':'));
// eslint-disable-next-line no-useless-escape
const authIgnoreRegex = new RegExp(`^\/(${_(ignoredEndpoints).compact().join('|')})\/?.*$`);
// Check for basic auth credentials if activated
const basicAuthActive = config.get('security.basicAuth.active') as boolean;
if (basicAuthActive) {
const basicAuthUser = (await GenericHelpers.getConfigValue(
'security.basicAuth.user',
)) as string;
if (basicAuthUser === '') {
throw new Error('Basic auth is activated but no user got defined. Please set one!');
}
const basicAuthPassword = (await GenericHelpers.getConfigValue(
'security.basicAuth.password',
)) as string;
if (basicAuthPassword === '') {
throw new Error('Basic auth is activated but no password got defined. Please set one!');
}
const basicAuthHashEnabled = (await GenericHelpers.getConfigValue(
'security.basicAuth.hash',
)) as boolean;
let validPassword: null | string = null;
this.app.use(
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
// Skip basic auth for a few listed endpoints or when instance owner has been setup
if (
authIgnoreRegex.exec(req.url) ||
config.get('userManagement.isInstanceOwnerSetUp')
) {
return next();
}
const realm = 'n8n - Editor UI';
const basicAuthData = basicAuth(req);
if (basicAuthData === undefined) {
// Authorization data is missing
return ResponseHelper.basicAuthAuthorizationError(
res,
realm,
'Authorization is required!',
);
}
if (basicAuthData.name === basicAuthUser) {
if (basicAuthHashEnabled) {
if (
validPassword === null &&
(await compare(basicAuthData.pass, basicAuthPassword))
) {
// Password is valid so save for future requests
validPassword = basicAuthData.pass;
}
if (validPassword === basicAuthData.pass && validPassword !== null) {
// Provided hash is correct
return next();
}
} else if (basicAuthData.pass === basicAuthPassword) {
// Provided password is correct
return next();
}
}
// Provided authentication data is wrong
return ResponseHelper.basicAuthAuthorizationError(
res,
realm,
'Authorization data is wrong!',
);
},
);
}
// Check for and validate JWT if configured
const jwtAuthActive = config.get('security.jwtAuth.active') as boolean;
if (jwtAuthActive) {
const jwtAuthHeader = (await GenericHelpers.getConfigValue(
'security.jwtAuth.jwtHeader',
)) as string;
if (jwtAuthHeader === '') {
throw new Error('JWT auth is activated but no request header was defined. Please set one!');
}
const jwksUri = (await GenericHelpers.getConfigValue('security.jwtAuth.jwksUri')) as string;
if (jwksUri === '') {
throw new Error('JWT auth is activated but no JWK Set URI was defined. Please set one!');
}
const jwtHeaderValuePrefix = (await GenericHelpers.getConfigValue(
'security.jwtAuth.jwtHeaderValuePrefix',
)) as string;
const jwtIssuer = (await GenericHelpers.getConfigValue(
'security.jwtAuth.jwtIssuer',
)) as string;
const jwtNamespace = (await GenericHelpers.getConfigValue(
'security.jwtAuth.jwtNamespace',
)) as string;
const jwtAllowedTenantKey = (await GenericHelpers.getConfigValue(
'security.jwtAuth.jwtAllowedTenantKey',
)) as string;
const jwtAllowedTenant = (await GenericHelpers.getConfigValue(
'security.jwtAuth.jwtAllowedTenant',
)) as string;
// eslint-disable-next-line no-inner-declarations
function isTenantAllowed(decodedToken: object): boolean {
if (jwtNamespace === '' || jwtAllowedTenantKey === '' || jwtAllowedTenant === '')
return true;
for (const [k, v] of Object.entries(decodedToken)) {
if (k === jwtNamespace) {
for (const [kn, kv] of Object.entries(v)) {
if (kn === jwtAllowedTenantKey && kv === jwtAllowedTenant) {
return true;
}
}
}
}
return false;
}
// eslint-disable-next-line consistent-return
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
if (authIgnoreRegex.exec(req.url)) {
return next();
}
let token = req.header(jwtAuthHeader) as string;
if (token === undefined || token === '') {
return ResponseHelper.jwtAuthAuthorizationError(res, 'Missing token');
}
if (jwtHeaderValuePrefix !== '' && token.startsWith(jwtHeaderValuePrefix)) {
token = token.replace(`${jwtHeaderValuePrefix} `, '').trimLeft();
}
const jwkClient = jwks({ cache: true, jwksUri });
// eslint-disable-next-line @typescript-eslint/ban-types
function getKey(header: any, callback: Function) {
jwkClient.getSigningKey(header.kid, (err: Error, key: any) => {
// eslint-disable-next-line @typescript-eslint/no-throw-literal
if (err) throw ResponseHelper.jwtAuthAuthorizationError(res, err.message);
const signingKey = key.publicKey || key.rsaPublicKey;
callback(null, signingKey);
});
}
const jwtVerifyOptions: jwt.VerifyOptions = {
issuer: jwtIssuer !== '' ? jwtIssuer : undefined,
ignoreExpiration: false,
};
jwt.verify(token, getKey, jwtVerifyOptions, (err: jwt.VerifyErrors, decoded: object) => {
if (err) {
ResponseHelper.jwtAuthAuthorizationError(res, 'Invalid token');
} else if (!isTenantAllowed(decoded)) {
ResponseHelper.jwtAuthAuthorizationError(res, 'Tenant not allowed');
} else {
next();
}
});
});
}
// Parse cookies for easier access
this.app.use(cookieParser());
// Get push connections
this.app.use(
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (req.url.indexOf(`/${this.restEndpoint}/push`) === 0) {
if (req.query.sessionId === undefined) {
next(new Error('The query parameter "sessionId" is missing!'));
return;
}
try {
const authCookie = req.cookies?.[AUTH_COOKIE_NAME] ?? '';
await resolveJwt(authCookie);
} catch (error) {
res.status(401).send('Unauthorized');
return;
}
this.push.add(req.query.sessionId as string, req, res);
return;
}
next();
},
);
// Compress the response data
this.app.use(compression());
// Make sure that each request has the "parsedUrl" parameter
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
(req as ICustomRequest).parsedUrl = parseUrl(req);
// @ts-ignore
req.rawBody = Buffer.from('', 'base64');
next();
});
// Support application/json type post data
this.app.use(
bodyParser.json({
limit: `${this.payloadSizeMax}mb`,
verify: (req, res, buf) => {
// @ts-ignore
req.rawBody = buf;
},
}),
);
// Support application/xml type post data
this.app.use(
// @ts-ignore
bodyParser.xml({
limit: `${this.payloadSizeMax}mb`,
xmlParseOptions: {
normalize: true, // Trim whitespace inside text nodes
normalizeTags: true, // Transform tags to lowercase
explicitArray: false, // Only put properties in array if length > 1
},
}),
);
this.app.use(
bodyParser.text({
limit: `${this.payloadSizeMax}mb`,
verify: (req, res, buf) => {
// @ts-ignore
req.rawBody = buf;
},
}),
);
// Make sure that Vue history mode works properly
this.app.use(
history({
rewrites: [
{
from: new RegExp(
// eslint-disable-next-line no-useless-escape
`^\/(${this.restEndpoint}|healthz|metrics|css|js|${this.endpointWebhook}|${this.endpointWebhookTest})\/?.*$`,
),
to: (context) => {
return context.parsedUrl.pathname!.toString();
},
},
],
}),
);
// support application/x-www-form-urlencoded post data
this.app.use(
bodyParser.urlencoded({
limit: `${this.payloadSizeMax}mb`,
extended: false,
verify: (req, res, buf) => {
// @ts-ignore
req.rawBody = buf;
},
}),
);
if (process.env.NODE_ENV !== 'production') {
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
// Allow access also from frontend when developing
res.header('Access-Control-Allow-Origin', 'http://localhost:8080');
res.header('Access-Control-Allow-Credentials', 'true');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE');
res.header(
'Access-Control-Allow-Headers',
'Origin, X-Requested-With, Content-Type, Accept, sessionid',
);
next();
});
}
// eslint-disable-next-line consistent-return
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
if (Db.collections.Workflow === null) {
const error = new ResponseHelper.ResponseError('Database is not ready!', undefined, 503);
return ResponseHelper.sendErrorResponse(res, error);
}
next();
});
// ----------------------------------------
// User Management
// ----------------------------------------
await userManagementRouter.addRoutes.apply(this, [ignoredEndpoints, this.restEndpoint]);
this.app.use(`/${this.restEndpoint}/credentials`, credentialsController);
// ----------------------------------------
// Healthcheck
// ----------------------------------------
// Does very basic health check
this.app.get('/healthz', async (req: express.Request, res: express.Response) => {
LoggerProxy.debug('Health check started!');
const connection = getConnectionManager().get();
try {
if (!connection.isConnected) {
// Connection is not active
throw new Error('No active database connection!');
}
// DB ping
await connection.query('SELECT 1');
} catch (err) {
LoggerProxy.error('No Database connection!', err);
const error = new ResponseHelper.ResponseError('No Database connection!', undefined, 503);
return ResponseHelper.sendErrorResponse(res, error);
}
// Everything fine
const responseData = {
status: 'ok',
};
LoggerProxy.debug('Health check completed successfully!');
ResponseHelper.sendSuccessResponse(res, responseData, true, 200);
});
// ----------------------------------------
// Metrics
// ----------------------------------------
if (enableMetrics) {
this.app.get('/metrics', async (req: express.Request, res: express.Response) => {
const response = await register.metrics();
res.setHeader('Content-Type', register.contentType);
ResponseHelper.sendSuccessResponse(res, response, true, 200);
});
}
// ----------------------------------------
// Workflow
// ----------------------------------------
// Creates a new workflow
this.app.post(
`/${this.restEndpoint}/workflows`,
ResponseHelper.send(async (req: WorkflowRequest.Create) => {
delete req.body.id; // delete if sent
const newWorkflow = new WorkflowEntity();
Object.assign(newWorkflow, req.body);
await validateEntity(newWorkflow);
await this.externalHooks.run('workflow.create', [newWorkflow]);
const { tags: tagIds } = req.body;
if (tagIds?.length && !config.get('workflowTagsDisabled')) {
newWorkflow.tags = await Db.collections.Tag!.findByIds(tagIds, {
select: ['id', 'name'],
});
}
await WorkflowHelpers.replaceInvalidCredentials(newWorkflow);
let savedWorkflow: undefined | WorkflowEntity;
await getConnection().transaction(async (transactionManager) => {
savedWorkflow = await transactionManager.save<WorkflowEntity>(newWorkflow);
const role = await Db.collections.Role!.findOneOrFail({
name: 'owner',
scope: 'workflow',
});
const newSharedWorkflow = new SharedWorkflow();
Object.assign(newSharedWorkflow, {
role,
user: req.user,
workflow: savedWorkflow,
});
await transactionManager.save<SharedWorkflow>(newSharedWorkflow);
});
if (!savedWorkflow) {
LoggerProxy.error('Failed to create workflow', { userId: req.user.id });
throw new ResponseHelper.ResponseError('Failed to save workflow');
}
if (tagIds && !config.get('workflowTagsDisabled')) {
savedWorkflow.tags = TagHelpers.sortByRequestOrder(savedWorkflow.tags, {
requestOrder: tagIds,
});
}
await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]);
void InternalHooksManager.getInstance().onWorkflowCreated(req.user.id, newWorkflow);
const { id, ...rest } = savedWorkflow;
return {
id: id.toString(),
...rest,
};
}),
);
// Reads and returns workflow data from an URL
this.app.get(
`/${this.restEndpoint}/workflows/from-url`,
ResponseHelper.send(
async (req: express.Request, res: express.Response): Promise<IWorkflowResponse> => {
if (req.query.url === undefined) {
throw new ResponseHelper.ResponseError(
`The parameter "url" is missing!`,
undefined,
400,
);
}
if (!/^http[s]?:\/\/.*\.json$/i.exec(req.query.url as string)) {
throw new ResponseHelper.ResponseError(
`The parameter "url" is not valid! It does not seem to be a URL pointing to a n8n workflow JSON file.`,
undefined,
400,
);
}
const data = await requestPromise.get(req.query.url as string);
let workflowData: IWorkflowResponse | undefined;
try {
workflowData = JSON.parse(data);
} catch (error) {
throw new ResponseHelper.ResponseError(
`The URL does not point to valid JSON file!`,
undefined,
400,
);
}
// Do a very basic check if it is really a n8n-workflow-json
if (
workflowData === undefined ||
workflowData.nodes === undefined ||
!Array.isArray(workflowData.nodes) ||
workflowData.connections === undefined ||
typeof workflowData.connections !== 'object' ||
Array.isArray(workflowData.connections)
) {
throw new ResponseHelper.ResponseError(
`The data in the file does not seem to be a n8n workflow JSON file!`,
undefined,
400,
);
}
return workflowData;
},
),
);
// Returns workflows
this.app.get(
`/${this.restEndpoint}/workflows`,
ResponseHelper.send(async (req: WorkflowRequest.GetAll) => {
let workflows: WorkflowEntity[] = [];
const filter: Record<string, string> = req.query.filter ? JSON.parse(req.query.filter) : {};
const query: FindManyOptions<WorkflowEntity> = {
select: ['id', 'name', 'active', 'createdAt', 'updatedAt'],
relations: ['tags'],
};
if (config.get('workflowTagsDisabled')) {
delete query.relations;
}
if (req.user.globalRole.name === 'owner') {
workflows = await Db.collections.Workflow!.find(
Object.assign(query, {
where: filter,
}),
);
} else {
const shared = await Db.collections.SharedWorkflow!.find({
relations: ['workflow'],
where: whereClause({
user: req.user,
entityType: 'workflow',
}),
});
if (!shared.length) return [];
workflows = await Db.collections.Workflow!.find(
Object.assign(query, {
where: {
id: In(shared.map(({ workflow }) => workflow.id)),
...filter,
},
}),
);
}
return workflows.map((workflow) => {
const { id, ...rest } = workflow;
return {
id: id.toString(),
...rest,
};
});
}),
);
this.app.get(
`/${this.restEndpoint}/workflows/new`,
ResponseHelper.send(async (req: WorkflowRequest.NewName) => {
const requestedName =
req.query.name && req.query.name !== '' ? req.query.name : this.defaultWorkflowName;
return await GenericHelpers.generateUniqueName(requestedName, 'workflow');
}),
);
// Returns a specific workflow
this.app.get(
`/${this.restEndpoint}/workflows/:id`,
ResponseHelper.send(async (req: WorkflowRequest.Get) => {
const { id: workflowId } = req.params;
let relations = ['workflow', 'workflow.tags'];
if (config.get('workflowTagsDisabled')) {
relations = relations.filter((relation) => relation !== 'workflow.tags');
}
const shared = await Db.collections.SharedWorkflow!.findOne({
relations,
where: whereClause({
user: req.user,
entityType: 'workflow',
entityId: workflowId,
}),
});
if (!shared) {
LoggerProxy.info('User attempted to access a workflow without permissions', {
workflowId,
userId: req.user.id,
});
throw new ResponseHelper.ResponseError(
`Workflow with ID "${workflowId}" could not be found.`,
undefined,
404,
);
}
const {
workflow: { id, ...rest },
} = shared;
return {
id: id.toString(),
...rest,
};
}),
);
// Updates an existing workflow
this.app.patch(
`/${this.restEndpoint}/workflows/:id`,
ResponseHelper.send(async (req: WorkflowRequest.Update) => {
const { id: workflowId } = req.params;
const updateData = new WorkflowEntity();
const { tags, ...rest } = req.body;
Object.assign(updateData, rest);
const shared = await Db.collections.SharedWorkflow!.findOne({
relations: ['workflow'],
where: whereClause({
user: req.user,
entityType: 'workflow',
entityId: workflowId,
}),
});
if (!shared) {
LoggerProxy.info('User attempted to update a workflow without permissions', {
workflowId,
userId: req.user.id,
});
throw new ResponseHelper.ResponseError(
`Workflow with ID "${workflowId}" could not be found to be updated.`,
undefined,
404,
);
}
// check credentials for old format
await WorkflowHelpers.replaceInvalidCredentials(updateData);
await this.externalHooks.run('workflow.update', [updateData]);
if (shared.workflow.active) {
// When workflow gets saved always remove it as the triggers could have been
// changed and so the changes would not take effect
await this.activeWorkflowRunner.remove(workflowId);
}
if (updateData.settings) {
if (updateData.settings.timezone === 'DEFAULT') {
// Do not save the default timezone
delete updateData.settings.timezone;
}
if (updateData.settings.saveDataErrorExecution === 'DEFAULT') {
// Do not save when default got set
delete updateData.settings.saveDataErrorExecution;
}
if (updateData.settings.saveDataSuccessExecution === 'DEFAULT') {
// Do not save when default got set
delete updateData.settings.saveDataSuccessExecution;
}
if (updateData.settings.saveManualExecutions === 'DEFAULT') {
// Do not save when default got set
delete updateData.settings.saveManualExecutions;
}
if (
parseInt(updateData.settings.executionTimeout as string, 10) === this.executionTimeout
) {
// Do not save when default got set
delete updateData.settings.executionTimeout;
}
}
if (updateData.name) {
updateData.updatedAt = this.getCurrentDate(); // required due to atomic update
await validateEntity(updateData);
}
await Db.collections.Workflow!.update(workflowId, updateData);
if (tags && !config.get('workflowTagsDisabled')) {
const tablePrefix = config.get('database.tablePrefix');
await TagHelpers.removeRelations(workflowId, tablePrefix);
if (tags.length) {
await TagHelpers.createRelations(workflowId, tags, tablePrefix);
}
}
const options: FindManyOptions<WorkflowEntity> = {
relations: ['tags'],
};
if (config.get('workflowTagsDisabled')) {
delete options.relations;
}
// We sadly get nothing back from "update". Neither if it updated a record
// nor the new value. So query now the hopefully updated entry.
const updatedWorkflow = await Db.collections.Workflow!.findOne(workflowId, options);
if (updatedWorkflow === undefined) {
throw new ResponseHelper.ResponseError(
`Workflow with ID "${workflowId}" could not be found to be updated.`,
undefined,
400,
);
}
if (updatedWorkflow.tags.length && tags?.length) {
updatedWorkflow.tags = TagHelpers.sortByRequestOrder(updatedWorkflow.tags, {
requestOrder: tags,
});
}
await this.externalHooks.run('workflow.afterUpdate', [updatedWorkflow]);
// @ts-ignore
void InternalHooksManager.getInstance().onWorkflowSaved(req.user.id, updatedWorkflow);
if (updatedWorkflow.active) {
// When the workflow is supposed to be active add it again
try {
await this.externalHooks.run('workflow.activate', [updatedWorkflow]);
await this.activeWorkflowRunner.add(
workflowId,
shared.workflow.active ? 'update' : 'activate',
);
} catch (error) {
// If workflow could not be activated set it again to inactive
updateData.active = false;
// @ts-ignore
await Db.collections.Workflow!.update(workflowId, updateData);
// Also set it in the returned data
updatedWorkflow.active = false;
// Now return the original error for UI to display
throw error;
}
}
const { id, ...remainder } = updatedWorkflow;
return {
id: id.toString(),
...remainder,
};
}),
);
// Deletes a specific workflow
this.app.delete(
`/${this.restEndpoint}/workflows/:id`,
ResponseHelper.send(async (req: WorkflowRequest.Delete) => {
const { id: workflowId } = req.params;
await this.externalHooks.run('workflow.delete', [workflowId]);
const shared = await Db.collections.SharedWorkflow!.findOne({
relations: ['workflow'],
where: whereClause({
user: req.user,
entityType: 'workflow',
entityId: workflowId,
}),
});
if (!shared) {
LoggerProxy.info('User attempted to delete a workflow without permissions', {
workflowId,
userId: req.user.id,
});
throw new ResponseHelper.ResponseError(
`Workflow with ID "${workflowId}" could not be found to be deleted.`,
undefined,
400,
);
}
if (shared.workflow.active) {
// deactivate before deleting
await this.activeWorkflowRunner.remove(workflowId);
}
await Db.collections.Workflow!.delete(workflowId);
void InternalHooksManager.getInstance().onWorkflowDeleted(req.user.id, workflowId);
await this.externalHooks.run('workflow.afterDelete', [workflowId]);
return true;
}),
);
this.app.post(
`/${this.restEndpoint}/workflows/run`,
ResponseHelper.send(
async (
req: WorkflowRequest.ManualRun,
res: express.Response,
): Promise<IExecutionPushResponse> => {
const { workflowData } = req.body;
const { runData } = req.body;
const { startNodes } = req.body;
const { destinationNode } = req.body;
const executionMode = 'manual';
const activationMode = 'manual';
const sessionId = GenericHelpers.getSessionId(req);
// If webhooks nodes exist and are active we have to wait for till we receive a call
if (
runData === undefined ||
startNodes === undefined ||
startNodes.length === 0 ||
destinationNode === undefined
) {
const additionalData = await WorkflowExecuteAdditionalData.getBase(req.user.id);
const nodeTypes = NodeTypes();
const workflowInstance = new Workflow({
id: workflowData.id?.toString(),
name: workflowData.name,
nodes: workflowData.nodes!,
connections: workflowData.connections!,
active: false,
nodeTypes,
staticData: undefined,
settings: workflowData.settings,
});
const needsWebhook = await this.testWebhooks.needsWebhookData(
workflowData,
workflowInstance,
additionalData,
executionMode,
activationMode,
sessionId,
destinationNode,
);
if (needsWebhook) {
return {
waitingForWebhook: true,
};
}
}
// For manual testing always set to not active
workflowData.active = false;
// Start the workflow
const data: IWorkflowExecutionDataProcess = {
destinationNode,
executionMode,
runData,
sessionId,
startNodes,
workflowData,
userId: req.user.id,
};
const workflowRunner = new WorkflowRunner();
const executionId = await workflowRunner.run(data);
return {
executionId,
};
},
),
);
// Retrieves all tags, with or without usage count
this.app.get(
`/${this.restEndpoint}/tags`,
ResponseHelper.send(
async (
req: express.Request,
res: express.Response,
): Promise<TagEntity[] | ITagWithCountDb[]> => {
if (config.get('workflowTagsDisabled')) {
throw new ResponseHelper.ResponseError('Workflow tags are disabled');
}
if (req.query.withUsageCount === 'true') {
const tablePrefix = config.get('database.tablePrefix');
return TagHelpers.getTagsWithCountDb(tablePrefix);
}
return Db.collections.Tag!.find({ select: ['id', 'name'] });
},
),
);
// Creates a tag
this.app.post(
`/${this.restEndpoint}/tags`,
ResponseHelper.send(
async (req: express.Request, res: express.Response): Promise<TagEntity | void> => {
if (config.get('workflowTagsDisabled')) {
throw new ResponseHelper.ResponseError('Workflow tags are disabled');
}
const newTag = new TagEntity();
newTag.name = req.body.name.trim();
await this.externalHooks.run('tag.beforeCreate', [newTag]);
await validateEntity(newTag);
const tag = await Db.collections.Tag!.save(newTag);
await this.externalHooks.run('tag.afterCreate', [tag]);
return tag;
},
),
);
// Updates a tag
this.app.patch(
`/${this.restEndpoint}/tags/:id`,
ResponseHelper.send(
async (req: express.Request, res: express.Response): Promise<TagEntity | void> => {
if (config.get('workflowTagsDisabled')) {
throw new ResponseHelper.ResponseError('Workflow tags are disabled');
}
const { name } = req.body;
const { id } = req.params;
const newTag = new TagEntity();
// @ts-ignore
newTag.id = id;
newTag.name = name.trim();
await this.externalHooks.run('tag.beforeUpdate', [newTag]);
await validateEntity(newTag);
const tag = await Db.collections.Tag!.save(newTag);
await this.externalHooks.run('tag.afterUpdate', [tag]);
return tag;
},
),
);
// Deletes a tag
this.app.delete(
`/${this.restEndpoint}/tags/:id`,
ResponseHelper.send(
async (req: TagsRequest.Delete, res: express.Response): Promise<boolean> => {
if (config.get('workflowTagsDisabled')) {
throw new ResponseHelper.ResponseError('Workflow tags are disabled');
}
if (
config.get('userManagement.isInstanceOwnerSetUp') === true &&
req.user.globalRole.name !== 'owner'
) {
throw new ResponseHelper.ResponseError(
'You are not allowed to perform this action',
undefined,
403,
'Only owners can remove tags',
);
}
const id = Number(req.params.id);
await this.externalHooks.run('tag.beforeDelete', [id]);
await Db.collections.Tag!.delete({ id });
await this.externalHooks.run('tag.afterDelete', [id]);
return true;
},
),
);
// Returns parameter values which normally get loaded from an external API or
// get generated dynamically
this.app.get(
`/${this.restEndpoint}/node-parameter-options`,
ResponseHelper.send(
async (req: NodeParameterOptionsRequest): Promise<INodePropertyOptions[]> => {
const nodeTypeAndVersion = JSON.parse(
req.query.nodeTypeAndVersion,
) as INodeTypeNameVersion;
const { path, methodName } = req.query;
const currentNodeParameters = JSON.parse(
req.query.currentNodeParameters,
) as INodeParameters;
let credentials: INodeCredentials | undefined;
if (req.query.credentials) {
credentials = JSON.parse(req.query.credentials);
}
const loadDataInstance = new LoadNodeParameterOptions(
nodeTypeAndVersion,
NodeTypes(),
path,
currentNodeParameters,
credentials,
);
const additionalData = await WorkflowExecuteAdditionalData.getBase(
req.user.id,
currentNodeParameters,
);
if (methodName) {
return loadDataInstance.getOptionsViaMethodName(methodName, additionalData);
}
// @ts-ignore
if (req.query.loadOptions) {
return loadDataInstance.getOptionsViaRequestProperty(
// @ts-ignore
JSON.parse(req.query.loadOptions as string),
additionalData,
);
}
return [];
},
),
);
// Returns all the node-types
this.app.get(
`/${this.restEndpoint}/node-types`,
ResponseHelper.send(
async (req: express.Request, res: express.Response): Promise<INodeTypeDescription[]> => {
const returnData: INodeTypeDescription[] = [];
const onlyLatest = req.query.onlyLatest === 'true';
const nodeTypes = NodeTypes();
const allNodes = nodeTypes.getAll();
const getNodeDescription = (nodeType: INodeType): INodeTypeDescription => {
const nodeInfo: INodeTypeDescription = { ...nodeType.description };
if (req.query.includeProperties !== 'true') {
// @ts-ignore
delete nodeInfo.properties;
}
return nodeInfo;
};
if (onlyLatest) {
allNodes.forEach((nodeData) => {
const nodeType = NodeHelpers.getVersionedNodeType(nodeData);
const nodeInfo: INodeTypeDescription = getNodeDescription(nodeType);
returnData.push(nodeInfo);
});
} else {
allNodes.forEach((nodeData) => {
const allNodeTypes = NodeHelpers.getVersionedNodeTypeAll(nodeData);
allNodeTypes.forEach((element) => {
const nodeInfo: INodeTypeDescription = getNodeDescription(element);
returnData.push(nodeInfo);
});
});
}
return returnData;
},
),
);
this.app.get(
`/${this.restEndpoint}/credential-translation`,
ResponseHelper.send(
async (
req: express.Request & { query: { credentialType: string } },
res: express.Response,
): Promise<object | null> => {
const translationPath = getCredentialTranslationPath({
locale: this.frontendSettings.defaultLocale,
credentialType: req.query.credentialType,
});
try {
return require(translationPath);
} catch (error) {
return null;
}
},
),
);
// Returns node information based on node names and versions
this.app.post(
`/${this.restEndpoint}/node-types`,
ResponseHelper.send(
async (req: express.Request, res: express.Response): Promise<INodeTypeDescription[]> => {
const nodeInfos = _.get(req, 'body.nodeInfos', []) as INodeTypeNameVersion[];
const { defaultLocale } = this.frontendSettings;
if (defaultLocale === 'en') {
return nodeInfos.reduce<INodeTypeDescription[]>((acc, { name, version }) => {
const { description } = NodeTypes().getByNameAndVersion(name, version);
acc.push(description);
return acc;
}, []);
}
async function populateTranslation(
name: string,
version: number,
nodeTypes: INodeTypeDescription[],
) {
const { description, sourcePath } = NodeTypes().getWithSourcePath(name, version);
const translationPath = await getNodeTranslationPath({
nodeSourcePath: sourcePath,
longNodeType: description.name,
locale: defaultLocale,
});
try {
const translation = await readFile(translationPath, 'utf8');
description.translation = JSON.parse(translation);
} catch (error) {
// ignore - no translation exists at path
}
nodeTypes.push(description);
}
const nodeTypes: INodeTypeDescription[] = [];
const promises = nodeInfos.map(async ({ name, version }) =>
populateTranslation(name, version, nodeTypes),
);
await Promise.all(promises);
return nodeTypes;
},
),
);
// Returns node information based on node names and versions
this.app.get(
`/${this.restEndpoint}/node-translation-headers`,
ResponseHelper.send(
async (req: express.Request, res: express.Response): Promise<object | void> => {
const packagesPath = pathJoin(__dirname, '..', '..', '..');
const headersPath = pathJoin(packagesPath, 'nodes-base', 'dist', 'nodes', 'headers');
try {
return require(headersPath);
} catch (error) {
res.status(500).send('Failed to find headers file');
}
},
),
);
// ----------------------------------------
// Node-Types
// ----------------------------------------
// Returns the node icon
this.app.get(
[
`/${this.restEndpoint}/node-icon/:nodeType`,
`/${this.restEndpoint}/node-icon/:scope/:nodeType`,
],
async (req: express.Request, res: express.Response): Promise<void> => {
try {
const nodeTypeName = `${req.params.scope ? `${req.params.scope}/` : ''}${
req.params.nodeType
}`;
const nodeTypes = NodeTypes();
const nodeType = nodeTypes.getByNameAndVersion(nodeTypeName);
if (nodeType === undefined) {
res.status(404).send('The nodeType is not known.');
return;
}
if (nodeType.description.icon === undefined) {
res.status(404).send('No icon found for node.');
return;
}
if (!nodeType.description.icon.startsWith('file:')) {
res.status(404).send('Node does not have a file icon.');
return;
}
const filepath = nodeType.description.icon.substr(5);
const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days
res.setHeader('Cache-control', `private max-age=${maxAge}`);
res.sendFile(filepath);
} catch (error) {
// Error response
return ResponseHelper.sendErrorResponse(res, error);
}
},
);
// ----------------------------------------
// Active Workflows
// ----------------------------------------
// Returns the active workflow ids
this.app.get(
`/${this.restEndpoint}/active`,
ResponseHelper.send(async (req: WorkflowRequest.GetAllActive) => {
const activeWorkflows = await this.activeWorkflowRunner.getActiveWorkflows(req.user);
return activeWorkflows.map(({ id }) => id.toString());
}),
);
// Returns if the workflow with the given id had any activation errors
this.app.get(
`/${this.restEndpoint}/active/error/:id`,
ResponseHelper.send(async (req: WorkflowRequest.GetAllActivationErrors) => {
const { id: workflowId } = req.params;
const shared = await Db.collections.SharedWorkflow!.findOne({
relations: ['workflow'],
where: whereClause({
user: req.user,
entityType: 'workflow',
entityId: workflowId,
}),
});
if (!shared) {
LoggerProxy.info('User attempted to access workflow errors without permissions', {
workflowId,
userId: req.user.id,
});
throw new ResponseHelper.ResponseError(
`Workflow with ID "${workflowId}" could not be found.`,
undefined,
400,
);
}
return this.activeWorkflowRunner.getActivationError(workflowId);
}),
);
// ----------------------------------------
// Credential-Types
// ----------------------------------------
// Returns all the credential types which are defined in the loaded n8n-modules
this.app.get(
`/${this.restEndpoint}/credential-types`,
ResponseHelper.send(
async (req: express.Request, res: express.Response): Promise<ICredentialType[]> => {
const returnData: ICredentialType[] = [];
const credentialTypes = CredentialTypes();
credentialTypes.getAll().forEach((credentialData) => {
returnData.push(credentialData);
});
return returnData;
},
),
);
this.app.get(
`/${this.restEndpoint}/credential-icon/:credentialType`,
async (req: express.Request, res: express.Response): Promise<void> => {
try {
const credentialName = req.params.credentialType;
const credentialType = CredentialTypes().getByName(credentialName);
if (credentialType === undefined) {
res.status(404).send('The credentialType is not known.');
return;
}
if (credentialType.icon === undefined) {
res.status(404).send('No icon found for credential.');
return;
}
if (!credentialType.icon.startsWith('file:')) {
res.status(404).send('Credential does not have a file icon.');
return;
}
const filepath = credentialType.icon.substr(5);
const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days
res.setHeader('Cache-control', `private max-age=${maxAge}`);
res.sendFile(filepath);
} catch (error) {
// Error response
return ResponseHelper.sendErrorResponse(res, error);
}
},
);
// ----------------------------------------
// OAuth1-Credential/Auth
// ----------------------------------------
// Authorize OAuth Data
this.app.get(
`/${this.restEndpoint}/oauth1-credential/auth`,
ResponseHelper.send(async (req: OAuthRequest.OAuth1Credential.Auth): Promise<string> => {
const { id: credentialId } = req.query;
if (!credentialId) {
LoggerProxy.error('OAuth1 credential authorization failed due to missing credential ID');
throw new ResponseHelper.ResponseError(
'Required credential ID is missing',
undefined,
400,
);
}
const credential = await getCredentialForUser(credentialId, req.user);
if (!credential) {
LoggerProxy.error(
'OAuth1 credential authorization failed because the current user does not have the correct permissions',
{ userId: req.user.id },
);
throw new ResponseHelper.ResponseError(
RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL,
undefined,
404,
);
}
const encryptionKey = await UserSettings.getEncryptionKey();
if (!encryptionKey) {
throw new ResponseHelper.ResponseError(
RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY,
undefined,
500,
);
}
const mode: WorkflowExecuteMode = 'internal';
const credentialsHelper = new CredentialsHelper(encryptionKey);
const decryptedDataOriginal = await credentialsHelper.getDecrypted(
credential as INodeCredentialsDetails,
credential.type,
mode,
true,
);
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(
decryptedDataOriginal,
credential.type,
mode,
);
const signatureMethod = _.get(oauthCredentials, 'signatureMethod') as string;
const oAuthOptions: clientOAuth1.Options = {
consumer: {
key: _.get(oauthCredentials, 'consumerKey') as string,
secret: _.get(oauthCredentials, 'consumerSecret') as string,
},
signature_method: signatureMethod,
// eslint-disable-next-line @typescript-eslint/naming-convention
hash_function(base, key) {
const algorithm = signatureMethod === 'HMAC-SHA1' ? 'sha1' : 'sha256';
return createHmac(algorithm, key).update(base).digest('base64');
},
};
const oauthRequestData = {
oauth_callback: `${WebhookHelpers.getWebhookBaseUrl()}${
this.restEndpoint
}/oauth1-credential/callback?cid=${credentialId}`,
};
await this.externalHooks.run('oauth1.authenticate', [oAuthOptions, oauthRequestData]);
// eslint-disable-next-line new-cap
const oauth = new clientOAuth1(oAuthOptions);
const options: RequestOptions = {
method: 'POST',
url: _.get(oauthCredentials, 'requestTokenUrl') as string,
data: oauthRequestData,
};
const data = oauth.toHeader(oauth.authorize(options));
// @ts-ignore
options.headers = data;
const response = await requestPromise(options);
// Response comes as x-www-form-urlencoded string so convert it to JSON
const responseJson = querystring.parse(response);
const returnUri = `${_.get(oauthCredentials, 'authUrl')}?oauth_token=${
responseJson.oauth_token
}`;
// Encrypt the data
const credentials = new Credentials(
credential as INodeCredentialsDetails,
credential.type,
credential.nodesAccess,
);
credentials.setData(decryptedDataOriginal, encryptionKey);
const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb;
// Add special database related data
newCredentialsData.updatedAt = this.getCurrentDate();
// Update the credentials in DB
await Db.collections.Credentials!.update(credentialId, newCredentialsData);
LoggerProxy.verbose('OAuth1 authorization successful for new credential', {
userId: req.user.id,
credentialId,
});
return returnUri;
}),
);
// Verify and store app code. Generate access tokens and store for respective credential.
this.app.get(
`/${this.restEndpoint}/oauth1-credential/callback`,
async (req: OAuthRequest.OAuth1Credential.Callback, res: express.Response) => {
try {
const { oauth_verifier, oauth_token, cid: credentialId } = req.query;
if (!oauth_verifier || !oauth_token) {
const errorResponse = new ResponseHelper.ResponseError(
`Insufficient parameters for OAuth1 callback. Received following query parameters: ${JSON.stringify(
req.query,
)}`,
undefined,
503,
);
LoggerProxy.error(
'OAuth1 callback failed because of insufficient parameters received',
{
userId: req.user.id,
credentialId,
},
);
return ResponseHelper.sendErrorResponse(res, errorResponse);
}
const credential = await getCredentialForUser(credentialId, req.user);
if (!credential) {
LoggerProxy.error('OAuth1 callback failed because of insufficient user permissions', {
userId: req.user.id,
credentialId,
});
const errorResponse = new ResponseHelper.ResponseError(
RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL,
undefined,
404,
);
return ResponseHelper.sendErrorResponse(res, errorResponse);
}
const encryptionKey = await UserSettings.getEncryptionKey();
if (!encryptionKey) {
const errorResponse = new ResponseHelper.ResponseError(
RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY,
undefined,
503,
);
return ResponseHelper.sendErrorResponse(res, errorResponse);
}
const mode: WorkflowExecuteMode = 'internal';
const credentialsHelper = new CredentialsHelper(encryptionKey);
const decryptedDataOriginal = await credentialsHelper.getDecrypted(
credential as INodeCredentialsDetails,
credential.type,
mode,
true,
);
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(
decryptedDataOriginal,
credential.type,
mode,
);
const options: OptionsWithUrl = {
method: 'POST',
url: _.get(oauthCredentials, 'accessTokenUrl') as string,
qs: {
oauth_token,
oauth_verifier,
},
};
let oauthToken;
try {
oauthToken = await requestPromise(options);
} catch (error) {
LoggerProxy.error('Unable to fetch tokens for OAuth1 callback', {
userId: req.user.id,
credentialId,
});
const errorResponse = new ResponseHelper.ResponseError(
'Unable to get access tokens!',
undefined,
404,
);
return ResponseHelper.sendErrorResponse(res, errorResponse);
}
// Response comes as x-www-form-urlencoded string so convert it to JSON
const oauthTokenJson = querystring.parse(oauthToken);
decryptedDataOriginal.oauthTokenData = oauthTokenJson;
const credentials = new Credentials(
credential as INodeCredentialsDetails,
credential.type,
credential.nodesAccess,
);
credentials.setData(decryptedDataOriginal, encryptionKey);
const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb;
// Add special database related data
newCredentialsData.updatedAt = this.getCurrentDate();
// Save the credentials in DB
await Db.collections.Credentials!.update(credentialId, newCredentialsData);
LoggerProxy.verbose('OAuth1 callback successful for new credential', {
userId: req.user.id,
credentialId,
});
res.sendFile(pathResolve(__dirname, '../../templates/oauth-callback.html'));
} catch (error) {
LoggerProxy.error('OAuth1 callback failed because of insufficient user permissions', {
userId: req.user.id,
credentialId: req.query.cid,
});
// Error response
return ResponseHelper.sendErrorResponse(res, error);
}
},
);
// ----------------------------------------
// OAuth2-Credential/Auth
// ----------------------------------------
// Authorize OAuth Data
this.app.get(
`/${this.restEndpoint}/oauth2-credential/auth`,
ResponseHelper.send(async (req: OAuthRequest.OAuth2Credential.Auth): Promise<string> => {
const { id: credentialId } = req.query;
if (!credentialId) {
throw new ResponseHelper.ResponseError(
'Required credential ID is missing',
undefined,
400,
);
}
const credential = await getCredentialForUser(credentialId, req.user);
if (!credential) {
LoggerProxy.error('Failed to authorize OAuth2 due to lack of permissions', {
userId: req.user.id,
credentialId,
});
throw new ResponseHelper.ResponseError(
RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL,
undefined,
404,
);
}
const encryptionKey = await UserSettings.getEncryptionKey();
if (!encryptionKey) {
throw new ResponseHelper.ResponseError(
RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY,
undefined,
500,
);
}
const mode: WorkflowExecuteMode = 'internal';
const credentialsHelper = new CredentialsHelper(encryptionKey);
const decryptedDataOriginal = await credentialsHelper.getDecrypted(
credential as INodeCredentialsDetails,
credential.type,
mode,
true,
);
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(
decryptedDataOriginal,
credential.type,
mode,
);
const token = new csrf();
// Generate a CSRF prevention token and send it as a OAuth2 state stringma/ERR
const csrfSecret = token.secretSync();
const state = {
token: token.create(csrfSecret),
cid: req.query.id,
};
const stateEncodedStr = Buffer.from(JSON.stringify(state)).toString('base64');
const oAuthOptions: clientOAuth2.Options = {
clientId: _.get(oauthCredentials, 'clientId') as string,
clientSecret: _.get(oauthCredentials, 'clientSecret', '') as string,
accessTokenUri: _.get(oauthCredentials, 'accessTokenUrl', '') as string,
authorizationUri: _.get(oauthCredentials, 'authUrl', '') as string,
redirectUri: `${WebhookHelpers.getWebhookBaseUrl()}${
this.restEndpoint
}/oauth2-credential/callback`,
scopes: _.split(_.get(oauthCredentials, 'scope', 'openid,') as string, ','),
state: stateEncodedStr,
};
await this.externalHooks.run('oauth2.authenticate', [oAuthOptions]);
const oAuthObj = new clientOAuth2(oAuthOptions);
// Encrypt the data
const credentials = new Credentials(
credential as INodeCredentialsDetails,
credential.type,
credential.nodesAccess,
);
decryptedDataOriginal.csrfSecret = csrfSecret;
credentials.setData(decryptedDataOriginal, encryptionKey);
const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb;
// Add special database related data
newCredentialsData.updatedAt = this.getCurrentDate();
// Update the credentials in DB
await Db.collections.Credentials!.update(req.query.id as string, newCredentialsData);
const authQueryParameters = _.get(oauthCredentials, 'authQueryParameters', '') as string;
let returnUri = oAuthObj.code.getUri();
// if scope uses comma, change it as the library always return then with spaces
if ((_.get(oauthCredentials, 'scope') as string).includes(',')) {
const data = querystring.parse(returnUri.split('?')[1]);
data.scope = _.get(oauthCredentials, 'scope') as string;
returnUri = `${_.get(oauthCredentials, 'authUrl', '')}?${querystring.stringify(data)}`;
}
if (authQueryParameters) {
returnUri += `&${authQueryParameters}`;
}
LoggerProxy.verbose('OAuth2 authentication successful for new credential', {
userId: req.user.id,
credentialId,
});
return returnUri;
}),
);
// ----------------------------------------
// OAuth2-Credential/Callback
// ----------------------------------------
// Verify and store app code. Generate access tokens and store for respective credential.
this.app.get(
`/${this.restEndpoint}/oauth2-credential/callback`,
async (req: OAuthRequest.OAuth2Credential.Callback, res: express.Response) => {
try {
// realmId it's currently just use for the quickbook OAuth2 flow
const { code, state: stateEncoded } = req.query;
if (!code || !stateEncoded) {
const errorResponse = new ResponseHelper.ResponseError(
`Insufficient parameters for OAuth2 callback. Received following query parameters: ${JSON.stringify(
req.query,
)}`,
undefined,
503,
);
return ResponseHelper.sendErrorResponse(res, errorResponse);
}
let state;
try {
state = JSON.parse(Buffer.from(stateEncoded, 'base64').toString());
} catch (error) {
const errorResponse = new ResponseHelper.ResponseError(
'Invalid state format returned',
undefined,
503,
);
return ResponseHelper.sendErrorResponse(res, errorResponse);
}
const credential = await getCredentialForUser(state.cid, req.user);
if (!credential) {
LoggerProxy.error('OAuth2 callback failed because of insufficient permissions', {
userId: req.user.id,
credentialId: state.cid,
});
const errorResponse = new ResponseHelper.ResponseError(
RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL,
undefined,
404,
);
return ResponseHelper.sendErrorResponse(res, errorResponse);
}
const encryptionKey = await UserSettings.getEncryptionKey();
if (!encryptionKey) {
const errorResponse = new ResponseHelper.ResponseError(
RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY,
undefined,
503,
);
return ResponseHelper.sendErrorResponse(res, errorResponse);
}
const mode: WorkflowExecuteMode = 'internal';
const credentialsHelper = new CredentialsHelper(encryptionKey);
const decryptedDataOriginal = await credentialsHelper.getDecrypted(
credential as INodeCredentialsDetails,
credential.type,
mode,
true,
);
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(
decryptedDataOriginal,
credential.type,
mode,
);
const token = new csrf();
if (
decryptedDataOriginal.csrfSecret === undefined ||
!token.verify(decryptedDataOriginal.csrfSecret as string, state.token)
) {
LoggerProxy.debug('OAuth2 callback state is invalid', {
userId: req.user.id,
credentialId: state.cid,
});
const errorResponse = new ResponseHelper.ResponseError(
'The OAuth2 callback state is invalid!',
undefined,
404,
);
return ResponseHelper.sendErrorResponse(res, errorResponse);
}
let options = {};
const oAuth2Parameters = {
clientId: _.get(oauthCredentials, 'clientId') as string,
clientSecret: _.get(oauthCredentials, 'clientSecret', '') as string | undefined,
accessTokenUri: _.get(oauthCredentials, 'accessTokenUrl', '') as string,
authorizationUri: _.get(oauthCredentials, 'authUrl', '') as string,
redirectUri: `${WebhookHelpers.getWebhookBaseUrl()}${
this.restEndpoint
}/oauth2-credential/callback`,
scopes: _.split(_.get(oauthCredentials, 'scope', 'openid,') as string, ','),
};
if ((_.get(oauthCredentials, 'authentication', 'header') as string) === 'body') {
options = {
body: {
client_id: _.get(oauthCredentials, 'clientId') as string,
client_secret: _.get(oauthCredentials, 'clientSecret', '') as string,
},
};
delete oAuth2Parameters.clientSecret;
}
await this.externalHooks.run('oauth2.callback', [oAuth2Parameters]);
const oAuthObj = new clientOAuth2(oAuth2Parameters);
const queryParameters = req.originalUrl.split('?').splice(1, 1).join('');
const oauthToken = await oAuthObj.code.getToken(
`${oAuth2Parameters.redirectUri}?${queryParameters}`,
options,
);
if (Object.keys(req.query).length > 2) {
_.set(oauthToken.data, 'callbackQueryString', _.omit(req.query, 'state', 'code'));
}
if (oauthToken === undefined) {
LoggerProxy.error('OAuth2 callback failed: unable to get access tokens', {
userId: req.user.id,
credentialId: state.cid,
});
const errorResponse = new ResponseHelper.ResponseError(
'Unable to get access tokens!',
undefined,
404,
);
return ResponseHelper.sendErrorResponse(res, errorResponse);
}
if (decryptedDataOriginal.oauthTokenData) {
// Only overwrite supplied data as some providers do for example just return the
// refresh_token on the very first request and not on subsequent ones.
Object.assign(decryptedDataOriginal.oauthTokenData, oauthToken.data);
} else {
// No data exists so simply set
decryptedDataOriginal.oauthTokenData = oauthToken.data;
}
_.unset(decryptedDataOriginal, 'csrfSecret');
const credentials = new Credentials(
credential as INodeCredentialsDetails,
credential.type,
credential.nodesAccess,
);
credentials.setData(decryptedDataOriginal, encryptionKey);
const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb;
// Add special database related data
newCredentialsData.updatedAt = this.getCurrentDate();
// Save the credentials in DB
await Db.collections.Credentials!.update(state.cid, newCredentialsData);
LoggerProxy.verbose('OAuth2 callback successful for new credential', {
userId: req.user.id,
credentialId: state.cid,
});
res.sendFile(pathResolve(__dirname, '../../templates/oauth-callback.html'));
} catch (error) {
// Error response
return ResponseHelper.sendErrorResponse(res, error);
}
},
);
// ----------------------------------------
// Executions
// ----------------------------------------
// Returns all finished executions
this.app.get(
`/${this.restEndpoint}/executions`,
ResponseHelper.send(
async (req: ExecutionRequest.GetAll): Promise<IExecutionsListResponse> => {
const filter = req.query.filter ? JSON.parse(req.query.filter) : {};
const limit = req.query.limit
? parseInt(req.query.limit, 10)
: DEFAULT_EXECUTIONS_GET_ALL_LIMIT;
const executingWorkflowIds: string[] = [];
if (config.get('executions.mode') === 'queue') {
const currentJobs = await Queue.getInstance().getJobs(['active', 'waiting']);
executingWorkflowIds.push(...currentJobs.map(({ data }) => data.executionId));
}
// We may have manual executions even with queue so we must account for these.
executingWorkflowIds.push(
...this.activeExecutionsInstance.getActiveExecutions().map(({ id }) => id),
);
const countFilter = cloneDeep(filter);
countFilter.waitTill &&= Not(IsNull());
countFilter.id = Not(In(executingWorkflowIds));
const sharedWorkflowIds = await getSharedWorkflowIds(req.user);
const findOptions: FindManyOptions<ExecutionEntity> = {
select: [
'id',
'finished',
'mode',
'retryOf',
'retrySuccessId',
'waitTill',
'startedAt',
'stoppedAt',
'workflowData',
],
where: { workflowId: In(sharedWorkflowIds) },
order: { id: 'DESC' },
take: limit,
};
Object.entries(filter).forEach(([key, value]) => {
let filterToAdd = {};
if (key === 'waitTill') {
filterToAdd = { waitTill: !IsNull() };
} else if (key === 'finished' && value === false) {
filterToAdd = { finished: false, waitTill: IsNull() };
} else {
filterToAdd = { [key]: value };
}
Object.assign(findOptions.where, filterToAdd);
});
const rangeQuery: string[] = [];
const rangeQueryParams: {
lastId?: string;
firstId?: string;
executingWorkflowIds?: string[];
} = {};
if (req.query.lastId) {
rangeQuery.push('id < :lastId');
rangeQueryParams.lastId = req.query.lastId;
}
if (req.query.firstId) {
rangeQuery.push('id > :firstId');
rangeQueryParams.firstId = req.query.firstId;
}
if (executingWorkflowIds.length > 0) {
rangeQuery.push(`id NOT IN (:...executingWorkflowIds)`);
rangeQueryParams.executingWorkflowIds = executingWorkflowIds;
}
if (rangeQuery.length) {
Object.assign(findOptions.where, {
id: Raw(() => rangeQuery.join(' and '), rangeQueryParams),
});
}
const executions = await Db.collections.Execution!.find(findOptions);
const { count, estimated } = await getExecutionsCount(countFilter, req.user);
const formattedExecutions = executions.map((execution) => {
return {
id: execution.id.toString(),
finished: execution.finished,
mode: execution.mode,
retryOf: execution.retryOf?.toString(),
retrySuccessId: execution?.retrySuccessId?.toString(),
waitTill: execution.waitTill as Date | undefined,
startedAt: execution.startedAt,
stoppedAt: execution.stoppedAt,
workflowId: execution.workflowData?.id?.toString() ?? '',
workflowName: execution.workflowData.name,
};
});
return {
count,
results: formattedExecutions,
estimated,
};
},
),
);
// Returns a specific execution
this.app.get(
`/${this.restEndpoint}/executions/:id`,
ResponseHelper.send(
async (
req: ExecutionRequest.Get,
): Promise<IExecutionResponse | IExecutionFlattedResponse | undefined> => {
const { id: executionId } = req.params;
const sharedWorkflowIds = await getSharedWorkflowIds(req.user);
if (!sharedWorkflowIds.length) return undefined;
const execution = await Db.collections.Execution!.findOne({
where: {
id: executionId,
workflowId: In(sharedWorkflowIds),
},
});
if (!execution) {
LoggerProxy.info(
'Attempt to read execution was blocked due to insufficient permissions',
{
userId: req.user.id,
executionId,
},
);
return undefined;
}
if (req.query.unflattedResponse === 'true') {
return ResponseHelper.unflattenExecutionData(execution);
}
const { id, ...rest } = execution;
// @ts-ignore
return {
id: id.toString(),
...rest,
};
},
),
);
// Retries a failed execution
this.app.post(
`/${this.restEndpoint}/executions/:id/retry`,
ResponseHelper.send(async (req: ExecutionRequest.Retry): Promise<boolean> => {
const { id: executionId } = req.params;
const sharedWorkflowIds = await getSharedWorkflowIds(req.user);
if (!sharedWorkflowIds.length) return false;
const execution = await Db.collections.Execution!.findOne({
where: {
id: executionId,
workflowId: In(sharedWorkflowIds),
},
});
if (!execution) {
LoggerProxy.info(
'Attempt to retry an execution was blocked due to insufficient permissions',
{
userId: req.user.id,
executionId,
},
);
throw new ResponseHelper.ResponseError(
`The execution with the ID "${executionId}" does not exist.`,
404,
404,
);
}
const fullExecutionData = ResponseHelper.unflattenExecutionData(execution);
if (fullExecutionData.finished) {
throw new Error('The execution succeeded, so it cannot be retried.');
}
const executionMode = 'retry';
fullExecutionData.workflowData.active = false;
// Start the workflow
const data: IWorkflowExecutionDataProcess = {
executionMode,
executionData: fullExecutionData.data,
retryOf: req.params.id,
workflowData: fullExecutionData.workflowData,
userId: req.user.id,
};
const { lastNodeExecuted } = data.executionData!.resultData;
if (lastNodeExecuted) {
// Remove the old error and the data of the last run of the node that it can be replaced
delete data.executionData!.resultData.error;
const { length } = data.executionData!.resultData.runData[lastNodeExecuted];
if (
length > 0 &&
data.executionData!.resultData.runData[lastNodeExecuted][length - 1].error !== undefined
) {
// Remove results only if it is an error.
// If we are retrying due to a crash, the information is simply success info from last node
data.executionData!.resultData.runData[lastNodeExecuted].pop();
// Stack will determine what to run next
}
}
if (req.body.loadWorkflow) {
// Loads the currently saved workflow to execute instead of the
// one saved at the time of the execution.
const workflowId = fullExecutionData.workflowData.id;
const workflowData = (await Db.collections.Workflow!.findOne(
workflowId,
)) as IWorkflowBase;
if (workflowData === undefined) {
throw new Error(
`The workflow with the ID "${workflowId}" could not be found and so the data not be loaded for the retry.`,
);
}
data.workflowData = workflowData;
const nodeTypes = NodeTypes();
const workflowInstance = new Workflow({
id: workflowData.id as string,
name: workflowData.name,
nodes: workflowData.nodes,
connections: workflowData.connections,
active: false,
nodeTypes,
staticData: undefined,
settings: workflowData.settings,
});
// Replace all of the nodes in the execution stack with the ones of the new workflow
for (const stack of data.executionData!.executionData!.nodeExecutionStack) {
// Find the data of the last executed node in the new workflow
const node = workflowInstance.getNode(stack.node.name);
if (node === null) {
LoggerProxy.error('Failed to retry an execution because a node could not be found', {
userId: req.user.id,
executionId,
nodeName: stack.node.name,
});
throw new Error(
`Could not find the node "${stack.node.name}" in workflow. It probably got deleted or renamed. Without it the workflow can sadly not be retried.`,
);
}
// Replace the node data in the stack that it really uses the current data
stack.node = node;
}
}
const workflowRunner = new WorkflowRunner();
const retriedExecutionId = await workflowRunner.run(data);
const executionData = await this.activeExecutionsInstance.getPostExecutePromise(
retriedExecutionId,
);
if (!executionData) {
throw new Error('The retry did not start for an unknown reason.');
}
return !!executionData.finished;
}),
);
// Delete Executions
// INFORMATION: We use POST instead of DELETE to not run into any issues
// with the query data getting to long
this.app.post(
`/${this.restEndpoint}/executions/delete`,
ResponseHelper.send(async (req: ExecutionRequest.Delete): Promise<void> => {
const { deleteBefore, ids, filters: requestFilters } = req.body;
if (!deleteBefore && !ids) {
throw new Error('Either "deleteBefore" or "ids" must be present in the request body');
}
const sharedWorkflowIds = await getSharedWorkflowIds(req.user);
const binaryDataManager = BinaryDataManager.getInstance();
// delete executions by date, if user may access the underyling worfklows
if (deleteBefore) {
const filters: IDataObject = {
startedAt: LessThanOrEqual(deleteBefore),
};
if (filters) {
Object.assign(filters, requestFilters);
}
const executions = await Db.collections.Execution!.find({
where: {
workflowId: In(sharedWorkflowIds),
...filters,
},
});
if (!executions.length) return;
const idsToDelete = executions.map(({ id }) => id.toString());
await Promise.all(
idsToDelete.map(async (id) => binaryDataManager.deleteBinaryDataByExecutionId(id)),
);
await Db.collections.Execution!.delete({ id: In(idsToDelete) });
return;
}
// delete executions by IDs, if user may access the underyling worfklows
if (ids) {
const executions = await Db.collections.Execution!.find({
where: {
id: In(ids),
workflowId: In(sharedWorkflowIds),
},
});
if (!executions.length) {
LoggerProxy.error('Failed to delete an execution due to insufficient permissions', {
userId: req.user.id,
executionIds: ids,
});
return;
}
const idsToDelete = executions.map(({ id }) => id.toString());
await Promise.all(
idsToDelete.map(async (id) => binaryDataManager.deleteBinaryDataByExecutionId(id)),
);
await Db.collections.Execution!.delete(idsToDelete);
}
}),
);
// ----------------------------------------
// Executing Workflows
// ----------------------------------------
// Returns all the currently working executions
this.app.get(
`/${this.restEndpoint}/executions-current`,
ResponseHelper.send(
async (req: ExecutionRequest.GetAllCurrent): Promise<IExecutionsSummary[]> => {
if (config.get('executions.mode') === 'queue') {
const currentJobs = await Queue.getInstance().getJobs(['active', 'waiting']);
const currentlyRunningQueueIds = currentJobs.map((job) => job.data.executionId);
const currentlyRunningManualExecutions =
this.activeExecutionsInstance.getActiveExecutions();
const manualExecutionIds = currentlyRunningManualExecutions.map(
(execution) => execution.id,
);
const currentlyRunningExecutionIds =
currentlyRunningQueueIds.concat(manualExecutionIds);
if (!currentlyRunningExecutionIds.length) return [];
const findOptions: FindManyOptions<ExecutionEntity> = {
select: ['id', 'workflowId', 'mode', 'retryOf', 'startedAt'],
order: { id: 'DESC' },
where: {
id: In(currentlyRunningExecutionIds),
},
};
const sharedWorkflowIds = await getSharedWorkflowIds(req.user);
if (!sharedWorkflowIds.length) return [];
if (req.query.filter) {
const { workflowId } = JSON.parse(req.query.filter);
if (workflowId && sharedWorkflowIds.includes(workflowId)) {
Object.assign(findOptions.where, { workflowId });
}
} else {
Object.assign(findOptions.where, { workflowId: In(sharedWorkflowIds) });
}
const executions = await Db.collections.Execution!.find(findOptions);
if (!executions.length) return [];
return executions.map((execution) => {
return {
id: execution.id,
workflowId: execution.workflowId,
mode: execution.mode,
retryOf: execution.retryOf !== null ? execution.retryOf : undefined,
startedAt: new Date(execution.startedAt),
} as IExecutionsSummary;
});
}
const executingWorkflows = this.activeExecutionsInstance.getActiveExecutions();
const returnData: IExecutionsSummary[] = [];
const filter = req.query.filter ? JSON.parse(req.query.filter) : {};
const sharedWorkflowIds = await getSharedWorkflowIds(req.user).then((ids) =>
ids.map((id) => id.toString()),
);
for (const data of executingWorkflows) {
if (
(filter.workflowId !== undefined && filter.workflowId !== data.workflowId) ||
!sharedWorkflowIds.includes(data.workflowId)
) {
continue;
}
returnData.push({
id: data.id.toString(),
workflowId: data.workflowId === undefined ? '' : data.workflowId.toString(),
mode: data.mode,
retryOf: data.retryOf,
startedAt: new Date(data.startedAt),
});
}
returnData.sort((a, b) => parseInt(b.id, 10) - parseInt(a.id, 10));
return returnData;
},
),
);
// Forces the execution to stop
this.app.post(
`/${this.restEndpoint}/executions-current/:id/stop`,
ResponseHelper.send(async (req: ExecutionRequest.Stop): Promise<IExecutionsStopData> => {
const { id: executionId } = req.params;
const sharedWorkflowIds = await getSharedWorkflowIds(req.user);
if (!sharedWorkflowIds.length) {
throw new ResponseHelper.ResponseError('Execution not found', undefined, 404);
}
const execution = await Db.collections.Execution!.findOne({
where: {
id: executionId,
workflowId: In(sharedWorkflowIds),
},
});
if (!execution) {
throw new ResponseHelper.ResponseError('Execution not found', undefined, 404);
}
if (config.get('executions.mode') === 'queue') {
// Manual executions should still be stoppable, so
// try notifying the `activeExecutions` to stop it.
const result = await this.activeExecutionsInstance.stopExecution(req.params.id);
if (result === undefined) {
// If active execution could not be found check if it is a waiting one
try {
return await this.waitTracker.stopExecution(req.params.id);
} catch (error) {
// Ignore, if it errors as then it is probably a currently running
// execution
}
} else {
return {
mode: result.mode,
startedAt: new Date(result.startedAt),
stoppedAt: result.stoppedAt ? new Date(result.stoppedAt) : undefined,
finished: result.finished,
} as IExecutionsStopData;
}
const currentJobs = await Queue.getInstance().getJobs(['active', 'waiting']);
const job = currentJobs.find((job) => job.data.executionId.toString() === req.params.id);
if (!job) {
throw new Error(`Could not stop "${req.params.id}" as it is no longer in queue.`);
} else {
await Queue.getInstance().stopJob(job);
}
const executionDb = (await Db.collections.Execution?.findOne(
req.params.id,
)) as IExecutionFlattedDb;
const fullExecutionData = ResponseHelper.unflattenExecutionData(executionDb);
const returnData: IExecutionsStopData = {
mode: fullExecutionData.mode,
startedAt: new Date(fullExecutionData.startedAt),
stoppedAt: fullExecutionData.stoppedAt
? new Date(fullExecutionData.stoppedAt)
: undefined,
finished: fullExecutionData.finished,
};
return returnData;
}
// Stop the execution and wait till it is done and we got the data
const result = await this.activeExecutionsInstance.stopExecution(executionId);
let returnData: IExecutionsStopData;
if (result === undefined) {
// If active execution could not be found check if it is a waiting one
returnData = await this.waitTracker.stopExecution(executionId);
} else {
returnData = {
mode: result.mode,
startedAt: new Date(result.startedAt),
stoppedAt: result.stoppedAt ? new Date(result.stoppedAt) : undefined,
finished: result.finished,
};
}
return returnData;
}),
);
// Removes a test webhook
this.app.delete(
`/${this.restEndpoint}/test-webhook/:id`,
ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<boolean> => {
// TODO UM: check if this needs validation with user management.
const workflowId = req.params.id;
return this.testWebhooks.cancelTestWebhook(workflowId);
}),
);
// ----------------------------------------
// Options
// ----------------------------------------
// Returns all the available timezones
this.app.get(
`/${this.restEndpoint}/options/timezones`,
ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<object> => {
return timezones;
}),
);
// ----------------------------------------
// Binary data
// ----------------------------------------
// Returns binary buffer
this.app.get(
`/${this.restEndpoint}/data/:path`,
ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<string> => {
// TODO UM: check if this needs permission check for UM
const dataPath = req.params.path;
return BinaryDataManager.getInstance()
.retrieveBinaryDataByIdentifier(dataPath)
.then((buffer: Buffer) => {
return buffer.toString('base64');
});
}),
);
// ----------------------------------------
// Settings
// ----------------------------------------
// Returns the current settings for the UI
this.app.get(
`/${this.restEndpoint}/settings`,
ResponseHelper.send(
async (req: express.Request, res: express.Response): Promise<IN8nUISettings> => {
return this.getSettingsForFrontend();
},
),
);
// ----------------------------------------
// Webhooks
// ----------------------------------------
if (config.get('endpoints.disableProductionWebhooksOnMainProcess') !== true) {
WebhookServer.registerProductionWebhooks.apply(this);
}
// Register all webhook requests (test for UI)
this.app.all(
`/${this.endpointWebhookTest}/*`,
async (req: express.Request, res: express.Response) => {
// Cut away the "/webhook-test/" to get the registred part of the url
const requestUrl = (req as ICustomRequest).parsedUrl!.pathname!.slice(
this.endpointWebhookTest.length + 2,
);
const method = req.method.toUpperCase() as WebhookHttpMethod;
if (method === 'OPTIONS') {
let allowedMethods: string[];
try {
allowedMethods = await this.testWebhooks.getWebhookMethods(requestUrl);
allowedMethods.push('OPTIONS');
// Add custom "Allow" header to satisfy OPTIONS response.
res.append('Allow', allowedMethods);
} catch (error) {
ResponseHelper.sendErrorResponse(res, error);
return;
}
ResponseHelper.sendSuccessResponse(res, {}, true, 204);
return;
}
if (!WEBHOOK_METHODS.includes(method)) {
ResponseHelper.sendErrorResponse(
res,
new Error(`The method ${method} is not supported.`),
);
return;
}
let response;
try {
response = await this.testWebhooks.callTestWebhook(method, requestUrl, req, res);
} catch (error) {
ResponseHelper.sendErrorResponse(res, error);
return;
}
if (response.noWebhookResponse === true) {
// Nothing else to do as the response got already sent
return;
}
ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
},
);
if (this.endpointPresetCredentials !== '') {
// POST endpoint to set preset credentials
this.app.post(
`/${this.endpointPresetCredentials}`,
async (req: express.Request, res: express.Response) => {
if (!this.presetCredentialsLoaded) {
const body = req.body as ICredentialsOverwrite;
if (req.headers['content-type'] !== 'application/json') {
ResponseHelper.sendErrorResponse(
res,
new Error(
'Body must be a valid JSON, make sure the content-type is application/json',
),
);
return;
}
const credentialsOverwrites = CredentialsOverwrites();
await credentialsOverwrites.init(body);
this.presetCredentialsLoaded = true;
ResponseHelper.sendSuccessResponse(res, { success: true }, true, 200);
} else {
ResponseHelper.sendErrorResponse(res, new Error('Preset credentials can be set once'));
}
},
);
}
if (config.get('endpoints.disableUi') !== true) {
// Read the index file and replace the path placeholder
const editorUiPath = require.resolve('n8n-editor-ui');
const filePath = pathJoin(pathDirname(editorUiPath), 'dist', 'index.html');
const n8nPath = config.get('path');
let readIndexFile = readFileSync(filePath, 'utf8');
readIndexFile = readIndexFile.replace(/\/%BASE_PATH%\//g, n8nPath);
readIndexFile = readIndexFile.replace(/\/favicon.ico/g, `${n8nPath}favicon.ico`);
// Serve the altered index.html file separately
this.app.get(`/index.html`, async (req: express.Request, res: express.Response) => {
res.send(readIndexFile);
});
// Serve the website
this.app.use(
'/',
express.static(pathJoin(pathDirname(editorUiPath), 'dist'), {
index: 'index.html',
setHeaders: (res, path) => {
if (res.req && res.req.url === '/index.html') {
// Set last modified date manually to n8n start time so
// that it hopefully refreshes the page when a new version
// got used
res.setHeader('Last-Modified', startTime);
}
},
}),
);
}
const startTime = new Date().toUTCString();
}
}
export async function start(): Promise<void> {
const PORT = config.get('port');
const ADDRESS = config.get('listen_address');
const app = new App();
await app.config();
let server;
if (app.protocol === 'https' && app.sslKey && app.sslCert) {
const https = require('https');
const privateKey = readFileSync(app.sslKey, 'utf8');
const cert = readFileSync(app.sslCert, 'utf8');
const credentials = { key: privateKey, cert };
server = https.createServer(credentials, app.app);
} else {
const http = require('http');
server = http.createServer(app.app);
}
server.listen(PORT, ADDRESS, async () => {
const versions = await GenericHelpers.getVersions();
console.log(`n8n ready on ${ADDRESS}, port ${PORT}`);
console.log(`Version: ${versions.cli}`);
const defaultLocale = config.get('defaultLocale');
if (defaultLocale !== 'en') {
console.log(`Locale: ${defaultLocale}`);
}
await app.externalHooks.run('n8n.ready', [app]);
const cpus = os.cpus();
const binarDataConfig = config.get('binaryDataManager') as IBinaryDataConfig;
const diagnosticInfo: IDiagnosticInfo = {
basicAuthActive: config.get('security.basicAuth.active') as boolean,
databaseType: (await GenericHelpers.getConfigValue('database.type')) as DatabaseType,
disableProductionWebhooksOnMainProcess:
config.get('endpoints.disableProductionWebhooksOnMainProcess') === true,
notificationsEnabled: config.get('versionNotifications.enabled') === true,
versionCli: versions.cli,
systemInfo: {
os: {
type: os.type(),
version: os.version(),
},
memory: os.totalmem() / 1024,
cpus: {
count: cpus.length,
model: cpus[0].model,
speed: cpus[0].speed,
},
},
executionVariables: {
executions_process: config.get('executions.process'),
executions_mode: config.get('executions.mode'),
executions_timeout: config.get('executions.timeout'),
executions_timeout_max: config.get('executions.maxTimeout'),
executions_data_save_on_error: config.get('executions.saveDataOnError'),
executions_data_save_on_success: config.get('executions.saveDataOnSuccess'),
executions_data_save_on_progress: config.get('executions.saveExecutionProgress'),
executions_data_save_manual_executions: config.get('executions.saveDataManualExecutions'),
executions_data_prune: config.get('executions.pruneData'),
executions_data_max_age: config.get('executions.pruneDataMaxAge'),
executions_data_prune_timeout: config.get('executions.pruneDataTimeout'),
},
deploymentType: config.get('deployment.type'),
binaryDataMode: binarDataConfig.mode,
n8n_multi_user_allowed:
config.get('userManagement.disabled') === false ||
config.get('userManagement.isInstanceOwnerSetUp') === true,
smtp_set_up: config.get('userManagement.emails.mode') === 'smtp',
};
void Db.collections
.Workflow!.findOne({
select: ['createdAt'],
order: { createdAt: 'ASC' },
})
.then(async (workflow) =>
InternalHooksManager.getInstance().onServerStarted(diagnosticInfo, workflow?.createdAt),
);
});
server.on('error', (error: Error & { code: string }) => {
if (error.code === 'EADDRINUSE') {
console.log(
`n8n's port ${PORT} is already in use. Do you have another instance of n8n running already?`,
);
process.exit(1);
}
});
}
async function getExecutionsCount(
countFilter: IDataObject,
user: User,
): Promise<{ count: number; estimated: boolean }> {
const dbType = (await GenericHelpers.getConfigValue('database.type')) as DatabaseType;
const filteredFields = Object.keys(countFilter).filter((field) => field !== 'id');
// For databases other than Postgres, do a regular count
// when filtering based on `workflowId` or `finished` fields.
if (dbType !== 'postgresdb' || filteredFields.length > 0 || user.globalRole.name !== 'owner') {
const sharedWorkflowIds = await getSharedWorkflowIds(user);
const count = await Db.collections.Execution!.count({
where: {
workflowId: In(sharedWorkflowIds),
...countFilter,
},
});
return { count, estimated: false };
}
try {
// Get an estimate of rows count.
const estimateRowsNumberSql =
"SELECT n_live_tup FROM pg_stat_all_tables WHERE relname = 'execution_entity';";
const rows: Array<{ n_live_tup: string }> = await Db.collections.Execution!.query(
estimateRowsNumberSql,
);
const estimate = parseInt(rows[0].n_live_tup, 10);
// If over 100k, return just an estimate.
if (estimate > 100_000) {
// if less than 100k, we get the real count as even a full
// table scan should not take so long.
return { count: estimate, estimated: true };
}
} catch (error) {
LoggerProxy.warn(`Failed to get executions count from Postgres: ${error}`);
}
const sharedWorkflowIds = await getSharedWorkflowIds(user);
const count = await Db.collections.Execution!.count({
where: {
workflowId: In(sharedWorkflowIds),
},
});
return { count, estimated: false };
}