| import type { InstalledPackages } from '@n8n/db'; |
| import { Delete, Get, Patch, Post, RestController, GlobalScope } from '@n8n/decorators'; |
|
|
| import { |
| RESPONSE_ERROR_MESSAGES, |
| STARTER_TEMPLATE_NAME, |
| UNKNOWN_FAILURE_REASON, |
| } from '@/constants'; |
| import { BadRequestError } from '@/errors/response-errors/bad-request.error'; |
| import { InternalServerError } from '@/errors/response-errors/internal-server.error'; |
| import { EventService } from '@/events/event.service'; |
| import type { CommunityPackages } from '@/interfaces'; |
| import { Push } from '@/push'; |
| import { NodeRequest } from '@/requests'; |
| import { CommunityPackagesService } from '@/services/community-packages.service'; |
|
|
| import { CommunityNodeTypesService } from '../services/community-node-types.service'; |
|
|
| const { |
| PACKAGE_NOT_INSTALLED, |
| PACKAGE_NAME_NOT_PROVIDED, |
| PACKAGE_VERSION_NOT_FOUND, |
| PACKAGE_DOES_NOT_CONTAIN_NODES, |
| PACKAGE_NOT_FOUND, |
| } = RESPONSE_ERROR_MESSAGES; |
|
|
| const isClientError = (error: Error) => |
| [PACKAGE_VERSION_NOT_FOUND, PACKAGE_DOES_NOT_CONTAIN_NODES, PACKAGE_NOT_FOUND].some((msg) => |
| error.message.includes(msg), |
| ); |
|
|
| export function isNpmError(error: unknown): error is { code: number; stdout: string } { |
| return typeof error === 'object' && error !== null && 'code' in error && 'stdout' in error; |
| } |
|
|
| @RestController('/community-packages') |
| export class CommunityPackagesController { |
| constructor( |
| private readonly push: Push, |
| private readonly communityPackagesService: CommunityPackagesService, |
| private readonly eventService: EventService, |
| private readonly communityNodeTypesService: CommunityNodeTypesService, |
| ) {} |
|
|
| @Post('/') |
| @GlobalScope('communityPackage:install') |
| async installPackage(req: NodeRequest.Post) { |
| const { name, verify, version } = req.body; |
|
|
| if (!name) { |
| throw new BadRequestError(PACKAGE_NAME_NOT_PROVIDED); |
| } |
|
|
| let checksum: string | undefined = undefined; |
|
|
| |
| if (verify) { |
| checksum = this.communityNodeTypesService.findVetted(name)?.checksum; |
| if (!checksum) { |
| throw new BadRequestError(`Package ${name} is not vetted for installation`); |
| } |
| } |
|
|
| let parsed: CommunityPackages.ParsedPackageName; |
|
|
| try { |
| parsed = this.communityPackagesService.parseNpmPackageName(name); |
| } catch (error) { |
| throw new BadRequestError( |
| error instanceof Error ? error.message : 'Failed to parse package name', |
| ); |
| } |
|
|
| if (parsed.packageName === STARTER_TEMPLATE_NAME) { |
| throw new BadRequestError( |
| [ |
| `Package "${parsed.packageName}" is only a template`, |
| 'Please enter an actual package to install', |
| ].join('.'), |
| ); |
| } |
|
|
| const isInstalled = await this.communityPackagesService.isPackageInstalled(parsed.packageName); |
| const hasLoaded = this.communityPackagesService.hasPackageLoaded(name); |
|
|
| if (isInstalled && hasLoaded) { |
| throw new BadRequestError( |
| [ |
| `Package "${parsed.packageName}" is already installed`, |
| 'To update it, click the corresponding button in the UI', |
| ].join('.'), |
| ); |
| } |
|
|
| const packageStatus = await this.communityPackagesService.checkNpmPackageStatus(name); |
|
|
| if (packageStatus.status !== 'OK') { |
| throw new BadRequestError(`Package "${name}" is banned so it cannot be installed`); |
| } |
|
|
| const packageVersion = version ?? parsed.version; |
| let installedPackage: InstalledPackages; |
| try { |
| installedPackage = await this.communityPackagesService.installPackage( |
| parsed.packageName, |
| packageVersion, |
| checksum, |
| ); |
| } catch (error) { |
| const errorMessage = error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON; |
|
|
| this.eventService.emit('community-package-installed', { |
| user: req.user, |
| inputString: name, |
| packageName: parsed.packageName, |
| success: false, |
| packageVersion, |
| failureReason: errorMessage, |
| }); |
|
|
| let message = [`Error loading package "${name}" `, errorMessage].join(':'); |
| if (error instanceof Error && error.cause instanceof Error) { |
| message += `\nCause: ${error.cause.message}`; |
| } |
|
|
| const clientError = error instanceof Error ? isClientError(error) : false; |
| throw new (clientError ? BadRequestError : InternalServerError)(message); |
| } |
|
|
| if (!hasLoaded) this.communityPackagesService.removePackageFromMissingList(name); |
|
|
| |
| installedPackage.installedNodes.forEach((node) => { |
| this.push.broadcast({ |
| type: 'reloadNodeType', |
| data: { |
| name: node.type, |
| version: node.latestVersion, |
| }, |
| }); |
| }); |
|
|
| this.eventService.emit('community-package-installed', { |
| user: req.user, |
| inputString: name, |
| packageName: parsed.packageName, |
| success: true, |
| packageVersion, |
| packageNodeNames: installedPackage.installedNodes.map((node) => node.name), |
| packageAuthor: installedPackage.authorName, |
| packageAuthorEmail: installedPackage.authorEmail, |
| }); |
|
|
| return installedPackage; |
| } |
|
|
| @Get('/') |
| @GlobalScope('communityPackage:list') |
| async getInstalledPackages() { |
| const installedPackages = await this.communityPackagesService.getAllInstalledPackages(); |
|
|
| if (installedPackages.length === 0) return []; |
|
|
| let pendingUpdates: CommunityPackages.AvailableUpdates | undefined; |
|
|
| try { |
| const command = ['npm', 'outdated', '--json'].join(' '); |
| await this.communityPackagesService.executeNpmCommand(command, { doNotHandleError: true }); |
| } catch (error) { |
| |
| |
| |
| if (isNpmError(error) && error.code === 1) { |
| pendingUpdates = JSON.parse(error.stdout) as CommunityPackages.AvailableUpdates; |
| } |
| } |
|
|
| let hydratedPackages = this.communityPackagesService.matchPackagesWithUpdates( |
| installedPackages, |
| pendingUpdates, |
| ); |
|
|
| try { |
| if (this.communityPackagesService.hasMissingPackages) { |
| hydratedPackages = this.communityPackagesService.matchMissingPackages(hydratedPackages); |
| } |
| } catch {} |
|
|
| return hydratedPackages; |
| } |
|
|
| @Delete('/') |
| @GlobalScope('communityPackage:uninstall') |
| async uninstallPackage(req: NodeRequest.Delete) { |
| const { name } = req.query; |
|
|
| if (!name) { |
| throw new BadRequestError(PACKAGE_NAME_NOT_PROVIDED); |
| } |
|
|
| try { |
| this.communityPackagesService.parseNpmPackageName(name); |
| } catch (error) { |
| const message = error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON; |
|
|
| throw new BadRequestError(message); |
| } |
|
|
| const installedPackage = await this.communityPackagesService.findInstalledPackage(name); |
|
|
| if (!installedPackage) { |
| throw new BadRequestError(PACKAGE_NOT_INSTALLED); |
| } |
|
|
| try { |
| await this.communityPackagesService.removePackage(name, installedPackage); |
| } catch (error) { |
| const message = [ |
| `Error removing package "${name}"`, |
| error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON, |
| ].join(':'); |
|
|
| throw new InternalServerError(message, error); |
| } |
|
|
| |
| installedPackage.installedNodes.forEach((node) => { |
| this.push.broadcast({ |
| type: 'removeNodeType', |
| data: { |
| name: node.type, |
| version: node.latestVersion, |
| }, |
| }); |
| }); |
|
|
| this.eventService.emit('community-package-deleted', { |
| user: req.user, |
| packageName: name, |
| packageVersion: installedPackage.installedVersion, |
| packageNodeNames: installedPackage.installedNodes.map((node) => node.name), |
| packageAuthor: installedPackage.authorName, |
| packageAuthorEmail: installedPackage.authorEmail, |
| }); |
| } |
|
|
| @Patch('/') |
| @GlobalScope('communityPackage:update') |
| async updatePackage(req: NodeRequest.Update) { |
| const { name } = req.body; |
|
|
| if (!name) { |
| throw new BadRequestError(PACKAGE_NAME_NOT_PROVIDED); |
| } |
|
|
| const previouslyInstalledPackage = |
| await this.communityPackagesService.findInstalledPackage(name); |
|
|
| if (!previouslyInstalledPackage) { |
| throw new BadRequestError(PACKAGE_NOT_INSTALLED); |
| } |
|
|
| try { |
| const newInstalledPackage = await this.communityPackagesService.updatePackage( |
| this.communityPackagesService.parseNpmPackageName(name).packageName, |
| previouslyInstalledPackage, |
| ); |
|
|
| |
| previouslyInstalledPackage.installedNodes.forEach((node) => { |
| this.push.broadcast({ |
| type: 'removeNodeType', |
| data: { |
| name: node.type, |
| version: node.latestVersion, |
| }, |
| }); |
| }); |
|
|
| newInstalledPackage.installedNodes.forEach((node) => { |
| this.push.broadcast({ |
| type: 'reloadNodeType', |
| data: { |
| name: node.type, |
| version: node.latestVersion, |
| }, |
| }); |
| }); |
|
|
| this.eventService.emit('community-package-updated', { |
| user: req.user, |
| packageName: name, |
| packageVersionCurrent: previouslyInstalledPackage.installedVersion, |
| packageVersionNew: newInstalledPackage.installedVersion, |
| packageNodeNames: newInstalledPackage.installedNodes.map((n) => n.name), |
| packageAuthor: newInstalledPackage.authorName, |
| packageAuthorEmail: newInstalledPackage.authorEmail, |
| }); |
|
|
| return newInstalledPackage; |
| } catch (error) { |
| previouslyInstalledPackage.installedNodes.forEach((node) => { |
| this.push.broadcast({ |
| type: 'removeNodeType', |
| data: { |
| name: node.type, |
| version: node.latestVersion, |
| }, |
| }); |
| }); |
|
|
| const message = [ |
| `Error removing package "${name}"`, |
| error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON, |
| ].join(':'); |
|
|
| throw new InternalServerError(message, error); |
| } |
| } |
| } |
|
|