| | import type { SourceControlledFile } from '@n8n/api-types'; |
| | import { Logger } from '@n8n/backend-common'; |
| | import type { TagEntity, WorkflowTagMapping } from '@n8n/db'; |
| | import { Container } from '@n8n/di'; |
| | import { generateKeyPairSync } from 'crypto'; |
| | import { constants as fsConstants, mkdirSync, accessSync } from 'fs'; |
| | import { jsonParse, UserError } from 'n8n-workflow'; |
| | import { ok } from 'node:assert/strict'; |
| | import { readFile as fsReadFile } from 'node:fs/promises'; |
| | import path from 'path'; |
| |
|
| | import { License } from '@/license'; |
| | import { isContainedWithin } from '@/utils/path-util'; |
| |
|
| | import { |
| | SOURCE_CONTROL_FOLDERS_EXPORT_FILE, |
| | SOURCE_CONTROL_GIT_KEY_COMMENT, |
| | SOURCE_CONTROL_TAGS_EXPORT_FILE, |
| | SOURCE_CONTROL_VARIABLES_EXPORT_FILE, |
| | } from './constants'; |
| | import type { ExportedFolders } from './types/exportable-folders'; |
| | import type { KeyPair } from './types/key-pair'; |
| | import type { KeyPairType } from './types/key-pair-type'; |
| | import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id'; |
| |
|
| | export function stringContainsExpression(testString: string): boolean { |
| | return /^=.*\{\{.*\}\}/.test(testString); |
| | } |
| |
|
| | export function getWorkflowExportPath(workflowId: string, workflowExportFolder: string): string { |
| | return path.join(workflowExportFolder, `${workflowId}.json`); |
| | } |
| |
|
| | export function getCredentialExportPath( |
| | credentialId: string, |
| | credentialExportFolder: string, |
| | ): string { |
| | return path.join(credentialExportFolder, `${credentialId}.json`); |
| | } |
| |
|
| | export function getVariablesPath(gitFolder: string): string { |
| | return path.join(gitFolder, SOURCE_CONTROL_VARIABLES_EXPORT_FILE); |
| | } |
| |
|
| | export function getTagsPath(gitFolder: string): string { |
| | return path.join(gitFolder, SOURCE_CONTROL_TAGS_EXPORT_FILE); |
| | } |
| |
|
| | export function getFoldersPath(gitFolder: string): string { |
| | return path.join(gitFolder, SOURCE_CONTROL_FOLDERS_EXPORT_FILE); |
| | } |
| |
|
| | export async function readTagAndMappingsFromSourceControlFile(file: string): Promise<{ |
| | tags: TagEntity[]; |
| | mappings: WorkflowTagMapping[]; |
| | }> { |
| | return jsonParse<{ tags: TagEntity[]; mappings: WorkflowTagMapping[] }>( |
| | await fsReadFile(file, { encoding: 'utf8' }), |
| | { fallbackValue: { tags: [], mappings: [] } }, |
| | ); |
| | } |
| |
|
| | export async function readFoldersFromSourceControlFile(file: string): Promise<ExportedFolders> { |
| | return jsonParse<ExportedFolders>(await fsReadFile(file, { encoding: 'utf8' }), { |
| | fallbackValue: { folders: [] }, |
| | }); |
| | } |
| |
|
| | export function sourceControlFoldersExistCheck( |
| | folders: string[], |
| | createIfNotExists = true, |
| | ): boolean { |
| | |
| | let existed = true; |
| | folders.forEach((folder) => { |
| | try { |
| | accessSync(folder, fsConstants.F_OK); |
| | } catch { |
| | existed = false; |
| | if (createIfNotExists) { |
| | try { |
| | mkdirSync(folder, { recursive: true }); |
| | } catch (error) { |
| | Container.get(Logger).error((error as Error).message); |
| | } |
| | } |
| | } |
| | }); |
| | return existed; |
| | } |
| |
|
| | export function isSourceControlLicensed() { |
| | const license = Container.get(License); |
| | return license.isSourceControlLicensed(); |
| | } |
| |
|
| | export async function generateSshKeyPair(keyType: KeyPairType) { |
| | const sshpk = await import('sshpk'); |
| | const keyPair: KeyPair = { |
| | publicKey: '', |
| | privateKey: '', |
| | }; |
| | let generatedKeyPair: KeyPair; |
| | switch (keyType) { |
| | case 'ed25519': |
| | generatedKeyPair = generateKeyPairSync('ed25519', { |
| | privateKeyEncoding: { format: 'pem', type: 'pkcs8' }, |
| | publicKeyEncoding: { format: 'pem', type: 'spki' }, |
| | }); |
| | break; |
| | case 'rsa': |
| | generatedKeyPair = generateKeyPairSync('rsa', { |
| | modulusLength: 4096, |
| | publicKeyEncoding: { |
| | type: 'spki', |
| | format: 'pem', |
| | }, |
| | privateKeyEncoding: { |
| | type: 'pkcs8', |
| | format: 'pem', |
| | }, |
| | }); |
| | break; |
| | } |
| | const keyPublic = sshpk.parseKey(generatedKeyPair.publicKey, 'pem'); |
| | keyPublic.comment = SOURCE_CONTROL_GIT_KEY_COMMENT; |
| | keyPair.publicKey = keyPublic.toString('ssh'); |
| | const keyPrivate = sshpk.parsePrivateKey(generatedKeyPair.privateKey, 'pem'); |
| | keyPrivate.comment = SOURCE_CONTROL_GIT_KEY_COMMENT; |
| | keyPair.privateKey = keyPrivate.toString('ssh-private'); |
| | return { |
| | privateKey: keyPair.privateKey, |
| | publicKey: keyPair.publicKey, |
| | }; |
| | } |
| |
|
| | export function getRepoType(repoUrl: string): 'github' | 'gitlab' | 'other' { |
| | if (repoUrl.includes('github.com')) { |
| | return 'github'; |
| | } else if (repoUrl.includes('gitlab.com')) { |
| | return 'gitlab'; |
| | } |
| | return 'other'; |
| | } |
| |
|
| | function filterSourceControlledFilesUniqueIds(files: SourceControlledFile[]) { |
| | return ( |
| | files.filter((file, index, self) => { |
| | return self.findIndex((f) => f.id === file.id) === index; |
| | }) || [] |
| | ); |
| | } |
| |
|
| | export function getTrackingInformationFromPullResult( |
| | userId: string, |
| | result: SourceControlledFile[], |
| | ) { |
| | const uniques = filterSourceControlledFilesUniqueIds(result); |
| | return { |
| | userId, |
| | credConflicts: uniques.filter( |
| | (file) => |
| | file.type === 'credential' && file.status === 'modified' && file.location === 'local', |
| | ).length, |
| | workflowConflicts: uniques.filter( |
| | (file) => file.type === 'workflow' && file.status === 'modified' && file.location === 'local', |
| | ).length, |
| | workflowUpdates: uniques.filter((file) => file.type === 'workflow').length, |
| | }; |
| | } |
| |
|
| | export function getTrackingInformationFromPrePushResult( |
| | userId: string, |
| | result: SourceControlledFile[], |
| | ) { |
| | const uniques = filterSourceControlledFilesUniqueIds(result); |
| | return { |
| | userId, |
| | workflowsEligible: uniques.filter((file) => file.type === 'workflow').length, |
| | workflowsEligibleWithConflicts: uniques.filter( |
| | (file) => file.type === 'workflow' && file.conflict, |
| | ).length, |
| | credsEligible: uniques.filter((file) => file.type === 'credential').length, |
| | credsEligibleWithConflicts: uniques.filter( |
| | (file) => file.type === 'credential' && file.conflict, |
| | ).length, |
| | variablesEligible: uniques.filter((file) => file.type === 'variables').length, |
| | }; |
| | } |
| |
|
| | export function getTrackingInformationFromPostPushResult( |
| | userId: string, |
| | result: SourceControlledFile[], |
| | ) { |
| | const uniques = filterSourceControlledFilesUniqueIds(result); |
| | return { |
| | userId, |
| | workflowsPushed: uniques.filter((file) => file.pushed && file.type === 'workflow').length ?? 0, |
| | workflowsEligible: uniques.filter((file) => file.type === 'workflow').length ?? 0, |
| | credsPushed: |
| | uniques.filter((file) => file.pushed && file.file.startsWith('credential_stubs')).length ?? 0, |
| | variablesPushed: |
| | uniques.filter((file) => file.pushed && file.file.startsWith('variable_stubs')).length ?? 0, |
| | }; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | export function normalizeAndValidateSourceControlledFilePath( |
| | gitFolderPath: string, |
| | filePath: string, |
| | ) { |
| | ok(path.isAbsolute(gitFolderPath), 'gitFolder must be an absolute path'); |
| |
|
| | const normalizedPath = path.isAbsolute(filePath) ? filePath : path.join(gitFolderPath, filePath); |
| |
|
| | if (!isContainedWithin(gitFolderPath, filePath)) { |
| | throw new UserError(`File path ${filePath} is invalid`); |
| | } |
| |
|
| | return normalizedPath; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | export function isWorkflowModified( |
| | local: SourceControlWorkflowVersionId, |
| | remote: SourceControlWorkflowVersionId, |
| | ): boolean { |
| | return ( |
| | remote.versionId !== local.versionId || |
| | (remote.parentFolderId !== undefined && remote.parentFolderId !== local.parentFolderId) |
| | ); |
| | } |
| |
|