diff --git a/packages/cli/src/Ldap/routes/ldap.controller.ee.ts b/packages/cli/src/Ldap/routes/ldap.controller.ee.ts deleted file mode 100644 index aac5938415..0000000000 --- a/packages/cli/src/Ldap/routes/ldap.controller.ee.ts +++ /dev/null @@ -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 }); -}); diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 92e81161d3..0f89c5ff54 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -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) { - 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 // ---------------------------------------- diff --git a/packages/cli/src/api/tags.api.ts b/packages/cli/src/api/tags.api.ts deleted file mode 100644 index 6c13c44b51..0000000000 --- a/packages/cli/src/api/tags.api.ts +++ /dev/null @@ -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 => { - 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 => { - 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 => { - 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 => { - 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; - }), -); diff --git a/packages/cli/src/controllers/index.ts b/packages/cli/src/controllers/index.ts index 37ce548a54..04b46af5f1 100644 --- a/packages/cli/src/controllers/index.ts +++ b/packages/cli/src/controllers/index.ts @@ -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'; diff --git a/packages/cli/src/controllers/ldap.controller.ts b/packages/cli/src/controllers/ldap.controller.ts new file mode 100644 index 0000000000..619a70db28 --- /dev/null +++ b/packages/cli/src/controllers/ldap.controller.ts @@ -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); + } + } +} diff --git a/packages/cli/src/api/nodeTypes.api.ts b/packages/cli/src/controllers/nodeTypes.controller.ts similarity index 60% rename from packages/cli/src/api/nodeTypes.api.ts rename to packages/cli/src/controllers/nodeTypes.controller.ts index de37b549f5..5f8af5a909 100644 --- a/packages/cli/src/api/nodeTypes.api.ts +++ b/packages/cli/src/controllers/nodeTypes.controller.ts @@ -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 => { + 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((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; - }), -); + } +} diff --git a/packages/cli/src/api/nodes.api.ts b/packages/cli/src/controllers/nodes.controller.ts similarity index 64% rename from packages/cli/src/api/nodes.api.ts rename to packages/cli/src/controllers/nodes.controller.ts index 70264cdab5..63116bb761 100644 --- a/packages/cli/src/api/nodes.api.ts +++ b/packages/cli/src/controllers/nodes.controller.ts @@ -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 => { + @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); } - }), -); + } +} diff --git a/packages/cli/src/controllers/tags.controller.ts b/packages/cli/src/controllers/tags.controller.ts new file mode 100644 index 0000000000..3c73235c85 --- /dev/null +++ b/packages/cli/src/controllers/tags.controller.ts @@ -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; + + constructor({ + config, + externalHooks, + repositories, + }: { + config: Config; + externalHooks: IExternalHooksClass; + repositories: Pick; + }) { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/packages/cli/src/decorators/Middleware.ts b/packages/cli/src/decorators/Middleware.ts new file mode 100644 index 0000000000..6d5e957f6e --- /dev/null +++ b/packages/cli/src/decorators/Middleware.ts @@ -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); +}; diff --git a/packages/cli/src/decorators/Route.ts b/packages/cli/src/decorators/Route.ts index 773bb2193a..59461cbc87 100644 --- a/packages/cli/src/decorators/Route.ts +++ b/packages/cli/src/decorators/Route.ts @@ -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'); diff --git a/packages/cli/src/decorators/constants.ts b/packages/cli/src/decorators/constants.ts index 9c77ab696e..6bff0e4a6c 100644 --- a/packages/cli/src/decorators/constants.ts +++ b/packages/cli/src/decorators/constants.ts @@ -1,2 +1,3 @@ export const CONTROLLER_ROUTES = 'CONTROLLER_ROUTES'; export const CONTROLLER_BASE_PATH = 'CONTROLLER_BASE_PATH'; +export const CONTROLLER_MIDDLEWARES = 'CONTROLLER_MIDDLEWARES'; diff --git a/packages/cli/src/decorators/index.ts b/packages/cli/src/decorators/index.ts index bda31ce887..71b82b5b69 100644 --- a/packages/cli/src/decorators/index.ts +++ b/packages/cli/src/decorators/index.ts @@ -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'; diff --git a/packages/cli/src/decorators/registerController.ts b/packages/cli/src/decorators/registerController.ts index 9f1ab5bba7..991b5a185e 100644 --- a/packages/cli/src/decorators/registerController.ts +++ b/packages/cli/src/decorators/registerController.ts @@ -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), ), diff --git a/packages/cli/src/decorators/types.ts b/packages/cli/src/decorators/types.ts index 829451f4d7..790833c3a9 100644 --- a/packages/cli/src/decorators/types.ts +++ b/packages/cli/src/decorators/types.ts @@ -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; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 81b43dd317..af889d15f9 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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'; diff --git a/packages/cli/src/middlewares/auth.ts b/packages/cli/src/middlewares/auth.ts index 34c76ea592..1299f47408 100644 --- a/packages/cli/src/middlewares/auth.ts +++ b/packages/cli/src/middlewares/auth.ts @@ -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 diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index f8c5af17b4..5c520407e0 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -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 }>; } diff --git a/packages/cli/test/integration/ldap/ldap.api.test.ts b/packages/cli/test/integration/ldap/ldap.api.test.ts index 36b8924469..f823355758 100644 --- a/packages/cli/test/integration/ldap/ldap.api.test.ts +++ b/packages/cli/test/integration/ldap/ldap.api.test.ts @@ -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 () => { diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index ba28f16732..6aeded0866 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -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 = ( ctor: new (...args: any[]) => T, @@ -155,10 +156,8 @@ export async function initTestServer({ const map: Record = { 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 { }; } -/** - * Initialize LDAP manager. - */ -export async function initLdapManager(): Promise { - await handleLdapInit(); -} - /** * Initialize node types. */