fix: File attachment bugfixes (no-changelog) (#22142)

Co-authored-by: Jaakko Husso <jaakko@n8n.io>
This commit is contained in:
Suguru Inoue
2025-11-24 12:58:20 +01:00
committed by GitHub
co-authored by Jaakko Husso
parent 75402fb681
commit 609880e8b3
7 changed files with 68 additions and 46 deletions
+1
View File
@@ -255,6 +255,7 @@ export const emptyChatModelsResponse: ChatModelsResponse = {
*/
export const chatAttachmentSchema = z.object({
data: z.string(),
mimeType: z.string(),
fileName: z.string(),
});
@@ -8,7 +8,6 @@ import type { ChatMessageId, ChatSessionId, ChatAttachment } from '@n8n/api-type
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import type Stream from 'node:stream';
import FileType from 'file-type';
@Service()
export class ChatHubAttachmentService {
@@ -137,18 +136,14 @@ export class ChatHubAttachmentService {
buffer: Buffer,
): Promise<IBinaryData> {
const sanitizedFileName = sanitizeFilename(attachment.fileName);
const fileTypeData = await FileType.fromBuffer(buffer);
// Only trust content-based detection for security
const mimeType = fileTypeData?.mime ?? 'application/octet-stream';
// Construct IBinaryData with all required fields
const binaryData: IBinaryData = {
data: attachment.data,
mimeType,
mimeType: attachment.mimeType,
fileName: sanitizedFileName,
fileSize: `${buffer.length}`,
fileExtension: fileTypeData?.ext,
fileExtension: sanitizedFileName?.split('.').pop(),
};
return await this.binaryDataService.store(
@@ -11,6 +11,7 @@ import {
ChatHubCreateAgentRequest,
ChatHubUpdateAgentRequest,
ChatHubConversationsRequest,
ViewableMimeTypes,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { AuthenticatedRequest } from '@n8n/db';
@@ -25,17 +26,18 @@ import {
Patch,
Query,
} from '@n8n/decorators';
import { sanitizeFilename } from '@n8n/utils';
import type { Response } from 'express';
import { jsonStringify } from 'n8n-workflow';
import { strict as assert } from 'node:assert';
import { ResponseError } from '@/errors/response-errors/abstract/response.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ChatHubAgentService } from './chat-hub-agent.service';
import { ChatHubAttachmentService } from './chat-hub.attachment.service';
import { ChatHubService } from './chat-hub.service';
import { ChatModelsRequestDto } from './dto/chat-models-request.dto';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { sanitizeFilename } from '@n8n/utils';
import { ResponseError } from '@/errors/response-errors/abstract/response.error';
@RestController('/chat')
export class ChatHubController {
@@ -97,14 +99,18 @@ export class ChatHubController {
const [{ mimeType, fileName }, attachmentAsStreamOrBuffer] =
await this.chatAttachmentService.getAttachment(sessionId, messageId, attachmentIndex);
res.setHeader('Content-Type', mimeType ?? 'application/octet-stream');
res.setHeader('Content-Type', mimeType);
if (attachmentAsStreamOrBuffer.fileSize) {
res.setHeader('Content-Length', attachmentAsStreamOrBuffer.fileSize);
}
if (fileName) {
res.setHeader('Content-Disposition', `attachment; filename="${sanitizeFilename(fileName)}"`);
if (!mimeType || !ViewableMimeTypes.includes(mimeType.toLowerCase())) {
// Force download if file is not viewable
res.setHeader(
'Content-Disposition',
`attachment${fileName ? `; filename=${sanitizeFilename(fileName)}` : ''}`,
);
}
if (attachmentAsStreamOrBuffer.type === 'buffer') {
@@ -126,7 +132,15 @@ export class ChatHubController {
res: Response,
@Body payload: ChatHubSendMessageRequest,
) {
this.logger.debug(`Chat send request received: ${JSON.stringify(payload)}`);
this.logger.debug(
`Chat send request received: ${jsonStringify({
...payload,
attachments: payload.attachments?.map((a) => ({
fileName: a.fileName,
data: `${a.data.length} characters (Base64)`,
})),
})}`,
);
try {
await this.chatService.sendHumanMessage(res, req.user, {
@@ -149,7 +163,7 @@ export class ChatHubController {
});
} else if (!res.writableEnded) {
res.write(
JSON.stringify({
jsonStringify({
type: 'error',
content: error.message,
}) + '\n',
@@ -170,7 +184,7 @@ export class ChatHubController {
@Param('messageId') editId: ChatMessageId,
@Body payload: ChatHubEditMessageRequest,
) {
this.logger.debug(`Chat edit request received: ${JSON.stringify(payload)}`);
this.logger.debug(`Chat edit request received: ${jsonStringify(payload)}`);
try {
await this.chatService.editMessage(res, req.user, {
@@ -195,7 +209,7 @@ export class ChatHubController {
});
} else if (!res.writableEnded) {
res.write(
JSON.stringify({
jsonStringify({
type: 'error',
content: error.message,
}) + '\n',
@@ -216,7 +230,7 @@ export class ChatHubController {
@Param('messageId') retryId: ChatMessageId,
@Body payload: ChatHubRegenerateMessageRequest,
) {
this.logger.debug(`Chat retry request received: ${JSON.stringify(payload)}`);
this.logger.debug(`Chat retry request received: ${jsonStringify(payload)}`);
try {
await this.chatService.regenerateAIMessage(res, req.user, {
@@ -241,7 +255,7 @@ export class ChatHubController {
});
} else if (!res.writableEnded) {
res.write(
JSON.stringify({
jsonStringify({
type: 'error',
content: error.message,
}) + '\n',
@@ -261,7 +275,7 @@ export class ChatHubController {
@Param('sessionId') sessionId: ChatSessionId,
@Param('messageId') messageId: ChatMessageId,
) {
this.logger.debug(`Chat stop request received: ${JSON.stringify({ sessionId, messageId })}`);
this.logger.debug(`Chat stop request received: ${jsonStringify({ sessionId, messageId })}`);
await this.chatService.stopGeneration(req.user, sessionId, messageId);
res.status(204).send();
@@ -1,5 +1,4 @@
import type { BinaryFileType, IBinaryData } from 'n8n-workflow';
import type { ChatAttachment } from '@n8n/api-types';
export async function convertFileToBinaryData(file: File): Promise<IBinaryData> {
const reader = new FileReader();
@@ -21,20 +20,3 @@ export async function convertFileToBinaryData(file: File): Promise<IBinaryData>
reader.readAsDataURL(file);
});
}
export async function convertFileToChatAttachment(file: File): Promise<ChatAttachment> {
const reader = new FileReader();
return await new Promise((resolve, reject) => {
reader.onload = () => {
const attachment: ChatAttachment = {
data: (reader.result as string).split('base64,')?.[1] ?? '',
fileName: file.name,
};
resolve(attachment);
};
reader.onerror = () => {
reject(new Error('Failed to convert file to chat attachment'));
};
reader.readAsDataURL(file);
});
}
@@ -47,13 +47,13 @@ import type {
ChatStreamingState,
} from './chat.types';
import { retry } from '@n8n/utils/retry';
import { convertFileToChatAttachment } from '@/app/utils/fileUtils';
import { buildUiMessages, isLlmProviderModel, isMatchedAgent } from './chat.utils';
import { createAiMessageFromStreamingState, flattenModel } from './chat.utils';
import { useToast } from '@/app/composables/useToast';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { deepCopy, type INode } from 'n8n-workflow';
import type { ChatHubLLMProvider, ChatProviderSettingsDto } from '@n8n/api-types';
import { convertFileToBinaryData } from '@/app/utils/fileUtils';
export const useChatStore = defineStore(CHAT_STORE, () => {
const rootStore = useRootStore();
@@ -492,7 +492,12 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
? conversation.activeMessageChain[conversation.activeMessageChain.length - 1]
: null;
const attachments = await Promise.all(files.map(convertFileToChatAttachment));
const binaryData = await Promise.all(files.map(convertFileToBinaryData));
const attachments = binaryData.map((attachment) => ({
fileName: attachment.fileName ?? 'unnamed file',
mimeType: attachment.mimeType,
data: attachment.data,
}));
addMessage(sessionId, {
id: messageId,
@@ -179,6 +179,15 @@ onBeforeMount(() => {
</div>
<div :class="$style.content">
<div v-if="isEditing" :class="$style.editContainer">
<div v-if="attachments.length > 0" :class="$style.attachments">
<ChatFile
v-for="(attachment, index) in attachments"
:key="index"
:file="attachment.file"
:is-removable="false"
:href="attachment.downloadUrl"
/>
</div>
<N8nInput
ref="textarea"
v-model="editedText"
@@ -278,7 +287,10 @@ onBeforeMount(() => {
display: flex;
flex-wrap: wrap;
gap: var(--spacing--2xs);
margin-top: var(--spacing--xs);
.chatMessage & {
margin-top: var(--spacing--xs);
}
}
.chatMessage {
@@ -407,9 +419,20 @@ onBeforeMount(() => {
}
.editContainer {
width: 100%;
border-radius: var(--radius--lg);
padding: var(--spacing--xs);
background-color: var(--color--background--light-3);
border: var(--border);
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
gap: var(--spacing--sm);
transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
&:focus-within,
&:hover {
border-color: var(--color--secondary);
}
}
.textarea {
@@ -418,8 +441,11 @@ onBeforeMount(() => {
.textarea textarea {
font-family: inherit;
background-color: var(--color--background--light-3);
border-radius: var(--radius--lg);
line-height: 1.5em;
resize: none;
background-color: transparent !important;
border: none !important;
padding: 0 !important;
}
.editActions {
@@ -198,7 +198,6 @@ defineExpose({
type="file"
:class="$style.fileInput"
multiple
accept="image/*,.pdf,.doc,.docx,.txt"
@change="handleFileSelect"
/>