Skip to content

Commit dd443ee

Browse files
committed
feat: file drag-and-drop, multi-type uploads, attachment icons; tests; v1.5.9
- Chat: full-page drag-and-drop zone for files; accept images, PDF, Excel, Word, text, CSV, JSON - Chat: non-image files uploaded via POST /api/uploads, sent as attachmentFilenames with message - Chat: attachment chips use FileIcon (reuse file-extension-icons), aligned remove button - API: uploads allowlist (images, audio, documents); blocklist (executables/scripts); saveFileFromBuffer - API: orchestrator accepts attachmentFilenames, injects file paths into model context - Unit tests: uploads-handler (validateUploadMimetype, extFromMimetype, processUploadFile), uploads.service saveFileFromBuffer - Docs: API.md updated for POST /uploads and send_chat_message attachmentFilenames - Version: 1.5.8 → 1.5.9
1 parent fbc61fc commit dd443ee

File tree

10 files changed

+338
-43
lines changed

10 files changed

+338
-43
lines changed

apps/api/src/app/orchestrator/orchestrator.service.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export class OrchestratorService implements OnModuleInit {
9696
images?: string[];
9797
audio?: string;
9898
audioFilename?: string;
99+
attachmentFilenames?: string[];
99100
story?: Array<{ id: string; type: string; message: string; timestamp: string; details?: string }>;
100101
}): Promise<void> {
101102
const action = msg.action;
@@ -120,7 +121,13 @@ export class OrchestratorService implements OnModuleInit {
120121
this.handleLogout();
121122
break;
122123
case WS_ACTION.SEND_CHAT_MESSAGE:
123-
await this.handleChatMessage(msg.text ?? '', msg.images, msg.audio, msg.audioFilename);
124+
await this.handleChatMessage(
125+
msg.text ?? '',
126+
msg.images,
127+
msg.audio,
128+
msg.audioFilename,
129+
msg.attachmentFilenames
130+
);
124131
break;
125132
case WS_ACTION.SUBMIT_STORY:
126133
this.handleSubmitStory(msg.story ?? []);
@@ -207,7 +214,13 @@ export class OrchestratorService implements OnModuleInit {
207214
this.strategy.executeLogout(connection);
208215
}
209216

210-
private async handleChatMessage(text: string, images?: string[], audio?: string, audioFilenameFromClient?: string): Promise<void> {
217+
private async handleChatMessage(
218+
text: string,
219+
images?: string[],
220+
audio?: string,
221+
audioFilenameFromClient?: string,
222+
attachmentFilenames?: string[]
223+
): Promise<void> {
211224
if (!this.isAuthenticated) {
212225
this._send(WS_EVENT.ERROR, { message: ERROR_CODE.NEED_AUTH });
213226
return;
@@ -266,6 +279,17 @@ export class OrchestratorService implements OnModuleInit {
266279
voiceContext = path ? `\n\nThe user attached a voice recording. File path: ${path}\n\n` : '';
267280
}
268281

282+
let attachmentContext = '';
283+
if (attachmentFilenames?.length) {
284+
const paths = attachmentFilenames
285+
.map((f) => this.uploadsService.getPath(f))
286+
.filter((p): p is string => p !== null);
287+
attachmentContext =
288+
paths.length > 0
289+
? `\n\nThe user attached ${paths.length} file(s). Full paths (for reference):\n${paths.map((p) => `- ${p}`).join('\n')}\n\n`
290+
: '';
291+
}
292+
269293
const atPathRegex = /@([^\s@]+)/g;
270294
const atPaths = [...new Set((text.match(atPathRegex) ?? []).map((m) => m.slice(1)))];
271295
let fileContext = '';
@@ -291,7 +315,7 @@ export class OrchestratorService implements OnModuleInit {
291315
}
292316
}
293317

294-
const fullPrompt = `${fileContext}${imageContext}${voiceContext}\n${text}`.trim();
318+
const fullPrompt = `${fileContext}${imageContext}${voiceContext}${attachmentContext}\n${text}`.trim();
295319
const model = this.modelStore.get();
296320

297321
const syntheticStepId = 'generating-response';

apps/api/src/app/uploads/uploads-handler.test.ts

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,47 @@ import { BadRequestException } from '@nestjs/common';
33
import {
44
processUploadFile,
55
validateUploadMimetype,
6+
extFromMimetype,
67
type MultipartFileResult,
78
} from './uploads-handler';
89

910
describe('validateUploadMimetype', () => {
10-
test('throws for non-audio mimetype', () => {
11-
expect(() => validateUploadMimetype('image/png')).toThrow(BadRequestException);
11+
test('allows image mimetypes', () => {
12+
expect(() => validateUploadMimetype('image/png')).not.toThrow();
13+
expect(() => validateUploadMimetype('image/jpeg')).not.toThrow();
1214
});
1315

14-
test('allows audio/* mimetypes', () => {
16+
test('allows audio mimetypes', () => {
1517
expect(() => validateUploadMimetype('audio/webm')).not.toThrow();
1618
expect(() => validateUploadMimetype('audio/ogg')).not.toThrow();
1719
expect(() => validateUploadMimetype('audio/custom')).not.toThrow();
1820
});
21+
22+
test('allows document mimetypes', () => {
23+
expect(() => validateUploadMimetype('application/pdf')).not.toThrow();
24+
expect(() => validateUploadMimetype('text/plain')).not.toThrow();
25+
expect(() => validateUploadMimetype('text/csv')).not.toThrow();
26+
expect(() =>
27+
validateUploadMimetype('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
28+
).not.toThrow();
29+
});
30+
31+
test('throws for blocked mimetype', () => {
32+
expect(() => validateUploadMimetype('application/x-msdownload')).toThrow(BadRequestException);
33+
});
34+
35+
test('throws for unknown mimetype', () => {
36+
expect(() => validateUploadMimetype('application/x-foo-bar')).toThrow(BadRequestException);
37+
});
38+
});
39+
40+
describe('extFromMimetype', () => {
41+
test('returns correct ext for known types', () => {
42+
expect(extFromMimetype('application/pdf')).toBe('pdf');
43+
expect(extFromMimetype('text/plain')).toBe('txt');
44+
expect(extFromMimetype('image/jpeg')).toBe('jpg');
45+
expect(extFromMimetype('image/png')).toBe('png');
46+
});
1947
});
2048

2149
describe('processUploadFile', () => {
@@ -25,14 +53,14 @@ describe('processUploadFile', () => {
2553
);
2654
});
2755

28-
test('throws when mimetype not audio', async () => {
56+
test('throws when mimetype blocked', async () => {
2957
const fileResult: MultipartFileResult = {
30-
mimetype: 'image/png',
58+
mimetype: 'application/x-msdownload',
3159
toBuffer: async () => Buffer.from(''),
3260
};
33-
await expect(processUploadFile(fileResult, () => 'x.webm')).rejects.toThrow(
34-
BadRequestException
35-
);
61+
await expect(
62+
processUploadFile(fileResult, () => 'x.webm')
63+
).rejects.toThrow(BadRequestException);
3664
});
3765

3866
test('returns filename for valid audio', async () => {
@@ -43,4 +71,13 @@ describe('processUploadFile', () => {
4371
const result = await processUploadFile(fileResult, () => 'saved.webm');
4472
expect(result).toEqual({ filename: 'saved.webm' });
4573
});
74+
75+
test('returns filename for valid image', async () => {
76+
const fileResult: MultipartFileResult = {
77+
mimetype: 'image/png',
78+
toBuffer: async () => Buffer.from(''),
79+
};
80+
const result = await processUploadFile(fileResult, () => 'saved.png');
81+
expect(result).toEqual({ filename: 'saved.png' });
82+
});
4683
});

apps/api/src/app/uploads/uploads-handler.ts

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,95 @@ const AUDIO_MIMES = new Set([
88
'audio/ogg;codecs=opus',
99
]);
1010

11+
const ALLOWED_MIME_PREFIXES = new Set([
12+
'image/',
13+
'audio/',
14+
'text/',
15+
'application/pdf',
16+
'application/json',
17+
'application/vnd.ms-excel',
18+
'application/vnd.openxmlformats-officedocument.spreadsheetml.',
19+
'application/msword',
20+
'application/vnd.openxmlformats-officedocument.wordprocessingml.',
21+
'application/rtf',
22+
]);
23+
24+
const BLOCKED_MIME_SUBSTRINGS = [
25+
'application/x-msdownload',
26+
'application/x-msi',
27+
'application/x-executable',
28+
'application/x-sh',
29+
'application/x-shellscript',
30+
'application/javascript',
31+
'text/javascript',
32+
'application/x-bat',
33+
'application/x-csh',
34+
'application/vnd.microsoft.portable-executable',
35+
];
36+
37+
const MIME_TO_EXT: Record<string, string> = {
38+
'audio/webm': 'webm',
39+
'audio/ogg': 'ogg',
40+
'audio/mp4': 'm4a',
41+
'application/pdf': 'pdf',
42+
'application/json': 'json',
43+
'text/plain': 'txt',
44+
'text/csv': 'csv',
45+
'text/markdown': 'md',
46+
'text/html': 'html',
47+
'application/rtf': 'rtf',
48+
'application/msword': 'doc',
49+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
50+
'application/vnd.ms-excel': 'xls',
51+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
52+
};
53+
1154
export type MultipartFileResult = { mimetype: string; toBuffer: () => Promise<Buffer> } | undefined;
1255

56+
function isAllowedMimetype(mimetype: string): boolean {
57+
const normalized = mimetype.split(';')[0].trim().toLowerCase();
58+
if (BLOCKED_MIME_SUBSTRINGS.some((s) => normalized.includes(s))) return false;
59+
if (AUDIO_MIMES.has(mimetype) || normalized.startsWith('audio/')) return true;
60+
if (normalized.startsWith('image/')) return true;
61+
if (normalized.startsWith('text/')) return true;
62+
for (const prefix of ALLOWED_MIME_PREFIXES) {
63+
if (prefix.endsWith('.') && normalized.startsWith(prefix)) return true;
64+
if (normalized === prefix || normalized.startsWith(prefix)) return true;
65+
}
66+
return false;
67+
}
68+
1369
export function validateUploadMimetype(mimetype: string): void {
14-
if (!AUDIO_MIMES.has(mimetype) && !mimetype.startsWith('audio/')) {
15-
throw new BadRequestException('Unsupported file type');
70+
if (!mimetype || !isAllowedMimetype(mimetype)) {
71+
throw new BadRequestException('Unsupported or blocked file type');
72+
}
73+
}
74+
75+
export function extFromMimetype(mimetype: string): string {
76+
const normalized = mimetype.split(';')[0].trim().toLowerCase();
77+
const exact = MIME_TO_EXT[normalized];
78+
if (exact) return exact;
79+
if (normalized.startsWith('image/')) {
80+
const sub = normalized.replace('image/', '');
81+
return sub === 'jpeg' ? 'jpg' : sub;
82+
}
83+
if (normalized.startsWith('audio/')) {
84+
if (normalized.includes('webm')) return 'webm';
85+
if (normalized.includes('ogg')) return 'ogg';
86+
if (normalized.includes('mp4')) return 'm4a';
87+
return 'webm';
1688
}
89+
return 'bin';
1790
}
1891

1992
export async function processUploadFile(
2093
fileResult: MultipartFileResult,
21-
saveAudioFromBuffer: (buffer: Buffer, mimetype: string) => string | Promise<string>
94+
saveFromBuffer: (buffer: Buffer, mimetype: string) => string | Promise<string>
2295
): Promise<{ filename: string }> {
2396
if (!fileResult) throw new BadRequestException('No file uploaded');
24-
const mimetype = fileResult.mimetype ?? 'audio/webm';
97+
const mimetype = fileResult.mimetype ?? 'application/octet-stream';
2598
validateUploadMimetype(mimetype);
2699
const buffer = await fileResult.toBuffer();
27-
const filename = await saveAudioFromBuffer(buffer, mimetype);
100+
const filename = await saveFromBuffer(buffer, mimetype);
28101
return { filename };
29102
}

apps/api/src/app/uploads/uploads.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export class UploadsController {
2424
async uploadFile(@Req() req: FastifyRequest): Promise<{ filename: string }> {
2525
const data = await (req as { file: () => Promise<MultipartFileResult> }).file();
2626
return processUploadFile(data, (buffer, mimetype) =>
27-
this.uploads.saveAudioFromBuffer(buffer, mimetype)
27+
this.uploads.saveFileFromBuffer(buffer, mimetype)
2828
);
2929
}
3030
}

apps/api/src/app/uploads/uploads.service.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,18 @@ describe('UploadsService', () => {
8888
const filename = await service.saveAudioFromBuffer(Buffer.from('x'), 'audio/webm');
8989
expect(service.getPath(filename)).toBe(join(subDir, 'uploads', filename));
9090
});
91+
92+
test('saveFileFromBuffer creates file with correct extension for PDF', async () => {
93+
const service = new UploadsService(config as never);
94+
const buf = Buffer.from('pdf content');
95+
const filename = await service.saveFileFromBuffer(buf, 'application/pdf');
96+
expect(filename).toMatch(/\.pdf$/);
97+
expect(readFileSync(service.getPath(filename)!)).toEqual(buf);
98+
});
99+
100+
test('saveFileFromBuffer uses correct extension for spreadsheet and text', async () => {
101+
const service = new UploadsService(config as never);
102+
expect(await service.saveFileFromBuffer(Buffer.from(''), 'text/csv')).toMatch(/\.csv$/);
103+
expect(await service.saveFileFromBuffer(Buffer.from(''), 'text/plain')).toMatch(/\.txt$/);
104+
});
91105
});

apps/api/src/app/uploads/uploads.service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { writeFile } from 'node:fs/promises';
44
import { join } from 'node:path';
55
import { randomUUID } from 'node:crypto';
66
import { ConfigService } from '../config/config.service';
7+
import { extFromMimetype } from './uploads-handler';
78

89
const DATA_URL_REGEX = /^data:([^;]+);base64,(.+)$/;
910

@@ -49,6 +50,12 @@ export class UploadsService {
4950
return this.writeFile(ext, buffer);
5051
}
5152

53+
async saveFileFromBuffer(buffer: Buffer, mimetype: string): Promise<string> {
54+
this.ensureUploadsDir();
55+
const ext = extFromMimetype(mimetype);
56+
return this.writeFile(ext, buffer);
57+
}
58+
5259
getPath(filename: string): string | null {
5360
if (!this.isSafeFilename(filename)) return null;
5461
const path = join(this.getUploadsDir(), filename);

apps/api/src/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ async function bootstrap() {
119119
images?: string[];
120120
audio?: string;
121121
audioFilename?: string;
122+
attachmentFilenames?: string[];
122123
};
123124
void orchestrator.handleClientMessage(msg);
124125
} catch {

0 commit comments

Comments
 (0)