Files
n8n-enterprise-unlocked/packages/core/src/BinaryData/FileSystem.manager.ts
Iván Ovejero 33991e92d0 fix(core): Fix missing execution ID in webhook-based workflow producing binary data (#7244)
Story: https://linear.app/n8n/issue/PAY-839

This is a longstanding bug, fixed now so that the S3 backend for binary
data can use execution IDs as part of the filename.

To reproduce:

1. Set up a workflow with a POST Webhook node that accepts binary data.
2. Activate the workflow and call it sending a binary file, e.g. `curl
-X POST -F "file=@/path/to/binary/file/test.jpg"
http://localhost:5678/webhook/uuid`
3. Check `~/.n8n/binaryData`. The binary data and metadata files will be
missing the execution ID, e.g. `11869055-83c4-4493-876a-9092c4708b9b`
instead of `39011869055-83c4-4493-876a-9092c4708b9b`.
2023-09-25 12:30:28 +02:00

162 lines
4.1 KiB
TypeScript

import { createReadStream } from 'fs';
import fs from 'fs/promises';
import path from 'path';
import { v4 as uuid } from 'uuid';
import { jsonParse } from 'n8n-workflow';
import { rename } from 'node:fs/promises';
import { FileNotFoundError } from '../errors';
import { ensureDirExists } from './utils';
import type { Readable } from 'stream';
import type { BinaryData } from './types';
const EXECUTION_ID_EXTRACTOR =
/^(\w+)(?:[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})$/;
export class FileSystemManager implements BinaryData.Manager {
constructor(private storagePath: string) {}
async init() {
await ensureDirExists(this.storagePath);
}
getPath(fileId: string) {
return this.resolvePath(fileId);
}
async getSize(fileId: string) {
const filePath = this.getPath(fileId);
try {
const stats = await fs.stat(filePath);
return stats.size;
} catch (error) {
throw new Error('Failed to find binary data file in filesystem', { cause: error });
}
}
getAsStream(fileId: string, chunkSize?: number) {
const filePath = this.getPath(fileId);
return createReadStream(filePath, { highWaterMark: chunkSize });
}
async getAsBuffer(fileId: string) {
const filePath = this.getPath(fileId);
try {
return await fs.readFile(filePath);
} catch {
throw new Error(`Error finding file: ${filePath}`);
}
}
async getMetadata(fileId: string): Promise<BinaryData.Metadata> {
const filePath = this.resolvePath(`${fileId}.metadata`);
return jsonParse(await fs.readFile(filePath, { encoding: 'utf-8' }));
}
async store(
binaryData: Buffer | Readable,
executionId: string,
{ mimeType, fileName }: BinaryData.PreWriteMetadata,
) {
const fileId = this.createFileId(executionId);
const filePath = this.getPath(fileId);
await fs.writeFile(filePath, binaryData);
const fileSize = await this.getSize(fileId);
await this.storeMetadata(fileId, { mimeType, fileName, fileSize });
return { fileId, fileSize };
}
async deleteOne(fileId: string) {
const filePath = this.getPath(fileId);
return fs.rm(filePath);
}
async deleteManyByExecutionIds(executionIds: string[]) {
const set = new Set(executionIds);
const fileNames = await fs.readdir(this.storagePath);
const deletedIds = [];
for (const fileName of fileNames) {
const executionId = fileName.match(EXECUTION_ID_EXTRACTOR)?.[1];
if (executionId && set.has(executionId)) {
const filePath = this.resolvePath(fileName);
await Promise.all([fs.rm(filePath), fs.rm(`${filePath}.metadata`)]);
deletedIds.push(executionId);
}
}
return deletedIds;
}
async copyByFilePath(
filePath: string,
executionId: string,
{ mimeType, fileName }: BinaryData.PreWriteMetadata,
) {
const newFileId = this.createFileId(executionId);
await fs.cp(filePath, this.getPath(newFileId));
const fileSize = await this.getSize(newFileId);
await this.storeMetadata(newFileId, { mimeType, fileName, fileSize });
return { fileId: newFileId, fileSize };
}
async copyByFileId(fileId: string, executionId: string) {
const newFileId = this.createFileId(executionId);
await fs.copyFile(this.resolvePath(fileId), this.resolvePath(newFileId));
return newFileId;
}
async rename(oldFileId: string, newFileId: string) {
const oldPath = this.getPath(oldFileId);
const newPath = this.getPath(newFileId);
await Promise.all([
rename(oldPath, newPath),
rename(`${oldPath}.metadata`, `${newPath}.metadata`),
]);
}
// ----------------------------------
// private methods
// ----------------------------------
private createFileId(executionId: string) {
return [executionId, uuid()].join('');
}
private resolvePath(...args: string[]) {
const returnPath = path.join(this.storagePath, ...args);
if (path.relative(this.storagePath, returnPath).startsWith('..')) {
throw new FileNotFoundError('Invalid path detected');
}
return returnPath;
}
private async storeMetadata(fileId: string, metadata: BinaryData.Metadata) {
const filePath = this.resolvePath(`${fileId}.metadata`);
await fs.writeFile(filePath, JSON.stringify(metadata), { encoding: 'utf-8' });
}
}