refactor(core): Convert more routes to use the decorator pattern (no-changelog) (#5611)

* move nodeTypes api to a controller class
* move tags api to a controller class
* move LDAP routes to a controller class
* move nodes routes to a controller class
This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2023-03-09 14:42:13 +01:00 committed by GitHub
parent 493f7a1c92
commit 356e916194
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 356 additions and 389 deletions

View file

@ -1,77 +0,0 @@
import express from 'express';
import { LdapManager } from '../LdapManager.ee';
import { getLdapConfig, getLdapSynchronizations, updateLdapConfig } from '../helpers';
import type { LdapConfiguration } from '../types';
import pick from 'lodash.pick';
import { NON_SENSIBLE_LDAP_CONFIG_PROPERTIES } from '../constants';
import { InternalHooks } from '@/InternalHooks';
import { Container } from 'typedi';
export const ldapController = express.Router();
/**
* GET /ldap/config
*/
ldapController.get('/config', async (req: express.Request, res: express.Response) => {
const data = await getLdapConfig();
return res.status(200).json({ data });
});
/**
* POST /ldap/test-connection
*/
ldapController.post('/test-connection', async (req: express.Request, res: express.Response) => {
try {
await LdapManager.getInstance().service.testConnection();
} catch (error) {
const errorObject = error as { message: string };
return res.status(400).json({ message: errorObject.message });
}
return res.status(200).json();
});
/**
* PUT /ldap/config
*/
ldapController.put('/config', async (req: LdapConfiguration.Update, res: express.Response) => {
try {
await updateLdapConfig(req.body);
} catch (e) {
if (e instanceof Error) {
return res.status(400).json({ message: e.message });
}
}
const data = await getLdapConfig();
void Container.get(InternalHooks).onUserUpdatedLdapSettings({
user_id: req.user.id,
...pick(data, NON_SENSIBLE_LDAP_CONFIG_PROPERTIES),
});
return res.status(200).json({ data });
});
/**
* POST /ldap/sync
*/
ldapController.post('/sync', async (req: LdapConfiguration.Sync, res: express.Response) => {
const runType = req.body.type;
try {
await LdapManager.getInstance().sync.run(runType);
} catch (e) {
if (e instanceof Error) {
return res.status(400).json({ message: e.message });
}
}
return res.status(200).json({});
});
/**
* GET /ldap/sync
*/
ldapController.get('/sync', async (req: LdapConfiguration.GetSync, res: express.Response) => {
const { page = '0', perPage = '20' } = req.query;
const data = await getLdapSynchronizations(parseInt(page, 10), parseInt(perPage, 10));
return res.status(200).json({ data });
});

View file

@ -59,7 +59,6 @@ import config from '@/config';
import * as Queue from '@/Queue';
import { getSharedWorkflowIds } from '@/WorkflowHelpers';
import { nodesController } from '@/api/nodes.api';
import { workflowsController } from '@/workflows/workflows.controller';
import {
EDITOR_UI_DIST_DIR,
@ -83,16 +82,18 @@ import type {
import { registerController } from '@/decorators';
import {
AuthController,
LdapController,
MeController,
NodesController,
NodeTypesController,
OwnerController,
PasswordResetController,
TagsController,
TranslationController,
UsersController,
} from '@/controllers';
import { executionsController } from '@/executions/executions.controller';
import { nodeTypesController } from '@/api/nodeTypes.api';
import { tagsController } from '@/api/tags.api';
import { workflowStatsController } from '@/api/workflowStats.api';
import { loadPublicApiVersions } from '@/PublicApi';
import {
@ -134,7 +135,6 @@ import { licenseController } from './license/license.controller';
import { Push, setupPushServer, setupPushHandler } from '@/push';
import { setupAuthMiddlewares } from './middlewares';
import { initEvents } from './events';
import { ldapController } from './Ldap/routes/ldap.controller.ee';
import { getLdapLoginLabel, isLdapEnabled, isLdapLoginEnabled } from './Ldap/helpers';
import { AbstractServer } from './AbstractServer';
import { configureMetrics } from './metrics';
@ -152,6 +152,7 @@ import { getSamlLoginLabel, isSamlLoginEnabled, isSamlLicensed } from './sso/sam
import { samlControllerPublic } from './sso/saml/routes/saml.controller.public.ee';
import { SamlService } from './sso/saml/saml.service.ee';
import { samlControllerProtected } from './sso/saml/routes/saml.controller.protected.ee';
import { LdapManager } from './Ldap/LdapManager.ee';
const exec = promisify(callbackExec);
@ -371,7 +372,7 @@ class Server extends AbstractServer {
}
private registerControllers(ignoredEndpoints: Readonly<string[]>) {
const { app, externalHooks, activeWorkflowRunner } = this;
const { app, externalHooks, activeWorkflowRunner, nodeTypes } = this;
const repositories = Db.collections;
setupAuthMiddlewares(app, ignoredEndpoints, this.restEndpoint, repositories.User);
@ -380,11 +381,13 @@ class Server extends AbstractServer {
const mailer = getMailerInstance();
const postHog = this.postHog;
const controllers = [
const controllers: object[] = [
new AuthController({ config, internalHooks, repositories, logger, postHog }),
new OwnerController({ config, internalHooks, repositories, logger }),
new MeController({ externalHooks, internalHooks, repositories, logger }),
new NodeTypesController({ config, nodeTypes }),
new PasswordResetController({ config, externalHooks, internalHooks, repositories, logger }),
new TagsController({ config, repositories, externalHooks }),
new TranslationController(config, this.credentialTypes),
new UsersController({
config,
@ -397,6 +400,18 @@ class Server extends AbstractServer {
postHog,
}),
];
if (isLdapEnabled()) {
const { service, sync } = LdapManager.getInstance();
controllers.push(new LdapController(service, sync, internalHooks));
}
if (config.getEnv('nodes.communityPackages.enabled')) {
controllers.push(
new NodesController(config, this.loadNodesAndCredentials, this.push, internalHooks),
);
}
controllers.forEach((controller) => registerController(app, config, controller));
}
@ -482,13 +497,6 @@ class Server extends AbstractServer {
this.app.use(`/${this.restEndpoint}/credentials`, credentialsController);
// ----------------------------------------
// Packages and nodes management
// ----------------------------------------
if (config.getEnv('nodes.communityPackages.enabled')) {
this.app.use(`/${this.restEndpoint}/nodes`, nodesController);
}
// ----------------------------------------
// Workflow
// ----------------------------------------
@ -504,18 +512,6 @@ class Server extends AbstractServer {
// ----------------------------------------
this.app.use(`/${this.restEndpoint}/workflow-stats`, workflowStatsController);
// ----------------------------------------
// Tags
// ----------------------------------------
this.app.use(`/${this.restEndpoint}/tags`, tagsController);
// ----------------------------------------
// LDAP
// ----------------------------------------
if (isLdapEnabled()) {
this.app.use(`/${this.restEndpoint}/ldap`, ldapController);
}
// ----------------------------------------
// SAML
// ----------------------------------------
@ -534,7 +530,6 @@ class Server extends AbstractServer {
this.app.use(`/${this.restEndpoint}/sso/saml`, samlControllerProtected);
// ----------------------------------------
// Returns parameter values which normally get loaded from an external API or
// get generated dynamically
this.app.get(
@ -645,12 +640,6 @@ class Server extends AbstractServer {
),
);
// ----------------------------------------
// Node-Types
// ----------------------------------------
this.app.use(`/${this.restEndpoint}/node-types`, nodeTypesController);
// ----------------------------------------
// Active Workflows
// ----------------------------------------

View file

@ -1,110 +0,0 @@
/* eslint-disable @typescript-eslint/no-unnecessary-boolean-literal-compare */
/* eslint-disable @typescript-eslint/no-invalid-void-type */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable no-param-reassign */
import express from 'express';
import * as Db from '@/Db';
import { ExternalHooks } from '@/ExternalHooks';
import type { ITagWithCountDb } from '@/Interfaces';
import * as ResponseHelper from '@/ResponseHelper';
import config from '@/config';
import * as TagHelpers from '@/TagHelpers';
import { validateEntity } from '@/GenericHelpers';
import { TagEntity } from '@db/entities/TagEntity';
import type { TagsRequest } from '@/requests';
import { Container } from 'typedi';
export const tagsController = express.Router();
const workflowsEnabledMiddleware: express.RequestHandler = (req, res, next) => {
if (config.getEnv('workflowTagsDisabled')) {
throw new ResponseHelper.BadRequestError('Workflow tags are disabled');
}
next();
};
// Retrieves all tags, with or without usage count
tagsController.get(
'/',
workflowsEnabledMiddleware,
ResponseHelper.send(async (req: express.Request): Promise<TagEntity[] | ITagWithCountDb[]> => {
if (req.query.withUsageCount === 'true') {
const tablePrefix = config.getEnv('database.tablePrefix');
return TagHelpers.getTagsWithCountDb(tablePrefix);
}
return Db.collections.Tag.find({ select: ['id', 'name', 'createdAt', 'updatedAt'] });
}),
);
// Creates a tag
tagsController.post(
'/',
workflowsEnabledMiddleware,
ResponseHelper.send(async (req: express.Request): Promise<TagEntity | void> => {
const newTag = new TagEntity();
newTag.name = req.body.name.trim();
await Container.get(ExternalHooks).run('tag.beforeCreate', [newTag]);
await validateEntity(newTag);
const tag = await Db.collections.Tag.save(newTag);
await Container.get(ExternalHooks).run('tag.afterCreate', [tag]);
return tag;
}),
);
// Updates a tag
tagsController.patch(
'/:id(\\d+)',
workflowsEnabledMiddleware,
ResponseHelper.send(async (req: express.Request): Promise<TagEntity | void> => {
const { name } = req.body;
const { id } = req.params;
const newTag = new TagEntity();
// @ts-ignore
newTag.id = id;
newTag.name = name.trim();
await Container.get(ExternalHooks).run('tag.beforeUpdate', [newTag]);
await validateEntity(newTag);
const tag = await Db.collections.Tag.save(newTag);
await Container.get(ExternalHooks).run('tag.afterUpdate', [tag]);
return tag;
}),
);
tagsController.delete(
'/:id(\\d+)',
workflowsEnabledMiddleware,
ResponseHelper.send(async (req: TagsRequest.Delete): Promise<boolean> => {
if (
config.getEnv('userManagement.isInstanceOwnerSetUp') === true &&
req.user.globalRole.name !== 'owner'
) {
throw new ResponseHelper.UnauthorizedError(
'You are not allowed to perform this action',
'Only owners can remove tags',
);
}
const id = req.params.id;
await Container.get(ExternalHooks).run('tag.beforeDelete', [id]);
await Db.collections.Tag.delete({ id });
await Container.get(ExternalHooks).run('tag.afterDelete', [id]);
return true;
}),
);

View file

@ -1,6 +1,10 @@
export { AuthController } from './auth.controller';
export { LdapController } from './ldap.controller';
export { MeController } from './me.controller';
export { NodesController } from './nodes.controller';
export { NodeTypesController } from './nodeTypes.controller';
export { OwnerController } from './owner.controller';
export { PasswordResetController } from './passwordReset.controller';
export { TagsController } from './tags.controller';
export { TranslationController } from './translation.controller';
export { UsersController } from './users.controller';

View file

@ -0,0 +1,65 @@
import pick from 'lodash.pick';
import { Get, Post, Put, RestController } from '@/decorators';
import { getLdapConfig, getLdapSynchronizations, updateLdapConfig } from '@/Ldap/helpers';
import { LdapService } from '@/Ldap/LdapService.ee';
import { LdapSync } from '@/Ldap/LdapSync.ee';
import { LdapConfiguration } from '@/Ldap/types';
import { BadRequestError } from '@/ResponseHelper';
import { NON_SENSIBLE_LDAP_CONFIG_PROPERTIES } from '@/Ldap/constants';
import { InternalHooks } from '@/InternalHooks';
@RestController('/ldap')
export class LdapController {
constructor(
private ldapService: LdapService,
private ldapSync: LdapSync,
private internalHooks: InternalHooks,
) {}
@Get('/config')
async getConfig() {
return getLdapConfig();
}
@Post('/test-connection')
async testConnection() {
try {
await this.ldapService.testConnection();
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
}
@Put('/config')
async updateConfig(req: LdapConfiguration.Update) {
try {
await updateLdapConfig(req.body);
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
const data = await getLdapConfig();
void this.internalHooks.onUserUpdatedLdapSettings({
user_id: req.user.id,
...pick(data, NON_SENSIBLE_LDAP_CONFIG_PROPERTIES),
});
return data;
}
@Get('/sync')
async getLdapSync(req: LdapConfiguration.GetSync) {
const { page = '0', perPage = '20' } = req.query;
return getLdapSynchronizations(parseInt(page, 10), parseInt(perPage, 10));
}
@Post('/sync')
async syncLdap(req: LdapConfiguration.Sync) {
try {
await this.ldapSync.run(req.body.type);
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
}
}

View file

@ -1,39 +1,43 @@
import express from 'express';
import { readFile } from 'fs/promises';
import get from 'lodash.get';
import { Request } from 'express';
import type { INodeTypeDescription, INodeTypeNameVersion } from 'n8n-workflow';
import config from '@/config';
import { NodeTypes } from '@/NodeTypes';
import * as ResponseHelper from '@/ResponseHelper';
import { Post, RestController } from '@/decorators';
import { getNodeTranslationPath } from '@/TranslationHelpers';
import { Container } from 'typedi';
import type { Config } from '@/config';
import type { NodeTypes } from '@/NodeTypes';
export const nodeTypesController = express.Router();
@RestController('/node-types')
export class NodeTypesController {
private readonly config: Config;
// Returns node information based on node names and versions
nodeTypesController.post(
'/',
ResponseHelper.send(async (req: express.Request): Promise<INodeTypeDescription[]> => {
private readonly nodeTypes: NodeTypes;
constructor({ config, nodeTypes }: { config: Config; nodeTypes: NodeTypes }) {
this.config = config;
this.nodeTypes = nodeTypes;
}
@Post('/')
async getNodeInfo(req: Request) {
const nodeInfos = get(req, 'body.nodeInfos', []) as INodeTypeNameVersion[];
const defaultLocale = config.getEnv('defaultLocale');
const defaultLocale = this.config.getEnv('defaultLocale');
if (defaultLocale === 'en') {
return nodeInfos.reduce<INodeTypeDescription[]>((acc, { name, version }) => {
const { description } = Container.get(NodeTypes).getByNameAndVersion(name, version);
const { description } = this.nodeTypes.getByNameAndVersion(name, version);
acc.push(description);
return acc;
}, []);
}
async function populateTranslation(
const populateTranslation = async (
name: string,
version: number,
nodeTypes: INodeTypeDescription[],
) {
const { description, sourcePath } = Container.get(NodeTypes).getWithSourcePath(name, version);
) => {
const { description, sourcePath } = this.nodeTypes.getWithSourcePath(name, version);
const translationPath = await getNodeTranslationPath({
nodeSourcePath: sourcePath,
longNodeType: description.name,
@ -44,12 +48,12 @@ nodeTypesController.post(
const translation = await readFile(translationPath, 'utf8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
description.translation = JSON.parse(translation);
} catch (error) {
} catch {
// ignore - no translation exists at path
}
nodeTypes.push(description);
}
};
const nodeTypes: INodeTypeDescription[] = [];
@ -60,5 +64,5 @@ nodeTypesController.post(
await Promise.all(promises);
return nodeTypes;
}),
);
}
}

View file

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

View file

@ -0,0 +1,102 @@
import { Request, Response, NextFunction } from 'express';
import type { Repository } from 'typeorm';
import type { Config } from '@/config';
import { Delete, Get, Middleware, Patch, Post, RestController } from '@/decorators';
import type { IDatabaseCollections, IExternalHooksClass, ITagWithCountDb } from '@/Interfaces';
import { TagEntity } from '@db/entities/TagEntity';
import { getTagsWithCountDb } from '@/TagHelpers';
import { validateEntity } from '@/GenericHelpers';
import { BadRequestError, UnauthorizedError } from '@/ResponseHelper';
import { TagsRequest } from '@/requests';
@RestController('/tags')
export class TagsController {
private config: Config;
private externalHooks: IExternalHooksClass;
private tagsRepository: Repository<TagEntity>;
constructor({
config,
externalHooks,
repositories,
}: {
config: Config;
externalHooks: IExternalHooksClass;
repositories: Pick<IDatabaseCollections, 'Tag'>;
}) {
this.config = config;
this.externalHooks = externalHooks;
this.tagsRepository = repositories.Tag;
}
// TODO: move this into a new decorator `@IfEnabled('workflowTagsDisabled')`
@Middleware()
workflowsEnabledMiddleware(req: Request, res: Response, next: NextFunction) {
if (this.config.getEnv('workflowTagsDisabled'))
throw new BadRequestError('Workflow tags are disabled');
next();
}
// Retrieves all tags, with or without usage count
@Get('/')
async getAll(req: TagsRequest.GetAll): Promise<TagEntity[] | ITagWithCountDb[]> {
const { withUsageCount } = req.query;
if (withUsageCount === 'true') {
const tablePrefix = this.config.getEnv('database.tablePrefix');
return getTagsWithCountDb(tablePrefix);
}
return this.tagsRepository.find({ select: ['id', 'name', 'createdAt', 'updatedAt'] });
}
// Creates a tag
@Post('/')
async createTag(req: TagsRequest.Create): Promise<TagEntity> {
const newTag = new TagEntity();
newTag.name = req.body.name.trim();
await this.externalHooks.run('tag.beforeCreate', [newTag]);
await validateEntity(newTag);
const tag = await this.tagsRepository.save(newTag);
await this.externalHooks.run('tag.afterCreate', [tag]);
return tag;
}
// Updates a tag
@Patch('/:id(\\d+)')
async updateTag(req: TagsRequest.Update): Promise<TagEntity> {
const { name } = req.body;
const { id } = req.params;
const newTag = new TagEntity();
newTag.id = id;
newTag.name = name.trim();
await this.externalHooks.run('tag.beforeUpdate', [newTag]);
await validateEntity(newTag);
const tag = await this.tagsRepository.save(newTag);
await this.externalHooks.run('tag.afterUpdate', [tag]);
return tag;
}
@Delete('/:id(\\d+)')
async deleteTag(req: TagsRequest.Delete) {
const isInstanceOwnerSetUp = this.config.getEnv('userManagement.isInstanceOwnerSetUp');
if (isInstanceOwnerSetUp && req.user.globalRole.name !== 'owner') {
throw new UnauthorizedError(
'You are not allowed to perform this action',
'Only owners can remove tags',
);
}
const { id } = req.params;
await this.externalHooks.run('tag.beforeDelete', [id]);
await this.tagsRepository.delete({ id });
await this.externalHooks.run('tag.afterDelete', [id]);
return true;
}
}

View file

@ -0,0 +1,11 @@
import { CONTROLLER_MIDDLEWARES } from './constants';
import type { MiddlewareMetadata } from './types';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const Middleware = (): MethodDecorator => (target, handlerName) => {
const controllerClass = target.constructor;
const middlewares = (Reflect.getMetadata(CONTROLLER_MIDDLEWARES, controllerClass) ??
[]) as MiddlewareMetadata[];
middlewares.push({ handlerName: String(handlerName) });
Reflect.defineMetadata(CONTROLLER_MIDDLEWARES, middlewares, controllerClass);
};

View file

@ -15,5 +15,6 @@ const RouteFactory =
export const Get = RouteFactory('get');
export const Post = RouteFactory('post');
export const Put = RouteFactory('put');
export const Patch = RouteFactory('patch');
export const Delete = RouteFactory('delete');

View file

@ -1,2 +1,3 @@
export const CONTROLLER_ROUTES = 'CONTROLLER_ROUTES';
export const CONTROLLER_BASE_PATH = 'CONTROLLER_BASE_PATH';
export const CONTROLLER_MIDDLEWARES = 'CONTROLLER_MIDDLEWARES';

View file

@ -1,3 +1,4 @@
export { RestController } from './RestController';
export { Get, Post, Patch, Delete } from './Route';
export { Get, Post, Put, Patch, Delete } from './Route';
export { Middleware } from './Middleware';
export { registerController } from './registerController';

View file

@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { Router } from 'express';
import type { Config } from '@/config';
import { CONTROLLER_BASE_PATH, CONTROLLER_ROUTES } from './constants';
import { CONTROLLER_BASE_PATH, CONTROLLER_MIDDLEWARES, CONTROLLER_ROUTES } from './constants';
import { send } from '@/ResponseHelper'; // TODO: move `ResponseHelper.send` to this file
import type { Application, Request, Response } from 'express';
import type { Controller, RouteMetadata } from './types';
import type { Application, Request, Response, RequestHandler } from 'express';
import type { Controller, MiddlewareMetadata, RouteMetadata } from './types';
export const registerController = (app: Application, config: Config, controller: object) => {
const controllerClass = controller.constructor;
@ -20,9 +20,17 @@ export const registerController = (app: Application, config: Config, controller:
const restBasePath = config.getEnv('endpoints.rest');
const prefix = `/${[restBasePath, controllerBasePath].join('/')}`.replace(/\/+/g, '/');
const middlewares = (
(Reflect.getMetadata(CONTROLLER_MIDDLEWARES, controllerClass) ?? []) as MiddlewareMetadata[]
).map(
({ handlerName }) =>
(controller as Controller)[handlerName].bind(controller) as RequestHandler,
);
routes.forEach(({ method, path, handlerName }) => {
router[method](
path,
...middlewares,
send(async (req: Request, res: Response) =>
(controller as Controller)[handlerName](req, res),
),

View file

@ -1,6 +1,10 @@
import type { Request, Response } from 'express';
export type Method = 'get' | 'post' | 'patch' | 'delete';
export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete';
export interface MiddlewareMetadata {
handlerName: string;
}
export interface RouteMetadata {
method: Method;

View file

@ -3,7 +3,6 @@ export * from './CredentialsHelper';
export * from './CredentialTypes';
export * from './CredentialsOverwrites';
export * from './Interfaces';
export * from './NodeTypes';
export * from './WaitingWebhooks';
export * from './WorkflowCredentials';
export * from './WorkflowRunner';

View file

@ -18,7 +18,7 @@ import {
} from '@/UserManagement/UserManagementHelper';
import type { Repository } from 'typeorm';
import type { User } from '@db/entities/User';
import { SamlUrls } from '../sso/saml/constants';
import { SamlUrls } from '@/sso/saml/constants';
const jwtFromRequest = (req: Request) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access

View file

@ -330,6 +330,9 @@ export type NodeListSearchRequest = AuthenticatedRequest<
// ----------------------------------
export declare namespace TagsRequest {
type GetAll = AuthenticatedRequest<{}, {}, {}, { withUsageCount: string }>;
type Create = AuthenticatedRequest<{}, {}, { name: string }>;
type Update = AuthenticatedRequest<{ id: string }, {}, { name: string }>;
type Delete = AuthenticatedRequest<{ id: string }>;
}

View file

@ -49,13 +49,11 @@ beforeAll(async () => {
authAgent = utils.createAuthAgent(app);
config.set(LDAP_ENABLED, true);
defaultLdapConfig.bindingAdminPassword = await encryptPassword(
defaultLdapConfig.bindingAdminPassword,
);
utils.initConfigFile();
await utils.initLdapManager();
});
beforeEach(async () => {

View file

@ -31,11 +31,8 @@ import { DeepPartial } from 'ts-essentials';
import config from '@/config';
import * as Db from '@/Db';
import { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { CredentialTypes } from '@/CredentialTypes';
import { ExternalHooks } from '@/ExternalHooks';
import { NodeTypes } from '@/NodeTypes';
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import { nodesController } from '@/api/nodes.api';
import { workflowsController } from '@/workflows/workflows.controller';
import { AUTH_COOKIE_NAME, NODE_PACKAGE_PREFIX } from '@/constants';
import { credentialsController } from '@/credentials/credentials.controller';
@ -65,7 +62,9 @@ import { eventBusRouter } from '@/eventbus/eventBusRoutes';
import { registerController } from '@/decorators';
import {
AuthController,
LdapController,
MeController,
NodesController,
OwnerController,
PasswordResetController,
UsersController,
@ -74,11 +73,13 @@ import { setupAuthMiddlewares } from '@/middlewares';
import * as testDb from '../shared/testDb';
import { v4 as uuid } from 'uuid';
import { handleLdapInit } from '@/Ldap/helpers';
import { ldapController } from '@/Ldap/routes/ldap.controller.ee';
import { InternalHooks } from '@/InternalHooks';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import { PostHogClient } from '@/posthog';
import { LdapManager } from '@/Ldap/LdapManager.ee';
import { LDAP_ENABLED } from '@/Ldap/constants';
import { handleLdapInit } from '@/Ldap/helpers';
import { Push } from '@/push';
export const mockInstance = <T>(
ctor: new (...args: any[]) => T,
@ -155,10 +156,8 @@ export async function initTestServer({
const map: Record<string, express.Router | express.Router[] | any> = {
credentials: { controller: credentialsController, path: 'credentials' },
workflows: { controller: workflowsController, path: 'workflows' },
nodes: { controller: nodesController, path: 'nodes' },
license: { controller: licenseController, path: 'license' },
eventBus: { controller: eventBusRouter, path: 'eventbus' },
ldap: { controller: ldapController, path: 'ldap' },
};
if (enablePublicAPI) {
@ -190,6 +189,27 @@ export async function initTestServer({
new AuthController({ config, logger, internalHooks, repositories }),
);
break;
case 'ldap':
config.set(LDAP_ENABLED, true);
await handleLdapInit();
const { service, sync } = LdapManager.getInstance();
registerController(
testServer.app,
config,
new LdapController(service, sync, internalHooks),
);
break;
case 'nodes':
registerController(
testServer.app,
config,
new NodesController(
config,
Container.get(LoadNodesAndCredentials),
Container.get(Push),
internalHooks,
),
);
case 'me':
registerController(
testServer.app,
@ -246,15 +266,7 @@ const classifyEndpointGroups = (endpointGroups: EndpointGroup[]) => {
const routerEndpoints: EndpointGroup[] = [];
const functionEndpoints: EndpointGroup[] = [];
const ROUTER_GROUP = [
'credentials',
'nodes',
'workflows',
'publicApi',
'ldap',
'eventBus',
'license',
];
const ROUTER_GROUP = ['credentials', 'workflows', 'publicApi', 'eventBus', 'license'];
endpointGroups.forEach((group) =>
(ROUTER_GROUP.includes(group) ? routerEndpoints : functionEndpoints).push(group),
@ -320,13 +332,6 @@ export async function initCredentialsTypes(): Promise<void> {
};
}
/**
* Initialize LDAP manager.
*/
export async function initLdapManager(): Promise<void> {
await handleLdapInit();
}
/**
* Initialize node types.
*/