diff --git a/src/d2m/converters/message-to-event.js b/src/d2m/converters/message-to-event.js index a291a5e..65fe3e4 100644 --- a/src/d2m/converters/message-to-event.js +++ b/src/d2m/converters/message-to-event.js @@ -135,7 +135,8 @@ const embedTitleParser = markdown.markdownEngine.parserFor({ * @param {{fetch?: typeof fetch}} [di] */ async function attachmentToEvent(mentions, attachment, alwaysLink, di) { - const external_url = dUtils.getPublicUrlForCdn(attachment.url) + const spoiler = attachment.filename.startsWith("SPOILER_") || !!((attachment.flags || 0) & DiscordTypes.AttachmentFlags.IsSpoiler); + const external_url = dUtils.getPublicUrlForCdn(attachment.url, spoiler) const emoji = attachment.content_type?.startsWith("image/jp") ? "šŸ“ø" : attachment.content_type?.startsWith("image/") ? "šŸ–¼ļø" @@ -143,15 +144,15 @@ async function attachmentToEvent(mentions, attachment, alwaysLink, di) { : attachment.content_type?.startsWith("text/") ? "šŸ“" : attachment.content_type?.startsWith("audio/") ? "šŸŽ¶" : "šŸ“„" - // no native media spoilers in Element, so we'll post a link instead, forcing it to not preview using a blockquote - if (attachment.filename.startsWith("SPOILER_") || !!((attachment.flags || 0) & DiscordTypes.AttachmentFlags.IsSpoiler)) { + // no native media spoilers in Element, so we'll post a spoiler (unpreviewable) link instead + if (spoiler) { return { $type: "m.room.message", "m.mentions": mentions, msgtype: "m.text", body: `${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})`, format: "org.matrix.custom.html", - formatted_body: `
${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})
` + 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. diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 44b9fe6..fe4801c 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -214,12 +214,12 @@ test("message2event: timestamps", async t => { + "\n3 October 2026 at 9:30 am (UTC)" + "\n24 December 2026 at 11:00 pm (UTC)", format: "org.matrix.custom.html", - formatted_body: `4 Oct 2026, 9:33:33 am (UTC)` - + `
4 October 2026 at 9:33 am (UTC)` - + `
Sunday, 4 October 2026 at 9:34 am (UTC)` - + `
9:34:08 am (UTC)` - + `
3 October 2026 at 9:30 am (UTC)` - + `
24 December 2026 at 11:00 pm (UTC)`, + formatted_body: `` + + `
` + + `
` + + `
` + + `
` + + `
`, "m.mentions": {} }]) }) @@ -366,9 +366,9 @@ test("message2event: spoiler attachment", async t => { $type: "m.room.message", "m.mentions": {}, msgtype: "m.text", - body: "šŸ“„ Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)", + body: "šŸ“„ Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler=yes (74 KB)", format: "org.matrix.custom.html", - formatted_body: "
šŸ“„ Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci (74 KB)
" + formatted_body: "šŸ“„ Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler=yes (74 KB)" }]) }) @@ -1205,13 +1205,13 @@ test("message2event: multiple attachments are combined into the same event where msgtype: "m.text", body: "hey" + "\nšŸ“„ Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)" - + "\nšŸ“ø Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg (38 KB)" + + "\nšŸ“ø Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler=yes (38 KB)" + "\nšŸ“„ Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)", format: "org.matrix.custom.html", formatted_body: "hey" + `
šŸ“„ Uploaded file: hey.jpg (100 MB)` - + `
šŸ“ø Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg (38 KB)
` - + `šŸ“„ Uploaded file: hey.jpg (100 MB)` + + `
šŸ“ø Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler=yes (38 KB)` + + `
šŸ“„ Uploaded file: hey.jpg (100 MB)` }, { $type: "m.room.message", "m.mentions": {}, diff --git a/src/discord/utils.js b/src/discord/utils.js index 6fb0b43..05ec3d1 100644 --- a/src/discord/utils.js +++ b/src/discord/utils.js @@ -134,14 +134,19 @@ function timestampToSnowflakeInexact(timestamp) { return String((timestamp - EPOCH) * 2**22) } -/** @param {string} url */ -function getPublicUrlForCdn(url) { +/** + * @param {string} url + * @param {boolean} spoiler + */ +function getPublicUrlForCdn(url, spoiler=false) { const match = url.match(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/) if (!match) return url const unsignedHash = hasher.h64(match[3]) // attachment ID const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash) - return `${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}` + const transformedUrl = new URL(`${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}`) + if (spoiler) transformedUrl.searchParams.set("spoiler", "yes") + return transformedUrl.toString() } /** diff --git a/src/web/routes/download-discord.js b/src/web/routes/download-discord.js index 769fc9c..6b9b458 100644 --- a/src/web/routes/download-discord.js +++ b/src/web/routes/download-discord.js @@ -1,7 +1,7 @@ // @ts-check const assert = require("assert/strict") -const {defineEventHandler, getValidatedRouterParams, sendRedirect, createError, H3Event} = require("h3") +const {defineEventHandler, getValidatedRouterParams, sendRedirect, createError, H3Event, getValidatedQuery} = require("h3") const {z} = require("zod") /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore @@ -9,13 +9,19 @@ let hasher = null // @ts-ignore require("xxhash-wasm")().then(h => hasher = h) -const {discord, as, select} = require("../../passthrough") +const {discord, as, select, sync} = require("../../passthrough") + +/** @type {import("../web-utils")} */ +const webUtils = sync.require("../web-utils") const schema = { params: z.object({ channel_id: z.string().regex(/^[0-9]+$/), attachment_id: z.string().regex(/^[0-9]+$/), file_name: z.string().regex(/^[-A-Za-z0-9_.,]+$/) + }), + query: z.object({ + spoiler: z.string().optional() }) } @@ -40,9 +46,15 @@ function timeUntilExpiry(url) { if (time > 0) return time } +/** + * @param {string} domain + */ function defineMediaProxyHandler(domain) { return defineEventHandler(async event => { const params = await getValidatedRouterParams(event, schema.params.parse) + const query = await getValidatedQuery(event, schema.query.parse) + + if (query.spoiler) webUtils.rejectUrlPreviewer(event) const unsignedHash = hasher.h64(params.attachment_id) const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range diff --git a/src/web/routes/download-discord.test.js b/src/web/routes/download-discord.test.js index 0d4b884..a1d6540 100644 --- a/src/web/routes/download-discord.test.js +++ b/src/web/routes/download-discord.test.js @@ -46,6 +46,62 @@ test("web download discord: works if a known attachment", async t => { t.match(event.node.res.getHeader("location"), /https:\/\/cdn.discordapp.com\/attachments\/655216173696286746\/1314358913482621010\/image\.png\?ex=/) }) +test("web download discord: works with spoilers for humans", async t => { + const event = {} + await router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name?spoiler=yes", { + params: { + channel_id: "655216173696286746", + attachment_id: "1314358913482621010", + file_name: "image.png" + }, + event, + snow: { + channel: { + async refreshAttachmentURLs(attachments) { + assert(Array.isArray(attachments)) + return { + refreshed_urls: attachments.map(a => ({ + original: a, + refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}` + })) + } + } + } + } + }) + t.equal(event.node.res.statusCode, 302) + t.match(event.node.res.getHeader("location"), /https:\/\/cdn.discordapp.com\/attachments\/655216173696286746\/1314358913482621010\/image\.png\?ex=/) +}) + +test("web download discord: rejects with spoilers for bots", async t => { + const event = {} + const [error] = await tryToCatch(() => router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name?spoiler=yes", { + params: { + channel_id: "655216173696286746", + attachment_id: "1314358913482621010", + file_name: "image.png" + }, + headers: { + "user-agent": "Synapse (bot; +https://github.com/matrix-org/synapse)" + }, + event, + snow: { + channel: { + async refreshAttachmentURLs(attachments) { + assert(Array.isArray(attachments)) + return { + refreshed_urls: attachments.map(a => ({ + original: a, + refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}` + })) + } + } + } + } + })) + t.equal(error.data, "Sorry URL previewer, you can't have this URL.") +}) + test("web download discord: uses cache", async t => { let notCalled = true const event = {}