From 021f02f9813e2962c9629b406ec72bb5a58418f8 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 14 Sep 2026 00:30:03 +1200 Subject: [PATCH] d->m: Try too-large PNGs as JPEG for upload Large PNGs were almost certainly originally JPEG photos that were converted to PNG by the user copy-pasting. If the PNG is just supposed to be that large, you can still get the original through the external URL option. Had to change _removeExpiryParams to be based on URL parsing instead of regexp because regexp didn't work with &format=jpeg on the end. --- src/d2m/actions/send-message.js | 2 +- src/d2m/converters/edit-to-changes.js | 4 +- src/d2m/converters/message-to-event.js | 77 +++++++++++++++------ src/d2m/converters/message-to-event.test.js | 39 +++++++++++ src/matrix/file.js | 11 ++- src/matrix/file.test.js | 6 ++ test/ooye-test-data.sql | 3 +- 7 files changed, 113 insertions(+), 29 deletions(-) diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js index 41ef70d..6ad58c5 100644 --- a/src/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -55,7 +55,7 @@ async function sendMessage(message, channel, guild) { } } - const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow}) + const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow, fetch}) const eventIDs = [] if (events.length) { db.prepare("INSERT OR IGNORE INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(message.id, historicalRoomIndex) diff --git a/src/d2m/converters/edit-to-changes.js b/src/d2m/converters/edit-to-changes.js index 61f3290..409adb0 100644 --- a/src/d2m/converters/edit-to-changes.js +++ b/src/d2m/converters/edit-to-changes.js @@ -69,8 +69,8 @@ async function editToChanges(message, guild, api) { // Figure out what we will be replacing them with - const newFallbackContent = await messageToEvent.messageToEvent(message, guild, {includeEditFallbackStar: true}, {api}) - const newInnerContent = await messageToEvent.messageToEvent(message, guild, {includeReplyFallback: false}, {api}) + const newFallbackContent = await messageToEvent.messageToEvent(message, guild, {includeEditFallbackStar: true}, {api, fetch}) + const newInnerContent = await messageToEvent.messageToEvent(message, guild, {includeReplyFallback: false}, {api, fetch}) assert.ok(newFallbackContent.length === newInnerContent.length) // Match the new events to the old events diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index 619588e..49e3b64 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -109,10 +109,11 @@ const embedTitleParser = markdown.markdownEngine.parserFor({ /** * @param {{room?: boolean, user_ids?: string[]}} mentions - * @param {Omit} attachment + * @param {Omit & {proxy_url?: string}} attachment * @param {boolean} [alwaysLink] + * @param {{fetch?: typeof fetch}} [di] */ -async function attachmentToEvent(mentions, attachment, alwaysLink) { +async function attachmentToEvent(mentions, attachment, alwaysLink, di) { const external_url = dUtils.getPublicUrlForCdn(attachment.url) const emoji = attachment.content_type?.startsWith("image/jp") ? "📸" @@ -132,8 +133,37 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { formatted_body: `
${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})
` } } + // attempt to convert large PNG image files to JPEG, since it's almost certainly a photo that was forced into PNG by the user copy-pasting. + if ( + attachment.content_type === "image/png" && attachment.size > reg.ooye.max_file_size + && !alwaysLink && attachment.proxy_url && attachment.width && attachment.height && di?.fetch + ) { + const proxyUrl = new URL(attachment.proxy_url) + proxyUrl.searchParams.set("format", "jpeg") + const jpegUrl = proxyUrl.toString() + const res = await di.fetch(jpegUrl, {method: "HEAD"}) + const newFilename = attachment.filename.replace(/\.png$/, ".jpg") + const newSize = Number(res.headers.get("content-length")) + if (res.ok && res.headers.get("content-type") === "image/jpeg" && !res.headers.has("content-encoding") && newSize <= reg.ooye.max_file_size) { + return { + $type: "m.room.message", + "m.mentions": mentions, + msgtype: "m.image", + url: await file.uploadDiscordFileToMxc(jpegUrl), + external_url, + body: attachment.description || newFilename, + filename: newFilename, + info: { + mimetype: "image/jpeg", + w: attachment.width, + h: attachment.height, + size: newSize + } + } + } + } // for large files, always link them instead of uploading so I don't use up all the space in the content repo - else if (alwaysLink || attachment.size > reg.ooye.max_file_size) { + if (alwaysLink || attachment.size > reg.ooye.max_file_size) { return { $type: "m.room.message", "m.mentions": mentions, @@ -142,7 +172,8 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { format: "org.matrix.custom.html", formatted_body: `${emoji} Uploaded file: ${attachment.filename} (${pb(attachment.size)})` } - } else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) { + } + if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) { return { $type: "m.room.message", "m.mentions": mentions, @@ -158,7 +189,8 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { size: attachment.size } } - } else if (attachment.content_type?.startsWith("video/") && attachment.width && attachment.height) { + } + if (attachment.content_type?.startsWith("video/") && attachment.width && attachment.height) { return { $type: "m.room.message", "m.mentions": mentions, @@ -174,7 +206,8 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { size: attachment.size } } - } else if (attachment.content_type?.startsWith("audio/")) { + } + if (attachment.content_type?.startsWith("audio/")) { return { $type: "m.room.message", "m.mentions": mentions, @@ -189,19 +222,19 @@ async function attachmentToEvent(mentions, attachment, alwaysLink) { duration: attachment.duration_secs && Math.round(attachment.duration_secs * 1000) } } - } else { - return { - $type: "m.room.message", - "m.mentions": mentions, - msgtype: "m.file", - url: await file.uploadDiscordFileToMxc(attachment.url), - external_url, - body: attachment.description || attachment.filename, - filename: attachment.filename, - info: { - mimetype: attachment.content_type, - size: attachment.size - } + } + // else + return { + $type: "m.room.message", + "m.mentions": mentions, + msgtype: "m.file", + url: await file.uploadDiscordFileToMxc(attachment.url), + external_url, + body: attachment.description || attachment.filename, + filename: attachment.filename, + info: { + mimetype: attachment.content_type, + size: attachment.size } } } @@ -295,7 +328,7 @@ function mergeTextEvents(newEvents, events, forceSameMsgtype, forceMerge = false * - includeEditFallbackStar: false * - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true. * - scanTextForMentions: true - needs to be set to false when converting forwarded messages etc which may be from a different channel that can't be scanned. - * @param {{api: import("../../matrix/api"), snow?: import("snowtransfer").SnowTransfer}} di simple-as-nails dependency injection for the matrix API + * @param {{api: import("../../matrix/api"), snow?: import("snowtransfer").SnowTransfer, fetch?: typeof fetch}} di simple-as-nails dependency injection for the matrix API * @returns {Promise<{$type: string, $sender?: string, [x: string]: any}[]>} */ async function messageToEvent(message, guild, options = {}, di) { @@ -882,7 +915,7 @@ async function messageToEvent(message, guild, options = {}, di) { // Then attachments if (message.attachments) { - const attachmentEvents = await Promise.all(message.attachments.map(attachment => attachmentToEvent(mentions, attachment))) + const attachmentEvents = await Promise.all(message.attachments.map(attachment => attachmentToEvent(mentions, attachment, false, {fetch: di?.fetch}))) // Try to merge attachment events with the previous event // This means that if the attachments ended up as a text link, and especially if there were many of them, the events will be joined together. @@ -913,7 +946,7 @@ async function messageToEvent(message, guild, options = {}, di) { url: file.url, height: file.height, width: file.width, - }, true) + }, true, {fetch: di?.fetch}) stack.msb.addLine(ev.body, ev.formatted_body) } else if (component.type === DiscordTypes.ComponentType.MediaGallery) { diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 0742c7b..aea24a8 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -1103,6 +1103,45 @@ test("message2event: very large attachment is linked instead of being uploaded", }]) }) +test("message2event: very large png is converted to jpeg for upload", async t => { + const events = await messageToEvent({ + content: "", + attachments: [{ + filename: "855064e9-bdae-487a-a497-c662bd510487.png", + url: "https://cdn.discordapp.com/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png", + proxy_url: "https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png", + content_type: "image/png", + width: 5472, + height: 3648, + size: 14967919 + }] + }, data.guild.general, {}, { + async fetch(url, init) { + t.equal(url, "https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg") + t.equal(init.method, "HEAD") + return new Response("wa", {headers: { + "Content-Type": "image/jpeg", + "Content-Length": 483085 + }}) + } + }) + t.deepEqual(events, [{ + $type: "m.room.message", + "m.mentions": {}, + msgtype: "m.image", + body: "855064e9-bdae-487a-a497-c662bd510487.jpg", + info: { + w: 5472, + h: 3648, + mimetype: "image/jpeg", + size: 483085 + }, + external_url: "https://bridge.example.org/download/discordcdn/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png", + filename: "855064e9-bdae-487a-a497-c662bd510487.jpg", + url: "mxc://cadence.moe/zXNXRvJwRDMIzZZVqHUXtGeD" + }]) +}) + test("message2event: multiple attachments are combined into the same event where possible", async t => { const events = await messageToEvent({ content: "hey", diff --git a/src/matrix/file.js b/src/matrix/file.js index c469aea..b338c59 100644 --- a/src/matrix/file.js +++ b/src/matrix/file.js @@ -15,10 +15,15 @@ const IMAGE_SIZE = 1024 const inflight = new Map() /** - * @param {string} url + * @param {string} urlString */ -function _removeExpiryParams(url) { - return url.replace(/\?(?:(?:ex|is|sg|hm)=[a-f0-9]+&?)*$/, "") +function _removeExpiryParams(urlString) { + const url = new URL(urlString) + url.searchParams.delete("ex") + url.searchParams.delete("is") + url.searchParams.delete("sg") + url.searchParams.delete("hm") + return url.toString() } /** diff --git a/src/matrix/file.test.js b/src/matrix/file.test.js index 2d59dbc..3a5c73b 100644 --- a/src/matrix/file.test.js +++ b/src/matrix/file.test.js @@ -20,3 +20,9 @@ test("removeExpiryParams: rearranged params are removed", t => { const result = file._removeExpiryParams(url) t.equal(result, "https://cdn.discordapp.com/attachments/112760669178241024/1157363960518029322/image.png") }) + +test("removeExpiryParams: works on media proxy and keeps quality", t => { + const url = "https://media.discordapp.net/attachments/1160894080998461480/1548665030898094240/855064e9-bdae-487a-a497-c662bd510487.png?ex=6aa7e234&is=6aa690b4&hm=7c153efcdd1feb1d36d0bb99955a2bfe12f26abea377fed02980521766f51620&format=jpeg" + const result = file._removeExpiryParams(url) + t.equal(result, "https://media.discordapp.net/attachments/1160894080998461480/1548665030898094240/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg") +}) diff --git a/test/ooye-test-data.sql b/test/ooye-test-data.sql index 524e1fe..072397f 100644 --- a/test/ooye-test-data.sql +++ b/test/ooye-test-data.sql @@ -168,7 +168,8 @@ INSERT INTO file (discord_url, mxc_url) VALUES ('https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml', 'mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP'), ('https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif', 'mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh'), ('https://cdn.discordapp.com/attachments/123/456/my_enemies.txt', 'mxc://cadence.moe/y89EOTRp2lbeOkgdsEleGOge'), -('https://cdn.discordapp.com/emojis/1254940125948022915.webp', 'mxc://cadence.moe/bvVJFgOIyNcAknKCbmaHDktG'); +('https://cdn.discordapp.com/emojis/1254940125948022915.webp', 'mxc://cadence.moe/bvVJFgOIyNcAknKCbmaHDktG'), +('https://media.discordapp.net/attachments/1160894080998461480/1548656385292509274/855064e9-bdae-487a-a497-c662bd510487.png?format=jpeg', 'mxc://cadence.moe/zXNXRvJwRDMIzZZVqHUXtGeD'); INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES ('230201364309868544', 'hippo', 0, 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'),