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:
Iván Ovejero
2022-08-02 10:40:57 +02:00
committed by GitHub
parent ad8d662976
commit 7e578b7f4d
13 changed files with 1042 additions and 867 deletions

View File

@@ -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);
}
}),
);