feat(core): Add support for signed URLs for binary data (#14492)

Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
Dana
2025-04-14 19:59:40 +02:00
committed by GitHub
parent 23f25cefbf
commit 7723a138a1
22 changed files with 537 additions and 122 deletions

View File

@@ -0,0 +1,85 @@
import { BinaryDataQueryDto } from '../binary-data-query.dto';
describe('BinaryDataQueryDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'filesystem mode with view action',
request: {
id: 'filesystem:some-id',
action: 'view',
},
},
{
name: 'filesystem-v2 mode with download action',
request: {
id: 'filesystem-v2:some-id',
action: 'download',
},
},
{
name: 's3 mode with view action and optional fields',
request: {
id: 's3:some-id',
action: 'view',
fileName: 'test.pdf',
mimeType: 'application/pdf',
},
},
])('should validate $name', ({ request }) => {
const result = BinaryDataQueryDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'missing mode separator',
request: {
id: 'filesystemsome-id',
action: 'view',
},
expectedErrorPath: ['id'],
},
{
name: 'invalid mode',
request: {
id: 'invalid:some-id',
action: 'view',
},
expectedErrorPath: ['id'],
},
{
name: 'invalid action',
request: {
id: 'filesystem:some-id',
action: 'invalid',
},
expectedErrorPath: ['action'],
},
{
name: 'missing id',
request: {
action: 'view',
},
expectedErrorPath: ['id'],
},
{
name: 'missing action',
request: {
id: 'filesystem:some-id',
},
expectedErrorPath: ['action'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = BinaryDataQueryDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View File

@@ -0,0 +1,71 @@
import { BinaryDataSignedQueryDto } from '../binary-data-signed-query.dto';
describe('BinaryDataSignedQueryDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'valid JWT token',
request: {
token:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c',
},
},
])('should validate $name', ({ request }) => {
const result = BinaryDataSignedQueryDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'missing token',
request: {},
expectedErrorPath: ['token'],
},
{
name: 'empty token',
request: {
token: '',
},
expectedErrorPath: ['token'],
},
{
name: 'non-string token',
request: {
token: 123,
},
expectedErrorPath: ['token'],
},
{
name: 'token without three segments',
request: {
token: 'header.payload',
},
expectedErrorPath: ['token'],
},
{
name: 'token with invalid characters',
request: {
token: 'header.payload.sign@ture',
},
expectedErrorPath: ['token'],
},
{
name: 'token with too many segments',
request: {
token: 'header.payload.signature.extra',
},
expectedErrorPath: ['token'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = BinaryDataSignedQueryDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View File

@@ -0,0 +1,22 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class BinaryDataQueryDto extends Z.class({
id: z
.string()
.refine((id) => id.includes(':'), {
message: 'Missing binary data mode',
})
.refine(
(id) => {
const [mode] = id.split(':');
return ['filesystem', 'filesystem-v2', 's3'].includes(mode);
},
{
message: 'Invalid binary data mode',
},
),
action: z.enum(['view', 'download']),
fileName: z.string().optional(),
mimeType: z.string().optional(),
}) {}

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class BinaryDataSignedQueryDto extends Z.class({
token: z.string().regex(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/, {
message: 'Token must be a valid JWT format',
}),
}) {}

View File

@@ -3,6 +3,9 @@ export { AiChatRequestDto } from './ai/ai-chat-request.dto';
export { AiApplySuggestionRequestDto } from './ai/ai-apply-suggestion-request.dto';
export { AiFreeCreditsRequestDto } from './ai/ai-free-credits-request.dto';
export { BinaryDataQueryDto } from './binary-data/binary-data-query.dto';
export { BinaryDataSignedQueryDto } from './binary-data/binary-data-signed-query.dto';
export { LoginRequestDto } from './auth/login-request.dto';
export { ResolveSignupTokenQueryDto } from './auth/resolve-signup-token-query.dto';

View File

@@ -64,7 +64,7 @@
"@types/flat": "^5.0.5",
"@types/formidable": "^3.4.5",
"@types/json-diff": "^1.0.0",
"@types/jsonwebtoken": "^9.0.6",
"@types/jsonwebtoken": "catalog:",
"@types/lodash": "catalog:",
"@types/psl": "^1.1.0",
"@types/replacestream": "^4.0.1",
@@ -132,7 +132,7 @@
"isbot": "3.6.13",
"json-diff": "1.0.6",
"jsonschema": "1.4.1",
"jsonwebtoken": "9.0.2",
"jsonwebtoken": "catalog:",
"ldapts": "4.2.6",
"lodash": "catalog:",
"luxon": "catalog:",

View File

@@ -1,15 +1,15 @@
import type { Response } from 'express';
import type { BinaryDataQueryDto, BinaryDataSignedQueryDto } from '@n8n/api-types';
import type { Request, Response } from 'express';
import { mock } from 'jest-mock-extended';
import { JsonWebTokenError } from 'jsonwebtoken';
import type { BinaryDataService } from 'n8n-core';
import { FileNotFoundError } from 'n8n-core';
import type { Readable } from 'node:stream';
import type { BinaryDataRequest } from '@/requests';
import { BinaryDataController } from '../binary-data.controller';
describe('BinaryDataController', () => {
const request = mock<BinaryDataRequest>();
const request = mock<Request>();
const response = mock<Response>();
const binaryDataService = mock<BinaryDataService>();
const controller = new BinaryDataController(binaryDataService);
@@ -20,55 +20,45 @@ describe('BinaryDataController', () => {
});
describe('get', () => {
it('should return 400 if binary data ID is missing', async () => {
// @ts-expect-error invalid query object
request.query = {};
await controller.get(request, response);
expect(response.status).toHaveBeenCalledWith(400);
expect(response.end).toHaveBeenCalledWith('Missing binary data ID');
});
it('should return 400 if binary data mode is missing', async () => {
request.query = { id: '123', action: 'view' };
const query = { id: '123', action: 'view' } as BinaryDataQueryDto;
await controller.get(request, response);
await controller.get(request, response, query);
expect(response.status).toHaveBeenCalledWith(400);
expect(response.end).toHaveBeenCalledWith('Missing binary data mode');
});
it('should return 400 if binary data mode is invalid', async () => {
request.query = { id: 'invalidMode:123', action: 'view' };
const query = { id: 'invalidMode:123', action: 'view' } as BinaryDataQueryDto;
await controller.get(request, response);
await controller.get(request, response, query);
expect(response.status).toHaveBeenCalledWith(400);
expect(response.end).toHaveBeenCalledWith('Invalid binary data mode');
});
it('should return 404 if file is not found', async () => {
request.query = { id: 'filesystem:123', action: 'view' };
const query = { id: 'filesystem:123', action: 'view' } as BinaryDataQueryDto;
binaryDataService.getAsStream.mockRejectedValue(new FileNotFoundError('File not found'));
await controller.get(request, response);
await controller.get(request, response, query);
expect(response.status).toHaveBeenCalledWith(404);
expect(response.end).toHaveBeenCalled();
});
it('should set Content-Type and Content-Length from query if provided', async () => {
request.query = {
const query = {
id: 'filesystem:123',
action: 'view',
fileName: 'test.txt',
mimeType: 'text/plain',
};
} as BinaryDataQueryDto;
binaryDataService.getAsStream.mockResolvedValue(mock());
await controller.get(request, response);
await controller.get(request, response, query);
expect(binaryDataService.getMetadata).not.toHaveBeenCalled();
expect(response.setHeader).toHaveBeenCalledWith('Content-Type', 'text/plain');
@@ -77,7 +67,7 @@ describe('BinaryDataController', () => {
});
it('should set Content-Type and Content-Length from metadata if not provided', async () => {
request.query = { id: 'filesystem:123', action: 'view' };
const query = { id: 'filesystem:123', action: 'view' } as BinaryDataQueryDto;
binaryDataService.getMetadata.mockResolvedValue({
fileName: 'test.txt',
@@ -86,7 +76,7 @@ describe('BinaryDataController', () => {
});
binaryDataService.getAsStream.mockResolvedValue(mock());
await controller.get(request, response);
await controller.get(request, response, query);
expect(binaryDataService.getMetadata).toHaveBeenCalledWith('filesystem:123');
expect(response.setHeader).toHaveBeenCalledWith('Content-Type', 'text/plain');
@@ -95,11 +85,15 @@ describe('BinaryDataController', () => {
});
it('should set Content-Disposition for download action', async () => {
request.query = { id: 'filesystem:123', action: 'download', fileName: 'test.txt' };
const query = {
id: 'filesystem:123',
action: 'download',
fileName: 'test.txt',
} as BinaryDataQueryDto;
binaryDataService.getAsStream.mockResolvedValue(mock());
await controller.get(request, response);
await controller.get(request, response, query);
expect(response.setHeader).toHaveBeenCalledWith(
'Content-Disposition',
@@ -108,42 +102,95 @@ describe('BinaryDataController', () => {
});
it('should set Content-Security-Policy for HTML in view mode', async () => {
request.query = {
const query = {
id: 'filesystem:123',
action: 'view',
fileName: 'test.html',
mimeType: 'text/html',
};
} as BinaryDataQueryDto;
binaryDataService.getAsStream.mockResolvedValue(mock());
await controller.get(request, response);
await controller.get(request, response, query);
expect(response.header).toHaveBeenCalledWith('Content-Security-Policy', 'sandbox');
});
it('should not set Content-Security-Policy for HTML in download mode', async () => {
request.query = {
const query = {
id: 'filesystem:123',
action: 'download',
fileName: 'test.html',
mimeType: 'text/html',
};
} as BinaryDataQueryDto;
binaryDataService.getAsStream.mockResolvedValue(mock());
await controller.get(request, response);
await controller.get(request, response, query);
expect(response.header).not.toHaveBeenCalledWith('Content-Security-Policy', 'sandbox');
});
it('should return the file stream on success', async () => {
request.query = { id: 'filesystem:123', action: 'view' };
const query = { id: 'filesystem:123', action: 'view' } as BinaryDataQueryDto;
const stream = mock<Readable>();
binaryDataService.getAsStream.mockResolvedValue(stream);
const result = await controller.get(request, response);
const result = await controller.get(request, response, query);
expect(result).toBe(stream);
expect(binaryDataService.getAsStream).toHaveBeenCalledWith('filesystem:123');
});
});
describe('getSigned', () => {
const query = { token: '12344' } as BinaryDataSignedQueryDto;
it('should return 400 if binary data mode is missing', async () => {
binaryDataService.validateSignedToken.mockReturnValueOnce('123');
await controller.getSigned(request, response, query);
expect(response.status).toHaveBeenCalledWith(400);
expect(response.end).toHaveBeenCalledWith('Missing binary data mode');
});
it('should return 400 if binary data mode is invalid', async () => {
binaryDataService.validateSignedToken.mockReturnValueOnce('invalid:123');
await controller.getSigned(request, response, query);
expect(response.status).toHaveBeenCalledWith(400);
expect(response.end).toHaveBeenCalledWith('Invalid binary data mode');
});
it('should return 400 if token is invalid', async () => {
binaryDataService.validateSignedToken.mockImplementation(() => {
throw new JsonWebTokenError('Invalid token');
});
await controller.getSigned(request, response, query);
expect(response.status).toHaveBeenCalledWith(400);
expect(response.end).toHaveBeenCalledWith('Invalid token');
});
it('should return 404 if file is not found', async () => {
binaryDataService.validateSignedToken.mockReturnValueOnce('filesystem:123');
binaryDataService.getAsStream.mockRejectedValue(new FileNotFoundError('File not found'));
await controller.getSigned(request, response, query);
expect(response.status).toHaveBeenCalledWith(404);
expect(response.end).toHaveBeenCalled();
});
it('should return the file stream on a valid signed token', async () => {
binaryDataService.validateSignedToken.mockReturnValueOnce('filesystem:123');
const stream = mock<Readable>();
binaryDataService.getAsStream.mockResolvedValue(stream);
const result = await controller.getSigned(request, response, query);
expect(result).toBe(stream);
expect(binaryDataService.getAsStream).toHaveBeenCalledWith('filesystem:123');

View File

@@ -1,61 +1,90 @@
import express from 'express';
import { BinaryDataQueryDto, BinaryDataSignedQueryDto } from '@n8n/api-types';
import { Request, Response } from 'express';
import { JsonWebTokenError } from 'jsonwebtoken';
import { BinaryDataService, FileNotFoundError, isValidNonDefaultMode } from 'n8n-core';
import { Get, RestController } from '@/decorators';
import { BinaryDataRequest } from '@/requests';
import { Get, Query, RestController } from '@/decorators';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@RestController('/binary-data')
export class BinaryDataController {
constructor(private readonly binaryDataService: BinaryDataService) {}
@Get('/')
async get(req: BinaryDataRequest, res: express.Response) {
const { id: binaryDataId, action } = req.query;
if (!binaryDataId) {
return res.status(400).end('Missing binary data ID');
}
if (!binaryDataId.includes(':')) {
return res.status(400).end('Missing binary data mode');
}
const [mode] = binaryDataId.split(':');
if (!isValidNonDefaultMode(mode)) {
return res.status(400).end('Invalid binary data mode');
}
let { fileName, mimeType } = req.query;
async get(
_: Request,
res: Response,
@Query { id: binaryDataId, action, fileName, mimeType }: BinaryDataQueryDto,
) {
try {
if (!fileName || !mimeType) {
try {
const metadata = await this.binaryDataService.getMetadata(binaryDataId);
fileName = metadata.fileName;
mimeType = metadata.mimeType;
res.setHeader('Content-Length', metadata.fileSize);
} catch {}
}
if (mimeType) {
res.setHeader('Content-Type', mimeType);
// Sandbox html files when viewed in a browser
if (mimeType.includes('html') && action === 'view') {
res.header('Content-Security-Policy', 'sandbox');
}
}
if (action === 'download' && fileName) {
const encodedFilename = encodeURIComponent(fileName);
res.setHeader('Content-Disposition', `attachment; filename="${encodedFilename}"`);
}
this.validateBinaryDataId(binaryDataId);
await this.setContentHeaders(binaryDataId, action, res, fileName, mimeType);
return await this.binaryDataService.getAsStream(binaryDataId);
} catch (error) {
if (error instanceof FileNotFoundError) return res.status(404).end();
if (error instanceof BadRequestError) return res.status(400).end(error.message);
else throw error;
}
}
@Get('/signed', { skipAuth: true })
async getSigned(_: Request, res: Response, @Query { token }: BinaryDataSignedQueryDto) {
try {
const binaryDataId = this.binaryDataService.validateSignedToken(token);
this.validateBinaryDataId(binaryDataId);
await this.setContentHeaders(binaryDataId, 'download', res);
return await this.binaryDataService.getAsStream(binaryDataId);
} catch (error) {
if (error instanceof FileNotFoundError) return res.status(404).end();
if (error instanceof BadRequestError || error instanceof JsonWebTokenError)
return res.status(400).end(error.message);
else throw error;
}
}
private validateBinaryDataId(binaryDataId: string) {
if (!binaryDataId) {
throw new BadRequestError('Missing binary data ID');
}
if (!binaryDataId.includes(':')) {
throw new BadRequestError('Missing binary data mode');
}
const [mode] = binaryDataId.split(':');
if (!isValidNonDefaultMode(mode)) {
throw new BadRequestError('Invalid binary data mode');
}
}
private async setContentHeaders(
binaryDataId: string,
action: 'view' | 'download',
res: Response,
fileName?: string,
mimeType?: string,
) {
if (!fileName || !mimeType) {
try {
const metadata = await this.binaryDataService.getMetadata(binaryDataId);
fileName = metadata.fileName;
mimeType = metadata.mimeType;
res.setHeader('Content-Length', metadata.fileSize);
} catch {}
}
if (mimeType) {
res.setHeader('Content-Type', mimeType);
// Sandbox html files when viewed in a browser
if (mimeType.includes('html') && action === 'view') {
res.header('Content-Security-Policy', 'sandbox');
}
}
if (action === 'download' && fileName) {
const encodedFilename = encodeURIComponent(fileName);
res.setHeader('Content-Disposition', `attachment; filename="${encodedFilename}"`);
}
}
}

View File

@@ -296,18 +296,6 @@ export declare namespace LicenseRequest {
type Activate = AuthenticatedRequest<{}, {}, { activationKey: string }, {}>;
}
export type BinaryDataRequest = AuthenticatedRequest<
{},
{},
{},
{
id: string;
action: 'view' | 'download';
fileName?: string;
mimeType?: string;
}
>;
// ----------------------------------
// /variables
// ----------------------------------

View File

@@ -110,7 +110,7 @@ export async function initNodeTypes(customNodes?: INodeTypeData) {
* Initialize a BinaryDataService for test runs.
*/
export async function initBinaryDataService(mode: 'default' | 'filesystem' = 'default') {
const binaryDataService = new BinaryDataService();
const binaryDataService = new BinaryDataService(mock(), mock());
await binaryDataService.init({
mode,
availableModes: [mode],

View File

@@ -31,6 +31,7 @@
"@types/aws4": "^1.5.1",
"@types/concat-stream": "^2.0.0",
"@types/express": "catalog:",
"@types/jsonwebtoken": "catalog:",
"@types/lodash": "catalog:",
"@types/mime-types": "^2.1.0",
"@types/uuid": "catalog:",
@@ -52,6 +53,7 @@
"file-type": "16.5.4",
"form-data": "catalog:",
"iconv-lite": "catalog:",
"jsonwebtoken": "catalog:",
"lodash": "catalog:",
"luxon": "catalog:",
"mime-types": "2.1.35",

View File

@@ -0,0 +1,75 @@
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';
const now = new Date('2025-01-01T01:23:45.678Z');
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' });
let service: BinaryDataService;
beforeEach(() => {
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);
});
});
describe('createSignedToken', () => {
it('should throw for binary-data without an id', () => {
const binaryData = mock<IBinaryData>({ id: undefined });
expect(() => service.createSignedToken(binaryData)).toThrow();
});
it('should create a signed token for valid binary-data', () => {
const token = service.createSignedToken(binaryData);
expect(token).toBe(validToken);
});
});
describe('validateSignedToken', () => {
const invalidToken = sign({ id: binaryData.id }, 'fake-secret');
const expiredToken = sign({ id: binaryData.id }, signingSecret, { expiresIn: '-1 day' });
it('should throw on invalid tokens', () => {
expect(() => service.validateSignedToken(invalidToken)).toThrow(JsonWebTokenError);
});
it('should throw on expired tokens', () => {
expect(() => service.validateSignedToken(expiredToken)).toThrow(TokenExpiredError);
});
it('should return binary-data id on valid tokens', () => {
const result = service.validateSignedToken(validToken);
expect(result).toBe(binaryData.id);
});
});
});

View File

@@ -0,0 +1,8 @@
import { Config, Env } from '@n8n/config';
@Config
export class BinaryDataConfig {
/** Secret for creating publicly-accesible signed URLs for binary data */
@Env('N8N_BINARY_DATA_SIGNING_SECRET')
signingSecret?: string = undefined;
}

View File

@@ -1,10 +1,15 @@
import { Container, Service } from '@n8n/di';
import { BINARY_ENCODING } from 'n8n-workflow';
import { createHash } from 'crypto';
import jwt from 'jsonwebtoken';
import { BINARY_ENCODING, UnexpectedError } from 'n8n-workflow';
import type { INodeExecutionData, IBinaryData } from 'n8n-workflow';
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';
import { InvalidManagerError } from '../errors/invalid-manager.error';
@@ -16,6 +21,14 @@ export class BinaryDataService {
private managers: Record<string, BinaryData.Manager> = {};
readonly signingSecret: string;
constructor({ encryptionKey }: InstanceSettings, binaryDataConfig: BinaryDataConfig) {
this.signingSecret =
binaryDataConfig.signingSecret ??
createHash('sha256').update(`url-signing:${encryptionKey}`).digest('hex');
}
async init(config: BinaryData.Config) {
if (!areConfigModes(config.availableModes)) throw new InvalidModeError();
@@ -40,6 +53,23 @@ export class BinaryDataService {
}
}
createSignedToken(binaryData: IBinaryData, expiresIn = '1 day') {
if (!binaryData.id) {
throw new UnexpectedError('URL signing is not available in memory mode');
}
const signingPayload: BinaryData.SigningPayload = {
id: binaryData.id,
};
return jwt.sign(signingPayload, this.signingSecret, { expiresIn });
}
validateSignedToken(token: string) {
const signedPayload = jwt.verify(token, this.signingSecret) as BinaryData.SigningPayload;
return signedPayload.id;
}
async copyBinaryFile(
workflowId: string,
executionId: string,

View File

@@ -70,4 +70,8 @@ export namespace BinaryData {
rename(oldFileId: string, newFileId: string): Promise<void>;
}
export type SigningPayload = {
id: string;
};
}

View File

@@ -13,6 +13,7 @@ import { join } from 'path';
import { Readable } from 'stream';
import { BinaryDataService } from '@/binary-data/binary-data.service';
import { type InstanceSettings } from '@/instance-settings';
import {
assertBinaryData,
@@ -41,7 +42,7 @@ describe('test binary data helper methods', () => {
const temporaryDir = mkdtempSync(join(tmpdir(), 'n8n'));
beforeEach(() => {
binaryDataService = new BinaryDataService();
binaryDataService = new BinaryDataService(mock<InstanceSettings>(), mock());
Container.set(BinaryDataService, binaryDataService);
});
@@ -478,3 +479,24 @@ describe('getBinaryHelperFunctions', () => {
);
});
});
describe('createBinarySignedUrl', () => {
const restApiUrl = 'https://n8n.host/rest';
it('should get a signed url', async () => {
const additionalData = { restApiUrl } as IWorkflowExecuteAdditionalData;
const helperFunctions = getBinaryHelperFunctions(additionalData, workflowId);
const binaryData = mock<IBinaryData>();
const token = 'signed-token';
const binaryDataService = mock<BinaryDataService>();
Container.set(BinaryDataService, binaryDataService);
binaryDataService.createSignedToken.mockReturnValueOnce(token);
const result = helperFunctions.createBinarySignedUrl(binaryData);
expect(result).toBe(`${restApiUrl}/binary-data/signed?token=${token}`);
expect(binaryDataService.createSignedToken).toHaveBeenCalledWith(binaryData, undefined);
});
});

View File

@@ -271,7 +271,7 @@ export async function prepareBinaryData(
}
export const getBinaryHelperFunctions = (
{ executionId }: IWorkflowExecuteAdditionalData,
{ executionId, restApiUrl }: IWorkflowExecuteAdditionalData,
workflowId: string,
): BinaryHelperFunctions => ({
getBinaryPath,
@@ -279,6 +279,10 @@ export const getBinaryHelperFunctions = (
getBinaryMetadata,
binaryToBuffer,
binaryToString,
createBinarySignedUrl(binaryData: IBinaryData, expiresIn?: string) {
const token = Container.get(BinaryDataService).createSignedToken(binaryData, expiresIn);
return `${restApiUrl}/binary-data/signed?token=${token}`;
},
prepareBinaryData: async (binaryData, filePath, mimeType) =>
await prepareBinaryData(binaryData, executionId!, workflowId, filePath, mimeType),
setBinaryDataBuffer: async (data, binaryData) =>

View File

@@ -848,7 +848,7 @@
"@types/html-to-text": "^9.0.1",
"@types/gm": "^1.25.0",
"@types/js-nacl": "^1.3.0",
"@types/jsonwebtoken": "^9.0.6",
"@types/jsonwebtoken": "catalog:",
"@types/lodash": "catalog:",
"@types/lossless-json": "^1.0.0",
"@types/mailparser": "^3.4.4",
@@ -895,7 +895,7 @@
"isbot": "3.6.13",
"iso-639-1": "2.1.15",
"js-nacl": "1.4.0",
"jsonwebtoken": "9.0.2",
"jsonwebtoken": "catalog:",
"kafkajs": "2.2.4",
"ldapts": "4.2.6",
"lodash": "catalog:",

View File

@@ -1,5 +1,6 @@
import { Container } from '@n8n/di';
import { readFileSync, readdirSync, mkdtempSync } from 'fs';
import { mock } from 'jest-mock-extended';
import { get } from 'lodash';
import { isEmpty } from 'lodash';
import { BinaryDataService, constructExecutionMetaData } from 'n8n-core';
@@ -36,7 +37,7 @@ export function createTemporaryDir(prefix = 'n8n') {
}
export async function initBinaryDataService() {
const binaryDataService = new BinaryDataService();
const binaryDataService = new BinaryDataService(mock(), mock());
await binaryDataService.init({
mode: 'default',
availableModes: ['default'],

View File

@@ -690,6 +690,7 @@ export interface BinaryHelperFunctions {
binaryToString(body: Buffer | Readable, encoding?: BufferEncoding): Promise<string>;
getBinaryPath(binaryDataId: string): string;
getBinaryStream(binaryDataId: string, chunkSize?: number): Promise<Readable>;
createBinarySignedUrl(binaryData: IBinaryData, expiresIn?: string): string;
getBinaryMetadata(binaryDataId: string): Promise<{
fileName?: string;
mimeType?: string;

55
pnpm-lock.yaml generated
View File

@@ -18,6 +18,9 @@ catalogs:
'@types/express':
specifier: ^5.0.1
version: 5.0.1
'@types/jsonwebtoken':
specifier: ^9.0.9
version: 9.0.9
'@types/lodash':
specifier: ^4.14.195
version: 4.14.195
@@ -48,6 +51,9 @@ catalogs:
iconv-lite:
specifier: 0.6.3
version: 0.6.3
jsonwebtoken:
specifier: 9.0.2
version: 9.0.2
lodash:
specifier: 4.17.21
version: 4.17.21
@@ -552,7 +558,7 @@ importers:
version: 3.666.0(@aws-sdk/client-sts@3.666.0)
'@getzep/zep-cloud':
specifier: 1.0.12
version: 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0))
version: 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a))
'@getzep/zep-js':
specifier: 0.9.0
version: 0.9.0
@@ -579,7 +585,7 @@ importers:
version: 0.3.2(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)
'@langchain/community':
specifier: 0.3.24
version: 0.3.24(c5fc7e11d6e6167a46cb8d3fd9b490a5)
version: 0.3.24(bae0580ee8bea2ce19e4657a460c92d0)
'@langchain/core':
specifier: 'catalog:'
version: 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
@@ -678,7 +684,7 @@ importers:
version: 23.0.1
langchain:
specifier: 0.3.11
version: 0.3.11(fd386e1130022c8548c06dd951c5cbf0)
version: 0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a)
lodash:
specifier: 'catalog:'
version: 4.17.21
@@ -1035,7 +1041,7 @@ importers:
specifier: 1.4.1
version: 1.4.1
jsonwebtoken:
specifier: 9.0.2
specifier: 'catalog:'
version: 9.0.2
ldapts:
specifier: 4.2.6
@@ -1195,8 +1201,8 @@ importers:
specifier: ^1.0.0
version: 1.0.0
'@types/jsonwebtoken':
specifier: ^9.0.6
version: 9.0.6
specifier: 'catalog:'
version: 9.0.9
'@types/lodash':
specifier: 'catalog:'
version: 4.14.195
@@ -1302,6 +1308,9 @@ importers:
iconv-lite:
specifier: 'catalog:'
version: 0.6.3
jsonwebtoken:
specifier: 'catalog:'
version: 9.0.2
lodash:
specifier: 'catalog:'
version: 4.17.21
@@ -1360,6 +1369,9 @@ importers:
'@types/express':
specifier: 'catalog:'
version: 5.0.1
'@types/jsonwebtoken':
specifier: 'catalog:'
version: 9.0.9
'@types/lodash':
specifier: 'catalog:'
version: 4.14.195
@@ -2083,7 +2095,7 @@ importers:
specifier: 1.4.0
version: 1.4.0
jsonwebtoken:
specifier: 9.0.2
specifier: 'catalog:'
version: 9.0.2
kafkajs:
specifier: 2.2.4
@@ -2231,8 +2243,8 @@ importers:
specifier: ^1.3.0
version: 1.3.0
'@types/jsonwebtoken':
specifier: ^9.0.6
version: 9.0.6
specifier: 'catalog:'
version: 9.0.9
'@types/lodash':
specifier: 'catalog:'
version: 4.14.195
@@ -6066,8 +6078,8 @@ packages:
'@types/jsonpath@0.2.0':
resolution: {integrity: sha512-v7qlPA0VpKUlEdhghbDqRoKMxFB3h3Ch688TApBJ6v+XLDdvWCGLJIYiPKGZnS6MAOie+IorCfNYVHOPIHSWwQ==}
'@types/jsonwebtoken@9.0.6':
resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==}
'@types/jsonwebtoken@9.0.9':
resolution: {integrity: sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==}
'@types/k6@0.52.0':
resolution: {integrity: sha512-yaw2wg61nKQtToDML+nngzgXVjZ6wNA4R0Q3jKDTeadG5EqfZgis5a1Q2hwY7kjuGuXmu8eM6gHg3tgnOj4vNw==}
@@ -16137,7 +16149,7 @@ snapshots:
'@gar/promisify@1.1.3':
optional: true
'@getzep/zep-cloud@1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0))':
'@getzep/zep-cloud@1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a))':
dependencies:
form-data: 4.0.0
node-fetch: 2.7.0(encoding@0.1.13)
@@ -16146,7 +16158,7 @@ snapshots:
zod: 3.24.1
optionalDependencies:
'@langchain/core': 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
langchain: 0.3.11(fd386e1130022c8548c06dd951c5cbf0)
langchain: 0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a)
transitivePeerDependencies:
- encoding
@@ -16661,7 +16673,7 @@ snapshots:
- aws-crt
- encoding
'@langchain/community@0.3.24(c5fc7e11d6e6167a46cb8d3fd9b490a5)':
'@langchain/community@0.3.24(bae0580ee8bea2ce19e4657a460c92d0)':
dependencies:
'@browserbasehq/stagehand': 1.9.0(@playwright/test@1.49.1)(deepmerge@4.3.1)(dotenv@16.4.5)(encoding@0.1.13)(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))(zod@3.24.1)
'@ibm-cloud/watsonx-ai': 1.1.2
@@ -16672,7 +16684,7 @@ snapshots:
flat: 5.0.2
ibm-cloud-sdk-core: 5.1.0
js-yaml: 4.1.0
langchain: 0.3.11(fd386e1130022c8548c06dd951c5cbf0)
langchain: 0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a)
langsmith: 0.2.15(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
openai: 4.78.1(encoding@0.1.13)(zod@3.24.1)
uuid: 10.0.0
@@ -16687,7 +16699,7 @@ snapshots:
'@aws-sdk/credential-provider-node': 3.666.0(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@aws-sdk/client-sts@3.666.0)
'@azure/storage-blob': 12.18.0(encoding@0.1.13)
'@browserbasehq/sdk': 2.0.0(encoding@0.1.13)
'@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0))
'@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a))
'@getzep/zep-js': 0.9.0
'@google-ai/generativelanguage': 2.6.0(encoding@0.1.13)
'@google-cloud/storage': 7.12.1(encoding@0.1.13)
@@ -18933,8 +18945,9 @@ snapshots:
'@types/jsonpath@0.2.0': {}
'@types/jsonwebtoken@9.0.6':
'@types/jsonwebtoken@9.0.9':
dependencies:
'@types/ms': 0.7.34
'@types/node': 18.16.16
'@types/k6@0.52.0': {}
@@ -22878,7 +22891,7 @@ snapshots:
'@types/debug': 4.1.12
'@types/node': 18.16.16
'@types/tough-cookie': 4.0.2
axios: 1.8.2(debug@4.4.0)
axios: 1.8.2
camelcase: 6.3.0
debug: 4.4.0(supports-color@8.1.1)
dotenv: 16.4.5
@@ -22888,7 +22901,7 @@ snapshots:
isstream: 0.1.2
jsonwebtoken: 9.0.2
mime-types: 2.1.35
retry-axios: 2.6.0(axios@1.8.2)
retry-axios: 2.6.0(axios@1.8.2(debug@4.4.0))
tough-cookie: 4.1.3
transitivePeerDependencies:
- supports-color
@@ -23882,7 +23895,7 @@ snapshots:
kuler@2.0.0: {}
langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0):
langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a):
dependencies:
'@langchain/core': 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
'@langchain/openai': 0.3.17(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)
@@ -26264,7 +26277,7 @@ snapshots:
onetime: 5.1.2
signal-exit: 3.0.7
retry-axios@2.6.0(axios@1.8.2):
retry-axios@2.6.0(axios@1.8.2(debug@4.4.0)):
dependencies:
axios: 1.8.2

View File

@@ -9,6 +9,7 @@ catalog:
'@sentry/node': 8.52.1
'@types/basic-auth': ^1.1.3
'@types/express': ^5.0.1
'@types/jsonwebtoken': ^9.0.9
'@types/lodash': ^4.14.195
'@types/uuid': ^10.0.0
'@types/xml2js': ^0.4.14
@@ -20,6 +21,7 @@ catalog:
flatted: 3.2.7
form-data: 4.0.0
iconv-lite: 0.6.3
jsonwebtoken: 9.0.2
lodash: 4.17.21
luxon: 3.4.4
nanoid: 3.3.8