import { Service } from 'typedi'; import { SourceControlPreferences } from './types/sourceControlPreferences'; import type { ValidationError } from 'class-validator'; import { validate } from 'class-validator'; import { readFileSync as fsReadFileSync, existsSync as fsExistsSync } from 'fs'; import { writeFile as fsWriteFile, rm as fsRm } from 'fs/promises'; import { generateSshKeyPair, isSourceControlLicensed, sourceControlFoldersExistCheck, } from './sourceControlHelper.ee'; import { InstanceSettings } from 'n8n-core'; import { jsonParse } from 'n8n-workflow'; import * as Db from '@/Db'; import { SOURCE_CONTROL_SSH_FOLDER, SOURCE_CONTROL_GIT_FOLDER, SOURCE_CONTROL_SSH_KEY_NAME, SOURCE_CONTROL_PREFERENCES_DB_KEY, } from './constants'; import path from 'path'; import type { KeyPairType } from './types/keyPairType'; import config from '@/config'; import { Logger } from '@/Logger'; @Service() export class SourceControlPreferencesService { private _sourceControlPreferences: SourceControlPreferences = new SourceControlPreferences(); readonly sshKeyName: string; readonly sshFolder: string; readonly gitFolder: string; constructor( instanceSettings: InstanceSettings, private readonly logger: Logger, ) { this.sshFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_SSH_FOLDER); this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER); this.sshKeyName = path.join(this.sshFolder, SOURCE_CONTROL_SSH_KEY_NAME); } public get sourceControlPreferences(): SourceControlPreferences { return { ...this._sourceControlPreferences, connected: this._sourceControlPreferences.connected ?? false, publicKey: this.getPublicKey(), }; } // merge the new preferences with the existing preferences when setting public set sourceControlPreferences(preferences: Partial) { this._sourceControlPreferences = SourceControlPreferences.merge( preferences, this._sourceControlPreferences, ); } public isSourceControlSetup() { return ( this.isSourceControlLicensedAndEnabled() && this.getPreferences().repositoryUrl && this.getPreferences().branchName ); } getPublicKey(): string { try { return fsReadFileSync(this.sshKeyName + '.pub', { encoding: 'utf8' }); } catch (error) { this.logger.error(`Failed to read public key: ${(error as Error).message}`); } return ''; } hasKeyPairFiles(): boolean { return fsExistsSync(this.sshKeyName) && fsExistsSync(this.sshKeyName + '.pub'); } async deleteKeyPairFiles(): Promise { try { await fsRm(this.sshFolder, { recursive: true }); } catch (error) { this.logger.error(`Failed to delete ssh folder: ${(error as Error).message}`); } } /** * Will generate an ed25519 key pair and save it to the database and the file system * Note: this will overwrite any existing key pair */ async generateAndSaveKeyPair(keyPairType?: KeyPairType): Promise { sourceControlFoldersExistCheck([this.gitFolder, this.sshFolder]); if (!keyPairType) { keyPairType = this.getPreferences().keyGeneratorType ?? (config.get('sourceControl.defaultKeyPairType') as KeyPairType) ?? 'ed25519'; } const keyPair = await generateSshKeyPair(keyPairType); if (keyPair.publicKey && keyPair.privateKey) { try { await fsWriteFile(this.sshKeyName + '.pub', keyPair.publicKey, { encoding: 'utf8', mode: 0o666, }); await fsWriteFile(this.sshKeyName, keyPair.privateKey, { encoding: 'utf8', mode: 0o600 }); } catch (error) { throw Error(`Failed to save key pair: ${(error as Error).message}`); } } // update preferences only after generating key pair to prevent endless loop if (keyPairType !== this.getPreferences().keyGeneratorType) { await this.setPreferences({ keyGeneratorType: keyPairType }); } return this.getPreferences(); } isBranchReadOnly(): boolean { return this._sourceControlPreferences.branchReadOnly; } isSourceControlConnected(): boolean { return this.sourceControlPreferences.connected; } isSourceControlLicensedAndEnabled(): boolean { return this.isSourceControlConnected() && isSourceControlLicensed(); } getBranchName(): string { return this.sourceControlPreferences.branchName; } getPreferences(): SourceControlPreferences { return this.sourceControlPreferences; } async validateSourceControlPreferences( preferences: Partial, allowMissingProperties = true, ): Promise { const preferencesObject = new SourceControlPreferences(preferences); const validationResult = await validate(preferencesObject, { forbidUnknownValues: false, skipMissingProperties: allowMissingProperties, stopAtFirstError: false, validationError: { target: false }, }); if (validationResult.length > 0) { throw new Error(`Invalid source control preferences: ${JSON.stringify(validationResult)}`); } return validationResult; } async setPreferences( preferences: Partial, saveToDb = true, ): Promise { sourceControlFoldersExistCheck([this.gitFolder, this.sshFolder]); if (!this.hasKeyPairFiles()) { const keyPairType = preferences.keyGeneratorType ?? (config.get('sourceControl.defaultKeyPairType') as KeyPairType); this.logger.debug(`No key pair files found, generating new pair using type: ${keyPairType}`); await this.generateAndSaveKeyPair(keyPairType); } this.sourceControlPreferences = preferences; if (saveToDb) { const settingsValue = JSON.stringify(this._sourceControlPreferences); try { await Db.collections.Settings.save({ key: SOURCE_CONTROL_PREFERENCES_DB_KEY, value: settingsValue, loadOnStartup: true, }); } catch (error) { throw new Error(`Failed to save source control preferences: ${(error as Error).message}`); } } return this.sourceControlPreferences; } async loadFromDbAndApplySourceControlPreferences(): Promise< SourceControlPreferences | undefined > { const loadedPreferences = await Db.collections.Settings.findOne({ where: { key: SOURCE_CONTROL_PREFERENCES_DB_KEY }, }); if (loadedPreferences) { try { const preferences = jsonParse(loadedPreferences.value); if (preferences) { // set local preferences but don't write back to db await this.setPreferences(preferences, false); return preferences; } } catch (error) { this.logger.warn( `Could not parse Source Control settings from database: ${(error as Error).message}`, ); } } await this.setPreferences(new SourceControlPreferences(), true); return this.sourceControlPreferences; } }