feat(Form Node): Support multiple files when returning binary from form ending (#33780)

This commit is contained in:
RomanDavydchuk
2026-07-13 15:07:47 +03:00
committed by GitHub
parent 0a45f1166c
commit cecec482bb
5 changed files with 173 additions and 56 deletions
@@ -201,15 +201,15 @@
};
document.addEventListener('DOMContentLoaded', function () {
const binary = "{{{responseBinary}}}"
const binaries = "{{{responseBinary}}}"
? JSON.parse(decodeURIComponent("{{{responseBinary}}}"))
: '';
: [];
const byteArray = binary.data.type === 'Buffer'
? new Uint8Array(binary.data.data)
: Uint8Array.from(binary.data, c => c.charCodeAt(0));
for (const binary of binaries) {
const byteArray = binary.data.type === 'Buffer'
? new Uint8Array(binary.data.data)
: Uint8Array.from(binary.data, c => c.charCodeAt(0));
if (binary) {
const blob = new Blob(
[byteArray],
{ type: binary.type }
+3 -3
View File
@@ -230,7 +230,7 @@ const completionProperties = updateDisplayOptions(
description: 'The text to display on the page. Use HTML to show a customized web page.',
},
{
displayName: 'Input Data Field Name',
displayName: 'Input Data Field Name(s)',
name: 'inputDataFieldName',
type: 'string',
displayOptions: {
@@ -241,8 +241,8 @@ const completionProperties = updateDisplayOptions(
default: 'data',
placeholder: 'e.g. data',
description:
'Find the name of input field containing the binary data to return in the Input panel on the left, in the Binary tab',
hint: 'The name of the input field containing the binary file data to be returned',
'Find the name of input field containing the binary data to return in the Input panel on the left, in the Binary tab. You can provide multiple comma-separated field names.',
hint: 'The name of the input field containing the binary file data to be returned. You can provide multiple comma-separated field names.',
},
...waitTimeProperties,
{
@@ -239,7 +239,7 @@ describe('Form Node', () => {
message: 'Test Message',
redirectUrl: undefined,
title: 'Test Title',
responseBinary: encodeURIComponent(JSON.stringify('')),
responseBinary: encodeURIComponent(JSON.stringify([])),
responseText: '',
dangerousCustomCss: undefined,
},
@@ -255,7 +255,7 @@ describe('Form Node', () => {
redirectUrl: undefined,
title: 'Test Title',
responseText: '<div>hey</div><script>alert("hi")</script>',
responseBinary: encodeURIComponent(JSON.stringify('')),
responseBinary: encodeURIComponent(JSON.stringify([])),
dangerousCustomCss: undefined,
},
},
@@ -268,7 +268,7 @@ describe('Form Node', () => {
formTitle: 'test',
message: 'Test Message',
redirectUrl: undefined,
responseBinary: encodeURIComponent(JSON.stringify('')),
responseBinary: encodeURIComponent(JSON.stringify([])),
title: 'Test Title',
responseText: 'my text over here',
dangerousCustomCss: undefined,
@@ -732,7 +732,7 @@ describe('Form Node', () => {
redirectUrl: 'https://n8n.io',
responseText: '',
title: 'Test Title',
responseBinary: encodeURIComponent(JSON.stringify('')),
responseBinary: encodeURIComponent(JSON.stringify([])),
});
});
});
@@ -133,7 +133,7 @@ describe('formCompletionUtils', () => {
formTitle: 'Form Title',
message: 'Form has been submitted successfully',
redirectUrl: undefined,
responseBinary: encodeURIComponent(JSON.stringify('')),
responseBinary: encodeURIComponent(JSON.stringify([])),
responseText: '',
title: 'Form Completion',
});
@@ -220,7 +220,7 @@ describe('formCompletionUtils', () => {
formTitle: 'Form Title',
message: 'Safe message<b>bold</b>',
redirectUrl: undefined,
responseBinary: encodeURIComponent(JSON.stringify('')),
responseBinary: encodeURIComponent(JSON.stringify([])),
responseText: 'Response text',
title: 'Form Completion',
dangerousCustomCss: undefined,
@@ -252,7 +252,7 @@ describe('formCompletionUtils', () => {
formTitle: 'Form Title',
message: `Some message${replacement}Other text`,
redirectUrl: undefined,
responseBinary: encodeURIComponent(JSON.stringify('')),
responseBinary: encodeURIComponent(JSON.stringify([])),
responseText: 'Response text',
title: 'Form Completion',
dangerousCustomCss: undefined,
@@ -330,11 +330,13 @@ describe('formCompletionUtils', () => {
message: 'Form has been submitted successfully',
redirectUrl: undefined,
responseBinary: encodeURIComponent(
JSON.stringify({
data: buffer,
fileName: expectedBinaryResponse.inputData.fileName,
type: expectedBinaryResponse.inputData.mimeType,
}),
JSON.stringify([
{
data: buffer,
fileName: expectedBinaryResponse.inputData.fileName,
type: expectedBinaryResponse.inputData.mimeType,
},
]),
),
responseText: '',
title: 'Form Completion',
@@ -384,11 +386,13 @@ describe('formCompletionUtils', () => {
message: 'Form has been submitted successfully',
redirectUrl: undefined,
responseBinary: encodeURIComponent(
JSON.stringify({
data: atob(expectedBinaryResponse.inputData.data),
fileName: expectedBinaryResponse.inputData.fileName,
type: expectedBinaryResponse.inputData.mimeType,
}),
JSON.stringify([
{
data: atob(expectedBinaryResponse.inputData.data),
fileName: expectedBinaryResponse.inputData.fileName,
type: expectedBinaryResponse.inputData.mimeType,
},
]),
),
responseText: '',
title: 'Form Completion',
@@ -524,11 +528,103 @@ describe('formCompletionUtils', () => {
const result = await binaryResponse(mockWebhookFunctions);
expect(result).toEqual({
data: atob(expectedBinaryResponse.inputData.data),
fileName: expectedBinaryResponse.inputData.fileName,
type: expectedBinaryResponse.inputData.mimeType,
expect(result).toEqual([
{
data: atob(expectedBinaryResponse.inputData.data),
fileName: expectedBinaryResponse.inputData.fileName,
type: expectedBinaryResponse.inputData.mimeType,
},
]);
});
it('should return multiple binary files from comma-separated field names', async () => {
const expectedBinaryResponse = {
inputData: {
data: 'Zmlyc3Q=',
fileName: 'first.txt',
mimeType: 'text/plain',
},
otherData: {
data: 'c2Vjb25k',
fileName: 'second.txt',
mimeType: 'text/plain',
},
};
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: Record<string, string> = {
inputDataFieldName: 'inputData, otherData',
};
return params[parameterName];
});
mockWebhookFunctions.getParentNodes.mockReturnValueOnce(parentNodesWithSingleNodeFile);
mockWebhookFunctions.evaluateExpression.mockImplementation((arg) => {
if (arg === `{{ $('${nodeNameWithFileToDownload}').first().binary }}`) {
return expectedBinaryResponse;
}
return undefined;
});
const result = await binaryResponse(mockWebhookFunctions);
expect(result).toEqual([
{
data: atob(expectedBinaryResponse.inputData.data),
fileName: expectedBinaryResponse.inputData.fileName,
type: expectedBinaryResponse.inputData.mimeType,
},
{
data: atob(expectedBinaryResponse.otherData.data),
fileName: expectedBinaryResponse.otherData.fileName,
type: expectedBinaryResponse.otherData.mimeType,
},
]);
});
it('should trim comma-separated field names', async () => {
const expectedBinaryResponse = {
inputData: {
data: 'Zmlyc3Q=',
fileName: 'first.txt',
mimeType: 'text/plain',
},
otherData: {
data: 'c2Vjb25k',
fileName: 'second.txt',
mimeType: 'text/plain',
},
};
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: Record<string, string> = {
inputDataFieldName: ' inputData , otherData ',
};
return params[parameterName];
});
mockWebhookFunctions.getParentNodes.mockReturnValueOnce(parentNodesWithSingleNodeFile);
mockWebhookFunctions.evaluateExpression.mockImplementation((arg) => {
if (arg === `{{ $('${nodeNameWithFileToDownload}').first().binary }}`) {
return expectedBinaryResponse;
}
return undefined;
});
const result = await binaryResponse(mockWebhookFunctions);
expect(result).toEqual([
{
data: atob(expectedBinaryResponse.inputData.data),
fileName: expectedBinaryResponse.inputData.fileName,
type: expectedBinaryResponse.inputData.mimeType,
},
{
data: atob(expectedBinaryResponse.otherData.data),
fileName: expectedBinaryResponse.otherData.fileName,
type: expectedBinaryResponse.otherData.mimeType,
},
]);
});
});
});
@@ -19,34 +19,55 @@ import {
validateSafeRedirectUrl,
} from './utils';
const getBinaryDataFromNode = (context: IWebhookFunctions, nodeName: string): IDataObject => {
return context.evaluateExpression(`{{ $('${nodeName}').first().binary }}`) as IDataObject;
type BinaryResponse = { data: string | Buffer; fileName: string; type: string };
const getBinaryDataFromNode = (
context: IWebhookFunctions,
nodeName: string,
): IDataObject | undefined => {
return context.evaluateExpression(`{{ $('${nodeName}').first().binary }}`) as
| IDataObject
| undefined;
};
export const binaryResponse = async (
context: IWebhookFunctions,
): Promise<{ data: string | Buffer; fileName: string; type: string }> => {
const inputDataFieldName = context.getNodeParameter('inputDataFieldName', '') as string;
const parentNodes = context.getParentNodes(context.getNode().name);
const binaryNode = parentNodes
.reverse()
.find((node) => getBinaryDataFromNode(context, node?.name)?.hasOwnProperty(inputDataFieldName));
if (!binaryNode) {
throw new OperationalError(`No binary data with field ${inputDataFieldName} found.`);
}
const binaryData = getBinaryDataFromNode(context, binaryNode?.name)[
inputDataFieldName
] as IBinaryData;
const getInputDataFieldNames = (inputDataFieldName: string) => {
const fieldNames = inputDataFieldName
.split(',')
.map((fieldName) => fieldName.trim())
.filter(Boolean);
return {
// If a binaryData has an id, the following field is set:
// N8N_DEFAULT_BINARY_DATA_MODE=filesystem
data: binaryData.id
? await context.helpers.binaryToBuffer(await context.helpers.getBinaryStream(binaryData.id))
: atob(binaryData.data),
fileName: binaryData.fileName ?? 'file',
type: binaryData.mimeType,
};
return fieldNames.length ? fieldNames : [inputDataFieldName];
};
export const binaryResponse = async (context: IWebhookFunctions): Promise<BinaryResponse[]> => {
const inputDataFieldName = context.getNodeParameter('inputDataFieldName', '') as string;
const inputDataFieldNames = getInputDataFieldNames(inputDataFieldName);
const responses: BinaryResponse[] = [];
const parentNodesBinaries = context
.getParentNodes(context.getNode().name)
.reverse()
.map((node) => getBinaryDataFromNode(context, node.name) ?? {});
for (const fieldName of inputDataFieldNames) {
const nodeBinary = parentNodesBinaries.find((bin) => Object.hasOwn(bin, fieldName));
if (!nodeBinary) {
throw new OperationalError(`No binary data with field ${fieldName} found.`);
}
const binaryData = nodeBinary[fieldName] as IBinaryData;
responses.push({
// If a binaryData has an id, the following field is set:
// N8N_DEFAULT_BINARY_DATA_MODE=filesystem
data: binaryData.id
? await context.helpers.binaryToBuffer(await context.helpers.getBinaryStream(binaryData.id))
: atob(binaryData.data),
fileName: binaryData.fileName ?? 'file',
type: binaryData.mimeType,
});
}
return responses;
};
export const renderFormCompletion = async (
@@ -70,7 +91,7 @@ export const renderFormCompletion = async (
| 'redirect'
| 'showText'
| 'returnBinary';
const binary = respondWith === 'returnBinary' ? await binaryResponse(context) : '';
const binary = respondWith === 'returnBinary' ? await binaryResponse(context) : [];
let title = options.formTitle;
if (!title) {