refactor(core): Reduce memory usage in the Webhook node (#4640)

use file streaming to pass webhook binaries around
This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2022-11-24 16:54:43 +01:00 committed by GitHub
parent 602b1e56d6
commit 07e4743a3e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 329 additions and 176 deletions

View file

@ -1497,18 +1497,22 @@ class App {
// Binary data // Binary data
// ---------------------------------------- // ----------------------------------------
// Returns binary buffer // Download binary
this.app.get( this.app.get(
`/${this.restEndpoint}/data/:path`, `/${this.restEndpoint}/data/:path`,
ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<string> => { async (req: express.Request, res: express.Response): Promise<void> => {
// TODO UM: check if this needs permission check for UM // TODO UM: check if this needs permission check for UM
const dataPath = req.params.path; const identifier = req.params.path;
return BinaryDataManager.getInstance() const binaryDataManager = BinaryDataManager.getInstance();
.retrieveBinaryDataByIdentifier(dataPath) const binaryPath = binaryDataManager.getBinaryPath(identifier);
.then((buffer: Buffer) => { const { mimeType, fileName, fileSize } = await binaryDataManager.getBinaryMetadata(
return buffer.toString('base64'); identifier,
}); );
}), if (mimeType) res.setHeader('Content-Type', mimeType);
if (fileName) res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
res.setHeader('Content-Length', fileSize);
res.sendFile(binaryPath);
},
); );
// ---------------------------------------- // ----------------------------------------

View file

@ -56,6 +56,7 @@
"n8n-workflow": "~0.126.0", "n8n-workflow": "~0.126.0",
"oauth-1.0a": "^2.2.6", "oauth-1.0a": "^2.2.6",
"p-cancelable": "^2.0.0", "p-cancelable": "^2.0.0",
"pretty-bytes": "^5.6.0",
"qs": "^6.10.1", "qs": "^6.10.1",
"request": "^2.88.2", "request": "^2.88.2",
"request-promise-native": "^1.0.7", "request-promise-native": "^1.0.7",

View file

@ -1,8 +1,9 @@
import { promises as fs } from 'fs'; import fs from 'fs/promises';
import { jsonParse } from 'n8n-workflow';
import path from 'path'; import path from 'path';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { IBinaryDataConfig, IBinaryDataManager } from '../Interfaces'; import { BinaryMetadata, IBinaryDataConfig, IBinaryDataManager } from '../Interfaces';
const PREFIX_METAFILE = 'binarymeta'; const PREFIX_METAFILE = 'binarymeta';
const PREFIX_PERSISTED_METAFILE = 'persistedmeta'; const PREFIX_PERSISTED_METAFILE = 'persistedmeta';
@ -43,17 +44,47 @@ export class BinaryDataFileSystem implements IBinaryDataManager {
.then(() => {}); .then(() => {});
} }
async getFileSize(identifier: string): Promise<number> {
const stats = await fs.stat(this.getBinaryPath(identifier));
return stats.size;
}
async copyBinaryFile(filePath: string, executionId: string): Promise<string> {
const binaryDataId = this.generateFileName(executionId);
await this.addBinaryIdToPersistMeta(executionId, binaryDataId);
await this.copyFileToLocalStorage(filePath, binaryDataId);
return binaryDataId;
}
async storeBinaryMetadata(identifier: string, metadata: BinaryMetadata) {
await fs.writeFile(this.getMetadataPath(identifier), JSON.stringify(metadata), {
encoding: 'utf-8',
});
}
async getBinaryMetadata(identifier: string): Promise<BinaryMetadata> {
return jsonParse(await fs.readFile(this.getMetadataPath(identifier), { encoding: 'utf-8' }));
}
async storeBinaryData(binaryBuffer: Buffer, executionId: string): Promise<string> { async storeBinaryData(binaryBuffer: Buffer, executionId: string): Promise<string> {
const binaryDataId = this.generateFileName(executionId); const binaryDataId = this.generateFileName(executionId);
return this.addBinaryIdToPersistMeta(executionId, binaryDataId).then(async () => await this.addBinaryIdToPersistMeta(executionId, binaryDataId);
this.saveToLocalStorage(binaryBuffer, binaryDataId).then(() => binaryDataId), await this.saveToLocalStorage(binaryBuffer, binaryDataId);
); return binaryDataId;
} }
async retrieveBinaryDataByIdentifier(identifier: string): Promise<Buffer> { async retrieveBinaryDataByIdentifier(identifier: string): Promise<Buffer> {
return this.retrieveFromLocalStorage(identifier); return this.retrieveFromLocalStorage(identifier);
} }
getBinaryPath(identifier: string): string {
return path.join(this.storagePath, identifier);
}
getMetadataPath(identifier: string): string {
return path.join(this.storagePath, `${identifier}.metadata`);
}
async markDataForDeletionByExecutionId(executionId: string): Promise<void> { async markDataForDeletionByExecutionId(executionId: string): Promise<void> {
const tt = new Date(new Date().getTime() + this.binaryDataTTL * 60000); const tt = new Date(new Date().getTime() + this.binaryDataTTL * 60000);
return fs.writeFile( return fs.writeFile(
@ -180,7 +211,7 @@ export class BinaryDataFileSystem implements IBinaryDataManager {
} }
private generateFileName(prefix: string): string { private generateFileName(prefix: string): string {
return `${prefix}_${uuid()}`; return [prefix, uuid()].join('');
} }
private getBinaryDataMetaPath() { private getBinaryDataMetaPath() {
@ -196,15 +227,19 @@ export class BinaryDataFileSystem implements IBinaryDataManager {
} }
private async deleteFromLocalStorage(identifier: string) { private async deleteFromLocalStorage(identifier: string) {
return fs.rm(path.join(this.storagePath, identifier)); return fs.rm(this.getBinaryPath(identifier));
}
private async copyFileToLocalStorage(source: string, identifier: string): Promise<void> {
await fs.cp(source, this.getBinaryPath(identifier));
} }
private async saveToLocalStorage(data: Buffer, identifier: string) { private async saveToLocalStorage(data: Buffer, identifier: string) {
await fs.writeFile(path.join(this.storagePath, identifier), data); await fs.writeFile(this.getBinaryPath(identifier), data);
} }
private async retrieveFromLocalStorage(identifier: string): Promise<Buffer> { private async retrieveFromLocalStorage(identifier: string): Promise<Buffer> {
const filePath = path.join(this.storagePath, identifier); const filePath = this.getBinaryPath(identifier);
try { try {
return await fs.readFile(filePath); return await fs.readFile(filePath);
} catch (e) { } catch (e) {

View file

@ -1,7 +1,9 @@
import { IBinaryData, INodeExecutionData } from 'n8n-workflow'; import prettyBytes from 'pretty-bytes';
import type { IBinaryData, INodeExecutionData } from 'n8n-workflow';
import { BINARY_ENCODING } from '../Constants'; import { BINARY_ENCODING } from '../Constants';
import { IBinaryDataConfig, IBinaryDataManager } from '../Interfaces'; import type { BinaryMetadata, IBinaryDataConfig, IBinaryDataManager } from '../Interfaces';
import { BinaryDataFileSystem } from './FileSystem'; import { BinaryDataFileSystem } from './FileSystem';
import { readFile, stat } from 'fs/promises';
export class BinaryDataManager { export class BinaryDataManager {
static instance: BinaryDataManager | undefined; static instance: BinaryDataManager | undefined;
@ -43,31 +45,59 @@ export class BinaryDataManager {
return BinaryDataManager.instance; return BinaryDataManager.instance;
} }
async copyBinaryFile(
binaryData: IBinaryData,
filePath: string,
executionId: string,
): Promise<IBinaryData> {
// If a manager handles this binary, copy over the binary file and return its reference id.
const manager = this.managers[this.binaryDataMode];
if (manager) {
const identifier = await manager.copyBinaryFile(filePath, executionId);
// Add data manager reference id.
binaryData.id = this.generateBinaryId(identifier);
// Prevent preserving data in memory if handled by a data manager.
binaryData.data = this.binaryDataMode;
const fileSize = await manager.getFileSize(identifier);
binaryData.fileSize = prettyBytes(fileSize);
await manager.storeBinaryMetadata(identifier, {
fileName: binaryData.fileName,
mimeType: binaryData.mimeType,
fileSize,
});
} else {
const { size } = await stat(filePath);
binaryData.fileSize = prettyBytes(size);
binaryData.data = await readFile(filePath, { encoding: BINARY_ENCODING });
}
return binaryData;
}
async storeBinaryData( async storeBinaryData(
binaryData: IBinaryData, binaryData: IBinaryData,
binaryBuffer: Buffer, binaryBuffer: Buffer,
executionId: string, executionId: string,
): Promise<IBinaryData> { ): Promise<IBinaryData> {
const retBinaryData = binaryData; binaryData.fileSize = prettyBytes(binaryBuffer.length);
// If a manager handles this binary, return the binary data with it's reference id. // If a manager handles this binary, return the binary data with its reference id.
if (this.managers[this.binaryDataMode]) { const manager = this.managers[this.binaryDataMode];
return this.managers[this.binaryDataMode] if (manager) {
.storeBinaryData(binaryBuffer, executionId) const identifier = await manager.storeBinaryData(binaryBuffer, executionId);
.then((filename) => { // Add data manager reference id.
// Add data manager reference id. binaryData.id = this.generateBinaryId(identifier);
retBinaryData.id = this.generateBinaryId(filename);
// Prevent preserving data in memory if handled by a data manager. // Prevent preserving data in memory if handled by a data manager.
retBinaryData.data = this.binaryDataMode; binaryData.data = this.binaryDataMode;
} else {
// Short-circuit return to prevent further actions. // Else fallback to storing this data in memory.
return retBinaryData; binaryData.data = binaryBuffer.toString(BINARY_ENCODING);
});
} }
// Else fallback to storing this data in memory.
retBinaryData.data = binaryBuffer.toString(BINARY_ENCODING);
return binaryData; return binaryData;
} }
@ -88,6 +118,24 @@ export class BinaryDataManager {
throw new Error('Storage mode used to store binary data not available'); throw new Error('Storage mode used to store binary data not available');
} }
getBinaryPath(identifier: string): string {
const { mode, id } = this.splitBinaryModeFileId(identifier);
if (this.managers[mode]) {
return this.managers[mode].getBinaryPath(id);
}
throw new Error('Storage mode used to store binary data not available');
}
async getBinaryMetadata(identifier: string): Promise<BinaryMetadata> {
const { mode, id } = this.splitBinaryModeFileId(identifier);
if (this.managers[mode]) {
return this.managers[mode].getBinaryMetadata(id);
}
throw new Error('Storage mode used to store binary data not available');
}
async markDataForDeletionByExecutionId(executionId: string): Promise<void> { async markDataForDeletionByExecutionId(executionId: string): Promise<void> {
if (this.managers[this.binaryDataMode]) { if (this.managers[this.binaryDataMode]) {
return this.managers[this.binaryDataMode].markDataForDeletionByExecutionId(executionId); return this.managers[this.binaryDataMode].markDataForDeletionByExecutionId(executionId);

View file

@ -260,6 +260,7 @@ export interface IWebhookFunctions extends IWebhookFunctionsBase {
filePath?: string, filePath?: string,
mimeType?: string, mimeType?: string,
): Promise<IBinaryData>; ): Promise<IBinaryData>;
copyBinaryFile(filePath: string, fileName: string, mimeType?: string): Promise<IBinaryData>;
request: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise<any>; request: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise<any>;
requestWithAuthentication( requestWithAuthentication(
this: IAllExecuteFunctions, this: IAllExecuteFunctions,
@ -306,10 +307,21 @@ export interface IBinaryDataConfig {
persistedBinaryDataTTL: number; persistedBinaryDataTTL: number;
} }
export interface BinaryMetadata {
fileName?: string;
mimeType?: string;
fileSize: number;
}
export interface IBinaryDataManager { export interface IBinaryDataManager {
init(startPurger: boolean): Promise<void>; init(startPurger: boolean): Promise<void>;
getFileSize(filePath: string): Promise<number>;
copyBinaryFile(filePath: string, executionId: string): Promise<string>;
storeBinaryMetadata(identifier: string, metadata: BinaryMetadata): Promise<void>;
getBinaryMetadata(identifier: string): Promise<BinaryMetadata>;
storeBinaryData(binaryBuffer: Buffer, executionId: string): Promise<string>; storeBinaryData(binaryBuffer: Buffer, executionId: string): Promise<string>;
retrieveBinaryDataByIdentifier(identifier: string): Promise<Buffer>; retrieveBinaryDataByIdentifier(identifier: string): Promise<Buffer>;
getBinaryPath(identifier: string): string;
markDataForDeletionByExecutionId(executionId: string): Promise<void>; markDataForDeletionByExecutionId(executionId: string): Promise<void>;
deleteMarkedFiles(): Promise<unknown>; deleteMarkedFiles(): Promise<unknown>;
deleteBinaryDataByIdentifier(identifier: string): Promise<void>; deleteBinaryDataByIdentifier(identifier: string): Promise<void>;

View file

@ -64,6 +64,7 @@ import {
NodeExecutionWithMetadata, NodeExecutionWithMetadata,
IPairedItemData, IPairedItemData,
deepCopy, deepCopy,
BinaryFileType,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { Agent } from 'https'; import { Agent } from 'https';
@ -77,8 +78,8 @@ import FormData from 'form-data';
import path from 'path'; import path from 'path';
import { OptionsWithUri, OptionsWithUrl, RequestCallback, RequiredUriUrl } from 'request'; import { OptionsWithUri, OptionsWithUrl, RequestCallback, RequiredUriUrl } from 'request';
import requestPromise, { RequestPromiseOptions } from 'request-promise-native'; import requestPromise, { RequestPromiseOptions } from 'request-promise-native';
import { fromBuffer } from 'file-type'; import FileType from 'file-type';
import { lookup } from 'mime-types'; import { lookup, extension } from 'mime-types';
import { IncomingHttpHeaders } from 'http'; import { IncomingHttpHeaders } from 'http';
import axios, { import axios, {
AxiosError, AxiosError,
@ -830,6 +831,13 @@ export async function getBinaryDataBuffer(
return BinaryDataManager.getInstance().retrieveBinaryData(binaryData); return BinaryDataManager.getInstance().retrieveBinaryData(binaryData);
} }
function fileTypeFromMimeType(mimeType: string): BinaryFileType | undefined {
if (mimeType.startsWith('image/')) return 'image';
if (mimeType.startsWith('video/')) return 'video';
if (mimeType.startsWith('text/') || mimeType.startsWith('application/json')) return 'text';
return;
}
/** /**
* Store an incoming IBinaryData & related buffer using the configured binary data manager. * Store an incoming IBinaryData & related buffer using the configured binary data manager.
* *
@ -846,10 +854,60 @@ export async function setBinaryDataBuffer(
return BinaryDataManager.getInstance().storeBinaryData(data, binaryData, executionId); return BinaryDataManager.getInstance().storeBinaryData(data, binaryData, executionId);
} }
export async function copyBinaryFile(
executionId: string,
filePath: string,
fileName: string,
mimeType?: string,
): Promise<IBinaryData> {
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 fileTypeData = await FileType.fromFile(filePath);
if (fileTypeData) {
mimeType = fileTypeData.mime;
fileExtension = fileTypeData.ext;
}
}
if (!mimeType) {
// Fall back to text
mimeType = 'text/plain';
}
} else if (!fileExtension) {
fileExtension = extension(mimeType) || undefined;
}
const returnData: IBinaryData = {
mimeType,
fileType: fileTypeFromMimeType(mimeType),
fileExtension,
data: '',
};
if (fileName) {
returnData.fileName = fileName;
} else if (filePath) {
returnData.fileName = path.parse(filePath).base;
}
return BinaryDataManager.getInstance().copyBinaryFile(returnData, filePath, executionId);
}
/** /**
* Takes a buffer and converts it into the format n8n uses. It encodes the binary data as * Takes a buffer and converts it into the format n8n uses. It encodes the binary data as
* base64 and adds metadata. * base64 and adds metadata.
*
*/ */
export async function prepareBinaryData( export async function prepareBinaryData(
binaryData: Buffer, binaryData: Buffer,
@ -871,7 +929,7 @@ export async function prepareBinaryData(
if (!mimeType) { if (!mimeType) {
// Use buffer to guess mime type // Use buffer to guess mime type
const fileTypeData = await fromBuffer(binaryData); const fileTypeData = await FileType.fromBuffer(binaryData);
if (fileTypeData) { if (fileTypeData) {
mimeType = fileTypeData.mime; mimeType = fileTypeData.mime;
fileExtension = fileTypeData.ext; fileExtension = fileTypeData.ext;
@ -882,10 +940,13 @@ export async function prepareBinaryData(
// Fall back to text // Fall back to text
mimeType = 'text/plain'; mimeType = 'text/plain';
} }
} else if (!fileExtension) {
fileExtension = extension(mimeType) || undefined;
} }
const returnData: IBinaryData = { const returnData: IBinaryData = {
mimeType, mimeType,
fileType: fileTypeFromMimeType(mimeType),
fileExtension, fileExtension,
data: '', data: '',
}; };
@ -3076,6 +3137,19 @@ export function getExecuteWebhookFunctions(
async setBinaryDataBuffer(data: IBinaryData, binaryData: Buffer): Promise<IBinaryData> { async setBinaryDataBuffer(data: IBinaryData, binaryData: Buffer): Promise<IBinaryData> {
return setBinaryDataBuffer.call(this, data, binaryData, additionalData.executionId!); return setBinaryDataBuffer.call(this, data, binaryData, additionalData.executionId!);
}, },
async copyBinaryFile(
filePath: string,
fileName: string,
mimeType?: string,
): Promise<IBinaryData> {
return copyBinaryFile.call(
this,
additionalData.executionId!,
filePath,
fileName,
mimeType,
);
},
async prepareBinaryData( async prepareBinaryData(
binaryData: Buffer, binaryData: Buffer,
filePath?: string, filePath?: string,

View file

@ -215,6 +215,7 @@ export interface IRestApi {
retryExecution(id: string, loadWorkflow?: boolean): Promise<boolean>; retryExecution(id: string, loadWorkflow?: boolean): Promise<boolean>;
getTimezones(): Promise<IDataObject>; getTimezones(): Promise<IDataObject>;
getBinaryBufferString(dataPath: string): Promise<string>; getBinaryBufferString(dataPath: string): Promise<string>;
getBinaryUrl(dataPath: string): string;
} }
export interface INodeTranslationHeaders { export interface INodeTranslationHeaders {
@ -226,14 +227,6 @@ export interface INodeTranslationHeaders {
}; };
} }
export interface IBinaryDisplayData {
index: number;
key: string;
node: string;
outputIndex: number;
runIndex: number;
}
export interface IStartRunData { export interface IStartRunData {
workflowData: IWorkflowData; workflowData: IWorkflowData;
startNodes?: string[]; startNodes?: string[];

View file

@ -20,10 +20,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { import type { IBinaryData, IRunData } from 'n8n-workflow';
IBinaryData,
IRunData,
} from 'n8n-workflow';
import BinaryDataDisplayEmbed from '@/components/BinaryDataDisplayEmbed.vue'; import BinaryDataDisplayEmbed from '@/components/BinaryDataDisplayEmbed.vue';
@ -44,7 +41,7 @@ export default mixins(
BinaryDataDisplayEmbed, BinaryDataDisplayEmbed,
}, },
props: [ props: [
'displayData', // IBinaryDisplayData 'displayData', // IBinaryData
'windowVisible', // boolean 'windowVisible', // boolean
], ],
computed: { computed: {
@ -67,14 +64,6 @@ export default mixins(
return binaryDataItem; return binaryDataItem;
}, },
embedClass (): string[] {
// @ts-ignore
if (this.binaryData! !== null && this.binaryData!.mimeType! !== undefined && (this.binaryData!.mimeType! as string).startsWith('image')) {
return ['image'];
}
return ['other'];
},
workflowRunData (): IRunData | null { workflowRunData (): IRunData | null {
const workflowExecution = this.workflowsStore.getWorkflowExecution; const workflowExecution = this.workflowsStore.getWorkflowExecution;
if (workflowExecution === null) { if (workflowExecution === null) {

View file

@ -7,7 +7,7 @@
Error loading binary data Error loading binary data
</div> </div>
<span v-else> <span v-else>
<video v-if="binaryData.mimeType && binaryData.mimeType.startsWith('video/')" controls autoplay> <video v-if="binaryData.fileType === 'video'" controls autoplay>
<source :src="embedSource" :type="binaryData.mimeType"> <source :src="embedSource" :type="binaryData.mimeType">
{{ $locale.baseText('binaryDataDisplay.yourBrowserDoesNotSupport') }} {{ $locale.baseText('binaryDataDisplay.yourBrowserDoesNotSupport') }}
</video> </video>
@ -17,10 +17,9 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
import { restApi } from '@/mixins/restApi'; import { restApi } from '@/mixins/restApi';
import type { IBinaryData } from 'n8n-workflow';
export default mixins( export default mixins(
restApi, restApi,
@ -28,7 +27,7 @@ export default mixins(
.extend({ .extend({
name: 'BinaryDataDisplayEmbed', name: 'BinaryDataDisplayEmbed',
props: [ props: [
'binaryData', // IBinaryDisplayData 'binaryData', // IBinaryData
], ],
data() { data() {
return { return {
@ -38,15 +37,15 @@ export default mixins(
}; };
}, },
async mounted() { async mounted() {
if(!this.binaryData.id) { const id = this.binaryData?.id;
if(!id) {
this.embedSource = 'data:' + this.binaryData.mimeType + ';base64,' + this.binaryData.data; this.embedSource = 'data:' + this.binaryData.mimeType + ';base64,' + this.binaryData.data;
this.isLoading = false; this.isLoading = false;
return; return;
} }
try { try {
const bufferString = await this.restApi().getBinaryBufferString(this.binaryData!.id!); this.embedSource = this.restApi().getBinaryUrl(id);
this.embedSource = 'data:' + this.binaryData.mimeType + ';base64,' + bufferString;
this.isLoading = false; this.isLoading = false;
} catch (e) { } catch (e) {
this.isLoading = false; this.isLoading = false;
@ -55,11 +54,8 @@ export default mixins(
}, },
methods: { methods: {
embedClass(): string[] { embedClass(): string[] {
// @ts-ignore const { fileType } = (this.binaryData || {}) as IBinaryData;
if (this.binaryData! !== null && this.binaryData!.mimeType! !== undefined && (this.binaryData!.mimeType! as string).startsWith('image')) { return [fileType ?? 'other'];
return ['image'];
}
return ['other'];
}, },
}, },
}); });

View file

@ -282,9 +282,13 @@
<div><n8n-text size="small" :bold="true">{{ $locale.baseText('runData.mimeType') }}: </n8n-text></div> <div><n8n-text size="small" :bold="true">{{ $locale.baseText('runData.mimeType') }}: </n8n-text></div>
<div :class="$style.binaryValue">{{binaryData.mimeType}}</div> <div :class="$style.binaryValue">{{binaryData.mimeType}}</div>
</div> </div>
<div v-if="binaryData.fileSize">
<div><n8n-text size="small" :bold="true">{{ $locale.baseText('runData.fileSize') }}: </n8n-text></div>
<div :class="$style.binaryValue">{{binaryData.fileSize}}</div>
</div>
<div :class="$style.binaryButtonContainer"> <div :class="$style.binaryButtonContainer">
<n8n-button size="small" :label="$locale.baseText('runData.showBinaryData')" class="binary-data-show-data-button" @click="displayBinaryData(index, key)" /> <n8n-button v-if="isViewable(index, key)" size="small" :label="$locale.baseText('runData.showBinaryData')" class="binary-data-show-data-button" @click="displayBinaryData(index, key)" />
<n8n-button v-if="isDownloadable(index, key)" size="small" type="secondary" :label="$locale.baseText('runData.downloadBinaryData')" class="binary-data-show-data-button" @click="downloadBinaryData(index, key)" /> <n8n-button v-if="isDownloadable(index, key)" size="small" type="secondary" :label="$locale.baseText('runData.downloadBinaryData')" class="binary-data-show-data-button" @click="downloadBinaryData(index, key)" />
</div> </div>
</div> </div>
@ -341,7 +345,6 @@ import {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { import {
IBinaryDisplayData,
IExecutionResponse, IExecutionResponse,
INodeUi, INodeUi,
INodeUpdatePropertiesInformation, INodeUpdatePropertiesInformation,
@ -363,7 +366,6 @@ import BinaryDataDisplay from '@/components/BinaryDataDisplay.vue';
import WarningTooltip from '@/components/WarningTooltip.vue'; import WarningTooltip from '@/components/WarningTooltip.vue';
import NodeErrorView from '@/components/Error/NodeErrorView.vue'; import NodeErrorView from '@/components/Error/NodeErrorView.vue';
import { copyPaste } from '@/mixins/copyPaste';
import { externalHooks } from "@/mixins/externalHooks"; import { externalHooks } from "@/mixins/externalHooks";
import { genericHelpers } from '@/mixins/genericHelpers'; import { genericHelpers } from '@/mixins/genericHelpers';
import { nodeHelpers } from '@/mixins/nodeHelpers'; import { nodeHelpers } from '@/mixins/nodeHelpers';
@ -385,7 +387,6 @@ export type EnterEditModeArgs = {
}; };
export default mixins( export default mixins(
copyPaste,
externalHooks, externalHooks,
genericHelpers, genericHelpers,
nodeHelpers, nodeHelpers,
@ -460,7 +461,7 @@ export default mixins(
showData: false, showData: false,
outputIndex: 0, outputIndex: 0,
binaryDataDisplayVisible: false, binaryDataDisplayVisible: false,
binaryDataDisplayData: null as IBinaryDisplayData | null, binaryDataDisplayData: null as IBinaryData | null,
MAX_DISPLAY_DATA_SIZE, MAX_DISPLAY_DATA_SIZE,
MAX_DISPLAY_ITEMS_AUTO_ALL, MAX_DISPLAY_ITEMS_AUTO_ALL,
@ -1041,23 +1042,26 @@ export default mixins(
this.workflowsStore.setWorkflowExecutionData(null); this.workflowsStore.setWorkflowExecutionData(null);
this.updateNodesExecutionIssues(); this.updateNodesExecutionIssues();
}, },
isViewable (index: number, key: string): boolean {
const { fileType }: IBinaryData = this.binaryData[index][key];
return !!fileType && ['image', 'video'].includes(fileType);
},
isDownloadable (index: number, key: string): boolean { isDownloadable (index: number, key: string): boolean {
const binaryDataItem: IBinaryData = this.binaryData[index][key]; const { mimeType, fileName }: IBinaryData = this.binaryData[index][key];
return !!(binaryDataItem.mimeType && binaryDataItem.fileName); return !!(mimeType && fileName);
}, },
async downloadBinaryData (index: number, key: string) { async downloadBinaryData (index: number, key: string) {
const binaryDataItem: IBinaryData = this.binaryData[index][key]; const { id, data, fileName, fileExtension, mimeType }: IBinaryData = this.binaryData[index][key];
let bufferString = 'data:' + binaryDataItem.mimeType + ';base64,'; if(id) {
if(binaryDataItem.id) { const url = this.restApi().getBinaryUrl(id);
bufferString += await this.restApi().getBinaryBufferString(binaryDataItem.id); saveAs(url, [fileName, fileExtension].join('.'));
return;
} else { } else {
bufferString += binaryDataItem.data; const bufferString = 'data:' + mimeType + ';base64,' + data;
const blob = await fetch(bufferString).then(d => d.blob());
saveAs(blob, fileName);
} }
const data = await fetch(bufferString);
const blob = await data.blob();
saveAs(blob, binaryDataItem.fileName);
}, },
displayBinaryData (index: number, key: string) { displayBinaryData (index: number, key: string) {
this.binaryDataDisplayVisible = true; this.binaryDataDisplayVisible = true;

View file

@ -182,6 +182,10 @@ export const restApi = Vue.extend({
getBinaryBufferString: (dataPath: string): Promise<string> => { getBinaryBufferString: (dataPath: string): Promise<string> => {
return self.restApi().makeRestApiRequest('GET', `/data/${dataPath}`); return self.restApi().makeRestApiRequest('GET', `/data/${dataPath}`);
}, },
getBinaryUrl: (dataPath: string): string => {
return self.rootStore.getRestApiContext.baseUrl + `/data/${dataPath}`;
},
}; };
}, },
}, },

View file

@ -982,6 +982,7 @@
"runData.items": "Items", "runData.items": "Items",
"runData.json": "JSON", "runData.json": "JSON",
"runData.mimeType": "Mime Type", "runData.mimeType": "Mime Type",
"runData.fileSize": "File Size",
"runData.ms": "ms", "runData.ms": "ms",
"runData.noBinaryDataFound": "No binary data found", "runData.noBinaryDataFound": "No binary data found",
"runData.noData": "No data", "runData.noData": "No data",

View file

@ -11,15 +11,16 @@ import {
NodeOperationError, NodeOperationError,
} from 'n8n-workflow'; } from 'n8n-workflow';
import basicAuth from 'basic-auth';
import { Response } from 'express';
import fs from 'fs'; import fs from 'fs';
import stream from 'stream';
import { promisify } from 'util';
import basicAuth from 'basic-auth';
import type { Response } from 'express';
import formidable from 'formidable'; import formidable from 'formidable';
import isbot from 'isbot'; import isbot from 'isbot';
import { file as tmpFile } from 'tmp-promise';
const pipeline = promisify(stream.pipeline);
function authorizationError(resp: Response, realm: string, responseCode: number, message?: string) { function authorizationError(resp: Response, realm: string, responseCode: number, message?: string) {
if (message === undefined) { if (message === undefined) {
@ -673,10 +674,8 @@ export class Wait implements INodeType {
} }
} }
// @ts-ignore const mimeType = headers['content-type'] ?? 'application/json';
const mimeType = headers['content-type'] || 'application/json';
if (mimeType.includes('multipart/form-data')) { if (mimeType.includes('multipart/form-data')) {
// @ts-ignore
const form = new formidable.IncomingForm({ multiples: true }); const form = new formidable.IncomingForm({ multiples: true });
return new Promise((resolve, _reject) => { return new Promise((resolve, _reject) => {
@ -715,12 +714,10 @@ export class Wait implements INodeType {
binaryPropertyName = `${options.binaryPropertyName}${count}`; binaryPropertyName = `${options.binaryPropertyName}${count}`;
} }
const fileJson = file.toJSON() as unknown as IDataObject; const fileJson = file.toJSON();
const fileContent = await fs.promises.readFile(file.path); returnItem.binary![binaryPropertyName] = await this.helpers.copyBinaryFile(
file.path,
returnItem.binary![binaryPropertyName] = await this.helpers.prepareBinaryData( fileJson.name || fileJson.filename,
Buffer.from(fileContent),
fileJson.name as string,
fileJson.type as string, fileJson.type as string,
); );
@ -735,38 +732,35 @@ export class Wait implements INodeType {
} }
if (options.binaryData === true) { if (options.binaryData === true) {
return new Promise((resolve, _reject) => { const binaryFile = await tmpFile({ prefix: 'n8n-webhook-' });
const binaryPropertyName = options.binaryPropertyName || 'data';
const data: Buffer[] = [];
req.on('data', (chunk) => { try {
data.push(chunk); await pipeline(req, fs.createWriteStream(binaryFile.path));
});
req.on('end', async () => { const returnItem: INodeExecutionData = {
const returnItem: INodeExecutionData = { binary: {},
binary: {}, json: {
json: { headers,
headers, params: this.getParamsData(),
params: this.getParamsData(), query: this.getQueryData(),
query: this.getQueryData(), body: this.getBodyData(),
body: this.getBodyData(), },
}, };
};
returnItem.binary![binaryPropertyName as string] = await this.helpers.prepareBinaryData( const binaryPropertyName = (options.binaryPropertyName || 'data') as string;
Buffer.concat(data), returnItem.binary![binaryPropertyName] = await this.helpers.copyBinaryFile(
); binaryFile.path,
mimeType,
);
return resolve({ return {
workflowData: [[returnItem]], workflowData: [[returnItem]],
}); };
}); } catch (error) {
throw new NodeOperationError(this.getNode(), error);
req.on('error', (error) => { } finally {
throw new NodeOperationError(this.getNode(), error); await binaryFile.cleanup();
}); }
});
} }
const response: INodeExecutionData = { const response: INodeExecutionData = {

View file

@ -10,15 +10,16 @@ import {
NodeOperationError, NodeOperationError,
} from 'n8n-workflow'; } from 'n8n-workflow';
import basicAuth from 'basic-auth';
import { Response } from 'express';
import fs from 'fs'; import fs from 'fs';
import stream from 'stream';
import { promisify } from 'util';
import basicAuth from 'basic-auth';
import type { Response } from 'express';
import formidable from 'formidable'; import formidable from 'formidable';
import isbot from 'isbot'; import isbot from 'isbot';
import { file as tmpFile } from 'tmp-promise';
const pipeline = promisify(stream.pipeline);
function authorizationError(resp: Response, realm: string, responseCode: number, message?: string) { function authorizationError(resp: Response, realm: string, responseCode: number, message?: string) {
if (message === undefined) { if (message === undefined) {
@ -485,10 +486,8 @@ export class Webhook implements INodeType {
} }
} }
// @ts-ignore const mimeType = headers['content-type'] ?? 'application/json';
const mimeType = headers['content-type'] || 'application/json';
if (mimeType.includes('multipart/form-data')) { if (mimeType.includes('multipart/form-data')) {
// @ts-ignore
const form = new formidable.IncomingForm({ multiples: true }); const form = new formidable.IncomingForm({ multiples: true });
return new Promise((resolve, _reject) => { return new Promise((resolve, _reject) => {
@ -527,12 +526,10 @@ export class Webhook implements INodeType {
binaryPropertyName = `${options.binaryPropertyName}${count}`; binaryPropertyName = `${options.binaryPropertyName}${count}`;
} }
const fileJson = file.toJSON() as unknown as IDataObject; const fileJson = file.toJSON();
const fileContent = await fs.promises.readFile(file.path); returnItem.binary![binaryPropertyName] = await this.helpers.copyBinaryFile(
file.path,
returnItem.binary![binaryPropertyName] = await this.helpers.prepareBinaryData( fileJson.name || fileJson.filename,
Buffer.from(fileContent),
fileJson.name as string,
fileJson.type as string, fileJson.type as string,
); );
@ -547,38 +544,35 @@ export class Webhook implements INodeType {
} }
if (options.binaryData === true) { if (options.binaryData === true) {
return new Promise((resolve, _reject) => { const binaryFile = await tmpFile({ prefix: 'n8n-webhook-' });
const binaryPropertyName = options.binaryPropertyName || 'data';
const data: Buffer[] = [];
req.on('data', (chunk) => { try {
data.push(chunk); await pipeline(req, fs.createWriteStream(binaryFile.path));
});
req.on('end', async () => { const returnItem: INodeExecutionData = {
const returnItem: INodeExecutionData = { binary: {},
binary: {}, json: {
json: { headers,
headers, params: this.getParamsData(),
params: this.getParamsData(), query: this.getQueryData(),
query: this.getQueryData(), body: this.getBodyData(),
body: this.getBodyData(), },
}, };
};
returnItem.binary![binaryPropertyName as string] = await this.helpers.prepareBinaryData( const binaryPropertyName = (options.binaryPropertyName || 'data') as string;
Buffer.concat(data), returnItem.binary![binaryPropertyName] = await this.helpers.copyBinaryFile(
); binaryFile.path,
mimeType,
);
return resolve({ return {
workflowData: [[returnItem]], workflowData: [[returnItem]],
}); };
}); } catch (error) {
throw new NodeOperationError(this.getNode(), error);
req.on('error', (error) => { } finally {
throw new NodeOperationError(this.getNode(), error); await binaryFile.cleanup();
}); }
});
} }
const response: INodeExecutionData = { const response: INodeExecutionData = {

View file

@ -27,13 +27,16 @@ export type IAllExecuteFunctions =
| ITriggerFunctions | ITriggerFunctions
| IWebhookFunctions; | IWebhookFunctions;
export type BinaryFileType = 'text' | 'image' | 'video';
export interface IBinaryData { export interface IBinaryData {
[key: string]: string | undefined; [key: string]: string | undefined;
data: string; data: string;
mimeType: string; mimeType: string;
fileType?: BinaryFileType;
fileName?: string; fileName?: string;
directory?: string; directory?: string;
fileExtension?: string; fileExtension?: string;
fileSize?: string;
id?: string; id?: string;
} }

View file

@ -349,6 +349,7 @@ importers:
n8n-workflow: ~0.126.0 n8n-workflow: ~0.126.0
oauth-1.0a: ^2.2.6 oauth-1.0a: ^2.2.6
p-cancelable: ^2.0.0 p-cancelable: ^2.0.0
pretty-bytes: ^5.6.0
qs: ^6.10.1 qs: ^6.10.1
request: ^2.88.2 request: ^2.88.2
request-promise-native: ^1.0.7 request-promise-native: ^1.0.7
@ -367,6 +368,7 @@ importers:
n8n-workflow: link:../workflow n8n-workflow: link:../workflow
oauth-1.0a: 2.2.6 oauth-1.0a: 2.2.6
p-cancelable: 2.1.1 p-cancelable: 2.1.1
pretty-bytes: 5.6.0
qs: 6.11.0 qs: 6.11.0
request: 2.88.2 request: 2.88.2
request-promise-native: 1.0.9_request@2.88.2 request-promise-native: 1.0.9_request@2.88.2
@ -17465,7 +17467,6 @@ packages:
/pretty-bytes/5.6.0: /pretty-bytes/5.6.0:
resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==}
engines: {node: '>=6'} engines: {node: '>=6'}
dev: true
/pretty-error/2.1.2: /pretty-error/2.1.2:
resolution: {integrity: sha512-EY5oDzmsX5wvuynAByrmY0P0hcp+QpnAKbJng2A2MPjVKXCxrDSUkzghVJ4ZGPIv+JC4gX8fPUWscC0RtjsWGw==} resolution: {integrity: sha512-EY5oDzmsX5wvuynAByrmY0P0hcp+QpnAKbJng2A2MPjVKXCxrDSUkzghVJ4ZGPIv+JC4gX8fPUWscC0RtjsWGw==}