mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-22 20:29:08 +00:00
refactor(core): Improve community node repo code (#3767)
* 📘 Tighten `NodeRequest` * :blue: Add `AuthAgent` type * ⚡ Add constants * 📘 Namespace npm types * 🧪 Set up `createAuthAgent` * 🧪 Refactor helpers tests * 🧪 Refactor endpoints tests * ⚡ Refactor CNR helpers * ⚡ Return promises in `packageModel` * ⚡ Refactor endpoints * ✏️ Restore naming * ➕ Expose dependency `jest-mock` * 📦 Update `package-lock.json` * 📦 Update `package-lock.json` * 👕 Fix lint * 🚚 Rename namespace * 🔥 Remove outdated comment * 🐛 Fix `Promise` comparison * ⏪ Undo `ResponseHelper` change * ✏️ Document `ResponseError` * 🎨 Fix formatting
This commit is contained in:
@@ -1,44 +1,44 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import express = require('express');
|
||||
import { LoggerProxy, PublicInstalledPackage } from 'n8n-workflow';
|
||||
import { getLogger } from '../Logger';
|
||||
import express from 'express';
|
||||
import { PublicInstalledPackage } from 'n8n-workflow';
|
||||
|
||||
import config from '../../config';
|
||||
import { ResponseHelper, LoadNodesAndCredentials, Push, InternalHooksManager } from '..';
|
||||
import { NodeRequest } from '../requests';
|
||||
import { RESPONSE_ERROR_MESSAGES } from '../constants';
|
||||
|
||||
import { RESPONSE_ERROR_MESSAGES, UNKNOWN_FAILURE_REASON } from '../constants';
|
||||
import {
|
||||
matchMissingPackages,
|
||||
matchPackagesWithUpdates,
|
||||
executeCommand,
|
||||
checkPackageStatus,
|
||||
hasPackageLoadedSuccessfully,
|
||||
checkNpmPackageStatus,
|
||||
hasPackageLoaded,
|
||||
removePackageFromMissingList,
|
||||
parsePackageName,
|
||||
parseNpmPackageName,
|
||||
isClientError,
|
||||
sanitizeNpmPackageName,
|
||||
isNpmError,
|
||||
} from '../CommunityNodes/helpers';
|
||||
import { getAllInstalledPackages, searchInstalledPackage } from '../CommunityNodes/packageModel';
|
||||
import {
|
||||
getAllInstalledPackages,
|
||||
findInstalledPackage,
|
||||
isPackageInstalled,
|
||||
} from '../CommunityNodes/packageModel';
|
||||
import { isAuthenticatedRequest } from '../UserManagement/UserManagementHelper';
|
||||
import config = require('../../config');
|
||||
import { NpmUpdatesAvailable } from '../Interfaces';
|
||||
|
||||
import type { NodeRequest } from '../requests';
|
||||
import type { CommunityPackages } from '../Interfaces';
|
||||
import { InstalledPackages } from '../databases/entities/InstalledPackages';
|
||||
|
||||
const { PACKAGE_NOT_INSTALLED, PACKAGE_NAME_NOT_PROVIDED } = RESPONSE_ERROR_MESSAGES;
|
||||
|
||||
export const nodesController = express.Router();
|
||||
|
||||
/**
|
||||
* Initialize Logger if needed
|
||||
*/
|
||||
nodesController.use((req, res, next) => {
|
||||
try {
|
||||
LoggerProxy.getInstance();
|
||||
} catch (error) {
|
||||
LoggerProxy.init(getLogger());
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
nodesController.use((req, res, next) => {
|
||||
if (!isAuthenticatedRequest(req) || req.user.globalRole.name !== 'owner') {
|
||||
res.status(403).json({ status: 'error', message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
@@ -50,246 +50,263 @@ nodesController.use((req, res, next) => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /nodes
|
||||
*
|
||||
* Install an n8n community package
|
||||
*/
|
||||
nodesController.post(
|
||||
'/',
|
||||
ResponseHelper.send(async (req: NodeRequest.Post) => {
|
||||
const { name } = req.body;
|
||||
let parsedPackageName;
|
||||
try {
|
||||
parsedPackageName = parsePackageName(name);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument
|
||||
throw new ResponseHelper.ResponseError(error.message, undefined, 400);
|
||||
|
||||
if (!name) {
|
||||
throw new ResponseHelper.ResponseError(PACKAGE_NAME_NOT_PROVIDED, undefined, 400);
|
||||
}
|
||||
|
||||
// Only install packages that haven't been installed
|
||||
// or that have failed loading
|
||||
const installedPackageInstalled = await searchInstalledPackage(parsedPackageName.packageName);
|
||||
const loadedPackage = hasPackageLoadedSuccessfully(name);
|
||||
if (installedPackageInstalled && loadedPackage) {
|
||||
let parsed: CommunityPackages.ParsedPackageName;
|
||||
|
||||
try {
|
||||
parsed = parseNpmPackageName(name);
|
||||
} catch (error) {
|
||||
throw new ResponseHelper.ResponseError(
|
||||
`Package "${parsedPackageName.packageName}" is already installed. For updating, click the corresponding button.`,
|
||||
error instanceof Error ? error.message : 'Failed to parse package name',
|
||||
undefined,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const packageStatus = await checkPackageStatus(name);
|
||||
const isInstalled = await isPackageInstalled(parsed.packageName);
|
||||
const hasLoaded = hasPackageLoaded(name);
|
||||
|
||||
if (isInstalled && hasLoaded) {
|
||||
throw new ResponseHelper.ResponseError(
|
||||
[
|
||||
`Package "${parsed.packageName}" is already installed`,
|
||||
'To update it, click the corresponding button in the UI',
|
||||
].join('.'),
|
||||
undefined,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const packageStatus = await checkNpmPackageStatus(name);
|
||||
|
||||
if (packageStatus.status !== 'OK') {
|
||||
throw new ResponseHelper.ResponseError(
|
||||
`Package "${name}" has been banned from n8n's repository and will not be installed`,
|
||||
`Package "${name}" is banned so it cannot be installed`,
|
||||
undefined,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
let installedPackage: InstalledPackages;
|
||||
|
||||
try {
|
||||
const installedPackage = await LoadNodesAndCredentials().loadNpmModule(
|
||||
parsedPackageName.packageName,
|
||||
parsedPackageName.version,
|
||||
installedPackage = await LoadNodesAndCredentials().loadNpmModule(
|
||||
parsed.packageName,
|
||||
parsed.version,
|
||||
);
|
||||
|
||||
if (!loadedPackage) {
|
||||
removePackageFromMissingList(name);
|
||||
}
|
||||
|
||||
// Inform the connected frontends that new nodes are available
|
||||
installedPackage.installedNodes.forEach((nodeData) => {
|
||||
const pushInstance = Push.getInstance();
|
||||
pushInstance.send('reloadNodeType', {
|
||||
name: nodeData.type,
|
||||
version: nodeData.latestVersion,
|
||||
});
|
||||
});
|
||||
|
||||
void InternalHooksManager.getInstance().onCommunityPackageInstallFinished({
|
||||
user_id: req.user.id,
|
||||
input_string: name,
|
||||
package_name: parsedPackageName.packageName,
|
||||
success: true,
|
||||
package_version: parsedPackageName.version,
|
||||
package_node_names: installedPackage.installedNodes.map((nodeData) => nodeData.name),
|
||||
package_author: installedPackage.authorName,
|
||||
package_author_email: installedPackage.authorEmail,
|
||||
});
|
||||
|
||||
return installedPackage;
|
||||
} catch (error) {
|
||||
let statusCode = 500;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
const errorMessage = error.message as string;
|
||||
if (
|
||||
errorMessage.includes(RESPONSE_ERROR_MESSAGES.PACKAGE_VERSION_NOT_FOUND) ||
|
||||
errorMessage.includes(RESPONSE_ERROR_MESSAGES.PACKAGE_DOES_NOT_CONTAIN_NODES) ||
|
||||
errorMessage.includes(RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND)
|
||||
) {
|
||||
statusCode = 400;
|
||||
}
|
||||
const errorMessage = error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON;
|
||||
|
||||
void InternalHooksManager.getInstance().onCommunityPackageInstallFinished({
|
||||
user_id: req.user.id,
|
||||
input_string: name,
|
||||
package_name: parsedPackageName.packageName,
|
||||
package_name: parsed.packageName,
|
||||
success: false,
|
||||
package_version: parsedPackageName.version,
|
||||
package_version: parsed.version,
|
||||
failure_reason: errorMessage,
|
||||
});
|
||||
throw new ResponseHelper.ResponseError(
|
||||
`Error loading package "${name}": ${errorMessage}`,
|
||||
undefined,
|
||||
statusCode,
|
||||
);
|
||||
|
||||
const message = [`Error loading package "${name}"`, errorMessage].join(':');
|
||||
|
||||
const clientError = error instanceof Error ? isClientError(error) : false;
|
||||
|
||||
throw new ResponseHelper.ResponseError(message, undefined, clientError ? 400 : 500);
|
||||
}
|
||||
|
||||
if (!hasLoaded) removePackageFromMissingList(name);
|
||||
|
||||
const pushInstance = Push.getInstance();
|
||||
|
||||
// broadcast to connected frontends that node list has been updated
|
||||
installedPackage.installedNodes.forEach((node) => {
|
||||
pushInstance.send('reloadNodeType', {
|
||||
name: node.type,
|
||||
version: node.latestVersion,
|
||||
});
|
||||
});
|
||||
|
||||
void InternalHooksManager.getInstance().onCommunityPackageInstallFinished({
|
||||
user_id: req.user.id,
|
||||
input_string: name,
|
||||
package_name: parsed.packageName,
|
||||
success: true,
|
||||
package_version: parsed.version,
|
||||
package_node_names: installedPackage.installedNodes.map((node) => node.name),
|
||||
package_author: installedPackage.authorName,
|
||||
package_author_email: installedPackage.authorEmail,
|
||||
});
|
||||
|
||||
return installedPackage;
|
||||
}),
|
||||
);
|
||||
|
||||
// Install new credentials/nodes from npm
|
||||
/**
|
||||
* GET /nodes
|
||||
*
|
||||
* Retrieve list of installed n8n community packages
|
||||
*/
|
||||
nodesController.get(
|
||||
'/',
|
||||
ResponseHelper.send(async (): Promise<PublicInstalledPackage[]> => {
|
||||
const packages = await getAllInstalledPackages();
|
||||
const installedPackages = await getAllInstalledPackages();
|
||||
|
||||
if (packages.length === 0) {
|
||||
return packages;
|
||||
}
|
||||
if (installedPackages.length === 0) return [];
|
||||
|
||||
let pendingUpdates: CommunityPackages.AvailableUpdates | undefined;
|
||||
|
||||
let pendingUpdates: NpmUpdatesAvailable | undefined;
|
||||
try {
|
||||
// Command succeeds when there are no updates.
|
||||
// NPM handles this oddly. It exits with code 1 when there are updates.
|
||||
// More here: https://github.com/npm/rfcs/issues/473
|
||||
await executeCommand('npm outdated --json', { doNotHandleError: true });
|
||||
const command = ['npm', 'outdated', '--json'].join(' ');
|
||||
await executeCommand(command, { doNotHandleError: true });
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||
if (error.code === 1) {
|
||||
// Updates available
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument
|
||||
pendingUpdates = JSON.parse(error.stdout);
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
let hydratedPackages = matchPackagesWithUpdates(packages, pendingUpdates);
|
||||
|
||||
let hydratedPackages = matchPackagesWithUpdates(installedPackages, pendingUpdates);
|
||||
|
||||
try {
|
||||
if (config.get('nodes.packagesMissing')) {
|
||||
// eslint-disable-next-line prettier/prettier, @typescript-eslint/no-unsafe-argument
|
||||
hydratedPackages = matchMissingPackages(hydratedPackages, config.get('nodes.packagesMissing'));
|
||||
const missingPackages = config.get('nodes.packagesMissing') as string | undefined;
|
||||
|
||||
if (missingPackages) {
|
||||
hydratedPackages = matchMissingPackages(hydratedPackages, missingPackages);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (_) {
|
||||
// Do nothing if setting is missing
|
||||
}
|
||||
|
||||
return hydratedPackages;
|
||||
}),
|
||||
);
|
||||
|
||||
// Uninstall credentials/nodes from npm
|
||||
/**
|
||||
* DELETE /nodes
|
||||
*
|
||||
* Uninstall an installed n8n community package
|
||||
*/
|
||||
nodesController.delete(
|
||||
'/',
|
||||
ResponseHelper.send(async (req: NodeRequest.Delete) => {
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name) {
|
||||
throw new ResponseHelper.ResponseError(
|
||||
RESPONSE_ERROR_MESSAGES.PACKAGE_NAME_NOT_PROVIDED,
|
||||
undefined,
|
||||
400,
|
||||
);
|
||||
}
|
||||
// This function also sanitizes the package name by throwing errors.
|
||||
parsePackageName(name);
|
||||
|
||||
const installedPackage = await searchInstalledPackage(name);
|
||||
|
||||
if (!installedPackage) {
|
||||
throw new ResponseHelper.ResponseError(
|
||||
RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_INSTALLED,
|
||||
undefined,
|
||||
400,
|
||||
);
|
||||
throw new ResponseHelper.ResponseError(PACKAGE_NAME_NOT_PROVIDED, undefined, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
void (await LoadNodesAndCredentials().removeNpmModule(name, installedPackage));
|
||||
|
||||
// Inform the connected frontends that the node list has been updated
|
||||
installedPackage.installedNodes.forEach((installedNode) => {
|
||||
const pushInstance = Push.getInstance();
|
||||
pushInstance.send('removeNodeType', {
|
||||
name: installedNode.type,
|
||||
version: installedNode.latestVersion,
|
||||
});
|
||||
});
|
||||
|
||||
void InternalHooksManager.getInstance().onCommunityPackageDeleteFinished({
|
||||
user_id: req.user.id,
|
||||
package_name: name,
|
||||
package_version: installedPackage.installedVersion,
|
||||
package_node_names: installedPackage.installedNodes.map((nodeData) => nodeData.name),
|
||||
package_author: installedPackage.authorName,
|
||||
package_author_email: installedPackage.authorEmail,
|
||||
});
|
||||
sanitizeNpmPackageName(name);
|
||||
} catch (error) {
|
||||
throw new ResponseHelper.ResponseError(
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-member-access
|
||||
`Error removing package "${name}": ${error.message}`,
|
||||
undefined,
|
||||
500,
|
||||
);
|
||||
const message = error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON;
|
||||
|
||||
throw new ResponseHelper.ResponseError(message, undefined, 400);
|
||||
}
|
||||
|
||||
const installedPackage = await findInstalledPackage(name);
|
||||
|
||||
if (!installedPackage) {
|
||||
throw new ResponseHelper.ResponseError(PACKAGE_NOT_INSTALLED, undefined, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
await LoadNodesAndCredentials().removeNpmModule(name, installedPackage);
|
||||
} catch (error) {
|
||||
const message = [
|
||||
`Error removing package "${name}"`,
|
||||
error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON,
|
||||
].join(':');
|
||||
|
||||
throw new ResponseHelper.ResponseError(message, undefined, 500);
|
||||
}
|
||||
|
||||
const pushInstance = Push.getInstance();
|
||||
|
||||
// broadcast to connected frontends that node list has been updated
|
||||
installedPackage.installedNodes.forEach((node) => {
|
||||
pushInstance.send('removeNodeType', {
|
||||
name: node.type,
|
||||
version: node.latestVersion,
|
||||
});
|
||||
});
|
||||
|
||||
void InternalHooksManager.getInstance().onCommunityPackageDeleteFinished({
|
||||
user_id: req.user.id,
|
||||
package_name: name,
|
||||
package_version: installedPackage.installedVersion,
|
||||
package_node_names: installedPackage.installedNodes.map((node) => node.name),
|
||||
package_author: installedPackage.authorName,
|
||||
package_author_email: installedPackage.authorEmail,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Update a package
|
||||
/**
|
||||
* PATCH /nodes
|
||||
*
|
||||
* Update an installed n8n community package
|
||||
*/
|
||||
nodesController.patch(
|
||||
'/',
|
||||
ResponseHelper.send(async (req: NodeRequest.Update) => {
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name) {
|
||||
throw new ResponseHelper.ResponseError(
|
||||
RESPONSE_ERROR_MESSAGES.PACKAGE_NAME_NOT_PROVIDED,
|
||||
undefined,
|
||||
400,
|
||||
);
|
||||
throw new ResponseHelper.ResponseError(PACKAGE_NAME_NOT_PROVIDED, undefined, 400);
|
||||
}
|
||||
|
||||
const parsedPackageData = parsePackageName(name);
|
||||
const packagePreviouslyInstalled = await searchInstalledPackage(name);
|
||||
const previouslyInstalledPackage = await findInstalledPackage(name);
|
||||
|
||||
if (!packagePreviouslyInstalled) {
|
||||
throw new ResponseHelper.ResponseError(
|
||||
RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_INSTALLED,
|
||||
undefined,
|
||||
400,
|
||||
);
|
||||
if (!previouslyInstalledPackage) {
|
||||
throw new ResponseHelper.ResponseError(PACKAGE_NOT_INSTALLED, undefined, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const newInstalledPackage = await LoadNodesAndCredentials().updateNpmModule(
|
||||
parsedPackageData.packageName,
|
||||
packagePreviouslyInstalled,
|
||||
parseNpmPackageName(name).packageName,
|
||||
previouslyInstalledPackage,
|
||||
);
|
||||
|
||||
const pushInstance = Push.getInstance();
|
||||
|
||||
// Inform the connected frontends that new nodes are available
|
||||
packagePreviouslyInstalled.installedNodes.forEach((installedNode) => {
|
||||
// broadcast to connected frontends that node list has been updated
|
||||
previouslyInstalledPackage.installedNodes.forEach((node) => {
|
||||
pushInstance.send('removeNodeType', {
|
||||
name: installedNode.type,
|
||||
version: installedNode.latestVersion,
|
||||
name: node.type,
|
||||
version: node.latestVersion,
|
||||
});
|
||||
});
|
||||
|
||||
newInstalledPackage.installedNodes.forEach((nodeData) => {
|
||||
newInstalledPackage.installedNodes.forEach((node) => {
|
||||
pushInstance.send('reloadNodeType', {
|
||||
name: nodeData.name,
|
||||
version: nodeData.latestVersion,
|
||||
name: node.name,
|
||||
version: node.latestVersion,
|
||||
});
|
||||
});
|
||||
|
||||
void InternalHooksManager.getInstance().onCommunityPackageUpdateFinished({
|
||||
user_id: req.user.id,
|
||||
package_name: name,
|
||||
package_version_current: packagePreviouslyInstalled.installedVersion,
|
||||
package_version_current: previouslyInstalledPackage.installedVersion,
|
||||
package_version_new: newInstalledPackage.installedVersion,
|
||||
package_node_names: newInstalledPackage.installedNodes.map((node) => node.name),
|
||||
package_author: newInstalledPackage.authorName,
|
||||
@@ -298,19 +315,20 @@ nodesController.patch(
|
||||
|
||||
return newInstalledPackage;
|
||||
} catch (error) {
|
||||
packagePreviouslyInstalled.installedNodes.forEach((installedNode) => {
|
||||
previouslyInstalledPackage.installedNodes.forEach((node) => {
|
||||
const pushInstance = Push.getInstance();
|
||||
pushInstance.send('removeNodeType', {
|
||||
name: installedNode.type,
|
||||
version: installedNode.latestVersion,
|
||||
name: node.type,
|
||||
version: node.latestVersion,
|
||||
});
|
||||
});
|
||||
throw new ResponseHelper.ResponseError(
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-member-access
|
||||
`Error updating package "${name}": ${error.message}`,
|
||||
undefined,
|
||||
500,
|
||||
);
|
||||
|
||||
const message = [
|
||||
`Error removing package "${name}"`,
|
||||
error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON,
|
||||
].join(':');
|
||||
|
||||
throw new ResponseHelper.ResponseError(message, undefined, 500);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user