Merge pull request #169 from phj1081/codex/owner/ejclaw

feat: add MEDIA outbound attachment directive
This commit is contained in:
Eyejoker
2026-05-26 17:33:44 +09:00
committed by GitHub
11 changed files with 373 additions and 29 deletions

View File

@@ -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. 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 ```text
![screenshot](/absolute/path/screenshot.png) 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.

View File

@@ -28,19 +28,20 @@ Your output is sent directly to the Discord group.
- Do not use generic recurring task registration from Codex - 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 - 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 ```text
![image](/absolute/path/image.png) 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 - URLs and relative paths are ignored
- Do not repeat the same file path elsewhere in the visible text - 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. - Do not use generic markdown links or plain file paths as attachment directives
- Use this for generated images and e2e screenshots; the Discord channel validates and uploads the file - 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) ## CI 감시 (watch_ci)

View File

@@ -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. 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 ```text
![screenshot](/absolute/path/screenshot.png) MEDIA:/absolute/path/preview.mp4
``` ```
- You may also use `[Image: /absolute/path/screenshot.png]` when that is shorter - `MEDIA:` lines are hidden from the visible message and uploaded as native Discord attachments
- Do not duplicate the same path elsewhere in the visible text - Use absolute local paths only, and do not repeat 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. - Do not rely on generic markdown links or plain file paths for attachments
- The channel harness validates and uploads attachments from these image markers - 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) ## CI monitoring (watch_ci)

View File

@@ -5,6 +5,8 @@ export const IMAGE_TAG_RE =
/\[Image:\s*(?:(?:[^\]\n]*?)\s*→\s*)?(\/[^\]\n]+)\]/g; /\[Image:\s*(?:(?:[^\]\n]*?)\s*→\s*)?(\/[^\]\n]+)\]/g;
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp)$/i; const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp)$/i;
const MARKDOWN_ABSOLUTE_LINK_RE = /!?\[[^\]\n]*\]\((\/[^)\n]+)\)/g; 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_POLL_MS = 500;
export const IPC_INPUT_SUBDIR = 'input'; export const IPC_INPUT_SUBDIR = 'input';
@@ -48,6 +50,7 @@ export interface NormalizedRunnerOutput {
output?: RunnerStructuredOutput; output?: RunnerStructuredOutput;
attachmentSource?: attachmentSource?:
| 'legacy-ejclaw-json' | 'legacy-ejclaw-json'
| 'media-tag'
| 'markdown-image' | 'markdown-image'
| 'image-tag' | 'image-tag'
| 'mixed' | '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( function imageTagPathsToAttachments(
imagePaths: string[], imagePaths: string[],
): RunnerOutputAttachment[] { ): RunnerOutputAttachment[] {
@@ -319,14 +387,16 @@ export function normalizeAgentOutput(
}; };
} }
const mediaExtracted = extractMediaAttachments(normalized.output.text);
const markdownExtracted = extractMarkdownImageAttachments( const markdownExtracted = extractMarkdownImageAttachments(
normalized.output.text, mediaExtracted.cleanText,
); );
const imageTagExtracted = extractImageTagPaths(markdownExtracted.cleanText); const imageTagExtracted = extractImageTagPaths(markdownExtracted.cleanText);
const imageTagAttachments = imageTagPathsToAttachments( const imageTagAttachments = imageTagPathsToAttachments(
imageTagExtracted.imagePaths, imageTagExtracted.imagePaths,
); );
const attachments = uniqueAttachments([ const attachments = uniqueAttachments([
...mediaExtracted.attachments,
...markdownExtracted.attachments, ...markdownExtracted.attachments,
...imageTagAttachments, ...imageTagAttachments,
]); ]);
@@ -339,11 +409,17 @@ export function normalizeAgentOutput(
} }
const attachmentSource = 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' ? 'mixed'
: markdownExtracted.attachments.length > 0 : mediaExtracted.attachments.length > 0
? 'markdown-image' ? 'media-tag'
: 'image-tag'; : markdownExtracted.attachments.length > 0
? 'markdown-image'
: 'image-tag';
return { return {
result: imageTagExtracted.cleanText, result: imageTagExtracted.cleanText,

View File

@@ -4,6 +4,7 @@ export {
} from './room-role-context.js'; } from './room-role-context.js';
export { export {
extractMarkdownImageAttachments, extractMarkdownImageAttachments,
extractMediaAttachments,
extractImageTagPaths, extractImageTagPaths,
IMAGE_TAG_RE, IMAGE_TAG_RE,
IPC_CLOSE_SENTINEL, IPC_CLOSE_SENTINEL,

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { import {
extractMarkdownImageAttachments, extractMarkdownImageAttachments,
extractMediaAttachments,
normalizeAgentOutput, normalizeAgentOutput,
} from '../src/agent-protocol.js'; } 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', () => { it('normalizes legacy image tags into internal attachments', () => {
expect( expect(
normalizeAgentOutput( normalizeAgentOutput(

View File

@@ -1,5 +1,6 @@
export { export {
extractMarkdownImageAttachments, extractMarkdownImageAttachments,
extractMediaAttachments,
extractImageTagPaths, extractImageTagPaths,
IMAGE_TAG_RE, IMAGE_TAG_RE,
IPC_CLOSE_SENTINEL, IPC_CLOSE_SENTINEL,

View File

@@ -9,7 +9,13 @@ export interface PreparedDiscordOutbound {
text: string; text: string;
cleanText: string; cleanText: string;
attachments: OutboundAttachment[]; attachments: OutboundAttachment[];
attachmentSource: 'structured' | 'md-link' | 'image-tag' | 'mixed' | 'none'; attachmentSource:
| 'structured'
| 'media-tag'
| 'md-link'
| 'image-tag'
| 'mixed'
| 'none';
silent: boolean; silent: boolean;
} }
@@ -51,9 +57,11 @@ export function prepareDiscordOutbound(
? 'structured' ? 'structured'
: normalized.attachmentSource === 'legacy-ejclaw-json' : normalized.attachmentSource === 'legacy-ejclaw-json'
? 'structured' ? 'structured'
: normalized.attachmentSource === 'markdown-image' : normalized.attachmentSource === 'media-tag'
? 'md-link' ? 'media-tag'
: (normalized.attachmentSource ?? 'none'); : normalized.attachmentSource === 'markdown-image'
? 'md-link'
: (normalized.attachmentSource ?? 'none');
return { return {
text: outboundText, text: outboundText,

View File

@@ -102,6 +102,10 @@ const ONE_PIXEL_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
'base64', '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[] = []; 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 () => { it('keeps non-image local markdown links readable without uploading files', async () => {
const channel = new DiscordChannel('test-token', createTestOpts()); const channel = new DiscordChannel('test-token', createTestOpts());
await channel.connect(); await channel.connect();

View File

@@ -10,6 +10,12 @@ const ONE_PIXEL_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
'base64', '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 cleanupDirs: string[] = [];
const cleanupFiles: 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', () => { it('requires workspace paths to be explicitly allowlisted', () => {
const dir = makeTempDir(process.cwd(), '.ejclaw-attachment-'); const dir = makeTempDir(process.cwd(), '.ejclaw-attachment-');
const imagePath = writeFile(dir, 'workspace-shot.png', ONE_PIXEL_PNG); 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', () => { it('rejects non-files and files over the size cap', () => {
const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-'); const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-');
const nestedDir = path.join(dir, 'nested.png'); const nestedDir = path.join(dir, 'nested.png');

View File

@@ -18,6 +18,29 @@ export interface ValidateOutboundAttachmentsResult {
const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024; const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i; 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[] { function unique(values: Array<string | null | undefined>): string[] {
return [ return [
...new Set(values.filter((value): value is string => Boolean(value))), ...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( function normalizeAttachmentName(
attachment: OutboundAttachment, attachment: OutboundAttachment,
realPath: string, realPath: string,
@@ -157,7 +255,9 @@ export function validateOutboundAttachments(
rejected.push({ path: requestedPath, reason: 'not-absolute' }); rejected.push({ path: requestedPath, reason: 'not-absolute' });
continue; continue;
} }
if (!IMAGE_EXTS.test(requestedPath)) { const expectedMime =
SUPPORTED_ATTACHMENT_MIMES[path.extname(requestedPath).toLowerCase()];
if (!expectedMime) {
rejected.push({ path: requestedPath, reason: 'unsupported-extension' }); rejected.push({ path: requestedPath, reason: 'unsupported-extension' });
continue; continue;
} }
@@ -183,11 +283,13 @@ export function validateOutboundAttachments(
rejected.push({ path: requestedPath, reason: 'outside-allowed-dirs' }); rejected.push({ path: requestedPath, reason: 'outside-allowed-dirs' });
continue; continue;
} }
const detectedMime = detectImageMime(realPath); const detectedMime = detectSupportedMime(realPath);
if (!detectedMime) { if (!detectedMime) {
rejected.push({ rejected.push({
path: requestedPath, path: requestedPath,
reason: 'invalid-image-signature', reason: IMAGE_EXTS.test(realPath)
? 'invalid-image-signature'
: 'invalid-file-signature',
}); });
continue; continue;
} }