mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-28 03:24:56 +08:00
fix: File attachment bugfixes (no-changelog) (#22142)
Co-authored-by: Jaakko Husso <jaakko@n8n.io>
This commit is contained in:
co-authored by
Jaakko Husso
parent
75402fb681
commit
609880e8b3
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user