import { binaryToBuffer, binaryToString } from '@n8n/backend-network'; import { Container } from '@n8n/di'; import chardet from 'chardet'; import { IncomingMessage } from 'http'; import get from 'lodash/get'; import { extension, lookup } from 'mime-types'; import type { StringValue as TimeUnitValue } from 'ms'; import type { BinaryHelperFunctions, IBinaryData, INode, ITaskDataConnections, IWorkflowExecuteAdditionalData, WorkflowSettingsBinaryMode, } from 'n8n-workflow'; import { NodeOperationError, fileTypeFromMimeType, UserError, UnexpectedError, isBinaryValue, BINARY_MODE_COMBINED, BINARY_MODE_SEPARATE, sanitizeFilename, } from 'n8n-workflow'; import path from 'path'; import type { Readable } from 'stream'; import { URL } from 'url'; import { BinaryDataService } from '@/binary-data/binary-data.service'; import type { BinaryData } from '@/binary-data/types'; function getBinaryPath(binaryDataId: string): string { return Container.get(BinaryDataService).getPath(binaryDataId); } /** * Returns binary file metadata */ async function getBinaryMetadata(binaryDataId: string): Promise { return await Container.get(BinaryDataService).getMetadata(binaryDataId); } /** * Returns binary file stream for piping */ async function getBinaryStream(binaryDataId: string, chunkSize?: number): Promise { return await Container.get(BinaryDataService).getAsStream(binaryDataId, chunkSize); } /** * If parameterData is a string, returns the binary data the given item index and * property name from the input data. * Else if parameterData is a binary data object, returns the binary data object. */ export function assertBinaryData( inputData: ITaskDataConnections, node: INode, itemIndex: number, parameterData: string | IBinaryData, inputIndex: number, binaryMode: WorkflowSettingsBinaryMode = BINARY_MODE_SEPARATE, ): IBinaryData { if (isBinaryValue(parameterData)) { return parameterData; } if (typeof parameterData !== 'string') { throw new NodeOperationError( node, 'Provided parameter is not a string or binary data object.', { itemIndex, description: 'Specify the property name of the binary data in input item or use an expression to access the binary data in previous nodes, e.g. "{{ $(_target_node_).item.binary[_binary_property_name_] }}"', }, ); } if (binaryMode === BINARY_MODE_COMBINED) { const itemData = inputData.main[inputIndex]![itemIndex].json; const binaryData = get(itemData, parameterData); if (!isBinaryValue(binaryData)) { throw new NodeOperationError( node, `The path '${parameterData}' does not resolve to a binary data object in the input data [item ${itemIndex}]`, { itemIndex, description: `Check that the path '${parameterData}' is correct, and that it resolves to a binary data object in the input data`, }, ); } return binaryData; } else { const binaryKeyData = inputData.main[inputIndex]![itemIndex].binary; if (binaryKeyData === undefined) { throw new NodeOperationError( node, `This operation expects the node's input data to contain a binary file '${parameterData}', but none was found [item ${itemIndex}]`, { itemIndex, description: 'Make sure that the previous node outputs a binary file', }, ); } const binaryPropertyData = binaryKeyData[parameterData]; if (binaryPropertyData === undefined) { throw new NodeOperationError( node, `The item has no binary field '${parameterData}' [item ${itemIndex}]`, { itemIndex, description: 'Check that the parameter where you specified the input binary field name is correct, and that it matches a field in the binary input', }, ); } return binaryPropertyData; } } /** * If parameterData is a string, returns the binary data buffer for the given item index and * property name from the input data. * Else returns the binary data buffer for the given binary data object. */ export async function getBinaryDataBuffer( inputData: ITaskDataConnections, itemIndex: number, parameterData: string | IBinaryData, inputIndex: number, binaryMode: WorkflowSettingsBinaryMode = BINARY_MODE_SEPARATE, ): Promise { let binaryData: IBinaryData; if (isBinaryValue(parameterData)) { binaryData = parameterData; } else if (typeof parameterData === 'string' && binaryMode !== BINARY_MODE_COMBINED) { binaryData = inputData.main[inputIndex]![itemIndex].binary![parameterData]; } else if (typeof parameterData === 'string') { const itemData = inputData.main[inputIndex]![itemIndex].json; const data = get(itemData, parameterData); if (!isBinaryValue(data)) { throw new UnexpectedError('Provided parameter is not a string or binary data object.'); } binaryData = data; } else { throw new UnexpectedError('Provided parameter is not a string or binary data object.'); } return await Container.get(BinaryDataService).getAsBuffer(binaryData); } export function detectBinaryEncoding(buffer: Buffer): string { return chardet.detect(buffer) as string; } /** * Store an incoming IBinaryData & related buffer using the configured binary data manager. * * @export * @param {IBinaryData} binaryData * @param {Buffer | Readable} bufferOrStream * @returns {Promise} */ export async function setBinaryDataBuffer( binaryData: IBinaryData, bufferOrStream: Buffer | Readable, workflowId: string, executionId: string, ): Promise { return await Container.get(BinaryDataService).store( { type: 'execution', workflowId, executionId }, bufferOrStream, binaryData, ); } export async function copyBinaryFile( workflowId: string, executionId: string, filePath: string, fileName: string, mimeType?: string, ): Promise { let fileExtension: string | undefined; if (!mimeType) { // If no mime type is given figure it out if (filePath) { // Use file path to guess mime type const mimeTypeLookup = lookup(filePath); if (mimeTypeLookup) { mimeType = mimeTypeLookup; } } if (!mimeType) { // read the first bytes of the file to guess mime type const { fileTypeFromFile } = await import('file-type'); const fileTypeData = await fileTypeFromFile(filePath); if (fileTypeData) { mimeType = fileTypeData.mime; fileExtension = fileTypeData.ext; } } } if (!fileExtension && mimeType) { fileExtension = extension(mimeType) || undefined; } if (!mimeType) { // Fall back to text mimeType = 'text/plain'; } const returnData: IBinaryData = { mimeType, fileType: fileTypeFromMimeType(mimeType), fileExtension, data: '', }; if (fileName) { returnData.fileName = sanitizeFilename(fileName); } else if (filePath) { returnData.fileName = sanitizeFilename(filePath); } return await Container.get(BinaryDataService).copyBinaryFile( { type: 'execution', workflowId, executionId }, returnData, filePath, ); } export const getNodeBinaryHelperFunctions = ( workflow: { id: string }, additionalData: IWorkflowExecuteAdditionalData, ) => ({ copyBinaryFile: async (filePath: string, fileName: string, mimeType?: string) => await copyBinaryFile(workflow.id, additionalData.executionId!, filePath, fileName, mimeType), }); /** * Takes a buffer and converts it into the format n8n uses. It encodes the binary data as * base64 and adds metadata. */ export async function prepareBinaryData( binaryData: Buffer | Readable, executionId: string, workflowId: string, filePath?: string, mimeType?: string, ): Promise { let fileExtension: string | undefined; let fullUrl: string | undefined; if (binaryData instanceof IncomingMessage) { if (!filePath) { try { const { responseUrl } = binaryData; let sanitizedUrl: string | undefined; let urlPathname: string | undefined; if (responseUrl) { const parsed = new URL(responseUrl); parsed.username = ''; parsed.password = ''; parsed.search = ''; parsed.hash = ''; sanitizedUrl = parsed.toString(); urlPathname = parsed.pathname; } const requestPathname = binaryData.req?.path ? new URL(binaryData.req.path, 'http://localhost').pathname : undefined; filePath = binaryData.contentDisposition?.filename ?? (urlPathname ?? requestPathname)?.slice(1); fullUrl = sanitizedUrl; } catch {} } if (!mimeType) { mimeType = binaryData.contentType; } } if (!mimeType) { // If no mime type is given figure it out if (filePath) { // Use file path to guess mime type const mimeTypeLookup = lookup(filePath); if (mimeTypeLookup) { mimeType = mimeTypeLookup; } } if (!mimeType) { if (Buffer.isBuffer(binaryData)) { // Use buffer to guess mime type const { fileTypeFromBuffer } = await import('file-type'); const fileTypeData = await fileTypeFromBuffer(binaryData); if (fileTypeData) { mimeType = fileTypeData.mime; fileExtension = fileTypeData.ext; } } else if (binaryData instanceof IncomingMessage) { mimeType = binaryData.headers['content-type']; } else { // TODO: detect filetype from other kind of streams } } } if (!fileExtension && mimeType) { fileExtension = extension(mimeType) || undefined; } if (!mimeType) { // Fall back to text mimeType = 'text/plain'; } const returnData: IBinaryData = { mimeType, fileType: fileTypeFromMimeType(mimeType), fileExtension, data: '', }; if (filePath) { const filePathParts = path.parse(filePath); if (fullUrl) { returnData.directory = fullUrl; } else if (filePathParts.dir !== '') { returnData.directory = filePathParts.dir; } returnData.fileName = filePathParts.base; // Remove the dot fileExtension = filePathParts.ext.slice(1); if (fileExtension) { returnData.fileExtension = fileExtension; } } return await setBinaryDataBuffer(returnData, binaryData, workflowId, executionId); } export const getBinaryHelperFunctions = ( { executionId, restApiUrl }: IWorkflowExecuteAdditionalData, workflowId: string, ): BinaryHelperFunctions => ({ getBinaryPath, getBinaryStream, getBinaryMetadata, binaryToBuffer, binaryToString, createBinarySignedUrl(binaryData: IBinaryData, expiresIn?: TimeUnitValue) { 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) => await setBinaryDataBuffer(data, binaryData, workflowId, executionId!), copyBinaryFile: async () => { throw new UserError('`copyBinaryFile` has been removed. Please upgrade this node.'); }, });