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 = {}