Spoiler Fix #90

Manually merged
cadence merged 11 commits from Guzio/out-of-your-element:spoilerfix into main 2026-10-20 12:45:17 +00:00
5 changed files with 94 additions and 20 deletions

View file

@ -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: `<blockquote>${emoji} Uploaded SPOILER file: <a href="${external_url}">${external_url}</a> (${pb(attachment.size)})</blockquote>`
formatted_body: `${emoji} Uploaded <strong>SPOILER</strong> file: <a href="${external_url}">${external_url}</a> (${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.

View file

@ -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: `<a href="https://bridge.example.org/time/1791106413/S">4 Oct 2026, 9:33:33 am (UTC)</a>`
+ `<br><a href="https://bridge.example.org/time/1791106436/f">4 October 2026 at 9:33 am (UTC)</a>`
+ `<br><a href="https://bridge.example.org/time/1791106440/F">Sunday, 4 October 2026 at 9:34 am (UTC)</a>`
+ `<br><a href="https://bridge.example.org/time/1791106448/T">9:34:08 am (UTC)</a>`
+ `<br><a href="https://bridge.example.org/time/1791019800/R">3 October 2026 at 9:30 am (UTC)</a>`
+ `<br><a href="https://bridge.example.org/time/1798153200/R">24 December 2026 at 11:00 pm (UTC)</a>`,
formatted_body: `<a href="https://bridge.example.org/time/1791106413/S"><time data-style="S" datetime="2026-10-04T09:33:33.000Z">4 Oct 2026, 9:33:33 am (UTC)</time></a>`
+ `<br><a href="https://bridge.example.org/time/1791106436/f"><time data-style="f" datetime="2026-10-04T09:33:56.000Z">4 October 2026 at 9:33 am (UTC)</time></a>`
+ `<br><a href="https://bridge.example.org/time/1791106440/F"><time data-style="F" datetime="2026-10-04T09:34:00.000Z">Sunday, 4 October 2026 at 9:34 am (UTC)</time></a>`
+ `<br><a href="https://bridge.example.org/time/1791106448/T"><time data-style="T" datetime="2026-10-04T09:34:08.000Z">9:34:08 am (UTC)</time></a>`
+ `<br><a href="https://bridge.example.org/time/1791019800/R"><time data-style="R" datetime="2026-10-03T09:30:00.000Z">3 October 2026 at 9:30 am (UTC)</time></a>`
+ `<br><a href="https://bridge.example.org/time/1798153200/R"><time data-style="R" datetime="2026-12-24T23:00:00.000Z">24 December 2026 at 11:00 pm (UTC)</time></a>`,
"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: "<blockquote>📄 Uploaded SPOILER file: <a href=\"https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci\">https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci</a> (74 KB)</blockquote>"
formatted_body: "📄 Uploaded <strong>SPOILER</strong> file: <a href=\"https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler=yes\">https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler=yes</a> (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"
+ `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)`
+ `<br><blockquote>📸 Uploaded SPOILER file: <a href="https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg">https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg</a> (38 KB)</blockquote>`
+ `📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)`
+ `<br>📸 Uploaded <strong>SPOILER</strong> file: <a href=\"https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler=yes\">https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler=yes</a> (38 KB)`
+ `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)`
}, {
$type: "m.room.message",
"m.mentions": {},

View file

@ -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()
}
/**

View file

@ -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

View file

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