diff --git a/packages/cli/commands/start.ts b/packages/cli/commands/start.ts index ab96543346..467916eaf8 100644 --- a/packages/cli/commands/start.ts +++ b/packages/cli/commands/start.ts @@ -33,6 +33,7 @@ import { } from '../src'; import { getLogger } from '../src/Logger'; +import { getAllInstalledPackages } from '../src/CommunityNodes/packageModel'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires const open = require('open'); @@ -60,6 +61,10 @@ export class Start extends Command { description: 'runs the webhooks via a hooks.n8n.cloud tunnel server. Use only for testing and development!', }), + reinstallMissingPackages: flags.boolean({ + description: + 'Attempts to self heal n8n if packages with nodes are missing. Might drastically increase startup times.', + }), }; /** @@ -206,6 +211,23 @@ export class Start extends Command { // Wait till the database is ready await startDbInitPromise; + const installedPackages = await getAllInstalledPackages(); + const missingPackages = new Set<{ + packageName: string; + version: string; + }>(); + installedPackages.forEach((installedpackage) => { + installedpackage.installedNodes.forEach((installedNode) => { + if (!loadNodesAndCredentials.nodeTypes[installedNode.type]) { + // Leave the list ready for installing in case we need. + missingPackages.add({ + packageName: installedpackage.packageName, + version: installedpackage.installedVersion, + }); + } + }); + }); + await UserSettings.getEncryptionKey(); // Load settings from database and set them to config. @@ -214,6 +236,42 @@ export class Start extends Command { config.set(setting.key, JSON.parse(setting.value)); }); + config.set('nodes.packagesMissing', ''); + if (missingPackages.size) { + LoggerProxy.error( + 'n8n detected that some packages are missing. For more information, visit https://docs.n8n.io/integrations/community-nodes/troubleshooting/', + ); + + if (flags.reinstallMissingPackages || process.env.N8N_REINSTALL_MISSING_PACKAGES) { + LoggerProxy.info('Attempting to reinstall missing packages', { missingPackages }); + try { + // Optimistic approach - stop if any installation fails + // eslint-disable-next-line no-restricted-syntax + for (const missingPackage of missingPackages) { + // eslint-disable-next-line no-await-in-loop + void (await loadNodesAndCredentials.loadNpmModule( + missingPackage.packageName, + missingPackage.version, + )); + missingPackages.delete(missingPackage); + } + LoggerProxy.info( + 'Packages reinstalled successfully. Resuming regular intiailization.', + ); + } catch (error) { + LoggerProxy.error('n8n was unable to install the missing packages.'); + } + } + } + if (missingPackages.size) { + config.set( + 'nodes.packagesMissing', + Array.from(missingPackages) + .map((missingPackage) => `${missingPackage.packageName}@${missingPackage.version}`) + .join(' '), + ); + } + if (config.getEnv('executions.mode') === 'queue') { const redisHost = config.getEnv('queue.bull.redis.host'); const redisPassword = config.getEnv('queue.bull.redis.password'); diff --git a/packages/cli/config/schema.ts b/packages/cli/config/schema.ts index d422776f55..50111f874a 100644 --- a/packages/cli/config/schema.ts +++ b/packages/cli/config/schema.ts @@ -740,6 +740,14 @@ export const schema = { default: 'n8n-nodes-base.errorTrigger', env: 'NODES_ERROR_TRIGGER_TYPE', }, + communityPackages: { + enabled: { + doc: 'Allows you to disable the usage of community packages for nodes', + format: Boolean, + default: true, + env: 'N8N_COMMUNITY_PACKAGES_ENABLED', + }, + }, }, logs: { diff --git a/packages/cli/package.json b/packages/cli/package.json index a910d5482e..75c2734db8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -86,7 +86,6 @@ "@types/supertest": "^2.0.11", "@types/uuid": "^8.3.0", "@types/validator": "^13.7.0", - "axios": "^0.21.1", "concurrently": "^5.1.0", "jest": "^27.4.7", "nodemon": "^2.0.2", @@ -109,6 +108,7 @@ "@types/shelljs": "^0.8.11", "@types/swagger-ui-express": "^4.1.3", "@types/yamljs": "^0.2.31", + "axios": "^0.21.1", "basic-auth": "^2.0.1", "bcryptjs": "^2.4.3", "body-parser": "^1.18.3", diff --git a/packages/cli/src/CommunityNodes/helpers.ts b/packages/cli/src/CommunityNodes/helpers.ts new file mode 100644 index 0000000000..9fb2199bdd --- /dev/null +++ b/packages/cli/src/CommunityNodes/helpers.ts @@ -0,0 +1,218 @@ +/* eslint-disable import/no-cycle */ +/* eslint-disable @typescript-eslint/naming-convention */ +import { promisify } from 'util'; +import { exec } from 'child_process'; +import { access as fsAccess, mkdir as fsMkdir } from 'fs/promises'; + +import { UserSettings } from 'n8n-core'; +import { LoggerProxy, PublicInstalledPackage } from 'n8n-workflow'; +import axios from 'axios'; +import { + NODE_PACKAGE_PREFIX, + NPM_COMMAND_TOKENS, + NPM_PACKAGE_STATUS_GOOD, + RESPONSE_ERROR_MESSAGES, +} from '../constants'; +import { NpmPackageStatusCheck, NpmUpdatesAvailable, ParsedNpmPackageName } from '../Interfaces'; +import { InstalledPackages } from '../databases/entities/InstalledPackages'; +import config from '../../config'; + +const execAsync = promisify(exec); + +export const parsePackageName = (originalString: string | undefined): ParsedNpmPackageName => { + if (!originalString) { + throw new Error('Package name was not provided'); + } + + if (new RegExp(/[^0-9a-z@\-./]/).test(originalString)) { + // Prevent any strings that are not valid npm package names or + // could indicate malicous commands + throw new Error('Package name must be a single word'); + } + + const scope = originalString.includes('/') ? originalString.split('/')[0] : undefined; + + const packageNameWithoutScope = scope ? originalString.replace(`${scope}/`, '') : originalString; + + if (!packageNameWithoutScope.startsWith(NODE_PACKAGE_PREFIX)) { + throw new Error('Package name must start with n8n-nodes-'); + } + + const version = packageNameWithoutScope.includes('@') + ? packageNameWithoutScope.split('@')[1] + : undefined; + + const packageName = version ? originalString.replace(`@${version}`, '') : originalString; + + return { + packageName, + scope, + version, + originalString, + }; +}; + +export const executeCommand = async ( + command: string, + options?: { + doNotHandleError?: boolean; + }, +): Promise => { + const downloadFolder = UserSettings.getUserN8nFolderDowloadedNodesPath(); + // Make sure the node-download folder exists + try { + await fsAccess(downloadFolder); + // eslint-disable-next-line no-empty + } catch (error) { + await fsMkdir(downloadFolder); + } + const execOptions = { + cwd: downloadFolder, + env: { + NODE_PATH: process.env.NODE_PATH, + PATH: process.env.PATH, + }, + }; + + try { + const commandResult = await execAsync(command, execOptions); + return commandResult.stdout; + } catch (error) { + if (options?.doNotHandleError) { + throw error; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const errorMessage = error.message as string; + + if ( + errorMessage.includes(NPM_COMMAND_TOKENS.NPM_PACKAGE_NOT_FOUND_ERROR) || + errorMessage.includes(NPM_COMMAND_TOKENS.NPM_NO_VERSION_AVAILABLE) + ) { + throw new Error(RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND); + } + if (errorMessage.includes(NPM_COMMAND_TOKENS.NPM_PACKAGE_VERSION_NOT_FOUND_ERROR)) { + throw new Error(RESPONSE_ERROR_MESSAGES.PACKAGE_VERSION_NOT_FOUND); + } + if ( + errorMessage.includes(NPM_COMMAND_TOKENS.NPM_DISK_NO_SPACE) || + errorMessage.includes(NPM_COMMAND_TOKENS.NPM_DISK_INSUFFICIENT_SPACE) + ) { + throw new Error(RESPONSE_ERROR_MESSAGES.DISK_IS_FULL); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + LoggerProxy.warn('npm command failed; see message', { errorMessage }); + + throw new Error('Package could not be installed - check logs for details'); + } +}; + +export function matchPackagesWithUpdates( + installedPackages: InstalledPackages[], + availableUpdates?: NpmUpdatesAvailable, +): PublicInstalledPackage[] { + if (!availableUpdates) { + return installedPackages; + } + const hydratedPackageList = [] as PublicInstalledPackage[]; + + for (let i = 0; i < installedPackages.length; i++) { + const installedPackage = installedPackages[i]; + const publicPackage = { ...installedPackage } as PublicInstalledPackage; + + if (availableUpdates[installedPackage.packageName]) { + publicPackage.updateAvailable = availableUpdates[installedPackage.packageName].latest; + } + hydratedPackageList.push(publicPackage); + } + + return hydratedPackageList; +} + +export function matchMissingPackages( + installedPackages: PublicInstalledPackage[], + missingPackages: string, +): PublicInstalledPackage[] { + const missingPackageNames = missingPackages.split(' '); + + const missingPackagesList = missingPackageNames.map((missingPackageName: string) => { + // Strip away versions but maintain scope and package name + try { + const parsedPackageData = parsePackageName(missingPackageName); + return parsedPackageData.packageName; + + // eslint-disable-next-line no-empty + } catch (_) {} + return undefined; + }); + + const hydratedPackageList = [] as PublicInstalledPackage[]; + installedPackages.forEach((installedPackage) => { + const hydratedInstalledPackage = { ...installedPackage }; + if (missingPackagesList.includes(hydratedInstalledPackage.packageName)) { + hydratedInstalledPackage.failedLoading = true; + } + hydratedPackageList.push(hydratedInstalledPackage); + }); + + return hydratedPackageList; +} + +export async function checkPackageStatus(packageName: string): Promise { + // You can change this URL for testing - the default testing url below + // is a postman mock service + const n8nBackendServiceUrl = 'https://api.n8n.io/api/package'; + + try { + const output = await axios.post( + n8nBackendServiceUrl, + { name: packageName }, + { + method: 'POST', + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (output.data.status !== NPM_PACKAGE_STATUS_GOOD) { + return output.data as NpmPackageStatusCheck; + } + } catch (error) { + // Do nothing if service is unreachable + } + return { status: NPM_PACKAGE_STATUS_GOOD }; +} + +export function hasPackageLoadedSuccessfully(packageName: string): boolean { + try { + const failedPackages = (config.get('nodes.packagesMissing') as string).split(' '); + + const packageFailedToLoad = failedPackages.find( + (packageNameAndVersion) => + packageNameAndVersion.startsWith(packageName) && + packageNameAndVersion.replace(packageName, '').startsWith('@'), + ); + if (packageFailedToLoad) { + return false; + } + return true; + } catch (_error) { + // If key doesn't exist it means all packages loaded fine + return true; + } +} + +export function removePackageFromMissingList(packageName: string): void { + try { + const failedPackages = (config.get('nodes.packagesMissing') as string).split(' '); + + const packageFailedToLoad = failedPackages.filter( + (packageNameAndVersion) => + !packageNameAndVersion.startsWith(packageName) || + !packageNameAndVersion.replace(packageName, '').startsWith('@'), + ); + + config.set('nodes.packagesMissing', packageFailedToLoad.join(' ')); + } catch (_error) { + // Do nothing + } +} diff --git a/packages/cli/src/CommunityNodes/packageModel.ts b/packages/cli/src/CommunityNodes/packageModel.ts new file mode 100644 index 0000000000..09b858cd6c --- /dev/null +++ b/packages/cli/src/CommunityNodes/packageModel.ts @@ -0,0 +1,75 @@ +/* eslint-disable import/no-cycle */ +import { INodeTypeData, INodeTypeNameVersion, LoggerProxy } from 'n8n-workflow'; +import { Db } from '..'; +import { InstalledNodes } from '../databases/entities/InstalledNodes'; +import { InstalledPackages } from '../databases/entities/InstalledPackages'; + +export async function searchInstalledPackage( + packageName: string, +): Promise { + const installedPackage = await Db.collections.InstalledPackages.findOne(packageName, { + relations: ['installedNodes'], + }); + return installedPackage; +} + +export async function getAllInstalledPackages(): Promise { + const installedPackages = await Db.collections.InstalledPackages.find({ + relations: ['installedNodes'], + }); + return installedPackages; +} + +export async function removePackageFromDatabase(packageName: InstalledPackages): Promise { + void (await Db.collections.InstalledPackages.remove(packageName)); +} + +export async function persistInstalledPackageData( + installedPackageName: string, + installedPackageVersion: string, + installedNodes: INodeTypeNameVersion[], + loadedNodeTypes: INodeTypeData, + authorName?: string, + authorEmail?: string, +): Promise { + let installedPackage: InstalledPackages; + + try { + await Db.transaction(async (transactionManager) => { + const promises = []; + + const installedPackagePayload = Object.assign(new InstalledPackages(), { + packageName: installedPackageName, + installedVersion: installedPackageVersion, + authorName, + authorEmail, + }); + installedPackage = await transactionManager.save(installedPackagePayload); + installedPackage.installedNodes = []; + + promises.push( + ...installedNodes.map(async (loadedNode) => { + const installedNodePayload = Object.assign(new InstalledNodes(), { + name: loadedNodeTypes[loadedNode.name].type.description.displayName, + type: loadedNode.name, + latestVersion: loadedNode.version, + package: installedPackageName, + }); + installedPackage.installedNodes.push(installedNodePayload); + return transactionManager.save(installedNodePayload); + }), + ); + + return promises; + }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return installedPackage!; + } catch (error) { + LoggerProxy.error('Failed to save installed packages and nodes', { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + error, + packageName: installedPackageName, + }); + throw error; + } +} diff --git a/packages/cli/src/Db.ts b/packages/cli/src/Db.ts index 9f980db523..514734ae5a 100644 --- a/packages/cli/src/Db.ts +++ b/packages/cli/src/Db.ts @@ -194,6 +194,8 @@ export async function init( collections.SharedCredentials = linkRepository(entities.SharedCredentials); collections.SharedWorkflow = linkRepository(entities.SharedWorkflow); collections.Settings = linkRepository(entities.Settings); + collections.InstalledPackages = linkRepository(entities.InstalledPackages); + collections.InstalledNodes = linkRepository(entities.InstalledNodes); isInitialized = true; diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index be09ec0353..2edd852bcb 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -35,6 +35,8 @@ import { User } from './databases/entities/User'; import { SharedCredentials } from './databases/entities/SharedCredentials'; import { SharedWorkflow } from './databases/entities/SharedWorkflow'; import { Settings } from './databases/entities/Settings'; +import { InstalledPackages } from './databases/entities/InstalledPackages'; +import { InstalledNodes } from './databases/entities/InstalledNodes'; export interface IActivationError { time: number; @@ -83,6 +85,8 @@ export interface IDatabaseCollections { SharedCredentials: Repository; SharedWorkflow: Repository; Settings: Repository; + InstalledPackages: Repository; + InstalledNodes: Repository; } export interface IWebhookDb { @@ -461,6 +465,19 @@ export interface IVersionNotificationSettings { infoUrl: string; } +export interface IN8nNodePackageJson { + name: string; + version: string; + n8n?: { + credentials?: string[]; + nodes?: string[]; + }; + author?: { + name?: string; + email?: string; + }; +} + export interface IN8nUISettings { endpointWebhook: string; endpointWebhookTest: string; @@ -494,6 +511,9 @@ export interface IN8nUISettings { enabled: boolean; host: string; }; + missingPackages?: boolean; + executionMode: 'regular' | 'queue'; + communityNodesEnabled: boolean; } export interface IPersonalizationSurveyAnswers { @@ -532,6 +552,8 @@ export type IPushData = | PushDataExecuteAfter | PushDataExecuteBefore | PushDataConsoleMessage + | PushDataReloadNodeType + | PushDataRemoveNodeType | PushDataTestWebhook; type PushDataExecutionFinished = { @@ -559,6 +581,16 @@ type PushDataConsoleMessage = { type: 'sendConsoleMessage'; }; +type PushDataReloadNodeType = { + data: IPushDataReloadNodeType; + type: 'reloadNodeType'; +}; + +type PushDataRemoveNodeType = { + data: IPushDataRemoveNodeType; + type: 'removeNodeType'; +}; + type PushDataTestWebhook = { data: IPushDataTestWebhook; type: 'testWebhookDeleted' | 'testWebhookReceived'; @@ -590,6 +622,16 @@ export interface IPushDataNodeExecuteBefore { nodeName: string; } +export interface IPushDataReloadNodeType { + name: string; + version: number; +} + +export interface IPushDataRemoveNodeType { + name: string; + version: number; +} + export interface IPushDataTestWebhook { executionId: string; workflowId: string; @@ -669,6 +711,31 @@ export interface IWorkflowExecuteProcess { export type WhereClause = Record; +/** ******************************** + * Commuinity nodes + ******************************** */ + +export type ParsedNpmPackageName = { + packageName: string; + originalString: string; + scope?: string; + version?: string; +}; + +export type NpmUpdatesAvailable = { + [packageName: string]: { + current: string; + wanted: string; + latest: string; + location: string; + }; +}; + +export type NpmPackageStatusCheck = { + status: 'OK' | 'Banned'; + reason?: string; +}; + // ---------------------------------- // telemetry // ---------------------------------- diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index e597bc7faa..7a39e75948 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -386,4 +386,45 @@ export class InternalHooksClass implements IInternalHooksClass { failedEmailData, ); } + + /** + * Community nodes backend telemetry events + */ + + async onCommunityPackageInstallFinished(installationData: { + user_id: string; + input_string: string; + package_name: string; + success: boolean; + package_version?: string; + package_node_names?: string[]; + package_author?: string; + package_author_email?: string; + failure_reason?: string; + }): Promise { + return this.telemetry.track('cnr package install finished', installationData); + } + + async onCommunityPackageUpdateFinished(updateData: { + user_id: string; + package_name: string; + package_version_current: string; + package_version_new: string; + package_node_names: string[]; + package_author?: string; + package_author_email?: string; + }): Promise { + return this.telemetry.track('cnr package updated', updateData); + } + + async onCommunityPackageDeleteFinished(updateData: { + user_id: string; + package_name: string; + package_version: string; + package_node_names: string[]; + package_author?: string; + package_author_email?: string; + }): Promise { + return this.telemetry.track('cnr package deleted', updateData); + } } diff --git a/packages/cli/src/LoadNodesAndCredentials.ts b/packages/cli/src/LoadNodesAndCredentials.ts index c154256d74..abc3d3b092 100644 --- a/packages/cli/src/LoadNodesAndCredentials.ts +++ b/packages/cli/src/LoadNodesAndCredentials.ts @@ -1,3 +1,5 @@ +/* eslint-disable import/no-cycle */ +/* eslint-disable no-underscore-dangle */ /* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable no-prototype-builtins */ /* eslint-disable no-param-reassign */ @@ -16,6 +18,7 @@ import { ILogger, INodeType, INodeTypeData, + INodeTypeNameVersion, INodeVersionedType, LoggerProxy, } from 'n8n-workflow'; @@ -28,8 +31,18 @@ import { } from 'fs/promises'; import glob from 'fast-glob'; import path from 'path'; +import { IN8nNodePackageJson } from './Interfaces'; import { getLogger } from './Logger'; import config from '../config'; +import { NodeTypes } from '.'; +import { InstalledPackages } from './databases/entities/InstalledPackages'; +import { InstalledNodes } from './databases/entities/InstalledNodes'; +import { executeCommand } from './CommunityNodes/helpers'; +import { RESPONSE_ERROR_MESSAGES } from './constants'; +import { + persistInstalledPackageData, + removePackageFromDatabase, +} from './CommunityNodes/packageModel'; const CUSTOM_NODES_CATEGORY = 'Custom Nodes'; @@ -50,6 +63,29 @@ class LoadNodesAndCredentialsClass { this.logger = getLogger(); LoggerProxy.init(this.logger); + // Make sure the imported modules can resolve dependencies fine. + process.env.NODE_PATH = module.paths.join(':'); + // @ts-ignore + module.constructor._initPaths(); + + this.nodeModulesPath = await this.getNodeModulesFolderLocation(); + + this.excludeNodes = config.getEnv('nodes.exclude'); + this.includeNodes = config.getEnv('nodes.include'); + + // Get all the installed packages which contain n8n nodes + const nodePackages = await this.getN8nNodePackages(this.nodeModulesPath); + + for (const packagePath of nodePackages) { + await this.loadDataFromPackage(packagePath); + } + + await this.loadNodesFromDownloadedPackages(); + + await this.loadNodesFromCustomFolders(); + } + + async getNodeModulesFolderLocation(): Promise { // Get the path to the node-modules folder to be later able // to load the credentials and nodes const checkPaths = [ @@ -63,29 +99,37 @@ class LoadNodesAndCredentialsClass { try { await fsAccess(checkPath); // Folder exists, so use it. - this.nodeModulesPath = path.dirname(checkPath); - break; + return path.dirname(checkPath); } catch (error) { // Folder does not exist so get next one // eslint-disable-next-line no-continue continue; } } + throw new Error('Could not find "node_modules" folder!'); + } - if (this.nodeModulesPath === '') { - throw new Error('Could not find "node_modules" folder!'); - } - - this.excludeNodes = config.getEnv('nodes.exclude'); - this.includeNodes = config.getEnv('nodes.include'); - - // Get all the installed packages which contain n8n nodes - const packages = await this.getN8nNodePackages(); - - for (const packageName of packages) { - await this.loadDataFromPackage(packageName); + async loadNodesFromDownloadedPackages(): Promise { + const nodePackages = []; + try { + // Read downloaded nodes and credentials + const downloadedNodesFolder = UserSettings.getUserN8nFolderDowloadedNodesPath(); + const downloadedNodesFolderModules = path.join(downloadedNodesFolder, 'node_modules'); + await fsAccess(downloadedNodesFolderModules); + const downloadedPackages = await this.getN8nNodePackages(downloadedNodesFolderModules); + nodePackages.push(...downloadedPackages); + // eslint-disable-next-line no-empty + } catch (error) {} + + for (const packagePath of nodePackages) { + try { + await this.loadDataFromPackage(packagePath); + // eslint-disable-next-line no-empty + } catch (error) {} } + } + async loadNodesFromCustomFolders(): Promise { // Read nodes and credentials from custom directories const customDirectories = []; @@ -112,10 +156,10 @@ class LoadNodesAndCredentialsClass { * @returns {Promise} * @memberof LoadNodesAndCredentialsClass */ - async getN8nNodePackages(): Promise { + async getN8nNodePackages(baseModulesPath: string): Promise { const getN8nNodePackagesRecursive = async (relativePath: string): Promise => { const results: string[] = []; - const nodeModulesPath = `${this.nodeModulesPath}/${relativePath}`; + const nodeModulesPath = `${baseModulesPath}/${relativePath}`; for (const file of await fsReaddir(nodeModulesPath)) { const isN8nNodesPackage = file.indexOf('n8n-nodes-') === 0; const isNpmScopedPackage = file.indexOf('@') === 0; @@ -126,7 +170,7 @@ class LoadNodesAndCredentialsClass { continue; } if (isN8nNodesPackage) { - results.push(`${relativePath}${file}`); + results.push(`${baseModulesPath}/${relativePath}${file}`); } if (isNpmScopedPackage) { results.push(...(await getN8nNodePackagesRecursive(`${relativePath}${file}/`))); @@ -188,6 +232,115 @@ class LoadNodesAndCredentialsClass { }; } + async loadNpmModule(packageName: string, version?: string): Promise { + const downloadFolder = UserSettings.getUserN8nFolderDowloadedNodesPath(); + const command = `npm install ${packageName}${version ? `@${version}` : ''}`; + + await executeCommand(command); + + const finalNodeUnpackedPath = path.join(downloadFolder, 'node_modules', packageName); + + const loadedNodes = await this.loadDataFromPackage(finalNodeUnpackedPath); + + if (loadedNodes.length > 0) { + const packageFile = await this.readPackageJson(finalNodeUnpackedPath); + // Save info to DB + try { + const installedPackage = await persistInstalledPackageData( + packageFile.name, + packageFile.version, + loadedNodes, + this.nodeTypes, + packageFile.author?.name, + packageFile.author?.email, + ); + this.attachNodesToNodeTypes(installedPackage.installedNodes); + return installedPackage; + } catch (error) { + LoggerProxy.error('Failed to save installed packages and nodes', { error, packageName }); + throw error; + } + } else { + // Remove this package since it contains no loadable nodes + const removeCommand = `npm remove ${packageName}`; + try { + await executeCommand(removeCommand); + } catch (error) { + // Do nothing + } + + throw new Error(RESPONSE_ERROR_MESSAGES.PACKAGE_DOES_NOT_CONTAIN_NODES); + } + } + + async removeNpmModule(packageName: string, installedPackage: InstalledPackages): Promise { + const command = `npm remove ${packageName}`; + + await executeCommand(command); + + void (await removePackageFromDatabase(installedPackage)); + + this.unloadNodes(installedPackage.installedNodes); + } + + async updateNpmModule( + packageName: string, + installedPackage: InstalledPackages, + ): Promise { + const downloadFolder = UserSettings.getUserN8nFolderDowloadedNodesPath(); + + const command = `npm update ${packageName}`; + + try { + await executeCommand(command); + } catch (error) { + if (error.message === RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND) { + throw new Error(`The npm package "${packageName}" could not be found.`); + } + throw error; + } + + this.unloadNodes(installedPackage.installedNodes); + + const finalNodeUnpackedPath = path.join(downloadFolder, 'node_modules', packageName); + + const loadedNodes = await this.loadDataFromPackage(finalNodeUnpackedPath); + + if (loadedNodes.length > 0) { + const packageFile = await this.readPackageJson(finalNodeUnpackedPath); + + // Save info to DB + try { + await removePackageFromDatabase(installedPackage); + + const newlyInstalledPackage = await persistInstalledPackageData( + packageFile.name, + packageFile.version, + loadedNodes, + this.nodeTypes, + packageFile.author?.name, + packageFile.author?.email, + ); + + this.attachNodesToNodeTypes(newlyInstalledPackage.installedNodes); + + return newlyInstalledPackage; + } catch (error) { + LoggerProxy.error('Failed to save installed packages and nodes', { error, packageName }); + throw error; + } + } else { + // Remove this package since it contains no loadable nodes + const removeCommand = `npm remove ${packageName}`; + try { + await executeCommand(removeCommand); + } catch (error) { + // Do nothing + } + throw new Error(RESPONSE_ERROR_MESSAGES.PACKAGE_DOES_NOT_CONTAIN_NODES); + } + } + /** * Loads a node from a file * @@ -196,19 +349,23 @@ class LoadNodesAndCredentialsClass { * @param {string} filePath The file to read node from * @returns {Promise} */ - async loadNodeFromFile(packageName: string, nodeName: string, filePath: string): Promise { + async loadNodeFromFile( + packageName: string, + nodeName: string, + filePath: string, + ): Promise { let tempNode: INodeType | INodeVersionedType; let fullNodeName: string; - - // eslint-disable-next-line import/no-dynamic-require, global-require, @typescript-eslint/no-var-requires - const tempModule = require(filePath); + let nodeVersion = 1; try { + // eslint-disable-next-line import/no-dynamic-require, global-require, @typescript-eslint/no-var-requires + const tempModule = require(filePath); tempNode = new tempModule[nodeName](); this.addCodex({ node: tempNode, filePath, isCustom: packageName === 'CUSTOM' }); } catch (error) { - // eslint-disable-next-line no-console - console.error(`Error loading node "${nodeName}" from: "${filePath}"`); + // eslint-disable-next-line no-console, @typescript-eslint/restrict-template-expressions + console.error(`Error loading node "${nodeName}" from: "${filePath}" - ${error.message}`); throw error; } @@ -234,6 +391,7 @@ class LoadNodesAndCredentialsClass { if (tempNode.hasOwnProperty('nodeVersions')) { const versionedNodeType = (tempNode as INodeVersionedType).getNodeType(); this.addCodex({ node: versionedNodeType, filePath, isCustom: packageName === 'CUSTOM' }); + nodeVersion = (tempNode as INodeVersionedType).currentVersion; if ( versionedNodeType.description.icon !== undefined && @@ -252,6 +410,12 @@ class LoadNodesAndCredentialsClass { { filePath }, ); } + } else { + // Short renaming to avoid type issues + const tmpNode = tempNode as INodeType; + nodeVersion = Array.isArray(tmpNode.description.version) + ? tmpNode.description.version.slice(-1)[0] + : tmpNode.description.version; } if (this.includeNodes !== undefined && !this.includeNodes.includes(fullNodeName)) { @@ -267,6 +431,12 @@ class LoadNodesAndCredentialsClass { type: tempNode, sourcePath: filePath, }; + + // eslint-disable-next-line consistent-return + return { + name: fullNodeName, + version: nodeVersion, + } as INodeTypeNameVersion; } /** @@ -341,7 +511,8 @@ class LoadNodesAndCredentialsClass { let fileName: string; let type: string; - const loadPromises = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const loadPromises: any[] = []; for (const filePath of files) { [fileName, type] = path.parse(filePath).name.split('.'); @@ -355,26 +526,33 @@ class LoadNodesAndCredentialsClass { await Promise.all(loadPromises); } + async readPackageJson(packagePath: string): Promise { + // Get the absolute path of the package + const packageFileString = await fsReadFile(path.join(packagePath, 'package.json'), 'utf8'); + return JSON.parse(packageFileString) as IN8nNodePackageJson; + } + /** * Loads nodes and credentials from the package with the given name * - * @param {string} packageName The name to read data from + * @param {string} packagePath The path to read data from * @returns {Promise} */ - async loadDataFromPackage(packageName: string): Promise { + async loadDataFromPackage(packagePath: string): Promise { // Get the absolute path of the package - const packagePath = path.join(this.nodeModulesPath, packageName); - - // Read the data from the package.json file to see if any n8n data is defiend - const packageFileString = await fsReadFile(path.join(packagePath, 'package.json'), 'utf8'); - const packageFile = JSON.parse(packageFileString); - if (!packageFile.hasOwnProperty('n8n')) { - return; + const packageFile = await this.readPackageJson(packagePath); + // if (!packageFile.hasOwnProperty('n8n')) { + if (!packageFile.n8n) { + return []; } + const packageName = packageFile.name; + let tempPath: string; let filePath: string; + const returnData: INodeTypeNameVersion[] = []; + // Read all node types let fileName: string; let type: string; @@ -382,7 +560,10 @@ class LoadNodesAndCredentialsClass { for (filePath of packageFile.n8n.nodes) { tempPath = path.join(packagePath, filePath); [fileName, type] = path.parse(filePath).name.split('.'); - await this.loadNodeFromFile(packageName, fileName, tempPath); + const loadData = await this.loadNodeFromFile(packageName, fileName, tempPath); + if (loadData) { + returnData.push(loadData); + } } } @@ -399,6 +580,27 @@ class LoadNodesAndCredentialsClass { this.loadCredentialsFromFile(fileName, tempPath); } } + + return returnData; + } + + unloadNodes(installedNodes: InstalledNodes[]): void { + const nodeTypes = NodeTypes(); + installedNodes.forEach((installedNode) => { + nodeTypes.removeNodeType(installedNode.type); + delete this.nodeTypes[installedNode.type]; + }); + } + + attachNodesToNodeTypes(installedNodes: InstalledNodes[]): void { + const nodeTypes = NodeTypes(); + installedNodes.forEach((installedNode) => { + nodeTypes.attachNodeType( + installedNode.type, + this.nodeTypes[installedNode.type].type, + this.nodeTypes[installedNode.type].sourcePath, + ); + }); } } diff --git a/packages/cli/src/NodeTypes.ts b/packages/cli/src/NodeTypes.ts index 3667059dce..78c2e5c7bb 100644 --- a/packages/cli/src/NodeTypes.ts +++ b/packages/cli/src/NodeTypes.ts @@ -57,6 +57,21 @@ class NodeTypesClass implements INodeTypes { } return NodeHelpers.getVersionedNodeType(this.nodeTypes[nodeType].type, version); } + + attachNodeType( + nodeTypeName: string, + nodeType: INodeType | INodeVersionedType, + sourcePath: string, + ): void { + this.nodeTypes[nodeTypeName] = { + type: nodeType, + sourcePath, + }; + } + + removeNodeType(nodeType: string): void { + delete this.nodeTypes[nodeType]; + } } let nodeTypesInstance: NodeTypesClass | undefined; diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index ec677a713f..4ce71acabf 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -90,6 +90,7 @@ import querystring from 'querystring'; import promClient, { Registry } from 'prom-client'; import * as Queue from './Queue'; import { + LoadNodesAndCredentials, ActiveExecutions, ActiveWorkflowRunner, CredentialsHelper, @@ -159,6 +160,7 @@ import { ExecutionEntity } from './databases/entities/ExecutionEntity'; import { SharedWorkflow } from './databases/entities/SharedWorkflow'; import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from './constants'; import { credentialsController } from './api/credentials.api'; +import { nodesController } from './api/nodes.api'; import { oauth2CredentialController } from './api/oauth2Credential.api'; import { getInstanceBaseUrl, @@ -329,6 +331,8 @@ class App { enabled: config.getEnv('templates.enabled'), host: config.getEnv('templates.host'), }, + executionMode: config.getEnv('executions.mode'), + communityNodesEnabled: config.getEnv('nodes.communityPackages.enabled'), }; } @@ -355,6 +359,10 @@ class App { config.getEnv('userManagement.skipInstanceOwnerSetup') === false, }); + if (config.get('nodes.packagesMissing').length > 0) { + this.frontendSettings.missingPackages = true; + } + return this.frontendSettings; } @@ -706,6 +714,13 @@ class App { this.app.use(`/${this.restEndpoint}/credentials`, credentialsController); + // ---------------------------------------- + // Packages and nodes management + // ---------------------------------------- + if (config.getEnv('nodes.communityPackages.enabled')) { + this.app.use(`/${this.restEndpoint}/nodes`, nodesController); + } + // ---------------------------------------- // Healthcheck // ---------------------------------------- diff --git a/packages/cli/src/api/nodes.api.ts b/packages/cli/src/api/nodes.api.ts new file mode 100644 index 0000000000..d62854331a --- /dev/null +++ b/packages/cli/src/api/nodes.api.ts @@ -0,0 +1,316 @@ +/* eslint-disable import/no-cycle */ +import express = require('express'); +import { LoggerProxy, PublicInstalledPackage } from 'n8n-workflow'; +import { getLogger } from '../Logger'; + +import { ResponseHelper, LoadNodesAndCredentials, Push, InternalHooksManager } from '..'; +import { NodeRequest } from '../requests'; +import { RESPONSE_ERROR_MESSAGES } from '../constants'; +import { + matchMissingPackages, + matchPackagesWithUpdates, + executeCommand, + checkPackageStatus, + hasPackageLoadedSuccessfully, + removePackageFromMissingList, + parsePackageName, +} from '../CommunityNodes/helpers'; +import { getAllInstalledPackages, searchInstalledPackage } from '../CommunityNodes/packageModel'; +import { isAuthenticatedRequest } from '../UserManagement/UserManagementHelper'; +import config = require('../../config'); +import { NpmUpdatesAvailable } from '../Interfaces'; + +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(); +}); + +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(); +}); + +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 + throw new ResponseHelper.ResponseError(error.message, 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) { + throw new ResponseHelper.ResponseError( + `Package "${parsedPackageName.packageName}" is already installed. For updating, click the corresponding button.`, + undefined, + 400, + ); + } + + const packageStatus = await checkPackageStatus(name); + if (packageStatus.status !== 'OK') { + throw new ResponseHelper.ResponseError( + `Package "${name}" has been banned from n8n's repository and will not be installed`, + undefined, + 400, + ); + } + + try { + const installedPackage = await LoadNodesAndCredentials().loadNpmModule( + parsedPackageName.packageName, + parsedPackageName.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.name, + 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; + } + + void InternalHooksManager.getInstance().onCommunityPackageInstallFinished({ + user_id: req.user.id, + input_string: name, + package_name: parsedPackageName.packageName, + success: false, + package_version: parsedPackageName.version, + failure_reason: errorMessage, + }); + throw new ResponseHelper.ResponseError( + `Error loading package "${name}": ${errorMessage}`, + undefined, + statusCode, + ); + } + }), +); + +// Install new credentials/nodes from npm +nodesController.get( + '/', + ResponseHelper.send(async (): Promise => { + const packages = await getAllInstalledPackages(); + + if (packages.length === 0) { + return packages; + } + + 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 }); + } 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 + pendingUpdates = JSON.parse(error.stdout); + } + } + let hydratedPackages = matchPackagesWithUpdates(packages, pendingUpdates); + try { + if (config.get('nodes.packagesMissing')) { + // eslint-disable-next-line prettier/prettier + hydratedPackages = matchMissingPackages(hydratedPackages, config.get('nodes.packagesMissing')); + } + } catch (error) { + // Do nothing if setting is missing + } + return hydratedPackages; + }), +); + +// Uninstall credentials/nodes from npm +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, + ); + } + + 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, + }); + } 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, + ); + } + }), +); + +// Update a 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, + ); + } + + const parsedPackageData = parsePackageName(name); + const packagePreviouslyInstalled = await searchInstalledPackage(name); + + if (!packagePreviouslyInstalled) { + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_INSTALLED, + undefined, + 400, + ); + } + + try { + const newInstalledPackage = await LoadNodesAndCredentials().updateNpmModule( + parsedPackageData.packageName, + packagePreviouslyInstalled, + ); + + const pushInstance = Push.getInstance(); + + // Inform the connected frontends that new nodes are available + packagePreviouslyInstalled.installedNodes.forEach((installedNode) => { + pushInstance.send('removeNodeType', { + name: installedNode.type, + version: installedNode.latestVersion, + }); + }); + + newInstalledPackage.installedNodes.forEach((nodeData) => { + pushInstance.send('reloadNodeType', { + name: nodeData.name, + version: nodeData.latestVersion, + }); + }); + + void InternalHooksManager.getInstance().onCommunityPackageUpdateFinished({ + user_id: req.user.id, + package_name: name, + package_version_current: packagePreviouslyInstalled.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) { + packagePreviouslyInstalled.installedNodes.forEach((installedNode) => { + const pushInstance = Push.getInstance(); + pushInstance.send('removeNodeType', { + name: installedNode.type, + version: installedNode.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, + ); + } + }), +); diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 59062c4644..409d16d310 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -4,9 +4,28 @@ import { RESPONSE_ERROR_MESSAGES as CORE_RESPONSE_ERROR_MESSAGES } from 'n8n-core'; +export const NODE_PACKAGE_PREFIX = 'n8n-nodes-'; + export const RESPONSE_ERROR_MESSAGES = { NO_CREDENTIAL: 'Credential not found', NO_ENCRYPTION_KEY: CORE_RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, + PACKAGE_NAME_NOT_PROVIDED: 'Package name is required', + PACKAGE_NAME_NOT_VALID: `Package name is not valid - it must start with "${NODE_PACKAGE_PREFIX}"`, + PACKAGE_NOT_INSTALLED: 'This package is not installed - you must install it first', + PACKAGE_NOT_FOUND: 'Package not found in npm', + PACKAGE_VERSION_NOT_FOUND: 'The specified package version was not found', + PACKAGE_DOES_NOT_CONTAIN_NODES: 'The specified package does not contain any nodes', + DISK_IS_FULL: 'There appears to be insufficient disk space', }; export const AUTH_COOKIE_NAME = 'n8n-auth'; + +export const NPM_COMMAND_TOKENS = { + NPM_PACKAGE_NOT_FOUND_ERROR: '404 Not Found', + NPM_PACKAGE_VERSION_NOT_FOUND_ERROR: 'No matching version found for', + NPM_NO_VERSION_AVAILABLE: 'No valid versions available', + NPM_DISK_NO_SPACE: 'ENOSPC', + NPM_DISK_INSUFFICIENT_SPACE: 'insufficient space', +}; + +export const NPM_PACKAGE_STATUS_GOOD = 'OK'; diff --git a/packages/cli/src/databases/entities/InstalledNodes.ts b/packages/cli/src/databases/entities/InstalledNodes.ts new file mode 100644 index 0000000000..7885517c22 --- /dev/null +++ b/packages/cli/src/databases/entities/InstalledNodes.ts @@ -0,0 +1,22 @@ +/* eslint-disable import/no-cycle */ +import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { InstalledPackages } from './InstalledPackages'; + +@Entity() +export class InstalledNodes { + @Column() + name: string; + + @PrimaryColumn() + type: string; + + @Column() + latestVersion: string; + + @ManyToOne( + () => InstalledPackages, + (installedPackages: InstalledPackages) => installedPackages.installedNodes, + ) + @JoinColumn({ name: 'package', referencedColumnName: 'packageName' }) + package: InstalledPackages; +} diff --git a/packages/cli/src/databases/entities/InstalledPackages.ts b/packages/cli/src/databases/entities/InstalledPackages.ts new file mode 100644 index 0000000000..af4aab87f2 --- /dev/null +++ b/packages/cli/src/databases/entities/InstalledPackages.ts @@ -0,0 +1,67 @@ +/* eslint-disable import/no-cycle */ +import { + BeforeUpdate, + Column, + CreateDateColumn, + Entity, + JoinColumn, + OneToMany, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { IsDate, IsOptional } from 'class-validator'; + +import config = require('../../../config'); +import { DatabaseType } from '../../index'; +import { InstalledNodes } from './InstalledNodes'; + +function getTimestampSyntax() { + const dbType = config.get('database.type') as DatabaseType; + + const map: { [key in DatabaseType]: string } = { + sqlite: "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')", + postgresdb: 'CURRENT_TIMESTAMP(3)', + mysqldb: 'CURRENT_TIMESTAMP(3)', + mariadb: 'CURRENT_TIMESTAMP(3)', + }; + + return map[dbType]; +} + +@Entity() +export class InstalledPackages { + @PrimaryColumn() + packageName: string; + + @Column() + installedVersion: string; + + @Column() + authorName?: string; + + @Column() + authorEmail?: string; + + @OneToMany(() => InstalledNodes, (installedNode) => installedNode.package) + @JoinColumn({ referencedColumnName: 'package' }) + installedNodes: InstalledNodes[]; + + @CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() }) + @IsOptional() // ignored by validation because set at DB level + @IsDate() + createdAt: Date; + + @UpdateDateColumn({ + precision: 3, + default: () => getTimestampSyntax(), + onUpdate: getTimestampSyntax(), + }) + @IsOptional() // ignored by validation because set at DB level + @IsDate() + updatedAt: Date; + + @BeforeUpdate() + setUpdateDate(): void { + this.updatedAt = new Date(); + } +} diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index 1492f8615e..97628e8ef8 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -10,6 +10,8 @@ import { Role } from './Role'; import { Settings } from './Settings'; import { SharedWorkflow } from './SharedWorkflow'; import { SharedCredentials } from './SharedCredentials'; +import { InstalledPackages } from './InstalledPackages'; +import { InstalledNodes } from './InstalledNodes'; export const entities = { CredentialsEntity, @@ -22,4 +24,6 @@ export const entities = { Settings, SharedWorkflow, SharedCredentials, + InstalledPackages, + InstalledNodes, }; diff --git a/packages/cli/src/databases/migrations/mysqldb/1652254514003-CommunityNodes.ts b/packages/cli/src/databases/migrations/mysqldb/1652254514003-CommunityNodes.ts new file mode 100644 index 0000000000..f23da9303e --- /dev/null +++ b/packages/cli/src/databases/migrations/mysqldb/1652254514003-CommunityNodes.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import * as config from '../../../../config'; + +export class CommunityNodes1652254514003 implements MigrationInterface { + name = 'CommunityNodes1652254514003'; + + public async up(queryRunner: QueryRunner): Promise { + const tablePrefix = config.getEnv('database.tablePrefix'); + + await queryRunner.query( + `CREATE TABLE \`${tablePrefix}installed_packages\` (` + + '`packageName` char(214) NOT NULL,' + + '`installedVersion` char(50) NOT NULL,' + + '`authorName` char(70) NULL,' + + '`authorEmail` char(70) NULL,' + + '`createdAt` datetime NULL DEFAULT CURRENT_TIMESTAMP,' + + '`updatedAt` datetime NULL DEFAULT CURRENT_TIMESTAMP,' + + 'PRIMARY KEY (\`packageName\`)' + + ') ENGINE=InnoDB;' + ); + + await queryRunner.query( + + `CREATE TABLE \`${tablePrefix}installed_nodes\` (` + + '`name` char(200) NOT NULL,' + + '`type` char(200) NOT NULL,' + + "`latestVersion` int NOT NULL DEFAULT '1'," + + '`package` char(214) NOT NULL,' + + 'PRIMARY KEY (`name`),' + + `INDEX \`FK_${tablePrefix}73f857fc5dce682cef8a99c11dbddbc969618951\` (\`package\` ASC)` + + ") ENGINE='InnoDB';" + ); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}installed_nodes\` ADD CONSTRAINT \`FK_${tablePrefix}73f857fc5dce682cef8a99c11dbddbc969618951\` FOREIGN KEY (\`package\`) REFERENCES \`${tablePrefix}installed_packages\`(\`packageName\`) ON DELETE CASCADE ON UPDATE CASCADE`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.getEnv('database.tablePrefix'); + + await queryRunner.query( + `ALTER TABLE ${tablePrefix}workflow_entity ADD UNIQUE INDEX \`IDX_${tablePrefix}943d8f922be094eb507cb9a7f9\` (\`name\`)`, + ); + + await queryRunner.query(`DROP TABLE "${tablePrefix}installed_nodes"`); + await queryRunner.query(`DROP TABLE "${tablePrefix}installed_packages"`); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index 31993d41cd..a52df459ca 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -14,6 +14,7 @@ import { AddExecutionEntityIndexes1644424784709 } from './1644424784709-AddExecu import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement'; import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail'; import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings'; +import { CommunityNodes1652254514003 } from './1652254514003-CommunityNodes'; import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn'; export const mysqlMigrations = [ @@ -33,5 +34,6 @@ export const mysqlMigrations = [ CreateUserManagement1646992772331, LowerCaseUserEmail1648740597343, AddUserSettings1652367743993, + CommunityNodes1652254514003, AddAPIKeyColumn1652905585850, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/1652254514002-CommunityNodes.ts b/packages/cli/src/databases/migrations/postgresdb/1652254514002-CommunityNodes.ts new file mode 100644 index 0000000000..47ac01dd55 --- /dev/null +++ b/packages/cli/src/databases/migrations/postgresdb/1652254514002-CommunityNodes.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import config = require('../../../../config'); +import { + logMigrationEnd, + logMigrationStart, +} from '../../utils/migrationHelpers'; + +export class CommunityNodes1652254514002 implements MigrationInterface { + name = 'CommunityNodes1652254514002'; + + public async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + + let tablePrefix = config.getEnv('database.tablePrefix'); + const schema = config.getEnv('database.postgresdb.schema'); + if (schema) { + tablePrefix = schema + '.' + tablePrefix; + } + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}installed_packages (` + + '"packageName" VARCHAR(214) NOT NULL,' + + '"installedVersion" VARCHAR(50) NOT NULL,' + + '"authorName" VARCHAR(70) NULL,' + + '"authorEmail" VARCHAR(70) NULL,' + + '"createdAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,' + + '"updatedAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,' + + `CONSTRAINT "PK_${tablePrefix}08cc9197c39b028c1e9beca225940576fd1a5804" PRIMARY KEY ("packageName")` + + ');', + ); + + await queryRunner.query( + + `CREATE TABLE ${tablePrefix}installed_nodes (` + + '"name" VARCHAR(200) NOT NULL, ' + + '"type" VARCHAR(200) NOT NULL, ' + + '"latestVersion" integer NOT NULL DEFAULT 1, ' + + '"package" VARCHAR(241) NOT NULL, ' + + `CONSTRAINT "PK_${tablePrefix}8ebd28194e4f792f96b5933423fc439df97d9689" PRIMARY KEY ("name"), ` + + `CONSTRAINT "FK_${tablePrefix}73f857fc5dce682cef8a99c11dbddbc969618951" FOREIGN KEY ("package") REFERENCES ${tablePrefix}installed_packages ("packageName") ON DELETE CASCADE ON UPDATE CASCADE ` + + ');' + ); + logMigrationEnd(this.name); + } + + public async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.query(`DROP TABLE "${tablePrefix}installed_nodes"`); + await queryRunner.query(`DROP TABLE "${tablePrefix}installed_packages"`); + } +} diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index 30cc17b873..74162b2fee 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -12,6 +12,7 @@ import { IncreaseTypeVarcharLimit1646834195327 } from './1646834195327-IncreaseT import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement'; import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail'; import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings'; +import { CommunityNodes1652254514002 } from './1652254514002-CommunityNodes'; import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn'; export const postgresMigrations = [ @@ -29,5 +30,6 @@ export const postgresMigrations = [ CreateUserManagement1646992772331, LowerCaseUserEmail1648740597343, AddUserSettings1652367743993, + CommunityNodes1652254514002, AddAPIKeyColumn1652905585850, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/1652254514001-CommunityNodes.ts b/packages/cli/src/databases/migrations/sqlite/1652254514001-CommunityNodes.ts new file mode 100644 index 0000000000..479e0300f4 --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1652254514001-CommunityNodes.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import config = require('../../../../config'); +import { + logMigrationEnd, + logMigrationStart, +} from '../../utils/migrationHelpers'; + +export class CommunityNodes1652254514001 implements MigrationInterface { + name = 'CommunityNodes1652254514001'; + + public async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query( + `CREATE TABLE "${tablePrefix}installed_packages" (` + + `"packageName" char(214) NOT NULL,` + + `"installedVersion" char(50) NOT NULL,` + + `"authorName" char(70) NULL,` + + `"authorEmail" char(70) NULL,` + + `"createdAt" datetime(3) NOT NULL DEFAULT 'STRFTIME(''%Y-%m-%d %H:%M:%f'', ''NOW'')',` + + `"updatedAt" datetime(3) NOT NULL DEFAULT 'STRFTIME(''%Y-%m-%d %H:%M:%f'', ''NOW'')',` + + `PRIMARY KEY("packageName")` + + `);` + ); + + await queryRunner.query( + `CREATE TABLE "${tablePrefix}installed_nodes" (` + + `"name" char(200) NOT NULL,` + + `"type" char(200) NOT NULL,` + + `"latestVersion" INTEGER DEFAULT 1,` + + `"package" char(214) NOT NULL,` + + `PRIMARY KEY("name"),` + + `FOREIGN KEY("package") REFERENCES "${tablePrefix}installed_packages"("packageName") ON DELETE CASCADE ON UPDATE CASCADE` + + `);` + ); + logMigrationEnd(this.name); + } + + public async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.query(`DROP TABLE "${tablePrefix}installed_nodes"`); + await queryRunner.query(`DROP TABLE "${tablePrefix}installed_packages"`); + } +} diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index 1d51552e91..d0dffdf6dc 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -11,6 +11,7 @@ import { AddExecutionEntityIndexes1644421939510 } from './1644421939510-AddExecu import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement'; import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail'; import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings'; +import { CommunityNodes1652254514001 } from './1652254514001-CommunityNodes' import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn'; const sqliteMigrations = [ @@ -27,6 +28,7 @@ const sqliteMigrations = [ CreateUserManagement1646992772331, LowerCaseUserEmail1648740597343, AddUserSettings1652367743993, + CommunityNodes1652254514001, AddAPIKeyColumn1652905585850, ]; diff --git a/packages/cli/src/requests.d.ts b/packages/cli/src/requests.d.ts index 9499dc25b1..28f3b911a7 100644 --- a/packages/cli/src/requests.d.ts +++ b/packages/cli/src/requests.d.ts @@ -290,3 +290,17 @@ export type NodeParameterOptionsRequest = AuthenticatedRequest< export declare namespace TagsRequest { type Delete = AuthenticatedRequest<{ id: string }>; } + +export declare namespace NodeRequest { + type RequestBody = { + name: string; + }; + + type GetAll = AuthenticatedRequest; + + type Post = AuthenticatedRequest<{}, {}, RequestBody>; + + type Delete = Post; + + type Update = Post; +} diff --git a/packages/cli/test/integration/nodes.api.test.ts b/packages/cli/test/integration/nodes.api.test.ts new file mode 100644 index 0000000000..6f7eee4b0a --- /dev/null +++ b/packages/cli/test/integration/nodes.api.test.ts @@ -0,0 +1,341 @@ +import { exec } from 'child_process'; +import express from 'express'; +import * as utils from './shared/utils'; +import type { InstalledNodePayload, InstalledPackagePayload } from './shared/types'; +import type { Role } from '../../src/databases/entities/Role'; +import type { User } from '../../src/databases/entities/User'; +import * as testDb from './shared/testDb'; + +jest.mock('../../src/CommunityNodes/helpers', () => ({ + matchPackagesWithUpdates: jest.requireActual('../../src/CommunityNodes/helpers').matchPackagesWithUpdates, + parsePackageName: jest.requireActual('../../src/CommunityNodes/helpers').parsePackageName, + hasPackageLoadedSuccessfully: jest.fn(), + searchInstalledPackage: jest.fn(), + executeCommand: jest.fn(), + checkPackageStatus: jest.fn(), + removePackageFromMissingList: jest.fn(), +})); + +jest.mock('../../src/CommunityNodes/packageModel', () => ({ + getAllInstalledPackages: jest.requireActual('../../src/CommunityNodes/packageModel').getAllInstalledPackages, + removePackageFromDatabase: jest.fn(), + searchInstalledPackage: jest.fn(), +})); + +import { executeCommand, checkPackageStatus, hasPackageLoadedSuccessfully, removePackageFromMissingList } from '../../src/CommunityNodes/helpers'; +import { getAllInstalledPackages, searchInstalledPackage, removePackageFromDatabase } from '../../src/CommunityNodes/packageModel'; +import { CURRENT_PACKAGE_VERSION, UPDATED_PACKAGE_VERSION } from './shared/constants'; +import { installedPackagePayload } from './shared/utils'; + +jest.mock('../../src/telemetry'); + +jest.mock('../../src/LoadNodesAndCredentials', () => ({ + LoadNodesAndCredentials: jest.fn(), +})); +import { LoadNodesAndCredentials } from '../../src/LoadNodesAndCredentials'; + + + +let app: express.Application; +let testDbName = ''; +let globalOwnerRole: Role; +let globalMemberRole: Role; +let ownerShell: User; + +beforeAll(async () => { + app = await utils.initTestServer({ endpointGroups: ['nodes'], applyAuth: true }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + + utils.initConfigFile(); + + globalOwnerRole = await testDb.getGlobalOwnerRole(); + globalMemberRole = await testDb.getGlobalMemberRole(); + ownerShell = await testDb.createUserShell(globalOwnerRole); + + utils.initTestLogger(); + utils.initTestTelemetry(); +}); + +beforeEach(async () => { + await testDb.truncate(['InstalledNodes', 'InstalledPackages'], testDbName); + // @ts-ignore + executeCommand.mockReset(); + // @ts-ignore + checkPackageStatus.mockReset(); + // @ts-ignore + searchInstalledPackage.mockReset(); + // @ts-ignore + hasPackageLoadedSuccessfully.mockReset(); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +test('GET /nodes should return empty list when no nodes are installed', async () => { + const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + const response = await authOwnerAgent.get('/nodes').send(); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(0); +}); + +test('GET /nodes should return list with installed package and node', async () => { + const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const installedPackage = await saveMockPackage(installedPackagePayload()); + await saveMockNode(utils.installedNodePayload(installedPackage.packageName)); + + const response = await authOwnerAgent.get('/nodes').send(); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0].installedNodes).toHaveLength(1); +}); + +test('GET /nodes should return list with multiple installed package and node', async () => { + const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const installedPackage1 = await saveMockPackage(installedPackagePayload()); + await saveMockNode(utils.installedNodePayload(installedPackage1.packageName)); + + const installedPackage2 = await saveMockPackage(installedPackagePayload()); + await saveMockNode(utils.installedNodePayload(installedPackage2.packageName)); + await saveMockNode(utils.installedNodePayload(installedPackage2.packageName)); + + const response = await authOwnerAgent.get('/nodes').send(); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(2); + expect([...response.body.data[0].installedNodes, ...response.body.data[1].installedNodes]).toHaveLength(3); +}); + +test('GET /nodes should not check for updates when there are no packages installed', async () => { + const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + await authOwnerAgent.get('/nodes').send(); + + expect(executeCommand).toHaveBeenCalledTimes(0); +}); + +test('GET /nodes should check for updates when there are packages installed', async () => { + const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const installedPackage = await saveMockPackage(installedPackagePayload()); + await saveMockNode(utils.installedNodePayload(installedPackage.packageName)); + + await authOwnerAgent.get('/nodes').send(); + + expect(executeCommand).toHaveBeenCalledWith('npm outdated --json', {"doNotHandleError": true}); +}); + +test('GET /nodes should mention updates when available', async () => { + const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const installedPackage = await saveMockPackage(installedPackagePayload()); + await saveMockNode(utils.installedNodePayload(installedPackage.packageName)); + + // @ts-ignore + executeCommand.mockImplementation(() => { + throw getNpmOutdatedError(installedPackage.packageName); + }); + + const response = await authOwnerAgent.get('/nodes').send(); + expect(response.body.data[0].installedVersion).toBe(CURRENT_PACKAGE_VERSION); + expect(response.body.data[0].updateAvailable).toBe(UPDATED_PACKAGE_VERSION); +}); + +// TEST POST ENDPOINT + +test('POST /nodes package name should not be empty', async () => { + const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const response = await authOwnerAgent.post('/nodes').send(); + + expect(response.statusCode).toBe(400); +}); + +test('POST /nodes Should not install duplicate packages', async () => { + const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const requestBody = { + name: installedPackagePayload().packageName, + }; + // @ts-ignore + searchInstalledPackage.mockImplementation(() => { + return true; + }); + // @ts-ignore + hasPackageLoadedSuccessfully.mockImplementation(() => { + return true; + }); + + const response = await authOwnerAgent.post('/nodes').send(requestBody); + expect(response.status).toBe(400); + expect(response.body.message).toContain('already installed'); +}); + +test('POST /nodes Should allow installing packages that could not be loaded', async () => { + const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const requestBody = { + name: installedPackagePayload().packageName, + }; + // @ts-ignore + searchInstalledPackage.mockImplementation(() => { + return true; + }); + // @ts-ignore + hasPackageLoadedSuccessfully.mockImplementation(() => { + return false; + }); + + // @ts-ignore + checkPackageStatus.mockImplementation(() => { + return {status:'OK'}; + }); + + // @ts-ignore + LoadNodesAndCredentials.mockImplementation(() => { + return { + loadNpmModule: () => { + return { + installedNodes: [], + }; + }, + }; + }); + + const response = await authOwnerAgent.post('/nodes').send(requestBody); + + expect(removePackageFromMissingList).toHaveBeenCalled(); + expect(response.status).toBe(200); +}); + +test('POST /nodes package should not install banned package', async () => { + const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const installedPackage = installedPackagePayload(); + const requestBody = { + name: installedPackage.packageName, + }; + + // @ts-ignore + checkPackageStatus.mockImplementation(() => { + return {status:'Banned'}; + }); + const response = await authOwnerAgent.post('/nodes').send(requestBody); + expect(response.statusCode).toBe(400); + expect(response.body.message).toContain('banned'); +}); + +// TEST DELETE ENDPOINT +test('DELETE /nodes package name should not be empty', async () => { + const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const response = await authOwnerAgent.delete('/nodes').send(); + + expect(response.statusCode).toBe(400); +}); + +test('DELETE /nodes Should return error when package was not installed', async () => { + const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const requestBody = { + name: installedPackagePayload().packageName, + }; + + const response = await authOwnerAgent.delete('/nodes').send(requestBody); + expect(response.status).toBe(400); + expect(response.body.message).toContain('not installed'); +}); + +// Useful test ? +test('DELETE /nodes package should be uninstall all conditions are true', async () => { + const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const requestBody = { + name: installedPackagePayload().packageName, + }; + // @ts-ignore + searchInstalledPackage.mockImplementation(() => { + return { + installedNodes: [], + }; + }); + + const removeNpmModuleMock = jest.fn(); + // @ts-ignore + LoadNodesAndCredentials.mockImplementation(() => { + return { + removeNpmModule: removeNpmModuleMock, + }; + }); + + const response = await authOwnerAgent.delete('/nodes').send(requestBody); + expect(response.statusCode).toBe(200); + expect(removeNpmModuleMock).toHaveBeenCalledTimes(1); +}); + +// TEST PATCH ENDPOINT + +test('PATCH /nodes package name should not be empty', async () => { + const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const response = await authOwnerAgent.patch('/nodes').send(); + + expect(response.statusCode).toBe(400); +}); + +test('PATCH /nodes Should return error when package was not installed', async () => { + const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const requestBody = { + name: installedPackagePayload().packageName, + }; + + const response = await authOwnerAgent.patch('/nodes').send(requestBody); + expect(response.status).toBe(400); + expect(response.body.message).toContain('not installed'); +}); + +test('PATCH /nodes package should be updated if all conditions are true', async () => { + const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + const requestBody = { + name: installedPackagePayload().packageName, + }; + // @ts-ignore + searchInstalledPackage.mockImplementation(() => { + return { + installedNodes: [], + }; + }); + + const updatedNpmModuleMock = jest.fn(() => ({ + installedNodes: [], + })); + + // @ts-ignore + LoadNodesAndCredentials.mockImplementation(() => { + return { + updateNpmModule: updatedNpmModuleMock, + }; + }); + + const response = await authOwnerAgent.patch('/nodes').send(requestBody); + expect(updatedNpmModuleMock).toHaveBeenCalledTimes(1); +}); + +async function saveMockPackage(payload: InstalledPackagePayload) { + return await testDb.saveInstalledPackage(payload); +} + +async function saveMockNode(payload: InstalledNodePayload) { + return await testDb.saveInstalledNode(payload); +} + +function getNpmOutdatedError(packageName: string) { + const errorOutput = new Error('Something went wrong'); + // @ts-ignore + errorOutput.code = 1; + // @ts-ignore + errorOutput.stdout = '{' + + `"${packageName}": {` + + `"current": "${CURRENT_PACKAGE_VERSION}",` + + `"wanted": "${CURRENT_PACKAGE_VERSION}",` + + `"latest": "${UPDATED_PACKAGE_VERSION}",` + + `"location": "node_modules/${packageName}"` + + '}' + + '}'; + + return errorOutput; +} diff --git a/packages/cli/test/integration/shared/constants.ts b/packages/cli/test/integration/shared/constants.ts index 1817789a15..67fb6a8bb8 100644 --- a/packages/cli/test/integration/shared/constants.ts +++ b/packages/cli/test/integration/shared/constants.ts @@ -71,6 +71,17 @@ export const BOOTSTRAP_POSTGRES_CONNECTION_NAME: Readonly = 'n8n_bs_post */ export const BOOTSTRAP_MYSQL_CONNECTION_NAME: Readonly = 'n8n_bs_mysql'; +/** + * Timeout (in milliseconds) to account for fake SMTP service being slow to respond. + */ +export const SMTP_TEST_TIMEOUT = 30_000; + +/** + * Nodes + */ +export const CURRENT_PACKAGE_VERSION = '0.1.0'; +export const UPDATED_PACKAGE_VERSION = '0.2.0'; + /** * Timeout (in milliseconds) to account for DB being slow to initialize. */ diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index 3d7cc61402..db55eb9ab5 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -24,8 +24,10 @@ import { categorize, getPostgresSchemaSection } from './utils'; import { createCredentiasFromCredentialsEntity } from '../../../src/CredentialsHelper'; import type { Role } from '../../../src/databases/entities/Role'; +import type { CollectionName, CredentialPayload, InstalledNodePayload, InstalledPackagePayload, MappingName } from './types'; +import { InstalledPackages } from '../../../src/databases/entities/InstalledPackages'; +import { InstalledNodes } from '../../../src/databases/entities/InstalledNodes'; import { User } from '../../../src/databases/entities/User'; -import type { CollectionName, CredentialPayload, MappingName } from './types'; import { WorkflowEntity } from '../../../src/databases/entities/WorkflowEntity'; import { ExecutionEntity } from '../../../src/databases/entities/ExecutionEntity'; import { TagEntity } from '../../../src/databases/entities/TagEntity'; @@ -258,6 +260,8 @@ function toTableName(sourceName: CollectionName | MappingName) { SharedCredentials: 'shared_credentials', SharedWorkflow: 'shared_workflow', Settings: 'settings', + InstalledPackages: 'installed_packages', + InstalledNodes: 'installed_nodes', }[sourceName]; } @@ -338,6 +342,29 @@ export function createUserShell(globalRole: Role): Promise { return Db.collections.User.save(shell); } +// -------------------------------------- +// Installed nodes and packages creation +// -------------------------------------- + +export async function saveInstalledPackage(installedPackagePayload: InstalledPackagePayload): Promise { + const newInstalledPackage = new InstalledPackages(); + + Object.assign(newInstalledPackage, installedPackagePayload); + + + const savedInstalledPackage = await Db.collections.InstalledPackages.save(newInstalledPackage); + return savedInstalledPackage; +} + +export async function saveInstalledNode(installedNodePayload: InstalledNodePayload): Promise { + const newInstalledNode = new InstalledNodes(); + + Object.assign(newInstalledNode, installedNodePayload); + + const savedInstalledNode = await Db.collections.InstalledNodes.save(newInstalledNode); + return savedInstalledNode; +} + export function addApiKey(user: User): Promise { user.apiKey = randomApiKey(); return Db.collections.User.save(user); diff --git a/packages/cli/test/integration/shared/types.d.ts b/packages/cli/test/integration/shared/types.d.ts index 1b31e598a4..7b71d39e1a 100644 --- a/packages/cli/test/integration/shared/types.d.ts +++ b/packages/cli/test/integration/shared/types.d.ts @@ -17,7 +17,8 @@ type EndpointGroup = | 'owner' | 'passwordReset' | 'credentials' - | 'publicApi'; + | 'publicApi' + | 'nodes'; export type CredentialPayload = { name: string; @@ -43,3 +44,16 @@ export interface TriggerTime { weekeday: number; [key: string]: string | number; } + +export type InstalledPackagePayload = { + packageName: string; + installedVersion: string; +} + +export type InstalledNodePayload = { + name: string; + type: string; + latestVersion: string; + package: string; +} + diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index cfd681187e..8feee9513c 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -23,8 +23,8 @@ import { } from 'n8n-workflow'; import config from '../../../config'; -import { AUTHLESS_ENDPOINTS, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from './constants'; -import { AUTH_COOKIE_NAME } from '../../../src/constants'; +import { AUTHLESS_ENDPOINTS, CURRENT_PACKAGE_VERSION, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from './constants'; +import { AUTH_COOKIE_NAME, NODE_PACKAGE_PREFIX } from '../../../src/constants'; import { addRoutes as authMiddleware } from '../../../src/UserManagement/routes'; import { ActiveWorkflowRunner, @@ -44,8 +44,17 @@ import { getLogger } from '../../../src/Logger'; import { credentialsController } from '../../../src/api/credentials.api'; import { loadPublicApiVersions } from '../../../src/PublicApi/'; import type { User } from '../../../src/databases/entities/User'; -import type { ApiPath, EndpointGroup, PostgresSchemaSection, TriggerTime } from './types'; +import type { + ApiPath, + EndpointGroup, + InstalledNodePayload, + InstalledPackagePayload, + PostgresSchemaSection, + TriggerTime, +} from './types'; import type { N8nApp } from '../../../src/UserManagement/Interfaces'; +import { nodesController } from '../../../src/api/nodes.api'; +import { randomName } from './random'; /** * Initialize a test server. @@ -89,6 +98,7 @@ export async function initTestServer({ const { apiRouters } = await loadPublicApiVersions(testServer.publicApiEndpoint); const map: Record = { credentials: credentialsController, + nodes: nodesController, publicApi: apiRouters, }; @@ -136,9 +146,7 @@ const classifyEndpointGroups = (endpointGroups: string[]) => { const functionEndpoints: string[] = []; endpointGroups.forEach((group) => - (group === 'credentials' || group === 'publicApi' ? routerEndpoints : functionEndpoints).push( - group, - ), + (['credentials', 'nodes', 'publicApi'].includes(group) ? routerEndpoints : functionEndpoints).push(group), ); return [routerEndpoints, functionEndpoints]; @@ -879,3 +887,24 @@ export function getPostgresSchemaSection( return null; } + +// ---------------------------------- +// nodes +// ---------------------------------- + +export function installedPackagePayload(): InstalledPackagePayload { + return { + packageName: NODE_PACKAGE_PREFIX + randomName(), + installedVersion: CURRENT_PACKAGE_VERSION, + }; +} + +export function installedNodePayload(packageName: string): InstalledNodePayload { + const nodeName = randomName(); + return { + name: nodeName, + type: nodeName, + latestVersion: CURRENT_PACKAGE_VERSION, + package: packageName, + }; +} diff --git a/packages/cli/test/unit/CommunityNodeHelpers.test.ts b/packages/cli/test/unit/CommunityNodeHelpers.test.ts new file mode 100644 index 0000000000..e1324b6eea --- /dev/null +++ b/packages/cli/test/unit/CommunityNodeHelpers.test.ts @@ -0,0 +1,331 @@ +import { checkPackageStatus, matchPackagesWithUpdates, executeCommand, parsePackageName, matchMissingPackages, hasPackageLoadedSuccessfully, removePackageFromMissingList } from '../../src/CommunityNodes/helpers'; +import { NODE_PACKAGE_PREFIX, NPM_COMMAND_TOKENS, NPM_PACKAGE_STATUS_GOOD, RESPONSE_ERROR_MESSAGES } from '../../src/constants'; + +jest.mock('fs/promises'); +import { access as fsAccess, mkdir as fsMkdir } from 'fs/promises'; + +jest.mock('child_process'); +import { exec } from 'child_process'; +import { InstalledPackages } from '../../src/databases/entities/InstalledPackages'; +import { installedNodePayload, installedPackagePayload } from '../integration/shared/utils'; +import { InstalledNodes } from '../../src/databases/entities/InstalledNodes'; +import { NpmUpdatesAvailable } from '../../src/Interfaces'; +import { randomName } from '../integration/shared/random'; + +import config from '../../config'; + +jest.mock('axios'); +import axios from 'axios'; + +describe('CommunityNodesHelper', () => { + + describe('parsePackageName', () => { + it('Should fail with empty package name', () => { + expect(() => parsePackageName('')).toThrowError() + }); + + it('Should fail with invalid package prefix name', () => { + expect(() => parsePackageName('INVALID_PREFIX@123')).toThrowError() + }); + + it('Should parse valid package name', () => { + const validPackageName = NODE_PACKAGE_PREFIX + 'cool-package-name'; + const parsedPackageName = parsePackageName(validPackageName); + + expect(parsedPackageName.originalString).toBe(validPackageName); + expect(parsedPackageName.packageName).toBe(validPackageName); + expect(parsedPackageName.scope).toBeUndefined(); + expect(parsedPackageName.version).toBeUndefined(); + }); + + it('Should parse valid package name and version', () => { + const validPackageName = NODE_PACKAGE_PREFIX + 'cool-package-name'; + const validPackageVersion = '0.1.1'; + const fullPackageName = `${validPackageName}@${validPackageVersion}`; + const parsedPackageName = parsePackageName(fullPackageName); + + expect(parsedPackageName.originalString).toBe(fullPackageName); + expect(parsedPackageName.packageName).toBe(validPackageName); + expect(parsedPackageName.scope).toBeUndefined(); + expect(parsedPackageName.version).toBe(validPackageVersion); + }); + + it('Should parse valid package name, scope and version', () => { + const validPackageScope = '@n8n'; + const validPackageName = NODE_PACKAGE_PREFIX + 'cool-package-name'; + const validPackageVersion = '0.1.1'; + const fullPackageName = `${validPackageScope}/${validPackageName}@${validPackageVersion}`; + const parsedPackageName = parsePackageName(fullPackageName); + + expect(parsedPackageName.originalString).toBe(fullPackageName); + expect(parsedPackageName.packageName).toBe(`${validPackageScope}/${validPackageName}`); + expect(parsedPackageName.scope).toBe(validPackageScope); + expect(parsedPackageName.version).toBe(validPackageVersion); + }); + }); + + describe('executeCommand', () => { + + beforeEach(() => { + // @ts-ignore + fsAccess.mockReset(); + // @ts-ignore + fsMkdir.mockReset(); + // @ts-ignore + exec.mockReset(); + }); + + it('Should call command with valid options', async () => { + // @ts-ignore + exec.mockImplementation((...args) => { + expect(args[1].cwd).toBeDefined(); + expect(args[1].env).toBeDefined(); + // PATH or NODE_PATH may be undefined depending on environment so we don't check for these keys. + const callbackFunction = args[args.length - 1]; + callbackFunction(null, { stdout: 'Done' }); + }); + + await executeCommand('ls'); + expect(fsAccess).toHaveBeenCalled(); + expect(exec).toHaveBeenCalled(); + expect(fsMkdir).toBeCalledTimes(0); + }); + + it ('Should make sure folder exists', async () => { + // @ts-ignore + exec.mockImplementation((...args) => { + const callbackFunction = args[args.length - 1]; + callbackFunction(null, { stdout: 'Done' }); + }); + + await executeCommand('ls'); + expect(fsAccess).toHaveBeenCalled(); + expect(exec).toHaveBeenCalled(); + expect(fsMkdir).toBeCalledTimes(0); + }); + + it ('Should try to create folder if it does not exist', async () => { + // @ts-ignore + exec.mockImplementation((...args) => { + const callbackFunction = args[args.length - 1]; + callbackFunction(null, { stdout: 'Done' }); + }); + + // @ts-ignore + fsAccess.mockImplementation(() => { + throw new Error('Folder does not exist.'); + }); + + await executeCommand('ls'); + expect(fsAccess).toHaveBeenCalled(); + expect(exec).toHaveBeenCalled(); + expect(fsMkdir).toHaveBeenCalled(); + }); + + it('Should throw especial error when package is not found', async() => { + // @ts-ignore + exec.mockImplementation((...args) => { + const callbackFunction = args[args.length - 1]; + callbackFunction(new Error('Something went wrong - ' + NPM_COMMAND_TOKENS.NPM_PACKAGE_NOT_FOUND_ERROR + '. Aborting.')); + }); + + await expect(async () => await executeCommand('ls')).rejects.toThrow(RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND); + + expect(fsAccess).toHaveBeenCalled(); + expect(exec).toHaveBeenCalled(); + expect(fsMkdir).toHaveBeenCalledTimes(0); + }); + }); + + + describe('crossInformationPackage', () => { + + it('Should return same list if availableUpdates is undefined', () => { + const fakePackages = generateListOfFakeInstalledPackages(); + const crossedData = matchPackagesWithUpdates(fakePackages); + expect(crossedData).toEqual(fakePackages); + }); + + it ('Should correctly match update versions for packages', () => { + const fakePackages = generateListOfFakeInstalledPackages(); + + const updates: NpmUpdatesAvailable = { + [fakePackages[0].packageName]: { + current: fakePackages[0].installedVersion, + wanted: fakePackages[0].installedVersion, + latest: '0.2.0', + location: fakePackages[0].packageName, + }, + [fakePackages[1].packageName]: { + current: fakePackages[0].installedVersion, + wanted: fakePackages[0].installedVersion, + latest: '0.3.0', + location: fakePackages[0].packageName, + } + }; + + const crossedData = matchPackagesWithUpdates(fakePackages, updates); + + // @ts-ignore + expect(crossedData[0].updateAvailable).toBe('0.2.0'); + // @ts-ignore + expect(crossedData[1].updateAvailable).toBe('0.3.0'); + + }); + + it ('Should correctly match update versions for single package', () => { + const fakePackages = generateListOfFakeInstalledPackages(); + + const updates: NpmUpdatesAvailable = { + [fakePackages[1].packageName]: { + current: fakePackages[0].installedVersion, + wanted: fakePackages[0].installedVersion, + latest: '0.3.0', + location: fakePackages[0].packageName, + } + }; + + const crossedData = matchPackagesWithUpdates(fakePackages, updates); + + // @ts-ignore + expect(crossedData[0].updateAvailable).toBeUndefined(); + // @ts-ignore + expect(crossedData[1].updateAvailable).toBe('0.3.0'); + + }); + + }); + + describe('matchMissingPackages', () => { + it('Should not match failed packages that do not exist', () => { + const fakePackages = generateListOfFakeInstalledPackages(); + const notFoundPackageList = `${NODE_PACKAGE_PREFIX}very-long-name-that-should-never-be-generated@1.0.0 ${NODE_PACKAGE_PREFIX}another-very-long-name-that-never-is-seen`; + const matchedPackages = matchMissingPackages(fakePackages, notFoundPackageList); + + expect(matchedPackages).toEqual(fakePackages); + expect(matchedPackages[0].failedLoading).toBeUndefined(); + expect(matchedPackages[1].failedLoading).toBeUndefined(); + }); + + it('Should match failed packages that should be present', () => { + const fakePackages = generateListOfFakeInstalledPackages(); + const notFoundPackageList = `${NODE_PACKAGE_PREFIX}very-long-name-that-should-never-be-generated@1.0.0 ${fakePackages[0].packageName}@${fakePackages[0].installedVersion}`; + const matchedPackages = matchMissingPackages(fakePackages, notFoundPackageList); + + expect(matchedPackages[0].failedLoading).toBe(true); + expect(matchedPackages[1].failedLoading).toBeUndefined(); + }); + + it('Should match failed packages even if version is wrong', () => { + const fakePackages = generateListOfFakeInstalledPackages(); + const notFoundPackageList = `${NODE_PACKAGE_PREFIX}very-long-name-that-should-never-be-generated@1.0.0 ${fakePackages[0].packageName}@123.456.789`; + const matchedPackages = matchMissingPackages(fakePackages, notFoundPackageList); + + expect(matchedPackages[0].failedLoading).toBe(true); + expect(matchedPackages[1].failedLoading).toBeUndefined(); + }); + }); + + describe('checkPackageStatus', () => { + it('Should call axios.post', async () => { + const packageName = NODE_PACKAGE_PREFIX + randomName(); + await checkPackageStatus(packageName); + expect(axios.post).toHaveBeenCalled(); + }); + + it('Should not fail if request fails', async () => { + const packageName = NODE_PACKAGE_PREFIX + randomName(); + axios.post = jest.fn(() => { + throw new Error('Something went wrong'); + }); + const result = await checkPackageStatus(packageName); + expect(result.status).toBe(NPM_PACKAGE_STATUS_GOOD); + }); + + it('Should warn if package is banned', async () => { + const packageName = NODE_PACKAGE_PREFIX + randomName(); + // @ts-ignore + axios.post = jest.fn(() => { + return { data: { status: 'Banned', reason: 'Not good' } }; + }); + const result = await checkPackageStatus(packageName); + expect(result.status).toBe('Banned'); + expect(result.reason).toBe('Not good'); + }); + }); + + describe('hasPackageLoadedSuccessfully', () => { + it('Should return true when failed package list does not exist', () => { + config.set('nodes.packagesMissing', undefined); + const result = hasPackageLoadedSuccessfully('package'); + expect(result).toBe(true); + }); + + it('Should return true when package is not in the list of missing packages', () => { + config.set('nodes.packagesMissing', 'packageA@0.1.0 packgeB@0.1.0'); + const result = hasPackageLoadedSuccessfully('packageC'); + expect(result).toBe(true); + }); + + it('Should return false when package is in the list of missing packages', () => { + config.set('nodes.packagesMissing', 'packageA@0.1.0 packgeB@0.1.0'); + const result = hasPackageLoadedSuccessfully('packageA'); + expect(result).toBe(false); + }); + }); + + describe('removePackageFromMissingList', () => { + it('Should do nothing if key does not exist', () => { + config.set('nodes.packagesMissing', undefined); + + removePackageFromMissingList('packageA'); + + const packageList = config.get('nodes.packagesMissing'); + expect(packageList).toBeUndefined(); + }); + + it('Should remove only correct package from list', () => { + config.set('nodes.packagesMissing', 'packageA@0.1.0 packageB@0.2.0 packageBB@0.2.0'); + + removePackageFromMissingList('packageB'); + + const packageList = config.get('nodes.packagesMissing'); + expect(packageList).toBe('packageA@0.1.0 packageBB@0.2.0'); + }); + + + it('Should not remove if package is not in the list', () => { + const failedToLoadList = 'packageA@0.1.0 packageB@0.2.0 packageBB@0.2.0'; + config.set('nodes.packagesMissing', failedToLoadList); + + removePackageFromMissingList('packageC'); + + const packageList = config.get('nodes.packagesMissing'); + expect(packageList).toBe(failedToLoadList); + }); + + }); +}); + +/** + * Generates a list with 2 packages, one with a single node and + * another with 2 nodes + * @returns + */ +function generateListOfFakeInstalledPackages(): InstalledPackages[] { + const fakeInstalledPackage1 = new InstalledPackages(); + Object.assign(fakeInstalledPackage1, installedPackagePayload()); + const fakeInstalledNode1 = new InstalledNodes(); + Object.assign(fakeInstalledNode1, installedNodePayload(fakeInstalledPackage1.packageName)); + fakeInstalledPackage1.installedNodes = [fakeInstalledNode1]; + + const fakeInstalledPackage2 = new InstalledPackages(); + Object.assign(fakeInstalledPackage2, installedPackagePayload()); + const fakeInstalledNode2 = new InstalledNodes(); + Object.assign(fakeInstalledNode2, installedNodePayload(fakeInstalledPackage2.packageName)); + const fakeInstalledNode3 = new InstalledNodes(); + Object.assign(fakeInstalledNode3, installedNodePayload(fakeInstalledPackage2.packageName)); + fakeInstalledPackage2.installedNodes = [fakeInstalledNode2, fakeInstalledNode3]; + + return [fakeInstalledPackage1, fakeInstalledPackage2]; +} diff --git a/packages/core/src/Constants.ts b/packages/core/src/Constants.ts index fc1103a493..7a34185781 100644 --- a/packages/core/src/Constants.ts +++ b/packages/core/src/Constants.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ export const BINARY_ENCODING = 'base64'; export const CUSTOM_EXTENSION_ENV = 'N8N_CUSTOM_EXTENSIONS'; +export const DOWNLOADED_NODES_SUBDIRECTORY = 'nodes'; export const ENCRYPTION_KEY_ENV_OVERWRITE = 'N8N_ENCRYPTION_KEY'; export const EXTENSIONS_SUBDIRECTORY = 'custom'; export const USER_FOLDER_ENV_OVERWRITE = 'N8N_USER_FOLDER'; diff --git a/packages/core/src/UserSettings.ts b/packages/core/src/UserSettings.ts index ff3a8d4140..2664affcea 100644 --- a/packages/core/src/UserSettings.ts +++ b/packages/core/src/UserSettings.ts @@ -9,6 +9,7 @@ import { createHash, randomBytes } from 'crypto'; import { ENCRYPTION_KEY_ENV_OVERWRITE, EXTENSIONS_SUBDIRECTORY, + DOWNLOADED_NODES_SUBDIRECTORY, IUserSettings, RESPONSE_ERROR_MESSAGES, USER_FOLDER_ENV_OVERWRITE, @@ -265,6 +266,17 @@ export function getUserN8nFolderCustomExtensionPath(): string { return path.join(getUserN8nFolderPath(), EXTENSIONS_SUBDIRECTORY); } +/** + * Returns the path to the n8n user folder with the nodes that + * have been downloaded + * + * @export + * @returns {string} + */ +export function getUserN8nFolderDowloadedNodesPath(): string { + return path.join(getUserN8nFolderPath(), DOWNLOADED_NODES_SUBDIRECTORY); +} + /** * Returns the home folder path of the user if * none can be found it falls back to the current diff --git a/packages/design-system/src/components/N8nActionBox/ActionBox.stories.js b/packages/design-system/src/components/N8nActionBox/ActionBox.stories.js index fa59b17fb3..bbdcd733d3 100644 --- a/packages/design-system/src/components/N8nActionBox/ActionBox.stories.js +++ b/packages/design-system/src/components/N8nActionBox/ActionBox.stories.js @@ -5,6 +5,12 @@ export default { title: 'Atoms/ActionBox', component: N8nActionBox, argTypes: { + calloutTheme: { + control: { + type: 'select', + options: ['info', 'success', 'warning', 'danger', 'custom'], + }, + }, }, parameters: { backgrounds: { default: '--color-background-light' }, diff --git a/packages/design-system/src/components/N8nActionBox/ActionBox.vue b/packages/design-system/src/components/N8nActionBox/ActionBox.vue index cc7a306a8c..7cf3a25b4f 100644 --- a/packages/design-system/src/components/N8nActionBox/ActionBox.vue +++ b/packages/design-system/src/components/N8nActionBox/ActionBox.vue @@ -3,12 +3,15 @@
{{ props.heading }}
-
+
- +
@@ -16,6 +19,7 @@ import N8nButton from '../N8nButton'; import N8nHeading from '../N8nHeading'; import N8nText from '../N8nText'; +import N8nCallout from '../N8nCallout'; export default { name: 'n8n-action-box', @@ -29,11 +33,22 @@ export default { description: { type: String, }, + calloutText: { + type: String, + }, + calloutTheme: { + type: String, + default: 'info', + }, + calloutIcon: { + type: String, + }, }, components: { N8nButton, N8nHeading, N8nText, + N8nCallout, }, }; @@ -61,6 +76,7 @@ export default { } .description { + color: var(--color-text-base); margin-bottom: var(--spacing-xl); } diff --git a/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue b/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue index d2fbc0621a..7265a1c03b 100644 --- a/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue +++ b/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue @@ -15,6 +15,15 @@ :disabled="action.disabled" > {{action.label}} +
+ +
@@ -100,4 +109,12 @@ export default { background-color: var(--color-background-xlight); } } + +.iconContainer { + display: inline; +} + +li:hover .iconContainer svg { + color: var(--color-primary-tint-1); +} diff --git a/packages/design-system/src/components/N8nCallout/Callout.stories.ts b/packages/design-system/src/components/N8nCallout/Callout.stories.ts new file mode 100644 index 0000000000..04bb81626d --- /dev/null +++ b/packages/design-system/src/components/N8nCallout/Callout.stories.ts @@ -0,0 +1,40 @@ +import N8nCallout from './Callout.vue'; +import { StoryFn } from '@storybook/vue'; + +export default { + title: 'Atoms/Callout', + component: N8nCallout, + argTypes: { + theme: { + control: { + type: 'select', + options: ['info', 'success', 'warning', 'danger', 'custom'], + }, + }, + message: { + control: { + type: 'text', + }, + }, + icon: { + control: { + type: 'text', + }, + }, + }, +}; + +const template : StoryFn = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + N8nCallout, + }, + template: ``, +}); + +export const callout = template.bind({}); +callout.args = { + theme: 'custom', + icon: 'code-branch', + message: 'This is a callout. Read more.', +}; diff --git a/packages/design-system/src/components/N8nCallout/Callout.vue b/packages/design-system/src/components/N8nCallout/Callout.vue new file mode 100644 index 0000000000..f64f8b3a9f --- /dev/null +++ b/packages/design-system/src/components/N8nCallout/Callout.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/packages/design-system/src/components/N8nCallout/__tests__/Callout.spec.ts b/packages/design-system/src/components/N8nCallout/__tests__/Callout.spec.ts new file mode 100644 index 0000000000..1740989427 --- /dev/null +++ b/packages/design-system/src/components/N8nCallout/__tests__/Callout.spec.ts @@ -0,0 +1,107 @@ +import { render } from '@testing-library/vue'; +import N8nCallout from '../Callout.vue'; + +describe('components', () => { + describe('N8NCallout', () => { + describe('props', () => { + it('should render info theme correctly', () => { + const wrapper = render(N8nCallout, { + props: { + theme: 'info', + message: 'This is an info callout.', + }, + stubs: [ + 'n8n-icon', + 'n8n-text', + ], + }); + expect(wrapper.html()).toMatchSnapshot(); + }); + it('should render success theme correctly', () => { + const wrapper = render(N8nCallout, { + props: { + theme: 'success', + message: 'This is an success callout.', + }, + stubs: [ + 'n8n-icon', + 'n8n-text', + ], + }); + expect(wrapper.html()).toMatchSnapshot(); + }); + it('should render warning theme correctly', () => { + const wrapper = render(N8nCallout, { + props: { + theme: 'warning', + message: 'This is an warning callout.', + }, + stubs: [ + 'n8n-icon', + 'n8n-text', + ], + }); + expect(wrapper.html()).toMatchSnapshot(); + }); + it('should render danger theme correctly', () => { + const wrapper = render(N8nCallout, { + props: { + theme: 'danger', + message: 'This is an danger callout.', + }, + stubs: [ + 'n8n-icon', + 'n8n-text', + ], + }); + expect(wrapper.html()).toMatchSnapshot(); + }); + it('should render custom theme correctly', () => { + const wrapper = render(N8nCallout, { + props: { + theme: 'custom', + message: 'This is an custom callout.', + icon: 'code', + }, + stubs: [ + 'n8n-icon', + 'n8n-text', + ], + }); + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + describe('content', () => { + it('should render custom HTML content correctly', () => { + const wrapper = render(N8nCallout, { + props: { + theme: 'custom', + message: 'This is an HTML callout. Read more', + icon: 'code', + }, + stubs: [ + 'n8n-icon', + 'n8n-text', + ], + }); + expect(wrapper.html()).toMatchSnapshot(); + }); + it('should pass props to text component correctly', () => { + const wrapper = render(N8nCallout, { + props: { + theme: 'warning', + message: 'This is a callout.', + bold: true, + align: 'center', + tag: 'p', + }, + stubs: [ + 'n8n-icon', + 'n8n-text', + ], + }); + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/packages/design-system/src/components/N8nCallout/__tests__/__snapshots__/Callout.spec.ts.snap b/packages/design-system/src/components/N8nCallout/__tests__/__snapshots__/Callout.spec.ts.snap new file mode 100644 index 0000000000..db3b1dfacb --- /dev/null +++ b/packages/design-system/src/components/N8nCallout/__tests__/__snapshots__/Callout.spec.ts.snap @@ -0,0 +1,78 @@ +// Vitest Snapshot v1 + +exports[`components > N8NCallout > content > should pass props to text component correctly 1`] = ` +"
+
+ +
+
+ This is a callout. +
+
" +`; + +exports[`components > N8NCallout > content > should render custom HTML content correctly 1`] = ` +"
+
+ +
+
+ This is an HTML callout. Read more +
+
" +`; + +exports[`components > N8NCallout > props > should render custom theme correctly 1`] = ` +"
+
+ +
+
+ This is an custom callout. +
+
" +`; + +exports[`components > N8NCallout > props > should render danger theme correctly 1`] = ` +"
+
+ +
+
+ This is an danger callout. +
+
" +`; + +exports[`components > N8NCallout > props > should render info theme correctly 1`] = ` +"
+
+ +
+
+ This is an info callout. +
+
" +`; + +exports[`components > N8NCallout > props > should render success theme correctly 1`] = ` +"
+
+ +
+
+ This is an success callout. +
+
" +`; + +exports[`components > N8NCallout > props > should render warning theme correctly 1`] = ` +"
+
+ +
+
+ This is an warning callout. +
+
" +`; diff --git a/packages/design-system/src/components/N8nCallout/index.ts b/packages/design-system/src/components/N8nCallout/index.ts new file mode 100644 index 0000000000..fea22c69f7 --- /dev/null +++ b/packages/design-system/src/components/N8nCallout/index.ts @@ -0,0 +1,3 @@ +import N8nCallout from './Callout.vue'; + +export default N8nCallout; diff --git a/packages/design-system/src/components/N8nIcon/Icon.stories.js b/packages/design-system/src/components/N8nIcon/Icon.stories.js index 0b626a5768..6d8c5c2754 100644 --- a/packages/design-system/src/components/N8nIcon/Icon.stories.js +++ b/packages/design-system/src/components/N8nIcon/Icon.stories.js @@ -10,7 +10,7 @@ export default { size: { control: { type: 'select', - options: ['small', 'medium', 'large'], + options: ['xsmall', 'small', 'medium', 'large'], }, }, spin: { diff --git a/packages/design-system/src/components/N8nIcon/Icon.vue b/packages/design-system/src/components/N8nIcon/Icon.vue index d5b7e81579..1d141929e2 100644 --- a/packages/design-system/src/components/N8nIcon/Icon.vue +++ b/packages/design-system/src/components/N8nIcon/Icon.vue @@ -37,6 +37,7 @@ export default { default: false, }, color: { + type: String, }, }, }; @@ -59,4 +60,8 @@ export default { .small { width: var(--font-size-2xs) !important; } + +.xsmall { + width: var(--font-size-3xs) !important; +} diff --git a/packages/design-system/src/components/N8nTabs/Tabs.vue b/packages/design-system/src/components/N8nTabs/Tabs.vue index 0d682eddcd..f889973d88 100644 --- a/packages/design-system/src/components/N8nTabs/Tabs.vue +++ b/packages/design-system/src/components/N8nTabs/Tabs.vue @@ -7,28 +7,35 @@
-
- -
- {{ option.label }} - -
-
+
+ +
+ +
+ {{ option.label }} + +
+
-
- - {{ option.label }} -
+
+ + {{ option.label }} +
+
@@ -82,6 +89,9 @@ export default Vue.extend({ }, }, methods: { + handleTooltipClick(tab: string, event: MouseEvent) { + this.$emit('tooltipClick', tab, event); + }, handleTabClick(tab: string) { this.$emit('input', tab); }, diff --git a/packages/design-system/src/components/index.ts b/packages/design-system/src/components/index.ts index 1dad93675a..8f7bdc4eb0 100644 --- a/packages/design-system/src/components/index.ts +++ b/packages/design-system/src/components/index.ts @@ -39,6 +39,7 @@ import N8nActionToggle from './N8nActionToggle'; import N8nAvatar from './N8nAvatar'; import N8nBadge from './N8nBadge'; import N8nButton from './N8nButton'; +import N8nCallout from './N8nCallout'; import N8nCard from './N8nCard'; import N8nFormBox from './N8nFormBox'; import N8nFormInput from './N8nFormInput'; @@ -80,6 +81,7 @@ export { N8nAvatar, N8nBadge, N8nButton, + N8nCallout, N8nCard, N8nHeading, N8nFormBox, diff --git a/packages/design-system/theme/src/_tokens.scss b/packages/design-system/theme/src/_tokens.scss index 6b14859d64..fc87bdd234 100644 --- a/packages/design-system/theme/src/_tokens.scss +++ b/packages/design-system/theme/src/_tokens.scss @@ -129,7 +129,7 @@ ); --color-warning-tint-1-h: 35; - --color-warning-tint-1-s: 78%; + --color-warning-tint-1-s: 77%; --color-warning-tint-1-l: 84%; --color-warning-tint-1: hsl( var(--color-warning-h), @@ -146,9 +146,9 @@ var(--color-warning-tint-2-l) ); - --color-danger-h: 0; - --color-danger-s: 87.6%; - --color-danger-l: 65.3%; + --color-danger-h: 355; + --color-danger-s: 83%; + --color-danger-l: 52%; --color-danger: hsl( var(--color-danger-h), var(--color-danger-s), diff --git a/packages/editor-ui/public/static/community_package_tooltip_img.png b/packages/editor-ui/public/static/community_package_tooltip_img.png new file mode 100644 index 0000000000..ea35b0476d Binary files /dev/null and b/packages/editor-ui/public/static/community_package_tooltip_img.png differ diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 459cc2c454..5c9445928d 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -21,8 +21,14 @@ import { ITelemetrySettings, IWorkflowSettings as IWorkflowSettingsWorkflow, WorkflowExecuteMode, + PublicInstalledPackage, } from 'n8n-workflow'; +import { + COMMUNITY_PACKAGE_MANAGE_ACTIONS, +} from './constants'; + + export * from 'n8n-design-system/src/types'; declare module 'jsplumb' { @@ -138,7 +144,6 @@ export interface INodeUpdatePropertiesInformation { export type XYPosition = [number, number]; export type MessageType = 'success' | 'warning' | 'info' | 'error'; - export interface INodeUi extends INode { position: XYPosition; color?: string; @@ -411,6 +416,8 @@ export type IPushData = | PushDataExecuteAfter | PushDataExecuteBefore | PushDataConsoleMessage + | PushDataReloadNodeType + | PushDataRemoveNodeType | PushDataTestWebhook; type PushDataExecutionFinished = { @@ -438,6 +445,16 @@ type PushDataConsoleMessage = { type: 'sendConsoleMessage'; }; +type PushDataReloadNodeType = { + data: IPushDataReloadNodeType; + type: 'reloadNodeType'; +}; + +type PushDataRemoveNodeType = { + data: IPushDataRemoveNodeType; + type: 'removeNodeType'; +}; + type PushDataTestWebhook = { data: IPushDataTestWebhook; type: 'testWebhookDeleted' | 'testWebhookReceived'; @@ -473,6 +490,15 @@ export interface IPushDataNodeExecuteBefore { nodeName: string; } +export interface IPushDataReloadNodeType { + name: string; + version: number; +} +export interface IPushDataRemoveNodeType { + name: string; + version: number; +} + export interface IPushDataTestWebhook { executionId: string; workflowId: string; @@ -557,13 +583,19 @@ export interface IUserManagementConfig { export interface IPermissionGroup { loginStatus?: ILogInStatus[]; role?: IRole[]; - um?: boolean; - api?: boolean; +} + +export interface IPermissionAllowGroup extends IPermissionGroup { + shouldAllow?: () => boolean; +} + +export interface IPermissionDenyGroup extends IPermissionGroup { + shouldDeny?: () => boolean; } export interface IPermissions { - allow?: IPermissionGroup; - deny?: IPermissionGroup; + allow?: IPermissionAllowGroup; + deny?: IPermissionDenyGroup; } export interface IUserPermissions { @@ -661,6 +693,8 @@ export interface IN8nUISettings { enabled: boolean; host: string; }; + executionMode: string; + communityNodesEnabled: boolean; publicApi: { enabled: boolean; latestVersion: number; @@ -826,6 +860,10 @@ export interface IRootState { nodeMetadata: {[nodeName: string]: INodeMetadata}; } +export interface ICommunityPackageMap { + [name: string]: PublicInstalledPackage; +} + export interface ICredentialTypeMap { [name: string]: ICredentialType; } @@ -931,6 +969,11 @@ export interface IUsersState { export interface IWorkflowsState { } +export interface ICommunityNodesState { + availablePackageCount: number; + installedPackages: ICommunityPackageMap; +} + export interface IRestApiContext { baseUrl: string; sessionId: string; @@ -964,4 +1007,5 @@ export interface ITab { href?: string; icon?: string; align?: 'right'; + tooltip?: string; } diff --git a/packages/editor-ui/src/api/communityNodes.ts b/packages/editor-ui/src/api/communityNodes.ts new file mode 100644 index 0000000000..4fe841053d --- /dev/null +++ b/packages/editor-ui/src/api/communityNodes.ts @@ -0,0 +1,20 @@ +import { IRestApiContext } from '@/Interface'; +import { PublicInstalledPackage } from 'n8n-workflow'; +import { get, post, makeRestApiRequest } from './helpers'; + +export async function getInstalledCommunityNodes(context: IRestApiContext): Promise { + const response = await get(context.baseUrl, '/nodes'); + return response.data || []; +} + +export async function installNewPackage(context: IRestApiContext, name: string): Promise { + return await post(context.baseUrl, '/nodes', { name }); +} + +export async function uninstallPackage(context: IRestApiContext, name: string): Promise { + return await makeRestApiRequest(context, 'DELETE', '/nodes', { name }); +} + +export async function updatePackage(context: IRestApiContext, name: string): Promise { + return await makeRestApiRequest(context, 'PATCH', '/nodes', { name }); +} diff --git a/packages/editor-ui/src/api/helpers.ts b/packages/editor-ui/src/api/helpers.ts index 1bb96a1740..a6e51e191b 100644 --- a/packages/editor-ui/src/api/helpers.ts +++ b/packages/editor-ui/src/api/helpers.ts @@ -52,7 +52,7 @@ async function request(config: {method: Method, baseURL: string, endpoint: strin if (process.env.NODE_ENV !== 'production' && !baseURL.includes('api.n8n.io') ) { options.withCredentials = true; } - if (['PATCH', 'POST', 'PUT'].includes(method)) { + if (['PATCH', 'POST', 'PUT', 'DELETE'].includes(method)) { options.data = data; } else { options.params = data; diff --git a/packages/editor-ui/src/api/settings.ts b/packages/editor-ui/src/api/settings.ts index f61814a737..0165a5356a 100644 --- a/packages/editor-ui/src/api/settings.ts +++ b/packages/editor-ui/src/api/settings.ts @@ -1,6 +1,6 @@ import { IRestApiContext, IN8nPrompts, IN8nValueSurveyData, IN8nUISettings } from '../Interface'; import { makeRestApiRequest, get, post } from './helpers'; -import { N8N_IO_BASE_URL } from '@/constants'; +import { N8N_IO_BASE_URL, NPM_COMMUNITY_NODE_SEARCH_API_URL } from '@/constants'; export function getSettings(context: IRestApiContext): Promise { return makeRestApiRequest(context, 'GET', '/settings'); @@ -17,3 +17,9 @@ export async function submitContactInfo(instanceId: string, userId: string, emai export async function submitValueSurvey(instanceId: string, userId: string, params: IN8nValueSurveyData): Promise { return await post(N8N_IO_BASE_URL, '/value-survey', params, {'n8n-instance-id': instanceId, 'n8n-user-id': userId}); } + +export async function getAvailableCommunityPackageCount(): Promise { + const response = await get(NPM_COMMUNITY_NODE_SEARCH_API_URL, 'search?q=keywords:n8n-community-node-package'); + + return response.total || 0; +} diff --git a/packages/editor-ui/src/components/CommunityPackageCard.vue b/packages/editor-ui/src/components/CommunityPackageCard.vue new file mode 100644 index 0000000000..2ff205016b --- /dev/null +++ b/packages/editor-ui/src/components/CommunityPackageCard.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/packages/editor-ui/src/components/CommunityPackageInstallModal.vue b/packages/editor-ui/src/components/CommunityPackageInstallModal.vue new file mode 100644 index 0000000000..1e055c08e2 --- /dev/null +++ b/packages/editor-ui/src/components/CommunityPackageInstallModal.vue @@ -0,0 +1,226 @@ + + + + + + + diff --git a/packages/editor-ui/src/components/CommunityPackageManageConfirmModal.vue b/packages/editor-ui/src/components/CommunityPackageManageConfirmModal.vue new file mode 100644 index 0000000000..0ea78ed3a0 --- /dev/null +++ b/packages/editor-ui/src/components/CommunityPackageManageConfirmModal.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue b/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue index d5eb29441f..57082c73c1 100644 --- a/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue +++ b/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue @@ -74,7 +74,7 @@ diff --git a/packages/editor-ui/src/components/Modals.vue b/packages/editor-ui/src/components/Modals.vue index 9492273b3c..e8a3259fd9 100644 --- a/packages/editor-ui/src/components/Modals.vue +++ b/packages/editor-ui/src/components/Modals.vue @@ -88,6 +88,20 @@ + + + + + + + + @@ -96,6 +110,8 @@ import Vue from "vue"; import { ABOUT_MODAL_KEY, CHANGE_PASSWORD_MODAL_KEY, + COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, + COMMUNITY_PACKAGE_INSTALL_MODAL_KEY, CONTACT_PROMPT_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_LIST_MODAL_KEY, @@ -114,6 +130,8 @@ import { } from '@/constants'; import AboutModal from './AboutModal.vue'; +import CommunityPackageManageConfirmModal from './CommunityPackageManageConfirmModal.vue'; +import CommunityPackageInstallModal from './CommunityPackageInstallModal.vue'; import ChangePasswordModal from "./ChangePasswordModal.vue"; import ContactPromptModal from './ContactPromptModal.vue'; import CredentialEdit from "./CredentialEdit/CredentialEdit.vue"; @@ -137,6 +155,8 @@ export default Vue.extend({ components: { AboutModal, ActivationModal, + CommunityPackageInstallModal, + CommunityPackageManageConfirmModal, ContactPromptModal, ChangePasswordModal, CredentialEdit, @@ -155,6 +175,8 @@ export default Vue.extend({ WorkflowOpen, }, data: () => ({ + COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, + COMMUNITY_PACKAGE_INSTALL_MODAL_KEY, CONTACT_PROMPT_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_LIST_MODAL_KEY, diff --git a/packages/editor-ui/src/components/NDVDraggablePanels.vue b/packages/editor-ui/src/components/NDVDraggablePanels.vue index 838cb1526a..c79a04d26c 100644 --- a/packages/editor-ui/src/components/NDVDraggablePanels.vue +++ b/packages/editor-ui/src/components/NDVDraggablePanels.vue @@ -1,16 +1,16 @@ + + diff --git a/packages/editor-ui/src/components/SettingsSidebar.vue b/packages/editor-ui/src/components/SettingsSidebar.vue index 5d208b9d81..1339291004 100644 --- a/packages/editor-ui/src/components/SettingsSidebar.vue +++ b/packages/editor-ui/src/components/SettingsSidebar.vue @@ -25,6 +25,12 @@ {{ $locale.baseText('settings.n8napi') }} + + + + + {{ $locale.baseText('settings.communityNodes') }} +
@@ -54,6 +60,9 @@ export default mixins( canAccessUsersSettings(): boolean { return this.canUserAccessRouteByName(VIEWS.USERS_SETTINGS); }, + canAccessCommunityNodes(): boolean { + return this.canUserAccessRouteByName(VIEWS.COMMUNITY_NODES); + }, canAccessApiSettings(): boolean { return this.canUserAccessRouteByName(VIEWS.API_SETTINGS); }, @@ -102,9 +111,8 @@ export default mixins( } .icon { - width: 24px; + width: 16px; display: inline-flex; - justify-content: center; margin-right: 10px; } diff --git a/packages/editor-ui/src/components/helpers.ts b/packages/editor-ui/src/components/helpers.ts index d04d52c03c..445c6645f4 100644 --- a/packages/editor-ui/src/components/helpers.ts +++ b/packages/editor-ui/src/components/helpers.ts @@ -6,6 +6,8 @@ import { INodeTypeDescription } from 'n8n-workflow'; const KEYWORDS_TO_FILTER = ['API', 'OAuth1', 'OAuth2']; const SI_SYMBOL = ['', 'k', 'M', 'G', 'T', 'P', 'E']; +const COMMUNITY_PACKAGE_NAME_REGEX = /(@\w+\/)?n8n-nodes-(?!base\b)\b\w+/g; + export function abbreviateNumber(num: number) { const tier = (Math.log10(Math.abs(num)) / 3) | 0; @@ -69,6 +71,14 @@ export function isNumber(value: unknown): value is number { return typeof value === 'number'; } +export function isCommunityPackageName(packageName: string): boolean { + COMMUNITY_PACKAGE_NAME_REGEX.lastIndex = 0; + // Community packages names start with <@username/>n8n-nodes- not followed by word 'base' + const nameMatch = COMMUNITY_PACKAGE_NAME_REGEX.exec(packageName); + + return !!nameMatch; +} + export function shorten(s: string, limit: number, keep: number) { if (s.length <= limit) { return s; diff --git a/packages/editor-ui/src/components/mixins/pushConnection.ts b/packages/editor-ui/src/components/mixins/pushConnection.ts index 7dd1f9a7e7..83d6b2ebaa 100644 --- a/packages/editor-ui/src/components/mixins/pushConnection.ts +++ b/packages/editor-ui/src/components/mixins/pushConnection.ts @@ -15,6 +15,10 @@ import { showMessage } from '@/components/mixins/showMessage'; import { titleChange } from '@/components/mixins/titleChange'; import { workflowHelpers } from '@/components/mixins/workflowHelpers'; +import { + INodeTypeNameVersion, +} from 'n8n-workflow'; + import mixins from 'vue-typed-mixins'; import { WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants'; import { getTriggerNodeServiceName } from '../helpers'; @@ -365,6 +369,30 @@ export const pushConnection = mixins( } this.processWaitingPushMessages(); + } else if (receivedData.type === 'reloadNodeType') { + const pushData = receivedData.data; + + const nodesToBeFetched: INodeTypeNameVersion[] = [pushData]; + + // Force reload of all credential types + this.$store.dispatch('credentials/fetchCredentialTypes', true) + .then(() => { + // Get the data of the node and update in internal storage + return this.restApi().getNodesInformation(nodesToBeFetched); + }) + .then((nodesInfo) => { + this.$store.commit('updateNodeTypes', nodesInfo); + }); + } else if (receivedData.type === 'removeNodeType') { + const pushData = receivedData.data; + + const nodesToBeRemoved: INodeTypeNameVersion[] = [pushData]; + + // Force reload of all credential types + this.$store.dispatch('credentials/fetchCredentialTypes') + .then(() => { + this.$store.commit('removeNodeTypes', nodesToBeRemoved); + }); } return true; }, diff --git a/packages/editor-ui/src/components/mixins/userHelpers.ts b/packages/editor-ui/src/components/mixins/userHelpers.ts index 720e7b7249..68e3a4412f 100644 --- a/packages/editor-ui/src/components/mixins/userHelpers.ts +++ b/packages/editor-ui/src/components/mixins/userHelpers.ts @@ -18,14 +18,11 @@ export const userHelpers = Vue.extend({ canUserAccessRoute(route: Route): boolean { const permissions: IPermissions = route.meta && route.meta.permissions; const currentUser = this.$store.getters['users/currentUser']; - const isUMEnabled = this.$store.getters['settings/isUserManagementEnabled']; - const isPublicApiEnabled = this.$store.getters['settings/isPublicApiEnabled']; - return permissions && isAuthorized(permissions, { - currentUser, - isUMEnabled, - isPublicApiEnabled, - }); + if (permissions && isAuthorized(permissions, currentUser)) { + return true; + } + return false; }, }, }); diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index f11eb36759..12b8c59af4 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -38,6 +38,14 @@ export const CONTACT_PROMPT_MODAL_KEY = 'contactPrompt'; export const VALUE_SURVEY_MODAL_KEY = 'valueSurvey'; export const EXECUTIONS_MODAL_KEY = 'executions'; export const WORKFLOW_ACTIVE_MODAL_KEY = 'activation'; +export const COMMUNITY_PACKAGE_INSTALL_MODAL_KEY = 'communityPackageInstall'; +export const COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY = 'communityPackageManageConfirm'; + +export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = { + UNINSTALL: 'uninstall', + UPDATE: 'update', + VIEW_DOCS: 'view-documentation', +}; // breakpoints export const BREAKPOINT_SM = 768; @@ -47,6 +55,14 @@ export const BREAKPOINT_XL = 1920; export const N8N_IO_BASE_URL = `https://api.n8n.io/api/`; +export const NPM_COMMUNITY_NODE_SEARCH_API_URL = `https://api.npms.io/v2/`; +export const NPM_PACKAGE_DOCS_BASE_URL = `https://www.npmjs.com/package/`; +export const NPM_KEYWORD_SEARCH_URL = `https://www.npmjs.com/search?q=keywords%3An8n-community-node-package`; +export const N8N_QUEUE_MODE_DOCS_URL = `https://docs.n8n.io/hosting/scaling/queue-mode/`; +export const COMMUNITY_NODES_INSTALLATION_DOCS_URL = `https://docs.n8n.io/integrations/community-nodes/installation/`; +export const COMMUNITY_NODES_RISKS_DOCS_URL = `https://docs.n8n.io/integrations/community-nodes/risks/`; +export const COMMUNITY_NODES_BLOCKLIST_DOCS_URL = `https://docs.n8n.io/integrations/community-nodes/blocklist/`; +export const CUSTOM_NODES_DOCS_URL = `https://docs.n8n.io/integrations/creating-nodes/code/create-n8n-nodes-module/`; // node types export const BAMBOO_HR_NODE_TYPE = 'n8n-nodes-base.bambooHr'; @@ -243,6 +259,7 @@ export enum VIEWS { PERSONAL_SETTINGS = "PersonalSettings", API_SETTINGS = "APISettings", NOT_FOUND = "NotFoundView", + COMMUNITY_NODES = "CommunityNodes", } export const MAPPING_PARAMS = [`$evaluateExpression`, `$item`, `$jmespath`, `$node`, `$binary`, `$data`, `$env`, `$json`, `$now`, `$parameters`, `$position`, `$resumeWebhookUrl`, `$runIndex`, `$today`, `$workflow`, '$parameter']; diff --git a/packages/editor-ui/src/modules/communityNodes.ts b/packages/editor-ui/src/modules/communityNodes.ts new file mode 100644 index 0000000000..0794fa3e6a --- /dev/null +++ b/packages/editor-ui/src/modules/communityNodes.ts @@ -0,0 +1,87 @@ +import { getInstalledCommunityNodes, installNewPackage, uninstallPackage, updatePackage } from '@/api/communityNodes'; +import { getAvailableCommunityPackageCount } from '@/api/settings'; +import { ICommunityNodesState, ICommunityPackageMap, IRootState } from '@/Interface'; +import { PublicInstalledPackage } from 'n8n-workflow'; +import Vue from 'vue'; +import { ActionContext, Module } from 'vuex'; + +const LOADER_DELAY = 300; + +const module: Module = { + namespaced: true, + state: { + // -1 means that package count has not been fetched yet + availablePackageCount: -1, + installedPackages: {}, + }, + mutations: { + setAvailablePackageCount: (state: ICommunityNodesState, count: number) => { + state.availablePackageCount = count; + }, + setInstalledPackages: (state: ICommunityNodesState, packages: PublicInstalledPackage[]) => { + state.installedPackages = packages.reduce((packageMap: ICommunityPackageMap, pack: PublicInstalledPackage) => { + packageMap[pack.packageName] = pack; + return packageMap; + }, {}); + }, + removePackageByName(state: ICommunityNodesState, name: string) { + Vue.delete(state.installedPackages, name); + }, + updatePackageObject(state: ICommunityNodesState, newPackage: PublicInstalledPackage) { + state.installedPackages[newPackage.packageName] = newPackage; + }, + }, + getters: { + availablePackageCount(state: ICommunityNodesState): number { + return state.availablePackageCount; + }, + getInstalledPackages(state: ICommunityNodesState): PublicInstalledPackage[] { + return Object.values(state.installedPackages).sort((a, b) => a.packageName.localeCompare(b.packageName)); + }, + getInstalledPackageByName(state: ICommunityNodesState) { + return (name: string) => state.installedPackages[name]; + }, + }, + actions: { + async fetchAvailableCommunityPackageCount(context: ActionContext) { + if(context.state.availablePackageCount === -1) { + const packageCount = await getAvailableCommunityPackageCount(); + context.commit('setAvailablePackageCount', packageCount); + } + }, + async fetchInstalledPackages(context: ActionContext) { + const installedPackages = await getInstalledCommunityNodes(context.rootGetters.getRestApiContext); + context.commit('setInstalledPackages', installedPackages); + const timeout = installedPackages.length > 0 ? 0 : LOADER_DELAY; + setTimeout(() => { + }, timeout); + }, + async installPackage(context: ActionContext, packageName: string) { + try { + await installNewPackage(context.rootGetters.getRestApiContext, packageName); + await context.dispatch('communityNodes/fetchInstalledPackages'); + } catch(error) { + throw(error); + } + }, + async uninstallPackage(context: ActionContext, packageName: string) { + try { + await uninstallPackage(context.rootGetters.getRestApiContext, packageName); + context.commit('removePackageByName', packageName); + } catch(error) { + throw(error); + } + }, + async updatePackage(context: ActionContext, packageName: string) { + try { + const packageToUpdate = context.getters.getInstalledPackageByName(packageName); + const updatedPackage = await updatePackage(context.rootGetters.getRestApiContext, packageToUpdate.packageName); + context.commit('updatePackageObject', updatedPackage); + } catch (error) { + throw(error); + } + }, + }, +}; + +export default module; diff --git a/packages/editor-ui/src/modules/credentials.ts b/packages/editor-ui/src/modules/credentials.ts index b4264e0209..05f624acd5 100644 --- a/packages/editor-ui/src/modules/credentials.ts +++ b/packages/editor-ui/src/modules/credentials.ts @@ -152,8 +152,8 @@ const module: Module = { }, }, actions: { - fetchCredentialTypes: async (context: ActionContext) => { - if (context.getters.allCredentialTypes.length > 0) { + fetchCredentialTypes: async (context: ActionContext, forceFetch: boolean) => { + if (context.getters.allCredentialTypes.length > 0 && forceFetch !== true) { return; } const credentialTypes = await getCredentialTypes(context.rootGetters.getRestApiContext); diff --git a/packages/editor-ui/src/modules/settings.ts b/packages/editor-ui/src/modules/settings.ts index f328277715..094e49e360 100644 --- a/packages/editor-ui/src/modules/settings.ts +++ b/packages/editor-ui/src/modules/settings.ts @@ -83,6 +83,12 @@ const module: Module = { templatesHost: (state): string => { return state.settings.templates.host; }, + isCommunityNodesFeatureEnabled: (state): boolean => { + return state.settings.communityNodesEnabled; + }, + isQueueModeEnabled: (state): boolean => { + return state.settings.executionMode === 'queue'; + }, }, mutations: { setSettings(state: ISettingsState, settings: IN8nUISettings) { @@ -103,6 +109,9 @@ const module: Module = { setTemplatesEndpointHealthy(state: ISettingsState) { state.templatesEndpointHealthy = true; }, + setCommunityNodesFeatureEnabled(state: ISettingsState, isEnabled: boolean) { + state.settings.communityNodesEnabled = isEnabled; + }, }, actions: { async getSettings(context: ActionContext) { @@ -125,6 +134,7 @@ const module: Module = { context.commit('setN8nMetadata', settings.n8nMetadata || {}, {root: true}); context.commit('setDefaultLocale', settings.defaultLocale, {root: true}); context.commit('versions/setVersionNotificationSettings', settings.versionNotifications, {root: true}); + context.commit('setCommunityNodesFeatureEnabled', settings.communityNodesEnabled === true); }, async fetchPromptsData(context: ActionContext) { if (!context.getters.isTelemetryEnabled) { diff --git a/packages/editor-ui/src/modules/ui.ts b/packages/editor-ui/src/modules/ui.ts index 9db9afddec..d009e7b0b0 100644 --- a/packages/editor-ui/src/modules/ui.ts +++ b/packages/editor-ui/src/modules/ui.ts @@ -1,5 +1,7 @@ import { ABOUT_MODAL_KEY, + COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, + COMMUNITY_PACKAGE_INSTALL_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, CHANGE_PASSWORD_MODAL_KEY, @@ -17,6 +19,7 @@ import { WORKFLOW_OPEN_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, VIEWS, + COMMUNITY_PACKAGE_MANAGE_ACTIONS, } from '@/constants'; import Vue from 'vue'; import { ActionContext, Module } from 'vuex'; @@ -85,6 +88,14 @@ const module: Module = { [WORKFLOW_ACTIVE_MODAL_KEY]: { open: false, }, + [COMMUNITY_PACKAGE_INSTALL_MODAL_KEY]: { + open: false, + }, + [COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY]: { + open: false, + mode: '', + activeId: null, + }, }, modalStack: [], sidebarMenuCollapsed: true, @@ -242,6 +253,16 @@ const module: Module = { context.commit('setMode', { name: CREDENTIAL_EDIT_MODAL_KEY, mode: 'new' }); context.commit('openModal', CREDENTIAL_EDIT_MODAL_KEY); }, + async openCommunityPackageUninstallConfirmModal(context: ActionContext, packageName: string) { + context.commit('setActiveId', { name: COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, id: packageName}); + context.commit('setMode', { name: COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, mode: COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL }); + context.commit('openModal', COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY); + }, + async openCommunityPackageUpdateConfirmModal(context: ActionContext, packageName: string) { + context.commit('setActiveId', { name: COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, id: packageName}); + context.commit('setMode', { name: COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, mode: COMMUNITY_PACKAGE_MANAGE_ACTIONS.UPDATE }); + context.commit('openModal', COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY); + }, }, }; diff --git a/packages/editor-ui/src/modules/userHelpers.ts b/packages/editor-ui/src/modules/userHelpers.ts index 3d78082b69..05a6d23bf7 100644 --- a/packages/editor-ui/src/modules/userHelpers.ts +++ b/packages/editor-ui/src/modules/userHelpers.ts @@ -42,25 +42,19 @@ export const PERMISSIONS: IUserPermissions = { }, }; -interface IsAuthorizedOptions { - currentUser: IUser | null; - isUMEnabled?: boolean; - isPublicApiEnabled?: boolean; -} - -export const isAuthorized = (permissions: IPermissions, { - currentUser, - isUMEnabled, - isPublicApiEnabled, -}: IsAuthorizedOptions): boolean => { +/** + * To be authorized, user must pass all deny rules and pass any of the allow rules. + * + * @param permissions + * @param currentUser + * @returns + */ +export const isAuthorized = (permissions: IPermissions, currentUser: IUser | null): boolean => { const loginStatus = currentUser ? LOGIN_STATUS.LoggedIn : LOGIN_STATUS.LoggedOut; - + // big AND block + // if any of these are false, block user if (permissions.deny) { - if (permissions.deny.hasOwnProperty('um') && permissions.deny.um === isUMEnabled) { - return false; - } - - if (permissions.deny.hasOwnProperty('api') && permissions.deny.api === isPublicApiEnabled) { + if (permissions.deny.shouldDeny && permissions.deny.shouldDeny()) { return false; } @@ -69,7 +63,7 @@ export const isAuthorized = (permissions: IPermissions, { } if (currentUser && currentUser.globalRole) { - const role = currentUser.isDefaultUser ? ROLE.Default: currentUser.globalRole.name; + const role = currentUser.isDefaultUser ? ROLE.Default : currentUser.globalRole.name; if (permissions.deny.role && permissions.deny.role.includes(role)) { return false; } @@ -79,12 +73,10 @@ export const isAuthorized = (permissions: IPermissions, { } } + // big OR block + // if any of these are true, allow user if (permissions.allow) { - if (permissions.allow.hasOwnProperty('um') && permissions.allow.um === isUMEnabled) { - return true; - } - - if (permissions.allow.hasOwnProperty('api') && permissions.allow.api === isPublicApiEnabled) { + if (permissions.allow.shouldAllow && permissions.allow.shouldAllow()) { return true; } @@ -93,7 +85,7 @@ export const isAuthorized = (permissions: IPermissions, { } if (currentUser && currentUser.globalRole) { - const role = currentUser.isDefaultUser ? ROLE.Default: currentUser.globalRole.name; + const role = currentUser.isDefaultUser ? ROLE.Default : currentUser.globalRole.name; if (permissions.allow.role && permissions.allow.role.includes(role)) { return true; } diff --git a/packages/editor-ui/src/modules/users.ts b/packages/editor-ui/src/modules/users.ts index 302b3caa94..e2ed105a60 100644 --- a/packages/editor-ui/src/modules/users.ts +++ b/packages/editor-ui/src/modules/users.ts @@ -98,21 +98,18 @@ const module: Module = { }, canUserDeleteTags(state: IUsersState, getters: any, rootState: IRootState, rootGetters: any) { // tslint:disable-line:no-any const currentUser = getters.currentUser; - const isUMEnabled = rootGetters['settings/isUserManagementEnabled']; - return isAuthorized(PERMISSIONS.TAGS.CAN_DELETE_TAGS, { currentUser, isUMEnabled }); + return isAuthorized(PERMISSIONS.TAGS.CAN_DELETE_TAGS, currentUser); }, canUserAccessSidebarUserInfo(state: IUsersState, getters: any, rootState: IRootState, rootGetters: any) { // tslint:disable-line:no-any const currentUser = getters.currentUser; - const isUMEnabled = rootGetters['settings/isUserManagementEnabled']; - return isAuthorized(PERMISSIONS.PRIMARY_MENU.CAN_ACCESS_USER_INFO, { currentUser, isUMEnabled }); + return isAuthorized(PERMISSIONS.PRIMARY_MENU.CAN_ACCESS_USER_INFO, currentUser); }, showUMSetupWarning(state: IUsersState, getters: any, rootState: IRootState, rootGetters: any) { // tslint:disable-line:no-any const currentUser = getters.currentUser; - const isUMEnabled = rootGetters['settings/isUserManagementEnabled']; - return isAuthorized(PERMISSIONS.USER_SETTINGS.VIEW_UM_SETUP_WARNING, { currentUser, isUMEnabled }); + return isAuthorized(PERMISSIONS.USER_SETTINGS.VIEW_UM_SETUP_WARNING, currentUser); }, personalizedNodeTypes(state: IUsersState, getters: any): string[] { // tslint:disable-line:no-any const user = getters.currentUser as IUser | null; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 5a04ab6b53..4124da3a11 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -2,11 +2,16 @@ "_reusableBaseText.cancel": "Cancel", "_reusableBaseText.name": "Name", "_reusableBaseText.save": "Save", + "_reusableDynamicText.readMore": "Read more", + "_reusableDynamicText.learnMore": "Learn more", + "_reusableDynamicText.moreInfo": "More info", "_reusableDynamicText.oauth2.clientId": "Client ID", "_reusableDynamicText.oauth2.clientSecret": "Client Secret", "generic.learnMore": "Learn more", "generic.confirm": "Confirm", "generic.cancel": "Cancel", + "generic.communityNode": "Community Node", + "generic.communityNode.tooltip": "This is a node from our community. It's part of the {packageName} package. Learn more", "generic.delete": "Delete", "generic.copy": "Copy", "generic.clickToCopy": "Click to copy", @@ -445,11 +450,16 @@ "nodeSettings.notesInFlow.description": "If active, the note above will display in the flow as a subtitle", "nodeSettings.notesInFlow.displayName": "Display Note in Flow?", "nodeSettings.parameters": "Parameters", + "nodeSettings.communityNodeTooltip": "This is a community node", "nodeSettings.retryOnFail.description": "If active, the node tries to execute again when it fails", "nodeSettings.retryOnFail.displayName": "Retry On Fail", "nodeSettings.scopes.expandedNoticeWithScopes": "{count} scope available for {activeCredential} credentials
{scopes}
Show less | {count} scopes available for {activeCredential} credentials
{scopes}
Show less", "nodeSettings.scopes.notice": "{count} scope available for {activeCredential} credentials | {count} scopes available for {activeCredential} credentials", "nodeSettings.theNodeIsNotValidAsItsTypeIsUnknown": "The node is not valid as its type ({nodeType}) is unknown", + "nodeSettings.communityNodeUnknown.title": "Install this node to use it", + "nodeSettings.communityNodeUnknown.description": "This node is not currently installed. It's part of the {packageName} community package.", + "nodeSettings.communityNodeUnknown.installLink.text": "How to install community nodes", + "nodeSettings.nodeTypeUnknown.description": "This node is not currently installed. It is either from a newer version of n8n, a custom node, or has an invalid structure", "nodeSettings.thisNodeDoesNotHaveAnyParameters": "This node does not have any parameters", "nodeSettings.useTheHttpRequestNode": "Use the HTTP Request node to make a custom API call. We'll take care of the {nodeTypeDisplayName} auth for you. Learn more", "nodeSettings.waitBetweenTries.description": "How long to wait between each attempt (in milliseconds)", @@ -658,6 +668,46 @@ "saveButton.saved": "Saved", "saveButton.saving": "Saving", "settings": "Settings", + "settings.communityNodes": "Community nodes", + "settings.communityNodes.empty.title": "Supercharge your workflows with community nodes", + "settings.communityNodes.empty.description": "Install over {count} node packages contributed by our community.
More info", + "settings.communityNodes.empty.description.no-packages": "Install node packages contributed by our community.
More info", + "settings.communityNodes.empty.installPackageLabel": "Install a community node", + "settings.communityNodes.queueMode.warning": "You need to install community nodes manually because your instance is running in queue mode. More info", + "settings.communityNodes.packageNodes.label": "{count} node | {count} nodes", + "settings.communityNodes.updateAvailable.tooltip": "A newer version is available", + "settings.communityNodes.viewDocsAction.label": "Documentation", + "settings.communityNodes.uninstallAction.label": "Uninstall package", + "settings.communityNodes.upToDate.tooltip": "You are up to date", + "settings.communityNodes.failedToLoad.tooltip": "There is a problem with this package, try uninstalling it then reinstalling to resolve this issue", + "settings.communityNodes.fetchError.title": "Problem fetching installed packages", + "settings.communityNodes.fetchError.message": "There may be a problem with your internet connection or your n8n instance", + "settings.communityNodes.installModal.title": "Install community nodes", + "settings.communityNodes.installModal.description": "Find community nodes to add on the npm public registry.", + "settings.communityNodes.browseButton.label": "Browse", + "settings.communityNodes.installModal.packageName.label": "npm Package Name", + "settings.communityNodes.installModal.packageName.tooltip": "

This is the title of the package on npmjs.com

Install a specific version by adding it after @, e.g. package-name@0.15.0

", + "settings.communityNodes.installModal.packageName.placeholder": "e.g. n8n-nodes-chatwork", + "settings.communityNodes.installModal.checkbox.label": "I understand the risks of installing unverified code from a public source.", + "settings.communityNodes.installModal.installButton.label": "Install", + "settings.communityNodes.installModal.installButton.label.loading": "Installing", + "settings.communityNodes.installModal.error.packageNameNotValid": "Package name must start with n8n-nodes-", + "settings.communityNodes.messages.install.success": "Package installed", + "settings.communityNodes.messages.install.error": "Error installing new package", + "settings.communityNodes.messages.uninstall.error": "Problem uninstalling package", + "settings.communityNodes.messages.uninstall.success.title": "Package uninstalled", + "settings.communityNodes.messages.update.success.title": "Package updated", + "settings.communityNodes.messages.update.success.message": "{packageName} updated to version {version}", + "settings.communityNodes.messages.update.error.title": "Problem updating package", + "settings.communityNodes.confirmModal.uninstall.title": "Uninstall package?", + "settings.communityNodes.confirmModal.uninstall.message": "Any workflows that use nodes from the {packageName} package won't be able to run. Are you sure?", + "settings.communityNodes.confirmModal.uninstall.buttonLabel": "Uninstall package", + "settings.communityNodes.confirmModal.uninstall.buttonLoadingLabel": "Uninstalling", + "settings.communityNodes.confirmModal.update.title": "Update community node package?", + "settings.communityNodes.confirmModal.update.message": "You are about to update {packageName} to version {version}", + "settings.communityNodes.confirmModal.update.description": "We recommend you deactivate workflows that use any of the package's nodes and reactivate them once the update is completed", + "settings.communityNodes.confirmModal.update.buttonLabel": "Update package", + "settings.communityNodes.confirmModal.update.buttonLoadingLabel": "Updating...", "settings.goBack": "Go back", "settings.personal": "Personal", "settings.personal.basicInformation": "Basic Information", diff --git a/packages/editor-ui/src/plugins/icons.ts b/packages/editor-ui/src/plugins/icons.ts index 914113e51e..17f3ac53d0 100644 --- a/packages/editor-ui/src/plugins/icons.ts +++ b/packages/editor-ui/src/plugins/icons.ts @@ -28,6 +28,7 @@ import { faCloud, faCloudDownloadAlt, faCopy, + faCube, faCut, faDotCircle, faEdit, @@ -92,6 +93,7 @@ import { faTerminal, faThLarge, faTimes, + faTimesCircle, faTrash, faUndo, faUnlink, @@ -136,6 +138,7 @@ addIcon(faClone); addIcon(faCloud); addIcon(faCloudDownloadAlt); addIcon(faCopy); +addIcon(faCube); addIcon(faCut); addIcon(faDotCircle); addIcon(faGripVertical); @@ -202,6 +205,7 @@ addIcon(faTasks); addIcon(faTerminal); addIcon(faThLarge); addIcon(faTimes); +addIcon(faTimesCircle); addIcon(faTrash); addIcon(faUndo); addIcon(faUnlink); diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index f10f904dce..cc1d05f8a1 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -8,6 +8,7 @@ import MainSidebar from '@/components/MainSidebar.vue'; import NodeView from '@/views/NodeView.vue'; import SettingsPersonalView from './views/SettingsPersonalView.vue'; import SettingsUsersView from './views/SettingsUsersView.vue'; +import SettingsCommunityNodesView from './views/SettingsCommunityNodesView.vue'; import SettingsApiView from './views/SettingsApiView.vue'; import SetupView from './views/SetupView.vue'; import SigninView from './views/SigninView.vue'; @@ -22,6 +23,7 @@ import { IPermissions, IRootState } from './Interface'; import { LOGIN_STATUS, ROLE } from './modules/userHelpers'; import { RouteConfigSingleView } from 'vue-router/types/router'; import { VIEWS } from './constants'; +import { store } from './store'; Vue.use(Router); @@ -272,7 +274,9 @@ const router = new Router({ role: [ROLE.Default], }, deny: { - um: false, + shouldDeny: () => { + return store.getters['settings/isUserManagementEnabled'] === false; + }, }, }, }, @@ -330,7 +334,9 @@ const router = new Router({ role: [ROLE.Default, ROLE.Owner], }, deny: { - um: false, + shouldDeny: () => { + return store.getters['settings/isUserManagementEnabled'] === false; + }, }, }, }, @@ -370,7 +376,31 @@ const router = new Router({ loginStatus: [LOGIN_STATUS.LoggedIn], }, deny: { - api: false, + shouldDeny: () => { + return store.getters['settings/isPublicApiEnabled'] === false; + }, + }, + }, + }, + }, + { + path: '/settings/community-nodes', + name: VIEWS.COMMUNITY_NODES, + components: { + default: SettingsCommunityNodesView, + }, + meta: { + telemetry: { + pageCategory: 'settings', + }, + permissions: { + allow: { + role: [ROLE.Default, ROLE.Owner], + }, + deny: { + shouldDeny: () => { + return store.getters['settings/isCommunityNodesFeatureEnabled'] === false; + }, }, }, }, diff --git a/packages/editor-ui/src/store.ts b/packages/editor-ui/src/store.ts index 26ac7bf79c..1c22c3b923 100644 --- a/packages/editor-ui/src/store.ts +++ b/packages/editor-ui/src/store.ts @@ -29,6 +29,7 @@ import { IWorkflowDb, XYPosition, IRestApiContext, + ICommunityNodesState, } from './Interface'; import credentials from './modules/credentials'; @@ -39,6 +40,8 @@ import users from './modules/users'; import workflows from './modules/workflows'; import versions from './modules/versions'; import templates from './modules/templates'; +import communityNodes from './modules/communityNodes'; +import { isCommunityPackageName } from './components/helpers'; Vue.use(Vuex); @@ -102,6 +105,7 @@ const modules = { versions, users, ui, + communityNodes, }; export const store = new Vuex.Store({ @@ -503,11 +507,9 @@ export const store = new Vuex.Store({ state.nodeViewOffsetPosition = data.newOffset; }, - // Node-Types setNodeTypes (state, nodeTypes: INodeTypeDescription[]) { Vue.set(state, 'nodeTypes', nodeTypes); }, - // Active Execution setExecutingNode (state, executingNode: string) { state.executingNode = executingNode; @@ -652,10 +654,18 @@ export const store = new Vuex.Store({ updateNodeTypes (state, nodeTypes: INodeTypeDescription[]) { const oldNodesToKeep = state.nodeTypes.filter(node => !nodeTypes.find(n => n.name === node.name && n.version.toString() === node.version.toString())); const newNodesState = [...oldNodesToKeep, ...nodeTypes]; + Vue.set(state, 'nodeTypes', newNodesState); state.nodeTypes = newNodesState; }, + removeNodeTypes (state, nodeTypes: INodeTypeDescription[]) { + console.log('Store will remove nodes: ', nodeTypes); // eslint-disable-line no-console + const oldNodesToKeep = state.nodeTypes.filter(node => !nodeTypes.find(n => n.name === node.name && n.version === node.version)); + Vue.set(state, 'nodeTypes', oldNodesToKeep); + state.nodeTypes = oldNodesToKeep; + }, + addSidebarMenuItems (state, menuItems: IMenuItem[]) { const updated = state.sidebarMenuItems.concat(menuItems); Vue.set(state, 'sidebarMenuItems', updated); @@ -843,7 +853,6 @@ export const store = new Vuex.Store({ allNodeTypes: (state): INodeTypeDescription[] => { return state.nodeTypes; }, - /** * Getter for node default names ending with a number: `'S3'`, `'Magento 2'`, etc. */ diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 91f4bc772a..4ad315ac0f 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -2866,11 +2866,14 @@ export default mixins( this.$store.commit('setNodeTypes', nodeTypes); }, async loadCredentialTypes (): Promise { - await this.$store.dispatch('credentials/fetchCredentialTypes'); + await this.$store.dispatch('credentials/fetchCredentialTypes', true); }, async loadCredentials (): Promise { await this.$store.dispatch('credentials/fetchAllCredentials'); }, + async loadCommunityNodes (): Promise { + await this.$store.dispatch('communityNodes/fetchInstalledPackages'); + }, async loadNodesProperties(nodeInfos: INodeTypeNameVersion[]): Promise { const allNodes:INodeTypeDescription[] = this.$store.getters.allNodeTypes; @@ -2953,6 +2956,7 @@ export default mixins( this.loadCredentials(), this.loadCredentialTypes(), this.loadNodeTypes(), + this.loadCommunityNodes(), ]; try { diff --git a/packages/editor-ui/src/views/SettingsCommunityNodesView.vue b/packages/editor-ui/src/views/SettingsCommunityNodesView.vue new file mode 100644 index 0000000000..9e09585387 --- /dev/null +++ b/packages/editor-ui/src/views/SettingsCommunityNodesView.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 304466db49..2b701ed9a9 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1588,3 +1588,22 @@ export interface IOAuth2Credentials { scope: string; oauthTokenData?: IDataObject; } + +export type PublicInstalledPackage = { + packageName: string; + installedVersion: string; + authorName?: string; + authorEmail?: string; + installedNodes: PublicInstalledNode[]; + createdAt: Date; + updatedAt: Date; + updateAvailable?: string; + failedLoading?: boolean; +}; + +export type PublicInstalledNode = { + name: string; + type: string; + latestVersion: string; + package: PublicInstalledPackage; +}; diff --git a/packages/workflow/src/RoutingNode.ts b/packages/workflow/src/RoutingNode.ts index 683bd77721..24cc4180ef 100644 --- a/packages/workflow/src/RoutingNode.ts +++ b/packages/workflow/src/RoutingNode.ts @@ -109,7 +109,20 @@ export class RoutingNode { if (credentialsDecrypted) { credentials = credentialsDecrypted.data; } else if (credentialType) { - credentials = (await executeFunctions.getCredentials(credentialType)) || {}; + try { + credentials = (await executeFunctions.getCredentials(credentialType)) || {}; + } catch (error) { + if ( + nodeType.description.credentials?.length && + nodeType.description.credentials[0].required + ) { + // Only throw error if credential is mandatory + throw error; + } else { + // Do not request cred type since it doesn't exist + credentialType = undefined; + } + } } // TODO: Think about how batching could be handled for REST APIs which support it diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts index 1492ee8d85..00e8f02993 100644 --- a/packages/workflow/src/Workflow.ts +++ b/packages/workflow/src/Workflow.ts @@ -909,7 +909,7 @@ export class Workflow { nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion) as INodeType; - if (nodeType.trigger !== undefined || nodeType.poll !== undefined) { + if (nodeType && (nodeType.trigger !== undefined || nodeType.poll !== undefined)) { if (node.disabled === true) { continue; }