mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-28 23:37:00 +02:00
495 lines
16 KiB
TypeScript
495 lines
16 KiB
TypeScript
import { inDevelopment, inProduction } from '@n8n/backend-common';
|
|
import { SecurityConfig, WorkflowsConfig } from '@n8n/config';
|
|
import { Time } from '@n8n/constants';
|
|
import type { APIRequest, AuthenticatedRequest } from '@n8n/db';
|
|
import { Container, Service } from '@n8n/di';
|
|
import cookieParser from 'cookie-parser';
|
|
import express from 'express';
|
|
import { access as fsAccess } from 'fs/promises';
|
|
import helmet from 'helmet';
|
|
import isEmpty from 'lodash/isEmpty';
|
|
import { InstanceSettings, installGlobalProxyAgent } from 'n8n-core';
|
|
import { jsonParse } from 'n8n-workflow';
|
|
import { resolve } from 'path';
|
|
|
|
import { AbstractServer } from '@/abstract-server';
|
|
import { AuthService } from '@/auth/auth.service';
|
|
import { CLI_DIR, EDITOR_UI_DIST_DIR, inE2ETests } from '@/constants';
|
|
import { ControllerRegistry } from '@/controller.registry';
|
|
import { CredentialsOverwrites } from '@/credentials-overwrites';
|
|
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
|
|
import { EventService } from '@/events/event.service';
|
|
import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay';
|
|
import type { ICredentialsOverwrite } from '@/interfaces';
|
|
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
|
import { handleMfaDisable, isMfaFeatureEnabled } from '@/mfa/helpers';
|
|
import { PostHogClient } from '@/posthog';
|
|
import { isApiEnabled, loadPublicApiVersions } from '@/public-api';
|
|
import { Push } from '@/push';
|
|
import * as ResponseHelper from '@/response-helper';
|
|
import type { FrontendService } from '@/services/frontend.service';
|
|
|
|
import '@/controllers/active-workflows.controller';
|
|
import '@/controllers/annotation-tags.controller.ee';
|
|
import '@/controllers/auth.controller';
|
|
import '@/controllers/binary-data.controller';
|
|
import '@/controllers/ai.controller';
|
|
import '@/controllers/dynamic-node-parameters.controller';
|
|
import '@/controllers/dynamic-templates.controller';
|
|
import '@/controllers/invitation.controller';
|
|
import '@/controllers/me.controller';
|
|
import '@/controllers/node-types.controller';
|
|
import '@/controllers/oauth/oauth1-credential.controller';
|
|
import '@/controllers/oauth/oauth2-credential.controller';
|
|
import '@/controllers/orchestration.controller';
|
|
import '@/controllers/owner.controller';
|
|
import '@/controllers/password-reset.controller';
|
|
import '@/controllers/project.controller';
|
|
import '@/controllers/role.controller';
|
|
import '@/controllers/tags.controller';
|
|
import '@/controllers/translation.controller';
|
|
import '@/controllers/folder.controller';
|
|
import '@/controllers/users.controller';
|
|
import '@/controllers/user-settings.controller';
|
|
import '@/controllers/workflow-statistics.controller';
|
|
import '@/controllers/api-keys.controller';
|
|
import '@/controllers/security-settings.controller';
|
|
import '@/credentials/credentials.controller';
|
|
import '@/events/events.controller';
|
|
import '@/executions/executions.controller';
|
|
import '@/license/license.controller';
|
|
import '@/evaluation.ee/test-runs.controller.ee';
|
|
import '@/workflows/workflow-history/workflow-history.controller';
|
|
import '@/workflows/workflows.controller';
|
|
import '@/modules/workflow-index/workflow-dependency.controller';
|
|
import '@/webhooks/webhooks.controller';
|
|
|
|
import { ChatServer } from './chat/chat-server';
|
|
import { MfaService } from './mfa/mfa.service';
|
|
import { PubSubRegistry } from './scaling/pubsub/pubsub.registry';
|
|
|
|
@Service()
|
|
export class Server extends AbstractServer {
|
|
private endpointPresetCredentials: string;
|
|
|
|
private presetCredentialsLoaded: boolean;
|
|
|
|
private frontendService?: FrontendService;
|
|
|
|
constructor(
|
|
private readonly loadNodesAndCredentials: LoadNodesAndCredentials,
|
|
private readonly postHogClient: PostHogClient,
|
|
private readonly eventService: EventService,
|
|
private readonly instanceSettings: InstanceSettings,
|
|
) {
|
|
super();
|
|
|
|
this.testWebhooksEnabled = true;
|
|
this.webhooksEnabled = !this.globalConfig.endpoints.disableProductionWebhooksOnMainProcess;
|
|
}
|
|
|
|
async start() {
|
|
if (!this.globalConfig.endpoints.disableUi) {
|
|
const { FrontendService } = await import('@/services/frontend.service');
|
|
this.frontendService = Container.get(FrontendService);
|
|
await import('@/controllers/module-settings.controller');
|
|
await import('@/controllers/third-party-licenses.controller');
|
|
}
|
|
|
|
this.presetCredentialsLoaded = false;
|
|
|
|
this.endpointPresetCredentials = this.globalConfig.credentials.overwrite.endpoint;
|
|
|
|
await super.start();
|
|
this.logger.debug(`Server ID: ${this.instanceSettings.hostId}`);
|
|
|
|
if (inDevelopment && process.env.N8N_DEV_RELOAD === 'true') {
|
|
void this.loadNodesAndCredentials.setupHotReload();
|
|
}
|
|
|
|
this.markAsReady();
|
|
|
|
this.eventService.emit('server-started');
|
|
}
|
|
|
|
private async registerAdditionalControllers() {
|
|
if (!inProduction && this.instanceSettings.isMultiMain) {
|
|
await import('@/controllers/debug.controller');
|
|
}
|
|
|
|
if (inE2ETests) {
|
|
await import('@/controllers/e2e.controller');
|
|
}
|
|
|
|
if (isMfaFeatureEnabled()) {
|
|
await Container.get(MfaService).init();
|
|
await import('@/controllers/mfa.controller');
|
|
}
|
|
|
|
if (!this.globalConfig.endpoints.disableUi) {
|
|
await import('@/controllers/cta.controller');
|
|
}
|
|
|
|
if (!this.globalConfig.tags.disabled) {
|
|
await import('@/controllers/tags.controller');
|
|
}
|
|
|
|
if (this.globalConfig.diagnostics.enabled) {
|
|
await import('@/controllers/telemetry.controller');
|
|
await import('@/controllers/posthog.controller');
|
|
}
|
|
|
|
// ----------------------------------------
|
|
// Variables
|
|
// ----------------------------------------
|
|
|
|
try {
|
|
await import('@/environments.ee/variables/variables.controller.ee');
|
|
} catch (error) {
|
|
this.logger.warn(`Variables initialization failed: ${(error as Error).message}`);
|
|
}
|
|
}
|
|
|
|
async configure(): Promise<void> {
|
|
if (this.globalConfig.endpoints.metrics.enable) {
|
|
const { PrometheusMetricsService } = await import('@/metrics/prometheus-metrics.service');
|
|
await Container.get(PrometheusMetricsService).init(this.app);
|
|
}
|
|
|
|
const { frontendService } = this;
|
|
if (frontendService) {
|
|
await this.externalHooks.run('frontend.settings', [await frontendService.getSettings()]);
|
|
}
|
|
|
|
await this.postHogClient.init();
|
|
|
|
const publicApiEndpoint = this.globalConfig.publicApi.path;
|
|
|
|
// ----------------------------------------
|
|
// Public API
|
|
// ----------------------------------------
|
|
|
|
if (isApiEnabled()) {
|
|
const { apiRouters, apiLatestVersion } = await loadPublicApiVersions(publicApiEndpoint);
|
|
this.app.use(...apiRouters);
|
|
if (frontendService) {
|
|
(await frontendService.getSettings()).publicApi.latestVersion = apiLatestVersion;
|
|
}
|
|
}
|
|
|
|
// Extract BrowserId from headers
|
|
this.app.use((req: APIRequest, _, next) => {
|
|
req.browserId = req.headers['browser-id'] as string;
|
|
next();
|
|
});
|
|
|
|
// Parse cookies for easier access
|
|
this.app.use(cookieParser());
|
|
|
|
const { restEndpoint, app } = this;
|
|
|
|
const push = Container.get(Push);
|
|
push.setupPushHandler(restEndpoint, app);
|
|
|
|
if (push.isBidirectional) {
|
|
const { CollaborationService } = await import('@/collaboration/collaboration.service');
|
|
|
|
const collaborationService = Container.get(CollaborationService);
|
|
collaborationService.init();
|
|
} else {
|
|
this.logger.warn(
|
|
'Collaboration features are disabled because push is configured unidirectional. Use N8N_PUSH_BACKEND=websocket environment variable to enable them.',
|
|
);
|
|
}
|
|
|
|
if (this.globalConfig.executions.mode === 'queue') {
|
|
const { ScalingService } = await import('@/scaling/scaling.service');
|
|
await Container.get(ScalingService).setupQueue();
|
|
}
|
|
|
|
await handleMfaDisable();
|
|
|
|
await this.registerAdditionalControllers();
|
|
|
|
// Reinitialize the PubSubRegistry
|
|
Container.get(PubSubRegistry).init();
|
|
|
|
// register all known controllers
|
|
Container.get(ControllerRegistry).activate(app);
|
|
|
|
// ----------------------------------------
|
|
// Options
|
|
// ----------------------------------------
|
|
|
|
// Returns all the available timezones
|
|
const tzDataFile = resolve(CLI_DIR, 'dist/timezones.json');
|
|
this.app.get(`/${this.restEndpoint}/options/timezones`, (_, res) =>
|
|
res.sendFile(tzDataFile, { dotfiles: 'allow' }),
|
|
);
|
|
|
|
this.configureSettingsRoute();
|
|
|
|
// ----------------------------------------
|
|
// EventBus Setup
|
|
// ----------------------------------------
|
|
const eventBus = Container.get(MessageEventBus);
|
|
await eventBus.initialize();
|
|
Container.get(LogStreamingEventRelay).init();
|
|
|
|
// ----------------------------------------
|
|
// Workflow Indexing Setup
|
|
// ----------------------------------------
|
|
await this.initializeWorkflowIndexing();
|
|
|
|
if (this.endpointPresetCredentials !== '') {
|
|
// POST endpoint to set preset credentials
|
|
const overwriteEndpointMiddleware =
|
|
Container.get(CredentialsOverwrites).getOverwriteEndpointMiddleware();
|
|
|
|
if (overwriteEndpointMiddleware) {
|
|
this.app.use(`/${this.endpointPresetCredentials}`, overwriteEndpointMiddleware);
|
|
}
|
|
|
|
const authenticationEnforced = overwriteEndpointMiddleware !== null;
|
|
this.app.post(
|
|
`/${this.endpointPresetCredentials}`,
|
|
async (req: express.Request, res: express.Response) => {
|
|
try {
|
|
// If authentication is enforced we can allow multiple overwrites
|
|
if (!this.presetCredentialsLoaded || authenticationEnforced) {
|
|
const body = req.body as ICredentialsOverwrite;
|
|
|
|
if (req.contentType !== 'application/json') {
|
|
ResponseHelper.sendErrorResponse(
|
|
res,
|
|
new Error(
|
|
'Body must be a valid JSON, make sure the content-type is application/json',
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
await Container.get(CredentialsOverwrites).setData(body, true, true);
|
|
|
|
this.presetCredentialsLoaded = true;
|
|
|
|
// Send push event to notify frontend to refetch types
|
|
Container.get(Push).broadcast({ type: 'nodeDescriptionUpdated', data: {} });
|
|
|
|
ResponseHelper.sendSuccessResponse(res, { success: true }, true, 200);
|
|
} else {
|
|
ResponseHelper.sendErrorResponse(
|
|
res,
|
|
new Error('Preset credentials can be set once'),
|
|
);
|
|
}
|
|
} catch (error) {
|
|
this.logger.error('Error handling credentials overwrite', { error });
|
|
ResponseHelper.sendErrorResponse(
|
|
res,
|
|
new Error(
|
|
'An error occurred while handling credentials overwrite, please check the logs for more details',
|
|
),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
const maxAge = Time.days.toMilliseconds;
|
|
const cacheOptions = inE2ETests || inDevelopment ? {} : { maxAge };
|
|
const { staticCacheDir } = Container.get(InstanceSettings);
|
|
|
|
// Protect type files with authentication regardless of UI availability
|
|
const authService = Container.get(AuthService);
|
|
const protectedTypeFiles = [
|
|
'/types/nodes.json',
|
|
'/types/credentials.json',
|
|
'/types/node-versions.json',
|
|
];
|
|
protectedTypeFiles.forEach((path) => {
|
|
this.app.get(
|
|
path,
|
|
authService.createAuthMiddleware({ allowSkipMFA: true, allowSkipPreviewAuth: true }),
|
|
async (_, res: express.Response) => {
|
|
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
|
|
res.sendFile(path.substring(1), {
|
|
root: staticCacheDir,
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
if (frontendService) {
|
|
this.app.use(
|
|
[
|
|
'/icons/{@:scope/}:packageName/*path/*file.svg',
|
|
'/icons/{@:scope/}:packageName/*path/*file.png',
|
|
],
|
|
async (req, res) => {
|
|
// eslint-disable-next-line prefer-const
|
|
let { scope, packageName } = req.params;
|
|
if (scope) packageName = `@${scope}/${packageName}`;
|
|
const filePath = this.loadNodesAndCredentials.resolveIcon(packageName, req.originalUrl);
|
|
if (filePath) {
|
|
try {
|
|
await fsAccess(filePath);
|
|
return res.sendFile(filePath, { maxAge, dotfiles: 'allow' });
|
|
} catch {}
|
|
}
|
|
res.sendStatus(404);
|
|
},
|
|
);
|
|
|
|
const serveSchemas: express.RequestHandler = async (req, res) => {
|
|
const { node, version, resource, operation } = req.params;
|
|
const filePath = this.loadNodesAndCredentials.resolveSchema({
|
|
node,
|
|
resource,
|
|
operation,
|
|
version,
|
|
});
|
|
|
|
if (filePath) {
|
|
try {
|
|
await fsAccess(filePath);
|
|
return res.sendFile(filePath, { ...cacheOptions, dotfiles: 'allow' });
|
|
} catch {}
|
|
}
|
|
res.sendStatus(404);
|
|
};
|
|
this.app.use('/schemas/:node/:version{/:resource}{/:operation}.json', serveSchemas);
|
|
|
|
const isTLSEnabled =
|
|
this.globalConfig.protocol === 'https' && !!(this.sslKey && this.sslCert);
|
|
const isPreviewMode = process.env.N8N_PREVIEW_MODE === 'true';
|
|
const cspDirectives = jsonParse<{ [key: string]: Iterable<string> }>(
|
|
Container.get(SecurityConfig).contentSecurityPolicy,
|
|
{
|
|
errorMessage: 'The contentSecurityPolicy is not valid JSON.',
|
|
},
|
|
);
|
|
const crossOriginOpenerPolicy = Container.get(SecurityConfig).crossOriginOpenerPolicy;
|
|
const cspReportOnly = Container.get(SecurityConfig).contentSecurityPolicyReportOnly;
|
|
const securityHeadersMiddleware = helmet({
|
|
contentSecurityPolicy: isEmpty(cspDirectives)
|
|
? false
|
|
: {
|
|
useDefaults: false,
|
|
reportOnly: cspReportOnly,
|
|
directives: {
|
|
...cspDirectives,
|
|
},
|
|
},
|
|
xFrameOptions:
|
|
isPreviewMode || inE2ETests || inDevelopment ? false : { action: 'sameorigin' },
|
|
dnsPrefetchControl: false,
|
|
// This is only relevant for Internet-explorer, which we do not support
|
|
ieNoOpen: false,
|
|
// This is already disabled in AbstractServer
|
|
xPoweredBy: false,
|
|
// Enable HSTS headers only when n8n handles TLS.
|
|
// if n8n is behind a reverse-proxy, then these headers needs to be configured there
|
|
strictTransportSecurity: isTLSEnabled
|
|
? {
|
|
maxAge: 180 * Time.days.toSeconds,
|
|
includeSubDomains: false,
|
|
preload: false,
|
|
}
|
|
: false,
|
|
crossOriginOpenerPolicy: {
|
|
policy: crossOriginOpenerPolicy,
|
|
},
|
|
});
|
|
|
|
// Route all UI urls to index.html to support history-api
|
|
const nonUIRoutes: readonly string[] = [
|
|
'favicon.ico',
|
|
'assets',
|
|
'static',
|
|
'types',
|
|
this.endpointHealth,
|
|
'metrics',
|
|
'e2e',
|
|
this.restEndpoint,
|
|
this.endpointPresetCredentials,
|
|
isApiEnabled() ? '' : publicApiEndpoint,
|
|
...this.globalConfig.endpoints.additionalNonUIRoutes.split(':'),
|
|
].filter((u) => !!u);
|
|
const nonUIRoutesRegex = new RegExp(`^/(${nonUIRoutes.join('|')})/?.*$`);
|
|
const historyApiHandler: express.RequestHandler = (req, res, next) => {
|
|
const {
|
|
method,
|
|
headers: { accept },
|
|
} = req;
|
|
if (
|
|
method === 'GET' &&
|
|
accept &&
|
|
(accept.includes('text/html') || accept.includes('*/*')) &&
|
|
!req.path.endsWith('.wasm') &&
|
|
!nonUIRoutesRegex.test(req.path)
|
|
) {
|
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate, proxy-revalidate');
|
|
securityHeadersMiddleware(req, res, () => {
|
|
res.sendFile('index.html', { root: staticCacheDir, maxAge: 0, lastModified: false });
|
|
});
|
|
} else {
|
|
next();
|
|
}
|
|
};
|
|
const setCustomCacheHeader = (res: express.Response) => {
|
|
if (/^\/types\/(nodes|credentials).json$/.test(res.req.url)) {
|
|
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
|
|
}
|
|
};
|
|
|
|
this.app.use(
|
|
'/',
|
|
historyApiHandler,
|
|
express.static(staticCacheDir, {
|
|
...cacheOptions,
|
|
setHeaders: setCustomCacheHeader,
|
|
}),
|
|
express.static(EDITOR_UI_DIST_DIR, cacheOptions),
|
|
);
|
|
} else {
|
|
this.app.use('/', express.static(staticCacheDir, cacheOptions));
|
|
}
|
|
|
|
installGlobalProxyAgent();
|
|
}
|
|
|
|
private configureSettingsRoute() {
|
|
const { frontendService } = this;
|
|
const authService = Container.get(AuthService);
|
|
|
|
if (frontendService) {
|
|
// Returns the current settings for the UI
|
|
this.app.get(
|
|
`/${this.restEndpoint}/settings`,
|
|
authService.createAuthMiddleware({ allowSkipMFA: false, allowUnauthenticated: true }),
|
|
ResponseHelper.send(async (req: AuthenticatedRequest) => {
|
|
return req.user
|
|
? await frontendService.getSettings()
|
|
: await frontendService.getPublicSettings(!!req.authInfo?.mfaEnrollmentRequired);
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
private async initializeWorkflowIndexing() {
|
|
if (Container.get(WorkflowsConfig).indexingEnabled) {
|
|
const { WorkflowIndexService } = await import(
|
|
'@/modules/workflow-index/workflow-index.service'
|
|
);
|
|
Container.get(WorkflowIndexService).init();
|
|
}
|
|
}
|
|
|
|
protected setupPushServer(): void {
|
|
const { restEndpoint, server, app } = this;
|
|
Container.get(Push).setupPushServer(restEndpoint, server, app);
|
|
Container.get(ChatServer).setup(server, app);
|
|
}
|
|
}
|