feat: add MEDIA outbound attachment directive
This commit is contained in:
@@ -5,12 +5,12 @@ Use it to acknowledge a request before starting longer work.
|
||||
|
||||
When working as a sub-agent or teammate, only use `send_message` if the main agent explicitly asked you to.
|
||||
|
||||
## Image attachments
|
||||
## Media attachments
|
||||
|
||||
When a locally generated image or screenshot should appear in Discord, include a Markdown image with an absolute local path:
|
||||
When a locally generated image, screenshot, video, audio, or document should appear in Discord, include a `MEDIA:` directive on its own line with an absolute local path:
|
||||
|
||||
```text
|
||||

|
||||
MEDIA:/absolute/path/preview.mp4
|
||||
```
|
||||
|
||||
You may also use `[Image: /absolute/path/screenshot.png]` when that is shorter. Use absolute local paths only, do not repeat the same path in the visible text, and only attach raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.
|
||||
`MEDIA:` lines are hidden from the visible message and uploaded as native Discord attachments. Use absolute local paths only, do not repeat the same path in the visible text, and do not use generic markdown links or plain file paths as attachment directives. Supported formats include PNG, JPEG, GIF, WebP, BMP, MP4, MOV, WebM, MP3, WAV, OGG, M4A, FLAC, PDF, ZIP, TXT, Markdown, CSV, and JSON. SVG is not accepted.
|
||||
|
||||
@@ -28,19 +28,20 @@ Your output is sent directly to the Discord group.
|
||||
- Do not use generic recurring task registration from Codex
|
||||
- If the user wants a reminder or other non-CI recurring task, tell them to ask Claude/클코 to schedule it
|
||||
|
||||
## Image attachments
|
||||
## Media attachments
|
||||
|
||||
When you need to show a locally generated image, screenshot, or other raster output in Discord, include a Markdown image with an absolute local path:
|
||||
When you need to show a locally generated image, screenshot, video, audio, or document in Discord, include a `MEDIA:` directive on its own line with an absolute local path:
|
||||
|
||||
```text
|
||||

|
||||
MEDIA:/absolute/path/preview.mp4
|
||||
```
|
||||
|
||||
- You may also use `[Image: /absolute/path/image.png]` when that is shorter
|
||||
- `MEDIA:` lines are hidden from the visible message and uploaded as native Discord attachments
|
||||
- URLs and relative paths are ignored
|
||||
- Do not repeat the same file path elsewhere in the visible text
|
||||
- Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.
|
||||
- Use this for generated images and e2e screenshots; the Discord channel validates and uploads the file
|
||||
- Do not use generic markdown links or plain file paths as attachment directives
|
||||
- Supported formats include PNG, JPEG, GIF, WebP, BMP, MP4, MOV, WebM, MP3, WAV, OGG, M4A, FLAC, PDF, ZIP, TXT, Markdown, CSV, and JSON. SVG is not accepted.
|
||||
- Use this for generated images, e2e screenshots, previews, audio samples, and documents; the Discord channel validates and uploads the file
|
||||
|
||||
## CI 감시 (watch_ci)
|
||||
|
||||
|
||||
@@ -20,18 +20,19 @@ Do not use markdown headings in chat replies. Keep messages clean and readable f
|
||||
|
||||
The group folder may contain a `conversations/` directory with searchable history from earlier sessions. Use it when you need prior context.
|
||||
|
||||
## Image attachments
|
||||
## Media attachments
|
||||
|
||||
For locally generated images or e2e screenshots that should appear in Discord, include a Markdown image with an absolute local path:
|
||||
For locally generated images, screenshots, videos, audio, or documents that should appear in Discord, include a `MEDIA:` directive on its own line with an absolute local path:
|
||||
|
||||
```text
|
||||

|
||||
MEDIA:/absolute/path/preview.mp4
|
||||
```
|
||||
|
||||
- You may also use `[Image: /absolute/path/screenshot.png]` when that is shorter
|
||||
- Do not duplicate the same path elsewhere in the visible text
|
||||
- Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.
|
||||
- The channel harness validates and uploads attachments from these image markers
|
||||
- `MEDIA:` lines are hidden from the visible message and uploaded as native Discord attachments
|
||||
- Use absolute local paths only, and do not repeat the same path elsewhere in the visible text
|
||||
- Do not rely on generic markdown links or plain file paths for attachments
|
||||
- Supported formats include PNG, JPEG, GIF, WebP, BMP, MP4, MOV, WebM, MP3, WAV, OGG, M4A, FLAC, PDF, ZIP, TXT, Markdown, CSV, and JSON. SVG is not accepted.
|
||||
- The channel harness validates and uploads attachments from these media directives
|
||||
|
||||
## CI monitoring (watch_ci)
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ export const IMAGE_TAG_RE =
|
||||
/\[Image:\s*(?:(?:[^\]\n]*?)\s*→\s*)?(\/[^\]\n]+)\]/g;
|
||||
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
||||
const MARKDOWN_ABSOLUTE_LINK_RE = /!?\[[^\]\n]*\]\((\/[^)\n]+)\)/g;
|
||||
const MEDIA_TAG_RE =
|
||||
/^[ \t]*MEDIA:\s*(?:"([^"\n]+)"|'([^'\n]+)'|`([^`\n]+)`|(\/\S+))[ \t]*$/gm;
|
||||
|
||||
export const IPC_POLL_MS = 500;
|
||||
export const IPC_INPUT_SUBDIR = 'input';
|
||||
@@ -48,6 +50,7 @@ export interface NormalizedRunnerOutput {
|
||||
output?: RunnerStructuredOutput;
|
||||
attachmentSource?:
|
||||
| 'legacy-ejclaw-json'
|
||||
| 'media-tag'
|
||||
| 'markdown-image'
|
||||
| 'image-tag'
|
||||
| 'mixed'
|
||||
@@ -124,6 +127,71 @@ export function extractMarkdownImageAttachments(text: string): {
|
||||
};
|
||||
}
|
||||
|
||||
function fencedCodeSpans(text: string): Array<[number, number]> {
|
||||
return [...text.matchAll(/```[\s\S]*?```/g)].map((match) => [
|
||||
match.index ?? 0,
|
||||
(match.index ?? 0) + match[0].length,
|
||||
]);
|
||||
}
|
||||
|
||||
function isInsideSpans(index: number, spans: Array<[number, number]>): boolean {
|
||||
return spans.some(([start, end]) => index >= start && index < end);
|
||||
}
|
||||
|
||||
function mediaMime(filePath: string): string | undefined {
|
||||
const lower = filePath.toLowerCase();
|
||||
if (lower.endsWith('.png')) return 'image/png';
|
||||
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
|
||||
if (lower.endsWith('.gif')) return 'image/gif';
|
||||
if (lower.endsWith('.webp')) return 'image/webp';
|
||||
if (lower.endsWith('.bmp')) return 'image/bmp';
|
||||
if (lower.endsWith('.mp4')) return 'video/mp4';
|
||||
if (lower.endsWith('.mov')) return 'video/quicktime';
|
||||
if (lower.endsWith('.webm')) return 'video/webm';
|
||||
if (lower.endsWith('.mp3')) return 'audio/mpeg';
|
||||
if (lower.endsWith('.wav')) return 'audio/wav';
|
||||
if (lower.endsWith('.ogg')) return 'audio/ogg';
|
||||
if (lower.endsWith('.m4a')) return 'audio/mp4';
|
||||
if (lower.endsWith('.flac')) return 'audio/flac';
|
||||
if (lower.endsWith('.pdf')) return 'application/pdf';
|
||||
if (lower.endsWith('.zip')) return 'application/zip';
|
||||
if (lower.endsWith('.csv')) return 'text/csv';
|
||||
if (lower.endsWith('.json')) return 'application/json';
|
||||
if (lower.endsWith('.md')) return 'text/markdown';
|
||||
if (lower.endsWith('.txt')) return 'text/plain';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractMediaAttachments(text: string): {
|
||||
cleanText: string;
|
||||
attachments: RunnerOutputAttachment[];
|
||||
} {
|
||||
const codeSpans = fencedCodeSpans(text);
|
||||
const attachments: RunnerOutputAttachment[] = [];
|
||||
const cleanText = text.replace(
|
||||
MEDIA_TAG_RE,
|
||||
(full: string, doubleQuoted, singleQuoted, backticked, bare, offset) => {
|
||||
if (isInsideSpans(offset, codeSpans)) return full;
|
||||
const filePath = String(
|
||||
doubleQuoted ?? singleQuoted ?? backticked ?? bare ?? '',
|
||||
).trim();
|
||||
if (!filePath.startsWith('/')) return full;
|
||||
const mime = mediaMime(filePath);
|
||||
attachments.push({
|
||||
path: filePath,
|
||||
name: attachmentName(filePath),
|
||||
...(mime ? { mime } : {}),
|
||||
});
|
||||
return '';
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
cleanText: cleanText.trim(),
|
||||
attachments: uniqueAttachments(attachments),
|
||||
};
|
||||
}
|
||||
|
||||
function imageTagPathsToAttachments(
|
||||
imagePaths: string[],
|
||||
): RunnerOutputAttachment[] {
|
||||
@@ -319,14 +387,16 @@ export function normalizeAgentOutput(
|
||||
};
|
||||
}
|
||||
|
||||
const mediaExtracted = extractMediaAttachments(normalized.output.text);
|
||||
const markdownExtracted = extractMarkdownImageAttachments(
|
||||
normalized.output.text,
|
||||
mediaExtracted.cleanText,
|
||||
);
|
||||
const imageTagExtracted = extractImageTagPaths(markdownExtracted.cleanText);
|
||||
const imageTagAttachments = imageTagPathsToAttachments(
|
||||
imageTagExtracted.imagePaths,
|
||||
);
|
||||
const attachments = uniqueAttachments([
|
||||
...mediaExtracted.attachments,
|
||||
...markdownExtracted.attachments,
|
||||
...imageTagAttachments,
|
||||
]);
|
||||
@@ -339,11 +409,17 @@ export function normalizeAgentOutput(
|
||||
}
|
||||
|
||||
const attachmentSource =
|
||||
markdownExtracted.attachments.length > 0 && imageTagAttachments.length > 0
|
||||
[
|
||||
mediaExtracted.attachments.length > 0,
|
||||
markdownExtracted.attachments.length > 0,
|
||||
imageTagAttachments.length > 0,
|
||||
].filter(Boolean).length > 1
|
||||
? 'mixed'
|
||||
: markdownExtracted.attachments.length > 0
|
||||
? 'markdown-image'
|
||||
: 'image-tag';
|
||||
: mediaExtracted.attachments.length > 0
|
||||
? 'media-tag'
|
||||
: markdownExtracted.attachments.length > 0
|
||||
? 'markdown-image'
|
||||
: 'image-tag';
|
||||
|
||||
return {
|
||||
result: imageTagExtracted.cleanText,
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
} from './room-role-context.js';
|
||||
export {
|
||||
extractMarkdownImageAttachments,
|
||||
extractMediaAttachments,
|
||||
extractImageTagPaths,
|
||||
IMAGE_TAG_RE,
|
||||
IPC_CLOSE_SENTINEL,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
extractMarkdownImageAttachments,
|
||||
extractMediaAttachments,
|
||||
normalizeAgentOutput,
|
||||
} from '../src/agent-protocol.js';
|
||||
|
||||
@@ -43,6 +44,69 @@ describe('normalizeAgentOutput', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes MEDIA directives into internal attachments', () => {
|
||||
expect(
|
||||
normalizeAgentOutput(
|
||||
'TASK_DONE\n\n사운드 프리뷰입니다.\nMEDIA:/tmp/adventurer-active-sfx-preview.mp4',
|
||||
),
|
||||
).toEqual({
|
||||
result: 'TASK_DONE\n\n사운드 프리뷰입니다.',
|
||||
output: {
|
||||
visibility: 'public',
|
||||
text: 'TASK_DONE\n\n사운드 프리뷰입니다.',
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/adventurer-active-sfx-preview.mp4',
|
||||
name: 'adventurer-active-sfx-preview.mp4',
|
||||
mime: 'video/mp4',
|
||||
},
|
||||
],
|
||||
},
|
||||
attachmentSource: 'media-tag',
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts quoted MEDIA paths with spaces', () => {
|
||||
expect(
|
||||
extractMediaAttachments(
|
||||
'프리뷰\nMEDIA:"/tmp/Maldhalla Demo/preview.mp4"',
|
||||
),
|
||||
).toEqual({
|
||||
cleanText: '프리뷰',
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/Maldhalla Demo/preview.mp4',
|
||||
name: 'preview.mp4',
|
||||
mime: 'video/mp4',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat MEDIA references in code fences as attachments', () => {
|
||||
const content = '```text\nMEDIA:/tmp/not-an-attachment.mp4\n```';
|
||||
expect(extractMediaAttachments(content)).toEqual({
|
||||
cleanText: content,
|
||||
attachments: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat inbound attachment placeholders as outbound directives', () => {
|
||||
expect(
|
||||
normalizeAgentOutput(
|
||||
'TASK_DONE\n\n[Video: preview.mp4 → /tmp/preview.mp4]\n확인했습니다.',
|
||||
),
|
||||
).toEqual({
|
||||
result:
|
||||
'TASK_DONE\n\n[Video: preview.mp4 → /tmp/preview.mp4]\n확인했습니다.',
|
||||
output: {
|
||||
visibility: 'public',
|
||||
text: 'TASK_DONE\n\n[Video: preview.mp4 → /tmp/preview.mp4]\n확인했습니다.',
|
||||
},
|
||||
attachmentSource: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes legacy image tags into internal attachments', () => {
|
||||
expect(
|
||||
normalizeAgentOutput(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
extractMarkdownImageAttachments,
|
||||
extractMediaAttachments,
|
||||
extractImageTagPaths,
|
||||
IMAGE_TAG_RE,
|
||||
IPC_CLOSE_SENTINEL,
|
||||
|
||||
@@ -9,7 +9,13 @@ export interface PreparedDiscordOutbound {
|
||||
text: string;
|
||||
cleanText: string;
|
||||
attachments: OutboundAttachment[];
|
||||
attachmentSource: 'structured' | 'md-link' | 'image-tag' | 'mixed' | 'none';
|
||||
attachmentSource:
|
||||
| 'structured'
|
||||
| 'media-tag'
|
||||
| 'md-link'
|
||||
| 'image-tag'
|
||||
| 'mixed'
|
||||
| 'none';
|
||||
silent: boolean;
|
||||
}
|
||||
|
||||
@@ -51,9 +57,11 @@ export function prepareDiscordOutbound(
|
||||
? 'structured'
|
||||
: normalized.attachmentSource === 'legacy-ejclaw-json'
|
||||
? 'structured'
|
||||
: normalized.attachmentSource === 'markdown-image'
|
||||
? 'md-link'
|
||||
: (normalized.attachmentSource ?? 'none');
|
||||
: normalized.attachmentSource === 'media-tag'
|
||||
? 'media-tag'
|
||||
: normalized.attachmentSource === 'markdown-image'
|
||||
? 'md-link'
|
||||
: (normalized.attachmentSource ?? 'none');
|
||||
|
||||
return {
|
||||
text: outboundText,
|
||||
|
||||
@@ -102,6 +102,10 @@ const ONE_PIXEL_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
const MINIMAL_MP4 = Buffer.from([
|
||||
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0x00,
|
||||
0x00, 0x02, 0x00, 0x69, 0x73, 0x6f, 0x6d, 0x69, 0x73, 0x6f, 0x32,
|
||||
]);
|
||||
|
||||
const tempFiles: string[] = [];
|
||||
|
||||
@@ -258,6 +262,39 @@ ${JSON.stringify({
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes MEDIA directives and sends them as files', async () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
await channel.connect();
|
||||
const filePath = path.join(
|
||||
os.tmpdir(),
|
||||
`adventurer-active-sfx-preview-${Date.now()}.mp4`,
|
||||
);
|
||||
fs.writeFileSync(filePath, MINIMAL_MP4);
|
||||
tempFiles.push(filePath);
|
||||
const mockChannel = {
|
||||
send: vi.fn().mockResolvedValue({ id: 'discord-message-media' }),
|
||||
sendTyping: vi.fn(),
|
||||
};
|
||||
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
|
||||
|
||||
await channel.sendMessage(
|
||||
'dc:1234567890123456',
|
||||
`사운드 프리뷰입니다.\nMEDIA:${filePath}`,
|
||||
);
|
||||
|
||||
expect(mockChannel.send).toHaveBeenCalledWith({
|
||||
content: '사운드 프리뷰입니다.',
|
||||
files: [
|
||||
{
|
||||
attachment: fs.realpathSync(filePath),
|
||||
name: path.basename(filePath),
|
||||
},
|
||||
],
|
||||
flags: 1 << 2,
|
||||
});
|
||||
expect(JSON.stringify(mockChannel.send.mock.calls)).not.toContain('MEDIA:');
|
||||
});
|
||||
|
||||
it('keeps non-image local markdown links readable without uploading files', async () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
await channel.connect();
|
||||
|
||||
@@ -10,6 +10,12 @@ const ONE_PIXEL_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
const MINIMAL_MP4 = Buffer.from([
|
||||
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0x00,
|
||||
0x00, 0x02, 0x00, 0x69, 0x73, 0x6f, 0x6d, 0x69, 0x73, 0x6f, 0x32,
|
||||
]);
|
||||
const MINIMAL_PDF = Buffer.from('%PDF-1.4\n', 'ascii');
|
||||
const MINIMAL_ZIP = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x14, 0x00]);
|
||||
|
||||
const cleanupDirs: string[] = [];
|
||||
const cleanupFiles: string[] = [];
|
||||
@@ -133,6 +139,43 @@ describe('validateOutboundAttachments', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts supported non-image media and document files', () => {
|
||||
const dir = makeTempDir(os.tmpdir(), 'ejclaw-media-');
|
||||
const mp4Path = writeFile(dir, 'preview.mp4', MINIMAL_MP4);
|
||||
const pdfPath = writeFile(dir, 'report.pdf', MINIMAL_PDF);
|
||||
const zipPath = writeFile(dir, 'archive.zip', MINIMAL_ZIP);
|
||||
const textPath = writeFile(dir, 'notes.txt', 'hello\n');
|
||||
|
||||
const result = validateOutboundAttachments([
|
||||
{ path: mp4Path, mime: 'video/mp4' },
|
||||
{ path: pdfPath, mime: 'application/pdf' },
|
||||
{ path: zipPath, mime: 'application/zip' },
|
||||
{ path: textPath, mime: 'text/plain' },
|
||||
]);
|
||||
|
||||
expect(result.rejected).toEqual([]);
|
||||
expect(result.files).toEqual([
|
||||
{
|
||||
attachment: fs.realpathSync(mp4Path),
|
||||
name: 'preview.mp4',
|
||||
},
|
||||
{
|
||||
attachment: fs.realpathSync(pdfPath),
|
||||
name: 'report.pdf',
|
||||
},
|
||||
{
|
||||
attachment: fs.realpathSync(zipPath),
|
||||
name: 'archive.zip',
|
||||
},
|
||||
{
|
||||
attachment: fs.realpathSync(textPath),
|
||||
name: 'notes.txt',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateOutboundAttachments policy checks', () => {
|
||||
it('requires workspace paths to be explicitly allowlisted', () => {
|
||||
const dir = makeTempDir(process.cwd(), '.ejclaw-attachment-');
|
||||
const imagePath = writeFile(dir, 'workspace-shot.png', ONE_PIXEL_PNG);
|
||||
@@ -243,6 +286,16 @@ describe('validateOutboundAttachments', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects supported non-image files with invalid signatures', () => {
|
||||
const dir = makeTempDir(os.tmpdir(), 'ejclaw-media-');
|
||||
const fakeMp4 = writeFile(dir, 'fake.mp4', 'not a movie');
|
||||
|
||||
expect(validateOutboundAttachments([{ path: fakeMp4 }])).toEqual({
|
||||
files: [],
|
||||
rejected: [{ path: fakeMp4, reason: 'invalid-file-signature' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-files and files over the size cap', () => {
|
||||
const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-');
|
||||
const nestedDir = path.join(dir, 'nested.png');
|
||||
|
||||
@@ -18,6 +18,29 @@ export interface ValidateOutboundAttachmentsResult {
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
|
||||
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
||||
const SUPPORTED_ATTACHMENT_MIMES: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.bmp': 'image/bmp',
|
||||
'.mp4': 'video/mp4',
|
||||
'.mov': 'video/quicktime',
|
||||
'.webm': 'video/webm',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.m4a': 'audio/mp4',
|
||||
'.flac': 'audio/flac',
|
||||
'.pdf': 'application/pdf',
|
||||
'.zip': 'application/zip',
|
||||
'.txt': 'text/plain',
|
||||
'.md': 'text/markdown',
|
||||
'.csv': 'text/csv',
|
||||
'.json': 'application/json',
|
||||
};
|
||||
|
||||
function unique(values: Array<string | null | undefined>): string[] {
|
||||
return [
|
||||
...new Set(values.filter((value): value is string => Boolean(value))),
|
||||
@@ -127,6 +150,81 @@ function detectImageMime(filePath: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function readHeader(filePath: string, byteCount = 512): Buffer {
|
||||
const handle = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const buffer = Buffer.alloc(byteCount);
|
||||
const bytesRead = fs.readSync(handle, buffer, 0, buffer.length, 0);
|
||||
return buffer.subarray(0, bytesRead);
|
||||
} finally {
|
||||
fs.closeSync(handle);
|
||||
}
|
||||
}
|
||||
|
||||
function isLikelyTextFile(header: Buffer): boolean {
|
||||
return !header.includes(0);
|
||||
}
|
||||
|
||||
function detectSupportedMime(filePath: string): string | null {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const expectedMime = SUPPORTED_ATTACHMENT_MIMES[ext];
|
||||
if (!expectedMime) return null;
|
||||
if (IMAGE_EXTS.test(filePath)) {
|
||||
return detectImageMime(filePath);
|
||||
}
|
||||
|
||||
const header = readHeader(filePath);
|
||||
switch (ext) {
|
||||
case '.mp4':
|
||||
case '.mov':
|
||||
case '.m4a':
|
||||
return header.subarray(4, 8).toString('ascii') === 'ftyp'
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.webm':
|
||||
return header.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]))
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.mp3':
|
||||
return header.subarray(0, 3).toString('ascii') === 'ID3' ||
|
||||
(header[0] === 0xff && (header[1] & 0xe0) === 0xe0)
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.wav':
|
||||
return header.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
header.subarray(8, 12).toString('ascii') === 'WAVE'
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.ogg':
|
||||
return header.subarray(0, 4).toString('ascii') === 'OggS'
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.flac':
|
||||
return header.subarray(0, 4).toString('ascii') === 'fLaC'
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.pdf':
|
||||
return header.subarray(0, 5).toString('ascii') === '%PDF-'
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.zip':
|
||||
return header
|
||||
.subarray(0, 4)
|
||||
.equals(Buffer.from([0x50, 0x4b, 0x03, 0x04])) ||
|
||||
header.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x05, 0x06])) ||
|
||||
header.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x07, 0x08]))
|
||||
? expectedMime
|
||||
: null;
|
||||
case '.txt':
|
||||
case '.md':
|
||||
case '.csv':
|
||||
case '.json':
|
||||
return isLikelyTextFile(header) ? expectedMime : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAttachmentName(
|
||||
attachment: OutboundAttachment,
|
||||
realPath: string,
|
||||
@@ -157,7 +255,9 @@ export function validateOutboundAttachments(
|
||||
rejected.push({ path: requestedPath, reason: 'not-absolute' });
|
||||
continue;
|
||||
}
|
||||
if (!IMAGE_EXTS.test(requestedPath)) {
|
||||
const expectedMime =
|
||||
SUPPORTED_ATTACHMENT_MIMES[path.extname(requestedPath).toLowerCase()];
|
||||
if (!expectedMime) {
|
||||
rejected.push({ path: requestedPath, reason: 'unsupported-extension' });
|
||||
continue;
|
||||
}
|
||||
@@ -183,11 +283,13 @@ export function validateOutboundAttachments(
|
||||
rejected.push({ path: requestedPath, reason: 'outside-allowed-dirs' });
|
||||
continue;
|
||||
}
|
||||
const detectedMime = detectImageMime(realPath);
|
||||
const detectedMime = detectSupportedMime(realPath);
|
||||
if (!detectedMime) {
|
||||
rejected.push({
|
||||
path: requestedPath,
|
||||
reason: 'invalid-image-signature',
|
||||
reason: IMAGE_EXTS.test(realPath)
|
||||
? 'invalid-image-signature'
|
||||
: 'invalid-file-signature',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user