mirror of
https://github.com/n8n-io/n8n.git
synced 2024-09-27 10:17:33 -07:00
66a345ea94
* Fix issue with auto refresh on execution list When auto refresh is on, we used to get executions from backend using the firstId field to filter recent executions. This is a problem when you have executions that do not finish in order, leaving gaps behind. This PR fixes this problem by refreshing the latest 30 executions and correctly adding them to the list. * Fixed an issues with auto refresh on executions ExecutionsList Fixed two bugs, one in frontend which was ignoring the first returned row from the backend and an issue with backend that was not using the overriden version of `executeWorkflow` function for sub sub workflows. * Fixed the display of manual executions when running with queues and improved display of subworkflows * Changing workflow ids array from variable to constant * Added unknown status to workflow execution and changed its color to orange
1998 lines
71 KiB
TypeScript
1998 lines
71 KiB
TypeScript
import * as express from 'express';
|
|
import {
|
|
readFileSync,
|
|
} from 'fs';
|
|
import {
|
|
dirname as pathDirname,
|
|
join as pathJoin,
|
|
resolve as pathResolve,
|
|
} from 'path';
|
|
import {
|
|
getConnection,
|
|
getConnectionManager,
|
|
In,
|
|
} from 'typeorm';
|
|
import * as bodyParser from 'body-parser';
|
|
require('body-parser-xml')(bodyParser);
|
|
import * as history from 'connect-history-api-fallback';
|
|
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';
|
|
import { compare } from 'bcryptjs';
|
|
|
|
import {
|
|
ActiveExecutions,
|
|
ActiveWorkflowRunner,
|
|
CredentialsHelper,
|
|
CredentialsOverwrites,
|
|
CredentialTypes,
|
|
Db,
|
|
ExternalHooks,
|
|
GenericHelpers,
|
|
IActivationError,
|
|
ICredentialsDb,
|
|
ICredentialsDecryptedDb,
|
|
ICredentialsDecryptedResponse,
|
|
ICredentialsOverwrite,
|
|
ICredentialsResponse,
|
|
ICustomRequest,
|
|
IExecutionDeleteFilter,
|
|
IExecutionFlatted,
|
|
IExecutionFlattedDb,
|
|
IExecutionFlattedResponse,
|
|
IExecutionPushResponse,
|
|
IExecutionResponse,
|
|
IExecutionsListResponse,
|
|
IExecutionsStopData,
|
|
IExecutionsSummary,
|
|
IExternalHooksClass,
|
|
IN8nUISettings,
|
|
IPackageVersions,
|
|
IWorkflowBase,
|
|
IWorkflowExecutionDataProcess,
|
|
IWorkflowResponse,
|
|
IWorkflowShortResponse,
|
|
LoadNodesAndCredentials,
|
|
NodeTypes,
|
|
Push,
|
|
ResponseHelper,
|
|
TestWebhooks,
|
|
WebhookHelpers,
|
|
WebhookServer,
|
|
WorkflowCredentials,
|
|
WorkflowExecuteAdditionalData,
|
|
WorkflowRunner,
|
|
} from './';
|
|
|
|
import {
|
|
Credentials,
|
|
LoadNodeParameterOptions,
|
|
UserSettings,
|
|
} from 'n8n-core';
|
|
|
|
import {
|
|
ICredentialsEncrypted,
|
|
ICredentialType,
|
|
IDataObject,
|
|
INodeCredentials,
|
|
INodeParameters,
|
|
INodePropertyOptions,
|
|
INodeTypeDescription,
|
|
IRunData,
|
|
IWorkflowCredentials,
|
|
Workflow,
|
|
WorkflowExecuteMode,
|
|
} from 'n8n-workflow';
|
|
|
|
import {
|
|
FindManyOptions,
|
|
FindOneOptions,
|
|
LessThan,
|
|
LessThanOrEqual,
|
|
MoreThanOrEqual,
|
|
Not,
|
|
} from 'typeorm';
|
|
|
|
import * as basicAuth from 'basic-auth';
|
|
import * as compression from 'compression';
|
|
import * as config from '../config';
|
|
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 * as Queue from '../src/Queue';
|
|
import { OptionsWithUrl } from 'request-promise-native';
|
|
|
|
class App {
|
|
|
|
app: express.Application;
|
|
activeWorkflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner;
|
|
testWebhooks: TestWebhooks.TestWebhooks;
|
|
endpointWebhook: string;
|
|
endpointWebhookTest: string;
|
|
endpointPresetCredentials: string;
|
|
externalHooks: IExternalHooksClass;
|
|
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;
|
|
|
|
presetCredentialsLoaded: boolean;
|
|
|
|
constructor() {
|
|
this.app = express();
|
|
|
|
this.endpointWebhook = config.get('endpoints.webhook') as string;
|
|
this.endpointWebhookTest = config.get('endpoints.webhookTest') 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.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.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();
|
|
|
|
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,
|
|
versionCli: '',
|
|
oauthCallbackUrls: {
|
|
'oauth1': urlBaseWebhook + `${this.restEndpoint}/oauth1-credential/callback`,
|
|
'oauth2': urlBaseWebhook + `${this.restEndpoint}/oauth2-credential/callback`,
|
|
},
|
|
};
|
|
}
|
|
|
|
|
|
/**
|
|
* Returns the current epoch time
|
|
*
|
|
* @returns {number}
|
|
* @memberof App
|
|
*/
|
|
getCurrentDate(): Date {
|
|
return new Date();
|
|
}
|
|
|
|
|
|
async config(): Promise<void> {
|
|
|
|
this.versions = await GenericHelpers.getVersions();
|
|
this.frontendSettings.versionCli = this.versions.cli;
|
|
|
|
await this.externalHooks.run('frontend.settings', [this.frontendSettings]);
|
|
|
|
const excludeEndpoints = config.get('security.excludeEndpoints') as string;
|
|
|
|
const ignoredEndpoints = ['healthz', this.endpointWebhook, this.endpointWebhookTest, this.endpointPresetCredentials];
|
|
ignoredEndpoints.push.apply(ignoredEndpoints, excludeEndpoints.split(':'));
|
|
|
|
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 === true) {
|
|
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) => {
|
|
if (req.url.match(authIgnoreRegex)) {
|
|
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 === true) {
|
|
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 === true) {
|
|
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;
|
|
|
|
function isTenantAllowed(decodedToken: object): boolean {
|
|
if (jwtNamespace === '' || jwtAllowedTenantKey === '' || jwtAllowedTenant === '') return true;
|
|
else {
|
|
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;
|
|
}
|
|
|
|
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
if (req.url.match(authIgnoreRegex)) {
|
|
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 });
|
|
function getKey(header: any, callback: Function) { // tslint:disable-line:no-any
|
|
jwkClient.getSigningKey(header.kid, (err: Error, key: any) => { // tslint:disable-line:no-any
|
|
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();
|
|
});
|
|
});
|
|
}
|
|
|
|
// Get push connections
|
|
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
if (req.url.indexOf(`/${this.restEndpoint}/push`) === 0) {
|
|
// TODO: Later also has to add some kind of authentication token
|
|
if (req.query.sessionId === undefined) {
|
|
next(new Error('The query parameter "sessionId" is missing!'));
|
|
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: '16mb', verify: (req, res, buf) => {
|
|
// @ts-ignore
|
|
req.rawBody = buf;
|
|
},
|
|
}));
|
|
|
|
// Support application/xml type post data
|
|
// @ts-ignore
|
|
this.app.use(bodyParser.xml({
|
|
limit: '16mb', 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: '16mb', 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(`^\/(${this.restEndpoint}|healthz|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({
|
|
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-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE');
|
|
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, sessionid');
|
|
next();
|
|
});
|
|
}
|
|
|
|
|
|
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();
|
|
});
|
|
|
|
|
|
|
|
// ----------------------------------------
|
|
// Healthcheck
|
|
// ----------------------------------------
|
|
|
|
|
|
// Does very basic health check
|
|
this.app.get('/healthz', async (req: express.Request, res: express.Response) => {
|
|
|
|
const connectionManager = getConnectionManager();
|
|
|
|
if (connectionManager.connections.length === 0) {
|
|
const error = new ResponseHelper.ResponseError('No Database connection found!', undefined, 503);
|
|
return ResponseHelper.sendErrorResponse(res, error);
|
|
}
|
|
|
|
if (connectionManager.connections[0].isConnected === false) {
|
|
// Connection is not active
|
|
const error = new ResponseHelper.ResponseError('Database connection not active!', undefined, 503);
|
|
return ResponseHelper.sendErrorResponse(res, error);
|
|
}
|
|
|
|
// Everything fine
|
|
const responseData = {
|
|
status: 'ok',
|
|
};
|
|
|
|
ResponseHelper.sendSuccessResponse(res, responseData, true, 200);
|
|
});
|
|
|
|
|
|
|
|
// ----------------------------------------
|
|
// Workflow
|
|
// ----------------------------------------
|
|
|
|
|
|
// Creates a new workflow
|
|
this.app.post(`/${this.restEndpoint}/workflows`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IWorkflowResponse> => {
|
|
|
|
const newWorkflowData = req.body as IWorkflowBase;
|
|
|
|
newWorkflowData.name = newWorkflowData.name.trim();
|
|
newWorkflowData.createdAt = this.getCurrentDate();
|
|
newWorkflowData.updatedAt = this.getCurrentDate();
|
|
|
|
newWorkflowData.id = undefined;
|
|
|
|
await this.externalHooks.run('workflow.create', [newWorkflowData]);
|
|
|
|
// Save the workflow in DB
|
|
const result = await Db.collections.Workflow!.save(newWorkflowData);
|
|
|
|
// Convert to response format in which the id is a string
|
|
(result as IWorkflowBase as IWorkflowResponse).id = result.id.toString();
|
|
return result as IWorkflowBase as IWorkflowResponse;
|
|
|
|
}));
|
|
|
|
|
|
// 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 (!(req.query.url as string).match(/^http[s]?:\/\/.*\.json$/i)) {
|
|
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: express.Request, res: express.Response): Promise<IWorkflowShortResponse[]> => {
|
|
const findQuery = {} as FindManyOptions;
|
|
if (req.query.filter) {
|
|
findQuery.where = JSON.parse(req.query.filter as string);
|
|
}
|
|
|
|
// Return only the fields we need
|
|
findQuery.select = ['id', 'name', 'active', 'createdAt', 'updatedAt'];
|
|
|
|
const results = await Db.collections.Workflow!.find(findQuery);
|
|
|
|
for (const entry of results) {
|
|
(entry as unknown as IWorkflowShortResponse).id = entry.id.toString();
|
|
}
|
|
|
|
return results as unknown as IWorkflowShortResponse[];
|
|
}));
|
|
|
|
|
|
// Returns a specific workflow
|
|
this.app.get(`/${this.restEndpoint}/workflows/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IWorkflowResponse | undefined> => {
|
|
const result = await Db.collections.Workflow!.findOne(req.params.id);
|
|
|
|
if (result === undefined) {
|
|
return undefined;
|
|
}
|
|
|
|
// Convert to response format in which the id is a string
|
|
(result as IWorkflowBase as IWorkflowResponse).id = result.id.toString();
|
|
return result as IWorkflowBase as IWorkflowResponse;
|
|
}));
|
|
|
|
|
|
// Updates an existing workflow
|
|
this.app.patch(`/${this.restEndpoint}/workflows/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IWorkflowResponse> => {
|
|
|
|
const newWorkflowData = req.body as IWorkflowBase;
|
|
const id = req.params.id;
|
|
|
|
await this.externalHooks.run('workflow.update', [newWorkflowData]);
|
|
|
|
const isActive = await this.activeWorkflowRunner.isActive(id);
|
|
|
|
if (isActive) {
|
|
// 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(id);
|
|
}
|
|
|
|
if (newWorkflowData.settings) {
|
|
if (newWorkflowData.settings.timezone === 'DEFAULT') {
|
|
// Do not save the default timezone
|
|
delete newWorkflowData.settings.timezone;
|
|
}
|
|
if (newWorkflowData.settings.saveDataErrorExecution === 'DEFAULT') {
|
|
// Do not save when default got set
|
|
delete newWorkflowData.settings.saveDataErrorExecution;
|
|
}
|
|
if (newWorkflowData.settings.saveDataSuccessExecution === 'DEFAULT') {
|
|
// Do not save when default got set
|
|
delete newWorkflowData.settings.saveDataSuccessExecution;
|
|
}
|
|
if (newWorkflowData.settings.saveManualExecutions === 'DEFAULT') {
|
|
// Do not save when default got set
|
|
delete newWorkflowData.settings.saveManualExecutions;
|
|
}
|
|
if (parseInt(newWorkflowData.settings.executionTimeout as string, 10) === this.executionTimeout) {
|
|
// Do not save when default got set
|
|
delete newWorkflowData.settings.executionTimeout;
|
|
}
|
|
}
|
|
|
|
newWorkflowData.updatedAt = this.getCurrentDate();
|
|
|
|
await Db.collections.Workflow!.update(id, newWorkflowData);
|
|
await this.externalHooks.run('workflow.afterUpdate', [newWorkflowData]);
|
|
|
|
// 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 responseData = await Db.collections.Workflow!.findOne(id);
|
|
|
|
if (responseData === undefined) {
|
|
throw new ResponseHelper.ResponseError(`Workflow with id "${id}" could not be found to be updated.`, undefined, 400);
|
|
}
|
|
|
|
if (responseData.active === true) {
|
|
// When the workflow is supposed to be active add it again
|
|
try {
|
|
await this.externalHooks.run('workflow.activate', [responseData]);
|
|
|
|
await this.activeWorkflowRunner.add(id);
|
|
} catch (error) {
|
|
// If workflow could not be activated set it again to inactive
|
|
newWorkflowData.active = false;
|
|
await Db.collections.Workflow!.update(id, newWorkflowData);
|
|
|
|
// Also set it in the returned data
|
|
responseData.active = false;
|
|
|
|
// Now return the original error for UI to display
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Convert to response format in which the id is a string
|
|
(responseData as IWorkflowBase as IWorkflowResponse).id = responseData.id.toString();
|
|
return responseData as IWorkflowBase as IWorkflowResponse;
|
|
}));
|
|
|
|
|
|
// Deletes a specific workflow
|
|
this.app.delete(`/${this.restEndpoint}/workflows/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<boolean> => {
|
|
const id = req.params.id;
|
|
|
|
await this.externalHooks.run('workflow.delete', [id]);
|
|
|
|
const isActive = await this.activeWorkflowRunner.isActive(id);
|
|
|
|
if (isActive) {
|
|
// Before deleting a workflow deactivate it
|
|
await this.activeWorkflowRunner.remove(id);
|
|
}
|
|
|
|
await Db.collections.Workflow!.delete(id);
|
|
await this.externalHooks.run('workflow.afterDelete', [id]);
|
|
|
|
return true;
|
|
}));
|
|
|
|
|
|
this.app.post(`/${this.restEndpoint}/workflows/run`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IExecutionPushResponse> => {
|
|
const workflowData = req.body.workflowData;
|
|
const runData: IRunData | undefined = req.body.runData;
|
|
const startNodes: string[] | undefined = req.body.startNodes;
|
|
const destinationNode: string | undefined = req.body.destinationNode;
|
|
const executionMode = '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 credentials = await WorkflowCredentials(workflowData.nodes);
|
|
const additionalData = await WorkflowExecuteAdditionalData.getBase(credentials);
|
|
const nodeTypes = NodeTypes();
|
|
const workflowInstance = new Workflow({ id: workflowData.id, 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, sessionId, destinationNode);
|
|
if (needsWebhook === true) {
|
|
return {
|
|
waitingForWebhook: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
// For manual testing always set to not active
|
|
workflowData.active = false;
|
|
|
|
const credentials = await WorkflowCredentials(workflowData.nodes);
|
|
|
|
// Start the workflow
|
|
const data: IWorkflowExecutionDataProcess = {
|
|
credentials,
|
|
destinationNode,
|
|
executionMode,
|
|
runData,
|
|
sessionId,
|
|
startNodes,
|
|
workflowData,
|
|
};
|
|
const workflowRunner = new WorkflowRunner();
|
|
const executionId = await workflowRunner.run(data);
|
|
|
|
return {
|
|
executionId,
|
|
};
|
|
}));
|
|
|
|
|
|
// 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: express.Request, res: express.Response): Promise<INodePropertyOptions[]> => {
|
|
const nodeType = req.query.nodeType as string;
|
|
let credentials: INodeCredentials | undefined = undefined;
|
|
const currentNodeParameters = JSON.parse('' + req.query.currentNodeParameters) as INodeParameters;
|
|
if (req.query.credentials !== undefined) {
|
|
credentials = JSON.parse(req.query.credentials as string);
|
|
}
|
|
const methodName = req.query.methodName as string;
|
|
|
|
const nodeTypes = NodeTypes();
|
|
|
|
const loadDataInstance = new LoadNodeParameterOptions(nodeType, nodeTypes, JSON.parse('' + req.query.currentNodeParameters), credentials!);
|
|
|
|
const workflowData = loadDataInstance.getWorkflowData() as IWorkflowBase;
|
|
const workflowCredentials = await WorkflowCredentials(workflowData.nodes);
|
|
const additionalData = await WorkflowExecuteAdditionalData.getBase(workflowCredentials, currentNodeParameters);
|
|
|
|
return loadDataInstance.getOptions(methodName, additionalData);
|
|
}));
|
|
|
|
|
|
// 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 nodeTypes = NodeTypes();
|
|
|
|
const allNodes = nodeTypes.getAll();
|
|
|
|
allNodes.forEach((nodeData) => {
|
|
// Make a copy of the object. If we don't do this, then when
|
|
// The method below is called the properties are removed for good
|
|
// This happens because nodes are returned as reference.
|
|
const nodeInfo: INodeTypeDescription = { ...nodeData.description };
|
|
if (req.query.includeProperties !== 'true') {
|
|
// @ts-ignore
|
|
delete nodeInfo.properties;
|
|
}
|
|
returnData.push(nodeInfo);
|
|
});
|
|
|
|
return returnData;
|
|
}));
|
|
|
|
|
|
// Returns node information baesd on namese
|
|
this.app.post(`/${this.restEndpoint}/node-types`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<INodeTypeDescription[]> => {
|
|
const nodeNames = _.get(req, 'body.nodeNames', []) as string[];
|
|
const nodeTypes = NodeTypes();
|
|
|
|
return nodeNames.map(name => {
|
|
try {
|
|
return nodeTypes.getByName(name);
|
|
} catch (e) {
|
|
return undefined;
|
|
}
|
|
}).filter(nodeData => !!nodeData).map(nodeData => nodeData!.description);
|
|
}));
|
|
|
|
|
|
|
|
// ----------------------------------------
|
|
// 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> => {
|
|
const nodeTypeName = `${req.params.scope ? `${req.params.scope}/` : ''}${req.params.nodeType}`;
|
|
|
|
const nodeTypes = NodeTypes();
|
|
const nodeType = nodeTypes.getByName(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);
|
|
|
|
res.sendFile(filepath);
|
|
});
|
|
|
|
|
|
|
|
// ----------------------------------------
|
|
// Active Workflows
|
|
// ----------------------------------------
|
|
|
|
|
|
// Returns the active workflow ids
|
|
this.app.get(`/${this.restEndpoint}/active`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<string[]> => {
|
|
const activeWorkflows = await this.activeWorkflowRunner.getActiveWorkflows();
|
|
return activeWorkflows.map(workflow => workflow.id.toString()) as string[];
|
|
}));
|
|
|
|
|
|
// Returns if the workflow with the given id had any activation errors
|
|
this.app.get(`/${this.restEndpoint}/active/error/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IActivationError | undefined> => {
|
|
const id = req.params.id;
|
|
return this.activeWorkflowRunner.getActivationError(id);
|
|
}));
|
|
|
|
|
|
|
|
// ----------------------------------------
|
|
// Credentials
|
|
// ----------------------------------------
|
|
|
|
|
|
// Deletes a specific credential
|
|
this.app.delete(`/${this.restEndpoint}/credentials/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<boolean> => {
|
|
const id = req.params.id;
|
|
|
|
await this.externalHooks.run('credentials.delete', [id]);
|
|
|
|
await Db.collections.Credentials!.delete({ id });
|
|
|
|
return true;
|
|
}));
|
|
|
|
// Creates new credentials
|
|
this.app.post(`/${this.restEndpoint}/credentials`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<ICredentialsResponse> => {
|
|
const incomingData = req.body;
|
|
|
|
if (!incomingData.name || incomingData.name.length < 3) {
|
|
throw new ResponseHelper.ResponseError(`Credentials name must be at least 3 characters long.`, undefined, 400);
|
|
}
|
|
|
|
// Add the added date for node access permissions
|
|
for (const nodeAccess of incomingData.nodesAccess) {
|
|
nodeAccess.date = this.getCurrentDate();
|
|
}
|
|
|
|
const encryptionKey = await UserSettings.getEncryptionKey();
|
|
if (encryptionKey === undefined) {
|
|
throw new Error('No encryption key got found to encrypt the credentials!');
|
|
}
|
|
|
|
if (incomingData.name === '') {
|
|
throw new Error('Credentials have to have a name set!');
|
|
}
|
|
|
|
// Check if credentials with the same name and type exist already
|
|
const findQuery = {
|
|
where: {
|
|
name: incomingData.name,
|
|
type: incomingData.type,
|
|
},
|
|
} as FindOneOptions;
|
|
|
|
const checkResult = await Db.collections.Credentials!.findOne(findQuery);
|
|
if (checkResult !== undefined) {
|
|
throw new ResponseHelper.ResponseError(`Credentials with the same type and name exist already.`, undefined, 400);
|
|
}
|
|
|
|
// Encrypt the data
|
|
const credentials = new Credentials(incomingData.name, incomingData.type, incomingData.nodesAccess);
|
|
credentials.setData(incomingData.data, encryptionKey);
|
|
const newCredentialsData = credentials.getDataToSave() as ICredentialsDb;
|
|
|
|
await this.externalHooks.run('credentials.create', [newCredentialsData]);
|
|
|
|
// Add special database related data
|
|
newCredentialsData.createdAt = this.getCurrentDate();
|
|
newCredentialsData.updatedAt = this.getCurrentDate();
|
|
|
|
// TODO: also add user automatically depending on who is logged in, if anybody is logged in
|
|
|
|
// Save the credentials in DB
|
|
const result = await Db.collections.Credentials!.save(newCredentialsData);
|
|
result.data = incomingData.data;
|
|
|
|
// Convert to response format in which the id is a string
|
|
(result as unknown as ICredentialsResponse).id = result.id.toString();
|
|
return result as unknown as ICredentialsResponse;
|
|
}));
|
|
|
|
|
|
// Updates existing credentials
|
|
this.app.patch(`/${this.restEndpoint}/credentials/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<ICredentialsResponse> => {
|
|
const incomingData = req.body;
|
|
|
|
const id = req.params.id;
|
|
|
|
if (incomingData.name === '') {
|
|
throw new Error('Credentials have to have a name set!');
|
|
}
|
|
|
|
// Add the date for newly added node access permissions
|
|
for (const nodeAccess of incomingData.nodesAccess) {
|
|
if (!nodeAccess.date) {
|
|
nodeAccess.date = this.getCurrentDate();
|
|
}
|
|
}
|
|
|
|
// Check if credentials with the same name and type exist already
|
|
const findQuery = {
|
|
where: {
|
|
id: Not(id),
|
|
name: incomingData.name,
|
|
type: incomingData.type,
|
|
},
|
|
} as FindOneOptions;
|
|
|
|
const checkResult = await Db.collections.Credentials!.findOne(findQuery);
|
|
if (checkResult !== undefined) {
|
|
throw new ResponseHelper.ResponseError(`Credentials with the same type and name exist already.`, undefined, 400);
|
|
}
|
|
|
|
const encryptionKey = await UserSettings.getEncryptionKey();
|
|
if (encryptionKey === undefined) {
|
|
throw new Error('No encryption key got found to encrypt the credentials!');
|
|
}
|
|
|
|
// Load the currently saved credentials to be able to persist some of the data if
|
|
const result = await Db.collections.Credentials!.findOne(id);
|
|
if (result === undefined) {
|
|
throw new ResponseHelper.ResponseError(`Credentials with the id "${id}" do not exist.`, undefined, 400);
|
|
}
|
|
|
|
const currentlySavedCredentials = new Credentials(result.name, result.type, result.nodesAccess, result.data);
|
|
const decryptedData = currentlySavedCredentials.getData(encryptionKey!);
|
|
|
|
// Do not overwrite the oauth data else data like the access or refresh token would get lost
|
|
// everytime anybody changes anything on the credentials even if it is just the name.
|
|
if (decryptedData.oauthTokenData) {
|
|
incomingData.data.oauthTokenData = decryptedData.oauthTokenData;
|
|
}
|
|
|
|
// Encrypt the data
|
|
const credentials = new Credentials(incomingData.name, incomingData.type, incomingData.nodesAccess);
|
|
credentials.setData(incomingData.data, encryptionKey);
|
|
const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb;
|
|
|
|
// Add special database related data
|
|
newCredentialsData.updatedAt = this.getCurrentDate();
|
|
|
|
await this.externalHooks.run('credentials.update', [newCredentialsData]);
|
|
|
|
// Update the credentials in DB
|
|
await Db.collections.Credentials!.update(id, newCredentialsData);
|
|
|
|
// 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 responseData = await Db.collections.Credentials!.findOne(id);
|
|
|
|
if (responseData === undefined) {
|
|
throw new ResponseHelper.ResponseError(`Credentials with id "${id}" could not be found to be updated.`, undefined, 400);
|
|
}
|
|
|
|
// Remove the encrypted data as it is not needed in the frontend
|
|
responseData.data = '';
|
|
|
|
// Convert to response format in which the id is a string
|
|
(responseData as unknown as ICredentialsResponse).id = responseData.id.toString();
|
|
return responseData as unknown as ICredentialsResponse;
|
|
}));
|
|
|
|
|
|
// Returns specific credentials
|
|
this.app.get(`/${this.restEndpoint}/credentials/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<ICredentialsDecryptedResponse | ICredentialsResponse | undefined> => {
|
|
const findQuery = {} as FindManyOptions;
|
|
|
|
// Make sure the variable has an expected value
|
|
const includeData = ['true', true].includes(req.query.includeData as string);
|
|
|
|
if (includeData !== true) {
|
|
// Return only the fields we need
|
|
findQuery.select = ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt'];
|
|
}
|
|
|
|
const result = await Db.collections.Credentials!.findOne(req.params.id);
|
|
|
|
if (result === undefined) {
|
|
return result;
|
|
}
|
|
|
|
let encryptionKey = undefined;
|
|
if (includeData === true) {
|
|
encryptionKey = await UserSettings.getEncryptionKey();
|
|
if (encryptionKey === undefined) {
|
|
throw new Error('No encryption key got found to decrypt the credentials!');
|
|
}
|
|
|
|
const credentials = new Credentials(result.name, result.type, result.nodesAccess, result.data);
|
|
(result as ICredentialsDecryptedDb).data = credentials.getData(encryptionKey!);
|
|
}
|
|
|
|
(result as ICredentialsDecryptedResponse).id = result.id.toString();
|
|
|
|
return result as ICredentialsDecryptedResponse;
|
|
}));
|
|
|
|
|
|
// Returns all the saved credentials
|
|
this.app.get(`/${this.restEndpoint}/credentials`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<ICredentialsResponse[]> => {
|
|
const findQuery = {} as FindManyOptions;
|
|
if (req.query.filter) {
|
|
findQuery.where = JSON.parse(req.query.filter as string);
|
|
if ((findQuery.where! as IDataObject).id !== undefined) {
|
|
// No idea if multiple where parameters make db search
|
|
// slower but to be sure that that is not the case we
|
|
// remove all unnecessary fields in case the id is defined.
|
|
findQuery.where = { id: (findQuery.where! as IDataObject).id };
|
|
}
|
|
}
|
|
|
|
findQuery.select = ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt'];
|
|
|
|
const results = await Db.collections.Credentials!.find(findQuery) as unknown as ICredentialsResponse[];
|
|
|
|
let encryptionKey = undefined;
|
|
|
|
const includeData = ['true', true].includes(req.query.includeData as string);
|
|
if (includeData === true) {
|
|
encryptionKey = await UserSettings.getEncryptionKey();
|
|
if (encryptionKey === undefined) {
|
|
throw new Error('No encryption key got found to decrypt the credentials!');
|
|
}
|
|
}
|
|
|
|
let result;
|
|
for (result of results) {
|
|
(result as ICredentialsDecryptedResponse).id = result.id.toString();
|
|
}
|
|
|
|
return results;
|
|
}));
|
|
|
|
|
|
|
|
// ----------------------------------------
|
|
// 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;
|
|
}));
|
|
|
|
// ----------------------------------------
|
|
// OAuth1-Credential/Auth
|
|
// ----------------------------------------
|
|
|
|
// Authorize OAuth Data
|
|
this.app.get(`/${this.restEndpoint}/oauth1-credential/auth`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<string> => {
|
|
if (req.query.id === undefined) {
|
|
res.status(500).send('Required credential id is missing!');
|
|
return '';
|
|
}
|
|
|
|
const result = await Db.collections.Credentials!.findOne(req.query.id as string);
|
|
if (result === undefined) {
|
|
res.status(404).send('The credential is not known.');
|
|
return '';
|
|
}
|
|
|
|
let encryptionKey = undefined;
|
|
encryptionKey = await UserSettings.getEncryptionKey();
|
|
if (encryptionKey === undefined) {
|
|
res.status(500).send('No encryption key got found to decrypt the credentials!');
|
|
return '';
|
|
}
|
|
|
|
// Decrypt the currently saved credentials
|
|
const workflowCredentials: IWorkflowCredentials = {
|
|
[result.type as string]: {
|
|
[result.name as string]: result as ICredentialsEncrypted,
|
|
},
|
|
};
|
|
const mode: WorkflowExecuteMode = 'internal';
|
|
const credentialsHelper = new CredentialsHelper(workflowCredentials, encryptionKey);
|
|
const decryptedDataOriginal = credentialsHelper.getDecrypted(result.name, result.type, mode, true);
|
|
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(decryptedDataOriginal, result.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,
|
|
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=${req.query.id}`,
|
|
};
|
|
|
|
await this.externalHooks.run('oauth1.authenticate', [oAuthOptions, oauthRequestData]);
|
|
|
|
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 as RequestOptions));
|
|
|
|
//@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(result.name, result.type, result.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(req.query.id as string, newCredentialsData);
|
|
|
|
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: express.Request, res: express.Response) => {
|
|
const { oauth_verifier, oauth_token, cid } = req.query;
|
|
|
|
if (oauth_verifier === undefined || oauth_token === undefined) {
|
|
const errorResponse = new ResponseHelper.ResponseError('Insufficient parameters for OAuth1 callback. Received following query parameters: ' + JSON.stringify(req.query), undefined, 503);
|
|
return ResponseHelper.sendErrorResponse(res, errorResponse);
|
|
}
|
|
|
|
const result = await Db.collections.Credentials!.findOne(cid as any); // tslint:disable-line:no-any
|
|
if (result === undefined) {
|
|
const errorResponse = new ResponseHelper.ResponseError('The credential is not known.', undefined, 404);
|
|
return ResponseHelper.sendErrorResponse(res, errorResponse);
|
|
}
|
|
|
|
let encryptionKey = undefined;
|
|
encryptionKey = await UserSettings.getEncryptionKey();
|
|
if (encryptionKey === undefined) {
|
|
const errorResponse = new ResponseHelper.ResponseError('No encryption key got found to decrypt the credentials!', undefined, 503);
|
|
return ResponseHelper.sendErrorResponse(res, errorResponse);
|
|
}
|
|
|
|
// Decrypt the currently saved credentials
|
|
const workflowCredentials: IWorkflowCredentials = {
|
|
[result.type as string]: {
|
|
[result.name as string]: result as ICredentialsEncrypted,
|
|
},
|
|
};
|
|
const mode: WorkflowExecuteMode = 'internal';
|
|
const credentialsHelper = new CredentialsHelper(workflowCredentials, encryptionKey);
|
|
const decryptedDataOriginal = credentialsHelper.getDecrypted(result.name, result.type, mode, true);
|
|
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(decryptedDataOriginal, result.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) {
|
|
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(result.name, result.type, result.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(cid as any, newCredentialsData); // tslint:disable-line:no-any
|
|
|
|
res.sendFile(pathResolve(__dirname, '../../templates/oauth-callback.html'));
|
|
});
|
|
|
|
|
|
// ----------------------------------------
|
|
// OAuth2-Credential/Auth
|
|
// ----------------------------------------
|
|
|
|
|
|
// Authorize OAuth Data
|
|
this.app.get(`/${this.restEndpoint}/oauth2-credential/auth`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<string> => {
|
|
if (req.query.id === undefined) {
|
|
res.status(500).send('Required credential id is missing.');
|
|
return '';
|
|
}
|
|
|
|
const result = await Db.collections.Credentials!.findOne(req.query.id as string);
|
|
if (result === undefined) {
|
|
res.status(404).send('The credential is not known.');
|
|
return '';
|
|
}
|
|
|
|
let encryptionKey = undefined;
|
|
encryptionKey = await UserSettings.getEncryptionKey();
|
|
if (encryptionKey === undefined) {
|
|
res.status(500).send('No encryption key got found to decrypt the credentials!');
|
|
return '';
|
|
}
|
|
|
|
// Decrypt the currently saved credentials
|
|
const workflowCredentials: IWorkflowCredentials = {
|
|
[result.type as string]: {
|
|
[result.name as string]: result as ICredentialsEncrypted,
|
|
},
|
|
};
|
|
const mode: WorkflowExecuteMode = 'internal';
|
|
const credentialsHelper = new CredentialsHelper(workflowCredentials, encryptionKey);
|
|
const decryptedDataOriginal = credentialsHelper.getDecrypted(result.name, result.type, mode, true);
|
|
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(decryptedDataOriginal, result.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') as string;
|
|
|
|
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(result.name, result.type, result.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] as string);
|
|
data.scope = _.get(oauthCredentials, 'scope') as string;
|
|
returnUri = `${_.get(oauthCredentials, 'authUrl', '')}?${querystring.stringify(data)}`;
|
|
}
|
|
|
|
if (authQueryParameters) {
|
|
returnUri += '&' + authQueryParameters;
|
|
}
|
|
|
|
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: express.Request, res: express.Response) => {
|
|
|
|
// realmId it's currently just use for the quickbook OAuth2 flow
|
|
const { code, state: stateEncoded } = req.query;
|
|
|
|
if (code === undefined || stateEncoded === undefined) {
|
|
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 as string, 'base64').toString());
|
|
} catch (error) {
|
|
const errorResponse = new ResponseHelper.ResponseError('Invalid state format returned', undefined, 503);
|
|
return ResponseHelper.sendErrorResponse(res, errorResponse);
|
|
}
|
|
|
|
const result = await Db.collections.Credentials!.findOne(state.cid);
|
|
if (result === undefined) {
|
|
const errorResponse = new ResponseHelper.ResponseError('The credential is not known.', undefined, 404);
|
|
return ResponseHelper.sendErrorResponse(res, errorResponse);
|
|
}
|
|
|
|
let encryptionKey = undefined;
|
|
encryptionKey = await UserSettings.getEncryptionKey();
|
|
if (encryptionKey === undefined) {
|
|
const errorResponse = new ResponseHelper.ResponseError('No encryption key got found to decrypt the credentials!', undefined, 503);
|
|
return ResponseHelper.sendErrorResponse(res, errorResponse);
|
|
}
|
|
|
|
// Decrypt the currently saved credentials
|
|
const workflowCredentials: IWorkflowCredentials = {
|
|
[result.type as string]: {
|
|
[result.name as string]: result as ICredentialsEncrypted,
|
|
},
|
|
};
|
|
const mode: WorkflowExecuteMode = 'internal';
|
|
const credentialsHelper = new CredentialsHelper(workflowCredentials, encryptionKey);
|
|
const decryptedDataOriginal = credentialsHelper.getDecrypted(result.name, result.type, mode, true);
|
|
const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(decryptedDataOriginal, result.type, mode);
|
|
|
|
const token = new csrf();
|
|
if (decryptedDataOriginal.csrfSecret === undefined || !token.verify(decryptedDataOriginal.csrfSecret as string, state.token)) {
|
|
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) {
|
|
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(result.name, result.type, result.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);
|
|
|
|
res.sendFile(pathResolve(__dirname, '../../templates/oauth-callback.html'));
|
|
});
|
|
|
|
|
|
// ----------------------------------------
|
|
// Executions
|
|
// ----------------------------------------
|
|
|
|
|
|
// Returns all finished executions
|
|
this.app.get(`/${this.restEndpoint}/executions`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IExecutionsListResponse> => {
|
|
let filter: any = {}; // tslint:disable-line:no-any
|
|
|
|
if (req.query.filter) {
|
|
filter = JSON.parse(req.query.filter as string);
|
|
}
|
|
|
|
let limit = 20;
|
|
if (req.query.limit) {
|
|
limit = parseInt(req.query.limit as string, 10);
|
|
}
|
|
|
|
const executingWorkflowIds: string[] = [];
|
|
|
|
if (config.get('executions.mode') === 'queue') {
|
|
const currentJobs = await Queue.getInstance().getJobs(['active', 'waiting']);
|
|
executingWorkflowIds.push(...currentJobs.map(job => job.data.executionId) as string[]);
|
|
}
|
|
// We may have manual executions even with queue so we must account for these.
|
|
executingWorkflowIds.push(...this.activeExecutionsInstance.getActiveExecutions().map(execution => execution.id.toString()) as string[]);
|
|
|
|
const countFilter = JSON.parse(JSON.stringify(filter));
|
|
countFilter.select = ['id'];
|
|
countFilter.where = {id: Not(In(executingWorkflowIds))};
|
|
|
|
const resultsQuery = await Db.collections.Execution!
|
|
.createQueryBuilder("execution")
|
|
.select([
|
|
'execution.id',
|
|
'execution.finished',
|
|
'execution.mode',
|
|
'execution.retryOf',
|
|
'execution.retrySuccessId',
|
|
'execution.startedAt',
|
|
'execution.stoppedAt',
|
|
'execution.workflowData',
|
|
])
|
|
.orderBy('execution.id', 'DESC')
|
|
.take(limit);
|
|
|
|
Object.keys(filter).forEach((filterField) => {
|
|
resultsQuery.andWhere(`execution.${filterField} = :${filterField}`, {[filterField]: filter[filterField]});
|
|
});
|
|
if (req.query.lastId) {
|
|
resultsQuery.andWhere(`execution.id < :lastId`, {lastId: req.query.lastId});
|
|
}
|
|
if (req.query.firstId) {
|
|
resultsQuery.andWhere(`execution.id > :firstId`, {firstId: req.query.firstId});
|
|
}
|
|
if (executingWorkflowIds.length > 0) {
|
|
resultsQuery.andWhere(`execution.id NOT IN (:...ids)`, {ids: executingWorkflowIds});
|
|
}
|
|
|
|
const resultsPromise = resultsQuery.getMany();
|
|
|
|
const countPromise = Db.collections.Execution!.count(countFilter);
|
|
|
|
const results: IExecutionFlattedDb[] = await resultsPromise;
|
|
const count = await countPromise;
|
|
|
|
const returnResults: IExecutionsSummary[] = [];
|
|
|
|
for (const result of results) {
|
|
returnResults.push({
|
|
id: result.id!.toString(),
|
|
finished: result.finished,
|
|
mode: result.mode,
|
|
retryOf: result.retryOf ? result.retryOf.toString() : undefined,
|
|
retrySuccessId: result.retrySuccessId ? result.retrySuccessId.toString() : undefined,
|
|
startedAt: result.startedAt,
|
|
stoppedAt: result.stoppedAt,
|
|
workflowId: result.workflowData!.id ? result.workflowData!.id!.toString() : '',
|
|
workflowName: result.workflowData!.name,
|
|
});
|
|
}
|
|
|
|
return {
|
|
count,
|
|
results: returnResults,
|
|
};
|
|
}));
|
|
|
|
|
|
// Returns a specific execution
|
|
this.app.get(`/${this.restEndpoint}/executions/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IExecutionResponse | IExecutionFlattedResponse | undefined> => {
|
|
const result = await Db.collections.Execution!.findOne(req.params.id);
|
|
|
|
if (result === undefined) {
|
|
return undefined;
|
|
}
|
|
|
|
if (req.query.unflattedResponse === 'true') {
|
|
const fullExecutionData = ResponseHelper.unflattenExecutionData(result);
|
|
return fullExecutionData as IExecutionResponse;
|
|
} else {
|
|
// Convert to response format in which the id is a string
|
|
(result as IExecutionFlatted as IExecutionFlattedResponse).id = result.id.toString();
|
|
return result as IExecutionFlatted as IExecutionFlattedResponse;
|
|
}
|
|
}));
|
|
|
|
|
|
// Retries a failed execution
|
|
this.app.post(`/${this.restEndpoint}/executions/:id/retry`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<boolean> => {
|
|
// Get the data to execute
|
|
const fullExecutionDataFlatted = await Db.collections.Execution!.findOne(req.params.id);
|
|
|
|
if (fullExecutionDataFlatted === undefined) {
|
|
throw new ResponseHelper.ResponseError(`The execution with the id "${req.params.id}" does not exist.`, 404, 404);
|
|
}
|
|
|
|
const fullExecutionData = ResponseHelper.unflattenExecutionData(fullExecutionDataFlatted);
|
|
|
|
if (fullExecutionData.finished === true) {
|
|
throw new Error('The execution did succeed and can so not be retried.');
|
|
}
|
|
|
|
const executionMode = 'retry';
|
|
|
|
const credentials = await WorkflowCredentials(fullExecutionData.workflowData.nodes);
|
|
|
|
fullExecutionData.workflowData.active = false;
|
|
|
|
// Start the workflow
|
|
const data: IWorkflowExecutionDataProcess = {
|
|
credentials,
|
|
executionMode,
|
|
executionData: fullExecutionData.data,
|
|
retryOf: req.params.id,
|
|
workflowData: fullExecutionData.workflowData,
|
|
};
|
|
|
|
const lastNodeExecuted = data!.executionData!.resultData.lastNodeExecuted as string | undefined;
|
|
|
|
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].length;
|
|
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 === true) {
|
|
// 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) {
|
|
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 executionId = await workflowRunner.run(data);
|
|
|
|
const executionData = await this.activeExecutionsInstance.getPostExecutePromise(executionId);
|
|
|
|
if (executionData === undefined) {
|
|
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: express.Request, res: express.Response): Promise<void> => {
|
|
const deleteData = req.body as IExecutionDeleteFilter;
|
|
|
|
if (deleteData.deleteBefore !== undefined) {
|
|
const filters = {
|
|
startedAt: LessThanOrEqual(deleteData.deleteBefore),
|
|
};
|
|
if (deleteData.filters !== undefined) {
|
|
Object.assign(filters, deleteData.filters);
|
|
}
|
|
|
|
await Db.collections.Execution!.delete(filters);
|
|
} else if (deleteData.ids !== undefined) {
|
|
// Deletes all executions with the given ids
|
|
await Db.collections.Execution!.delete(deleteData.ids);
|
|
} else {
|
|
throw new Error('Required body-data "ids" or "deleteBefore" is missing!');
|
|
}
|
|
}));
|
|
|
|
|
|
// ----------------------------------------
|
|
// Executing Workflows
|
|
// ----------------------------------------
|
|
|
|
|
|
// Returns all the currently working executions
|
|
this.app.get(`/${this.restEndpoint}/executions-current`, ResponseHelper.send(async (req: express.Request, res: express.Response): 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 === 0) {
|
|
return [];
|
|
}
|
|
|
|
const resultsQuery = await Db.collections.Execution!
|
|
.createQueryBuilder("execution")
|
|
.select([
|
|
'execution.id',
|
|
'execution.workflowId',
|
|
'execution.mode',
|
|
'execution.retryOf',
|
|
'execution.startedAt',
|
|
])
|
|
.orderBy('execution.id', 'DESC')
|
|
.andWhere(`execution.id IN (:...ids)`, {ids: currentlyRunningExecutionIds});
|
|
|
|
if (req.query.filter) {
|
|
const filter = JSON.parse(req.query.filter as string);
|
|
if (filter.workflowId !== undefined) {
|
|
resultsQuery.andWhere('execution.workflowId = :workflowId', {workflowId: filter.workflowId});
|
|
}
|
|
}
|
|
|
|
const results = await resultsQuery.getMany();
|
|
|
|
return results.map(result => {
|
|
return {
|
|
id: result.id,
|
|
workflowId: result.workflowId,
|
|
mode: result.mode,
|
|
retryOf: result.retryOf !== null ? result.retryOf : undefined,
|
|
startedAt: new Date(result.startedAt),
|
|
} as IExecutionsSummary;
|
|
});
|
|
} else {
|
|
const executingWorkflows = this.activeExecutionsInstance.getActiveExecutions();
|
|
|
|
const returnData: IExecutionsSummary[] = [];
|
|
|
|
let filter: any = {}; // tslint:disable-line:no-any
|
|
if (req.query.filter) {
|
|
filter = JSON.parse(req.query.filter as string);
|
|
}
|
|
|
|
for (const data of executingWorkflows) {
|
|
if (filter.workflowId !== undefined && filter.workflowId !== 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),
|
|
}
|
|
);
|
|
}
|
|
|
|
return returnData;
|
|
}
|
|
}));
|
|
|
|
// Forces the execution to stop
|
|
this.app.post(`/${this.restEndpoint}/executions-current/:id/stop`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IExecutionsStopData> => {
|
|
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) {
|
|
const returnData: IExecutionsStopData = {
|
|
mode: result.mode,
|
|
startedAt: new Date(result.startedAt),
|
|
stoppedAt: result.stoppedAt ? new Date(result.stoppedAt) : undefined,
|
|
finished: result.finished,
|
|
};
|
|
|
|
return returnData;
|
|
}
|
|
|
|
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) as IExecutionResponse;
|
|
|
|
const returnData: IExecutionsStopData = {
|
|
mode: fullExecutionData.mode,
|
|
startedAt: new Date(fullExecutionData.startedAt),
|
|
stoppedAt: fullExecutionData.stoppedAt ? new Date(fullExecutionData.stoppedAt) : undefined,
|
|
finished: fullExecutionData.finished,
|
|
};
|
|
|
|
return returnData;
|
|
|
|
} else {
|
|
const executionId = req.params.id;
|
|
|
|
// Stopt he execution and wait till it is done and we got the data
|
|
const result = await this.activeExecutionsInstance.stopExecution(executionId);
|
|
|
|
if (result === undefined) {
|
|
throw new Error(`The execution id "${executionId}" could not be found.`);
|
|
}
|
|
|
|
const returnData: IExecutionsStopData = {
|
|
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> => {
|
|
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;
|
|
}));
|
|
|
|
|
|
|
|
|
|
// ----------------------------------------
|
|
// Settings
|
|
// ----------------------------------------
|
|
|
|
|
|
// Returns the settings which are needed in the UI
|
|
this.app.get(`/${this.restEndpoint}/settings`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IN8nUISettings> => {
|
|
return this.frontendSettings;
|
|
}));
|
|
|
|
|
|
|
|
// ----------------------------------------
|
|
// Webhooks
|
|
// ----------------------------------------
|
|
|
|
if (config.get('endpoints.disableProductionWebhooksOnMainProcess') !== true) {
|
|
WebhookServer.registerProductionWebhooks.apply(this);
|
|
}
|
|
|
|
// HEAD webhook requests (test for UI)
|
|
this.app.head(`/${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);
|
|
|
|
let response;
|
|
try {
|
|
response = await this.testWebhooks.callTestWebhook('HEAD', 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);
|
|
});
|
|
|
|
// HEAD webhook requests (test for UI)
|
|
this.app.options(`/${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);
|
|
|
|
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);
|
|
});
|
|
|
|
// GET webhook requests (test for UI)
|
|
this.app.get(`/${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);
|
|
|
|
let response;
|
|
try {
|
|
response = await this.testWebhooks.callTestWebhook('GET', 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);
|
|
});
|
|
|
|
// POST webhook requests (test for UI)
|
|
this.app.post(`/${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);
|
|
|
|
let response;
|
|
try {
|
|
response = await this.testWebhooks.callTestWebhook('POST', 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);
|
|
});
|
|
|
|
|
|
if (this.endpointPresetCredentials !== '') {
|
|
|
|
// POST endpoint to set preset credentials
|
|
this.app.post(`/${this.endpointPresetCredentials}`, async (req: express.Request, res: express.Response) => {
|
|
|
|
if (this.presetCredentialsLoaded === false) {
|
|
|
|
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 loadNodesAndCredentials = LoadNodesAndCredentials();
|
|
|
|
const credentialsOverwrites = CredentialsOverwrites();
|
|
|
|
await credentialsOverwrites.init(body);
|
|
|
|
const credentialTypes = CredentialTypes();
|
|
|
|
await credentialTypes.init(loadNodesAndCredentials.credentialTypes);
|
|
|
|
this.presetCredentialsLoaded = true;
|
|
|
|
ResponseHelper.sendSuccessResponse(res, { success: true }, true, 200);
|
|
|
|
} else {
|
|
ResponseHelper.sendErrorResponse(res, new Error('Preset credentials can be set once'));
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
// 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
|
|
const startTime = (new Date()).toUTCString();
|
|
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);
|
|
}
|
|
},
|
|
}));
|
|
}
|
|
|
|
}
|
|
|
|
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}`);
|
|
|
|
await app.externalHooks.run('n8n.ready', [app]);
|
|
});
|
|
}
|