n8cn / packages /cli /src /environments.ee /source-control /source-control-helper.ee.ts
gallyga's picture
Add n8n Chinese version
aec3094
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 {
// running these file access function synchronously to avoid race conditions
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,
};
}
/**
* Normalizes and validates the given source controlled file path. Ensures
* the path is absolute and contained within the git folder.
*
* @throws {UserError} If the path is not within the git folder
*/
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;
}
/**
* Checks if a workflow has been modified by comparing version IDs and parent folder IDs
* between local and remote versions
*/
export function isWorkflowModified(
local: SourceControlWorkflowVersionId,
remote: SourceControlWorkflowVersionId,
): boolean {
return (
remote.versionId !== local.versionId ||
(remote.parentFolderId !== undefined && remote.parentFolderId !== local.parentFolderId)
);
}