Files
Automata/packages/cli/src/api/nodes.api.ts

344 lines
9.2 KiB
TypeScript

import express from 'express';
import type { PublicInstalledPackage } from 'n8n-workflow';
import config from '@/config';
import { InternalHooksManager } from '@/InternalHooksManager';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import * as Push from '@/Push';
import * as ResponseHelper from '@/ResponseHelper';
import {
checkNpmPackageStatus,
executeCommand,
hasPackageLoaded,
isClientError,
isNpmError,
matchMissingPackages,
matchPackagesWithUpdates,
parseNpmPackageName,
removePackageFromMissingList,
sanitizeNpmPackageName,
} from '@/CommunityNodes/helpers';
import {
findInstalledPackage,
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';
const { PACKAGE_NOT_INSTALLED, PACKAGE_NAME_NOT_PROVIDED } = RESPONSE_ERROR_MESSAGES;
export const nodesController = express.Router();
nodesController.use((req, res, next) => {
if (!isAuthenticatedRequest(req) || req.user.globalRole.name !== 'owner') {
res.status(403).json({ status: 'error', message: 'Unauthorized' });
return;
}
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;
}
next();
});
/**
* POST /nodes
*
* Install an n8n community package
*/
nodesController.post(
'/',
ResponseHelper.send(async (req: NodeRequest.Post) => {
const { name } = req.body;
if (!name) {
throw new ResponseHelper.BadRequestError(PACKAGE_NAME_NOT_PROVIDED);
}
let parsed: CommunityPackages.ParsedPackageName;
try {
parsed = parseNpmPackageName(name);
} catch (error) {
throw new ResponseHelper.BadRequestError(
error instanceof Error ? error.message : 'Failed to parse package name',
);
}
if (parsed.packageName === STARTER_TEMPLATE_NAME) {
throw new ResponseHelper.BadRequestError(
[
`Package "${parsed.packageName}" is only a template`,
'Please enter an actual package to install',
].join('.'),
);
}
const isInstalled = await isPackageInstalled(parsed.packageName);
const hasLoaded = hasPackageLoaded(name);
if (isInstalled && hasLoaded) {
throw new ResponseHelper.BadRequestError(
[
`Package "${parsed.packageName}" is already installed`,
'To update it, click the corresponding button in the UI',
].join('.'),
);
}
const packageStatus = await checkNpmPackageStatus(name);
if (packageStatus.status !== 'OK') {
throw new ResponseHelper.BadRequestError(
`Package "${name}" is banned so it cannot be installed`,
);
}
let installedPackage: InstalledPackages;
try {
installedPackage = await LoadNodesAndCredentials().loadNpmModule(
parsed.packageName,
parsed.version,
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON;
void InternalHooksManager.getInstance().onCommunityPackageInstallFinished({
user: req.user,
input_string: name,
package_name: parsed.packageName,
success: false,
package_version: parsed.version,
failure_reason: errorMessage,
});
const message = [`Error loading package "${name}"`, errorMessage].join(':');
const clientError = error instanceof Error ? isClientError(error) : false;
throw new ResponseHelper[clientError ? 'BadRequestError' : 'InternalServerError'](message);
}
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: req.user,
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;
}),
);
/**
* GET /nodes
*
* Retrieve list of installed n8n community packages
*/
nodesController.get(
'/',
ResponseHelper.send(async (): Promise<PublicInstalledPackage[]> => {
const installedPackages = await getAllInstalledPackages();
if (installedPackages.length === 0) return [];
let pendingUpdates: CommunityPackages.AvailableUpdates | undefined;
try {
const command = ['npm', 'outdated', '--json'].join(' ');
await executeCommand(command, { doNotHandleError: true });
} catch (error) {
// 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(installedPackages, pendingUpdates);
try {
const missingPackages = config.get('nodes.packagesMissing') as string | undefined;
if (missingPackages) {
hydratedPackages = matchMissingPackages(hydratedPackages, missingPackages);
}
} catch (_) {
// Do nothing if setting is missing
}
return hydratedPackages;
}),
);
/**
* DELETE /nodes
*
* Uninstall an installed n8n community package
*/
nodesController.delete(
'/',
ResponseHelper.send(async (req: NodeRequest.Delete) => {
const { name } = req.query;
if (!name) {
throw new ResponseHelper.BadRequestError(PACKAGE_NAME_NOT_PROVIDED);
}
try {
sanitizeNpmPackageName(name);
} catch (error) {
const message = error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON;
throw new ResponseHelper.BadRequestError(message);
}
const installedPackage = await findInstalledPackage(name);
if (!installedPackage) {
throw new ResponseHelper.BadRequestError(PACKAGE_NOT_INSTALLED);
}
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.InternalServerError(message);
}
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: req.user,
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,
});
}),
);
/**
* 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.BadRequestError(PACKAGE_NAME_NOT_PROVIDED);
}
const previouslyInstalledPackage = await findInstalledPackage(name);
if (!previouslyInstalledPackage) {
throw new ResponseHelper.BadRequestError(PACKAGE_NOT_INSTALLED);
}
try {
const newInstalledPackage = await LoadNodesAndCredentials().updateNpmModule(
parseNpmPackageName(name).packageName,
previouslyInstalledPackage,
);
const pushInstance = Push.getInstance();
// broadcast to connected frontends that node list has been updated
previouslyInstalledPackage.installedNodes.forEach((node) => {
pushInstance.send('removeNodeType', {
name: node.type,
version: node.latestVersion,
});
});
newInstalledPackage.installedNodes.forEach((node) => {
pushInstance.send('reloadNodeType', {
name: node.name,
version: node.latestVersion,
});
});
void InternalHooksManager.getInstance().onCommunityPackageUpdateFinished({
user: req.user,
package_name: name,
package_version_current: previouslyInstalledPackage.installedVersion,
package_version_new: newInstalledPackage.installedVersion,
package_node_names: newInstalledPackage.installedNodes.map((node) => node.name),
package_author: newInstalledPackage.authorName,
package_author_email: newInstalledPackage.authorEmail,
});
return newInstalledPackage;
} catch (error) {
previouslyInstalledPackage.installedNodes.forEach((node) => {
const pushInstance = Push.getInstance();
pushInstance.send('removeNodeType', {
name: node.type,
version: node.latestVersion,
});
});
const message = [
`Error removing package "${name}"`,
error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON,
].join(':');
throw new ResponseHelper.InternalServerError(message);
}
}),
);