From d0e906763eb79de8c637079f80d87cbdfd4f838a Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Thu, 21 May 2026 23:44:03 +0700 Subject: [PATCH] fix(moderation): fix image attachment pipeline causing PIL BadRequestError on NVIDIA inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-layer defect chain causing 'cannot identify image file <_io.BytesIO object>': 1. attachmentUploader: hardcoded 'application/octet-stream' on Tele CDN upload regardless of actual file MIME type — CDN stored images under wrong type. 2. messageCapture: processAttachmentUpload call site never forwarded attachment.contentType into the options bag, so the fix in (1) would have received undefined and fallen back to octet-stream anyway. 3. llmModerationClient: blindly trusted att.type from the DB record (Discord-provided MIME) when constructing data: URLs, but validated neither the HTTP status of the CDN re-fetch nor the actual byte content. Stale/expired CDN URLs returning HTML error pages were base64-encoded and sent to the model as 'image/jpeg', causing PIL to reject the stream. Fixes: - uploadAttachmentToTele now accepts contentType param (defaults to application/octet-stream for non-image files) - processAttachmentUpload options bag gains optional contentType field - messageCapture forwards attachment.contentType at the call site - Added sniffImageMimeType() using magic-byte probes for JPEG, PNG, GIF, WebP, AVIF/HEIF — runs on every downloaded attachment buffer before base64 encoding; skips the attachment (logs headerHex for diagnosis) if bytes don't match a known image format - data: URL now uses the sniffed MIME type, not the DB record --- src/moderation/attachmentUploader.ts | 14 +++- src/moderation/llmModerationClient.ts | 110 +++++++++++++++++++++++++- src/moderation/messageCapture.ts | 1 + 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/src/moderation/attachmentUploader.ts b/src/moderation/attachmentUploader.ts index 014493e..8007fb3 100644 --- a/src/moderation/attachmentUploader.ts +++ b/src/moderation/attachmentUploader.ts @@ -35,12 +35,13 @@ function shouldRefreshDiscordUrl(error: unknown): boolean { export async function uploadAttachmentToTele( fileBuffer: Buffer, filename: string, + contentType = "application/octet-stream", ): Promise { try { const result = await uploadToTele({ buffer: fileBuffer, filename, - contentType: "application/octet-stream", + contentType, uploadUrl: config.TELE_UPLOAD_URL, timeoutMs: config.ATTACHMENT_UPLOAD_TIMEOUT_MS, retries: config.ATTACHMENT_RETRY_ATTEMPTS, @@ -88,7 +89,10 @@ export async function processAttachmentUpload( attachmentId: string, discordUrl: string, filename: string, - options: { refreshDiscordUrl?: RefreshDiscordAttachmentUrl } = {}, + options: { + refreshDiscordUrl?: RefreshDiscordAttachmentUrl; + contentType?: string; + } = {}, ): Promise { try { let currentDiscordUrl = discordUrl; @@ -114,7 +118,11 @@ export async function processAttachmentUpload( ); } - const uploadedUrl = await uploadAttachmentToTele(buffer, filename); + const uploadedUrl = await uploadAttachmentToTele( + buffer, + filename, + options.contentType, + ); await updateAttachmentAsUploaded(attachmentId, uploadedUrl, Date.now()); } catch (error) { diff --git a/src/moderation/llmModerationClient.ts b/src/moderation/llmModerationClient.ts index 3ea3d8c..c7657c3 100644 --- a/src/moderation/llmModerationClient.ts +++ b/src/moderation/llmModerationClient.ts @@ -414,6 +414,88 @@ interface ModerationOutput { raw: unknown; } +/** + * Sniff the first bytes of a buffer to determine if it is a supported image + * format. Returns the canonical MIME type string on success, or null if the + * bytes are not a recognizable image. + * + * Supported probes (in order): + * - JPEG: FF D8 FF + * - PNG: 89 50 4E 47 0D 0A 1A 0A + * - GIF: 47 49 46 38 (GIF8) + * - WebP: 52 49 46 46 ?? ?? ?? ?? 57 45 42 50 (RIFF....WEBP) + * - AVIF / HEIF: 4-byte big-endian size + 66 74 79 70 (ftyp ISO base-media box) + */ +function sniffImageMimeType(buf: Buffer): string | null { + if (buf.length < 12) return null; + + // JPEG + if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) { + return "image/jpeg"; + } + + // PNG + if ( + buf[0] === 0x89 && + buf[1] === 0x50 && + buf[2] === 0x4e && + buf[3] === 0x47 && + buf[4] === 0x0d && + buf[5] === 0x0a && + buf[6] === 0x1a && + buf[7] === 0x0a + ) { + return "image/png"; + } + + // GIF + if ( + buf[0] === 0x47 && + buf[1] === 0x49 && + buf[2] === 0x46 && + buf[3] === 0x38 + ) { + return "image/gif"; + } + + // WebP: RIFF????WEBP + if ( + buf[0] === 0x52 && + buf[1] === 0x49 && + buf[2] === 0x46 && + buf[3] === 0x46 && + buf[8] === 0x57 && + buf[9] === 0x45 && + buf[10] === 0x42 && + buf[11] === 0x50 + ) { + return "image/webp"; + } + + // AVIF / HEIF: ISO base media file format — ftyp box at offset 4 + if ( + buf.length >= 12 && + buf[4] === 0x66 && + buf[5] === 0x74 && + buf[6] === 0x79 && + buf[7] === 0x70 + ) { + const brand = buf.subarray(8, 12).toString("ascii"); + if (brand.startsWith("avif") || brand.startsWith("avis")) { + return "image/avif"; + } + if ( + brand.startsWith("mif1") || + brand.startsWith("heic") || + brand.startsWith("heis") + ) { + return "image/heic"; + } + } + + return null; +} + /** * Runs LLM-based moderation analysis on messages. * POSTs to AI_LLM_BASE_URL with auth bearer token. @@ -497,15 +579,35 @@ Return ONLY valid JSON, no other text.`; const res = await fetch(urlToUse); if (!res.ok) { log.warn( - { attachmentId: att.id, status: res.status }, - "Failed to fetch attachment image", + { attachmentId: att.id, status: res.status, url: urlToUse }, + "Failed to fetch attachment image — non-2xx status", ); return []; } const buffer = await res.arrayBuffer(); - const base64Str = Buffer.from(buffer).toString("base64"); - const dataUrl = `data:${att.type};base64,${base64Str}`; + const imageBytes = Buffer.from(buffer); + + // Guard against HTML error pages, redirects, or octet streams + // that the CDN might serve under a stale URL. + const sniffedMime = sniffImageMimeType(imageBytes); + if (!sniffedMime) { + log.warn( + { + attachmentId: att.id, + url: urlToUse, + dbType: att.type, + bytesLength: imageBytes.length, + // First 16 bytes as hex to aid diagnosis + headerHex: imageBytes.subarray(0, 16).toString("hex"), + }, + "Skipping attachment: downloaded bytes are not a recognised image format", + ); + return []; + } + + const base64Str = imageBytes.toString("base64"); + const dataUrl = `data:${sniffedMime};base64,${base64Str}`; return [ { diff --git a/src/moderation/messageCapture.ts b/src/moderation/messageCapture.ts index 011f008..ce224ff 100644 --- a/src/moderation/messageCapture.ts +++ b/src/moderation/messageCapture.ts @@ -144,6 +144,7 @@ export async function captureMessage( attachment.url, attachment.name || "unknown", { + contentType: attachment.contentType ?? undefined, refreshDiscordUrl: async () => { const freshMessage = await message.channel.messages.fetch( message.id,