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 47 additions and 19 deletions
Showing only changes of commit 5da1a090c6 - Show all commits

rewrite in QP

Guzio 2026-10-20 12:35:03 +02:00

View file

@ -136,7 +136,7 @@ const embedTitleParser = markdown.markdownEngine.parserFor({
*/ */
async function attachmentToEvent(mentions, attachment, alwaysLink, di) { async function attachmentToEvent(mentions, attachment, alwaysLink, di) {
const spoiler = attachment.filename.startsWith("SPOILER_") || !!((attachment.flags || 0) & DiscordTypes.AttachmentFlags.IsSpoiler); const spoiler = attachment.filename.startsWith("SPOILER_") || !!((attachment.flags || 0) & DiscordTypes.AttachmentFlags.IsSpoiler);
const external_url = dUtils.getPublicUrlForCdn(attachment.url, spoiler ? "spoiler/" : "") const external_url = dUtils.getPublicUrlForCdn(attachment.url, spoiler ? "?spoiler" : "")
const emoji = const emoji =
attachment.content_type?.startsWith("image/jp") ? "📸" attachment.content_type?.startsWith("image/jp") ? "📸"
: attachment.content_type?.startsWith("image/") ? "🖼️" : attachment.content_type?.startsWith("image/") ? "🖼️"

View file

@ -366,9 +366,9 @@ test("message2event: spoiler attachment", async t => {
$type: "m.room.message", $type: "m.room.message",
"m.mentions": {}, "m.mentions": {},
msgtype: "m.text", msgtype: "m.text",
body: "📄 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/1100319550446252084/spoiler/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 (74 KB)",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: "📄 Uploaded <strong>SPOILER</strong> file: <a href=\"https://bridge.example.org/download/discordcdn/1100319550446252084/spoiler/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci\">https://bridge.example.org/download/discordcdn/1100319550446252084/spoiler/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci</a> (74 KB)" formatted_body: "📄 Uploaded <strong>SPOILER</strong> file: <a href=\"https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler\">https://bridge.example.org/download/discordcdn/1100319550446252084/1147465564307079258/SPOILER_69-GNDP-CADENCE.nfs.gci?spoiler</a> (74 KB)"
}]) }])
}) })
@ -1205,12 +1205,12 @@ test("message2event: multiple attachments are combined into the same event where
msgtype: "m.text", msgtype: "m.text",
body: "hey" body: "hey"
+ "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)" + "\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/spoiler/456/SPOILER_secret.jpg (38 KB)" + "\n📸 Uploaded SPOILER file: https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler (38 KB)"
+ "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)", + "\n📄 Uploaded file: https://bridge.example.org/download/discordcdn/123/456/789.mega (100 MB)",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: "hey" formatted_body: "hey"
+ `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)` + `<br>📄 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/spoiler/456/SPOILER_secret.jpg\">https://bridge.example.org/download/discordcdn/123/spoiler/456/SPOILER_secret.jpg</a> (38 KB)` + `<br>📸 Uploaded <strong>SPOILER</strong> file: <a href=\"https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler\">https://bridge.example.org/download/discordcdn/123/456/SPOILER_secret.jpg?spoiler</a> (38 KB)`
+ `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)` + `<br>📄 Uploaded file: <a href="https://bridge.example.org/download/discordcdn/123/456/789.mega">hey.jpg</a> (100 MB)`
}, { }, {
$type: "m.room.message", $type: "m.room.message",

View file

@ -136,7 +136,7 @@ function timestampToSnowflakeInexact(timestamp) {
/** /**
* @param {string} url * @param {string} url
* @param {""|"spoiler/"} spoiler * @param {""|"?spoiler"} spoiler
*/ */
function getPublicUrlForCdn(url, spoiler="") { function getPublicUrlForCdn(url, spoiler="") {
const match = url.match(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/) const match = url.match(/https:\/\/(cdn|media)\.discordapp\.(?:com|net)\/attachments\/([0-9]+)\/([0-9]+)\/([-A-Za-z0-9_.,]+)/)
@ -144,7 +144,7 @@ function getPublicUrlForCdn(url, spoiler="") {
const unsignedHash = hasher.h64(match[3]) // attachment ID const unsignedHash = hasher.h64(match[3]) // attachment ID
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash) db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash)
return `${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${spoiler}${match[3]}/${match[4]}` return `${reg.ooye.bridge_origin}/download/discord${match[1]}/${match[2]}/${match[3]}/${match[4]}${spoiler}`
} }
/** /**

View file

@ -1,7 +1,7 @@
// @ts-check // @ts-check
const assert = require("assert/strict") 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") const {z} = require("zod")
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore /** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
@ -15,10 +15,13 @@ const {discord, as, select, sync} = require("../../passthrough")
const webUtils = sync.require("../web-utils") const webUtils = sync.require("../web-utils")
const schema = { const schema = {
params: z.object({ router: z.object({
channel_id: z.string().regex(/^[0-9]+$/), channel_id: z.string().regex(/^[0-9]+$/),
attachment_id: z.string().regex(/^[0-9]+$/), attachment_id: z.string().regex(/^[0-9]+$/),
file_name: z.string().regex(/^[-A-Za-z0-9_.,]+$/) file_name: z.string().regex(/^[-A-Za-z0-9_.,]+$/)
}),
query: z.object({
spoiler: z.optional(z.string().max(0)) // Lets you choose between no query param, and a ?spoiler query param (H3 considers it an empty string). Booleans do NOT work here, unfortunately.
}) })
} }
@ -45,15 +48,15 @@ function timeUntilExpiry(url) {
/** /**
* @param {string} domain * @param {string} domain
* @param {boolean} spoiler
*/ */
function defineMediaProxyHandler(domain, spoiler) { function defineMediaProxyHandler(domain) {
return defineEventHandler(async event => { return defineEventHandler(async event => {
const params = await getValidatedRouterParams(event, schema.params.parse) const routerParams = await getValidatedRouterParams(event, schema.router.parse)
const queryParams = await getValidatedQuery(event, schema.query.parse)
if (spoiler) webUtils.rejectUrlPreviewer(event) if (queryParams.spoiler === "") webUtils.rejectUrlPreviewer(event)
const unsignedHash = hasher.h64(params.attachment_id) const unsignedHash = hasher.h64(routerParams.attachment_id)
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
const row = select("media_proxy", "permitted_hash", {permitted_hash: signedHash}).get() const row = select("media_proxy", "permitted_hash", {permitted_hash: signedHash}).get()
@ -64,7 +67,7 @@ function defineMediaProxyHandler(domain, spoiler) {
}) })
} }
const url = `https://${domain}/attachments/${params.channel_id}/${params.attachment_id}/${params.file_name}` const url = `https://${domain}/attachments/${routerParams.channel_id}/${routerParams.attachment_id}/${routerParams.file_name}`
let promise = cache.get(url) let promise = cache.get(url)
/** @type {string | undefined} */ /** @type {string | undefined} */
let refreshed let refreshed
@ -90,9 +93,7 @@ function defineMediaProxyHandler(domain, spoiler) {
}) })
} }
as.router.get(`/download/discordcdn/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("cdn.discordapp.com", false)) as.router.get(`/download/discordcdn/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("cdn.discordapp.com"))
as.router.get(`/download/discordmedia/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("media.discordapp.net", false)) as.router.get(`/download/discordmedia/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("media.discordapp.net"))
as.router.get(`/download/discordcdn/:channel_id/spoiler/:attachment_id/:file_name`, defineMediaProxyHandler("cdn.discordapp.com", true))
as.router.get(`/download/discordmedia/:channel_id/spoiler/:attachment_id/:file_name`, defineMediaProxyHandler("media.discordapp.net", true))
module.exports._cache = cache module.exports._cache = cache

View file

@ -46,6 +46,33 @@ 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=/) t.match(event.node.res.getHeader("location"), /https:\/\/cdn.discordapp.com\/attachments\/655216173696286746\/1314358913482621010\/image\.png\?ex=/)
}) })
test("web download discord: works with spoilers", async t => {
const event = {}
await router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name?spoiler", {
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: uses cache", async t => { test("web download discord: uses cache", async t => {
let notCalled = true let notCalled = true
const event = {} const event = {}