refactor(core): Migrate binary-data config to a decorated config class (#14616)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™
2025-04-15 10:32:38 +02:00
committed by GitHub
parent a12c9522d5
commit 2ca742cb15
23 changed files with 208 additions and 166 deletions

View File

@@ -2,8 +2,6 @@ import { mock } from 'jest-mock-extended';
import { sign, JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import type { IBinaryData } from 'n8n-workflow';
import { type InstanceSettings } from '@/instance-settings';
import type { BinaryDataConfig } from '../binary-data.config';
import { BinaryDataService } from '../binary-data.service';
@@ -13,7 +11,6 @@ jest.useFakeTimers({ now });
describe('BinaryDataService', () => {
const signingSecret = 'test-signing-secret';
const config = mock<BinaryDataConfig>({ signingSecret });
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-encryption-key' });
const binaryData = mock<IBinaryData>({ id: 'filesystem:id_123' });
const validToken = sign({ id: binaryData.id }, signingSecret, { expiresIn: '1 day' });
@@ -22,23 +19,7 @@ describe('BinaryDataService', () => {
jest.resetAllMocks();
config.signingSecret = signingSecret;
service = new BinaryDataService(instanceSettings, config);
});
describe('constructor', () => {
it('should derive the signingSecret from the encryption-key, if not provided via BinaryDataConfig', () => {
config.signingSecret = undefined;
const service = new BinaryDataService(instanceSettings, config);
expect(service.signingSecret).toBe(
'f7a78761c5cc17a2753e7e9d85d90e12de87d8131aea4479a11d1c7bb9655b20',
);
});
it('should use signingSecret as provided in BinaryDataConfig', () => {
expect(service.signingSecret).toBe(signingSecret);
});
service = new BinaryDataService(config);
});
describe('createSignedToken', () => {

View File

@@ -0,0 +1,72 @@
import { Container } from '@n8n/di';
import { InstanceSettings } from '@/instance-settings';
import { mockInstance } from '@test/utils';
import { BinaryDataConfig } from '../binary-data.config';
describe('BinaryDataConfig', () => {
const n8nFolder = '/test/n8n';
const encryptionKey = 'test-encryption-key';
console.warn = jest.fn().mockImplementation(() => {});
const now = new Date('2025-01-01T01:23:45.678Z');
jest.useFakeTimers({ now });
beforeEach(() => {
process.env = {};
jest.resetAllMocks();
Container.reset();
mockInstance(InstanceSettings, { encryptionKey, n8nFolder });
});
it('should use default values when no env variables are defined', () => {
const config = Container.get(BinaryDataConfig);
expect(config.availableModes).toEqual(['filesystem']);
expect(config.mode).toBe('default');
expect(config.localStoragePath).toBe('/test/n8n/binaryData');
});
it('should use values from env variables when defined', () => {
process.env.N8N_AVAILABLE_BINARY_DATA_MODES = 'filesystem,s3';
process.env.N8N_DEFAULT_BINARY_DATA_MODE = 's3';
process.env.N8N_BINARY_DATA_STORAGE_PATH = '/custom/storage/path';
process.env.N8N_BINARY_DATA_SIGNING_SECRET = 'super-secret';
const config = Container.get(BinaryDataConfig);
expect(config.mode).toEqual('s3');
expect(config.availableModes).toEqual(['filesystem', 's3']);
expect(config.localStoragePath).toEqual('/custom/storage/path');
expect(config.signingSecret).toBe('super-secret');
});
it('should derive the signing secret from the encryption-key, when none is passed in', () => {
const config = Container.get(BinaryDataConfig);
expect(config.signingSecret).toBe('96eHYcXMF6J1Pn6dhdkOEt6H2BMa6kR5oR0ce7llWyA=');
});
it('should fallback to default for mode', () => {
process.env.N8N_DEFAULT_BINARY_DATA_MODE = 'invalid-mode';
const config = Container.get(BinaryDataConfig);
expect(config.mode).toEqual('default');
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('Invalid value for N8N_DEFAULT_BINARY_DATA_MODE'),
);
});
it('should fallback to default for available modes', () => {
process.env.N8N_AVAILABLE_BINARY_DATA_MODES = 'filesystem,invalid-mode,s3';
const config = Container.get(BinaryDataConfig);
expect(config.availableModes).toEqual(['filesystem']);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('Invalid value for N8N_AVAILABLE_BINARY_DATA_MODES'),
);
});
});

View File

@@ -1,8 +1,42 @@
import { Config, Env } from '@n8n/config';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { z } from 'zod';
import { InstanceSettings } from '@/instance-settings';
const binaryDataModesSchema = z.enum(['default', 'filesystem', 's3']);
const availableModesSchema = z
.string()
.transform((value) => value.split(','))
.pipe(binaryDataModesSchema.array());
@Config
export class BinaryDataConfig {
/** Secret for creating publicly-accesible signed URLs for binary data */
/** Available modes of binary data storage, as comma separated strings. */
@Env('N8N_AVAILABLE_BINARY_DATA_MODES', availableModesSchema)
availableModes: z.infer<typeof availableModesSchema> = ['filesystem'];
/** Storage mode for binary data. */
@Env('N8N_DEFAULT_BINARY_DATA_MODE', binaryDataModesSchema)
mode: z.infer<typeof binaryDataModesSchema> = 'default';
/** Path for binary data storage in "filesystem" mode. */
@Env('N8N_BINARY_DATA_STORAGE_PATH')
localStoragePath: string;
/**
* Secret for creating publicly-accesible signed URLs for binary data.
* When not passed in, this will be derived from the instances's encryption-key
**/
@Env('N8N_BINARY_DATA_SIGNING_SECRET')
signingSecret?: string = undefined;
signingSecret: string;
constructor({ encryptionKey, n8nFolder }: InstanceSettings) {
this.localStoragePath = path.join(n8nFolder, 'binaryData');
this.signingSecret = createHash('sha256')
.update(`url-signing:${encryptionKey}`)
.digest('base64');
}
}

View File

@@ -1,5 +1,4 @@
import { Container, Service } from '@n8n/di';
import { createHash } from 'crypto';
import jwt from 'jsonwebtoken';
import { BINARY_ENCODING, UnexpectedError } from 'n8n-workflow';
import type { INodeExecutionData, IBinaryData } from 'n8n-workflow';
@@ -7,8 +6,6 @@ import { readFile, stat } from 'node:fs/promises';
import prettyBytes from 'pretty-bytes';
import type { Readable } from 'stream';
import { InstanceSettings } from '@/instance-settings';
import { BinaryDataConfig } from './binary-data.config';
import type { BinaryData } from './types';
import { areConfigModes, binaryToBuffer } from './utils';
@@ -21,15 +18,10 @@ export class BinaryDataService {
private managers: Record<string, BinaryData.Manager> = {};
readonly signingSecret: string;
constructor(private readonly config: BinaryDataConfig) {}
constructor({ encryptionKey }: InstanceSettings, binaryDataConfig: BinaryDataConfig) {
this.signingSecret =
binaryDataConfig.signingSecret ??
createHash('sha256').update(`url-signing:${encryptionKey}`).digest('hex');
}
async init(config: BinaryData.Config) {
async init() {
const { config } = this;
if (!areConfigModes(config.availableModes)) throw new InvalidModeError();
this.mode = config.mode === 'filesystem' ? 'filesystem-v2' : config.mode;
@@ -62,11 +54,13 @@ export class BinaryDataService {
id: binaryData.id,
};
return jwt.sign(signingPayload, this.signingSecret, { expiresIn });
const { signingSecret } = this.config;
return jwt.sign(signingPayload, signingSecret, { expiresIn });
}
validateSignedToken(token: string) {
const signedPayload = jwt.verify(token, this.signingSecret) as BinaryData.SigningPayload;
const { signingSecret } = this.config;
const signedPayload = jwt.verify(token, signingSecret) as BinaryData.SigningPayload;
return signedPayload.id;
}

View File

@@ -1,4 +1,5 @@
export * from './binary-data.service';
export { BinaryDataConfig } from './binary-data.config';
export * from './types';
export { ObjectStoreService } from './object-store/object-store.service.ee';
export { isStoredMode as isValidNonDefaultMode } from './utils';

View File

@@ -22,12 +22,6 @@ export namespace BinaryData {
*/
export type StoredMode = Exclude<ConfigMode | UpgradedMode, 'default'>;
export type Config = {
mode: ConfigMode;
availableModes: ConfigMode[];
localStoragePath: string;
};
export type Metadata = {
fileName?: string;
mimeType?: string;