/* 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 { 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(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(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 => { 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 = req.query.filter ? JSON.parse(req.query.filter) : {}; const query: FindManyOptions = { 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 = { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { const nodeInfos = _.get(req, 'body.nodeInfos', []) as INodeTypeNameVersion[]; const { defaultLocale } = this.frontendSettings; if (defaultLocale === 'en') { return nodeInfos.reduce((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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 = { 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 => { 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 => { 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 => { 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 => { 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 = { 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 => { 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 => { // 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 => { return timezones; }), ); // ---------------------------------------- // Binary data // ---------------------------------------- // Returns binary buffer this.app.get( `/${this.restEndpoint}/data/:path`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { // 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 => { 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 { 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 }; }