diff --git a/src/m2d/actions/emoji-sheet.js b/src/m2d/actions/emoji-sheet.js index 5f96297b..c81960d5 100644 --- a/src/m2d/actions/emoji-sheet.js +++ b/src/m2d/actions/emoji-sheet.js @@ -8,6 +8,8 @@ const {sync} = require("../../passthrough") /** @type {import("../converters/emoji-sheet")} */ const emojiSheetConverter = sync.require("../converters/emoji-sheet") +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") /** * Downloads the emoji from the web and converts to uncompressed PNG data. @@ -16,16 +18,12 @@ const emojiSheetConverter = sync.require("../converters/emoji-sheet") */ async function getAndConvertEmoji(mxc) { const abortController = new AbortController() - - const url = utils.getPublicUrlForMxc(mxc) - assert(url) - /** @type {import("node-fetch").Response} */ // If it turns out to be a GIF, we want to abandon the connection without downloading the whole thing. // If we were using connection pooling, we would be forced to download the entire GIF. // So we set no agent to ensure we are not connection pooling. // @ts-ignore the signal is slightly different from the type it wants (still works fine) - const res = await fetch(url, {agent: false, signal: abortController.signal}) + const res = await api.getMedia(mxc, {agent: false, signal: abortController.signal}) return emojiSheetConverter.convertImageStream(res.body, () => { abortController.abort() res.body.pause() diff --git a/src/m2d/actions/send-event.js b/src/m2d/actions/send-event.js index 4422ad3b..0a270a09 100644 --- a/src/m2d/actions/send-event.js +++ b/src/m2d/actions/send-event.js @@ -23,7 +23,7 @@ const editMessage = sync.require("../../d2m/actions/edit-message") const emojiSheet = sync.require("../actions/emoji-sheet") /** - * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[], pendingFiles?: ({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer | Readable})[]}} message + * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | Readable}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | Readable})[]}} message * @returns {Promise} */ async function resolvePendingFiles(message) { @@ -39,7 +39,7 @@ async function resolvePendingFiles(message) { // Encrypted file const d = crypto.createDecipheriv("aes-256-ctr", Buffer.from(p.key, "base64url"), Buffer.from(p.iv, "base64url")) // @ts-ignore - fetch(p.url).then(res => res.body.pipe(d)) + await api.getMedia(p.mxc).then(res => res.body.pipe(d)) return { name: p.name, file: d @@ -47,7 +47,7 @@ async function resolvePendingFiles(message) { } else { // Unencrypted file /** @type {Readable} */ // @ts-ignore - const body = await fetch(p.url).then(res => res.body) + const body = await api.getMedia(p.mxc).then(res => res.body) return { name: p.name, file: body @@ -79,7 +79,7 @@ async function sendEvent(event) { // no need to sync the matrix member to the other side. but if I did need to, this is where I'd do it - let {messagesToEdit, messagesToSend, messagesToDelete, ensureJoined} = await eventToMessage.eventToMessage(event, guild, {api, snow: discord.snow, fetch, mxcDownloader: emojiSheet.getAndConvertEmoji}) + let {messagesToEdit, messagesToSend, messagesToDelete, ensureJoined} = await eventToMessage.eventToMessage(event, guild, {api, snow: discord.snow, mxcDownloader: emojiSheet.getAndConvertEmoji}) messagesToEdit = await Promise.all(messagesToEdit.map(async e => { e.message = await resolvePendingFiles(e.message) diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index 76a32bc7..346d8c90 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -305,7 +305,7 @@ function getUserOrProxyOwnerID(mxid) { * This function will strip them from the content and generate the correct pending file of the sprite sheet. * @param {string} content * @param {{id: string, name: string}[]} attachments - * @param {({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} pendingFiles + * @param {({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} pendingFiles * @param {(mxc: string) => Promise} mxcDownloader function that will download the mxc URLs and convert to uncompressed PNG data. use `getAndConvertEmoji` or a mock. */ async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles, mxcDownloader) { @@ -389,7 +389,7 @@ async function handleRoomOrMessageLinks(input, di) { * @param {string} senderMxid * @param {string} roomID * @param {DiscordTypes.APIGuild} guild - * @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, fetch: import("node-fetch")["default"]}} di + * @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer}} di */ async function checkWrittenMentions(content, senderMxid, roomID, guild, di) { let writtenMentionMatch = content.match(/(?:^|[^"[<>/A-Za-z0-9])@([A-Za-z][A-Za-z0-9._\[\]\(\)-]+):?/d) // /d flag for indices requires node.js 16+ @@ -440,7 +440,7 @@ const attachmentEmojis = new Map([ /** * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File} event * @param {import("discord-api-types/v10").APIGuild} guild - * @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, fetch: import("node-fetch")["default"], mxcDownloader: (mxc: string) => Promise}} di simple-as-nails dependency injection for the matrix API + * @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, mxcDownloader: (mxc: string) => Promise}} di simple-as-nails dependency injection for the matrix API */ async function eventToMessage(event, guild, di) { let displayName = event.sender @@ -466,7 +466,7 @@ async function eventToMessage(event, guild, di) { let content = event.content.body // ultimate fallback const attachments = [] - /** @type {({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */ + /** @type {({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */ const pendingFiles = [] /** @type {DiscordTypes.APIUser[]} */ const ensureJoined = [] @@ -767,29 +767,23 @@ async function eventToMessage(event, guild, di) { const description = (event.content.body !== event.content.filename && event.content.filename && event.content.body) || undefined if ("url" in event.content) { // Unencrypted - const url = mxUtils.getPublicUrlForMxc(event.content.url) - assert(url) attachments.push({id: "0", description, filename}) - pendingFiles.push({name: filename, url}) + pendingFiles.push({name: filename, mxc: event.content.url}) } else { // Encrypted - const url = mxUtils.getPublicUrlForMxc(event.content.file.url) - assert(url) assert.equal(event.content.file.key.alg, "A256CTR") attachments.push({id: "0", description, filename}) - pendingFiles.push({name: filename, url, key: event.content.file.key.k, iv: event.content.file.iv}) + pendingFiles.push({name: filename, mxc: event.content.file.url, key: event.content.file.key.k, iv: event.content.file.iv}) } } else if (event.type === "m.sticker") { content = "" - const url = mxUtils.getPublicUrlForMxc(event.content.url) - assert(url) let filename = event.content.body if (event.type === "m.sticker") { let mimetype if (event.content.info?.mimetype?.includes("/")) { mimetype = event.content.info.mimetype } else { - const res = await di.fetch(url, {method: "HEAD"}) + const res = await di.api.getMedia(event.content.url, {method: "HEAD"}) if (res.status === 200) { mimetype = res.headers.get("content-type") } @@ -798,7 +792,7 @@ async function eventToMessage(event, guild, di) { filename += "." + mimetype.split("/")[1] } attachments.push({id: "0", filename}) - pendingFiles.push({name: filename, url}) + pendingFiles.push({name: filename, mxc: event.content.url}) } content = displayNameRunoff + replyLine + content diff --git a/src/m2d/converters/event-to-message.test.js b/src/m2d/converters/event-to-message.test.js index b6748458..fdaad337 100644 --- a/src/m2d/converters/event-to-message.test.js +++ b/src/m2d/converters/event-to-message.test.js @@ -3550,7 +3550,7 @@ test("event2message: text attachments work", async t => { content: "", avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", attachments: [{id: "0", description: undefined, filename: "chiki-powerups.txt"}], - pendingFiles: [{name: "chiki-powerups.txt", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/zyThGlYQxvlvBVbVgKDDbiHH"}] + pendingFiles: [{name: "chiki-powerups.txt", mxc: "mxc://cadence.moe/zyThGlYQxvlvBVbVgKDDbiHH"}] }] } ) @@ -3586,7 +3586,7 @@ test("event2message: image attachments work", async t => { content: "", avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", attachments: [{id: "0", description: undefined, filename: "cool cat.png"}], - pendingFiles: [{name: "cool cat.png", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}] + pendingFiles: [{name: "cool cat.png", mxc: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}] }] } ) @@ -3622,7 +3622,7 @@ test("event2message: image attachments can have a custom description", async t = content: "", avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", attachments: [{id: "0", description: "Cat emoji surrounded by pink hearts", filename: "cool cat.png"}], - pendingFiles: [{name: "cool cat.png", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}] + pendingFiles: [{name: "cool cat.png", url: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}] }] } ) @@ -3674,7 +3674,7 @@ test("event2message: encrypted image attachments work", async t => { attachments: [{id: "0", description: undefined, filename: "image.png"}], pendingFiles: [{ name: "image.png", - url: "https://matrix.cadence.moe/_matrix/media/r0/download/heyquark.com/LOGkUTlVFrqfiExlGZNgCJJX", + mxc: "mxc://heyquark.com/LOGkUTlVFrqfiExlGZNgCJJX", key: "QTo-oMPnN1Rbc7vBFg9WXMgoctscdyxdFEIYm8NYceo", iv: "Va9SHZpIn5kAAAAAAAAAAA" }] @@ -3717,7 +3717,7 @@ test("event2message: stickers work", async t => { content: "", avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU", attachments: [{id: "0", filename: "get_real2.gif"}], - pendingFiles: [{name: "get_real2.gif", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/NyMXQFAAdniImbHzsygScbmN"}] + pendingFiles: [{name: "get_real2.gif", mxc: "mxc://cadence.moe/NyMXQFAAdniImbHzsygScbmN"}] }] } ) @@ -3736,15 +3736,17 @@ test("event2message: stickers fetch mimetype from server when mimetype not provi event_id: "$mL-eEVWCwOvFtoOiivDP7gepvf-fTYH6_ioK82bWDI0", room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" }, {}, { - async fetch(url, options) { - called++ - t.equal(url, "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf") - t.equal(options.method, "HEAD") - return { - status: 200, - headers: new Map([ - ["content-type", "image/gif"] - ]) + api: { + async getMedia(mxc, options) { + called++ + t.equal(mxc, "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf") + t.equal(options.method, "HEAD") + return { + status: 200, + headers: new Map([ + ["content-type", "image/gif"] + ]) + } } } }), @@ -3757,7 +3759,7 @@ test("event2message: stickers fetch mimetype from server when mimetype not provi content: "", avatar_url: undefined, attachments: [{id: "0", filename: "YESYESYES.gif"}], - pendingFiles: [{name: "YESYESYES.gif", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf"}] + pendingFiles: [{name: "YESYESYES.gif", mxc: "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJf"}] }] } ) @@ -3777,15 +3779,17 @@ test("event2message: stickers with unknown mimetype are not allowed", async t => event_id: "$mL-eEVWCwOvFtoOiivDP7gepvf-fTYH6_ioK82bWDI0", room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe" }, {}, { - async fetch(url, options) { - called++ - t.equal(url, "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/ybOWQCaXysnyUGuUCaQlTGJe") - t.equal(options.method, "HEAD") - return { - status: 404, - headers: new Map([ - ["content-type", "application/json"] - ]) + api: { + async getMedia(mxc, options) { + called++ + t.equal(mxc, "mxc://cadence.moe/ybOWQCaXysnyUGuUCaQlTGJe") + t.equal(options.method, "HEAD") + return { + status: 404, + headers: new Map([ + ["content-type", "application/json"] + ]) + } } } }) diff --git a/src/m2d/converters/utils.js b/src/m2d/converters/utils.js index c1f5f010..c5386270 100644 --- a/src/m2d/converters/utils.js +++ b/src/m2d/converters/utils.js @@ -223,10 +223,10 @@ async function getViaServersQuery(roomID, api) { */ function getPublicUrlForMxc(mxc) { assert(hasher, "xxhash is not ready yet") - const avatarURLParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) - if (!avatarURLParts) return null + const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) + if (!mediaParts) return null - const serverAndMediaID = `${avatarURLParts[1]}/${avatarURLParts[2]}` + const serverAndMediaID = `${mediaParts[1]}/${mediaParts[2]}` const unsignedHash = hasher.h64(serverAndMediaID) const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range db.prepare("INSERT OR IGNORE INTO media_proxy (permitted_hash) VALUES (?)").run(signedHash) diff --git a/src/matrix/api.js b/src/matrix/api.js index d33961d8..2c5fd4e8 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -297,6 +297,7 @@ async function setUserPowerCascade(roomID, mxid, power) { } async function ping() { + // not using mreq so that we can read the status code const res = await fetch(`${mreq.baseUrl}/client/v1/appservice/${reg.id}/ping`, { method: "POST", headers: { @@ -312,6 +313,21 @@ async function ping() { } } +/** + * @param {string} mxc + * @param {RequestInit} [init] + */ +function getMedia(mxc, init = {}) { + const mediaParts = mxc?.match(/^mxc:\/\/([^/]+)\/(\w+)$/) + assert(mediaParts) + return fetch(`${mreq.baseUrl}/client/v1/media/download/${mediaParts[1]}/${mediaParts[2]}`, { + headers: { + Authorization: `Bearer ${reg.as_token}` + }, + ...init + }) +} + module.exports.path = path module.exports.register = register module.exports.createRoom = createRoom @@ -336,3 +352,4 @@ module.exports.profileSetAvatarUrl = profileSetAvatarUrl module.exports.setUserPower = setUserPower module.exports.setUserPowerCascade = setUserPowerCascade module.exports.ping = ping +module.exports.getMedia = getMedia diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index 7ad84f51..7a35e124 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -217,9 +217,8 @@ const commands = [{ } else { // Upload it to Discord and have the bridge sync it back to Matrix again for (const e of toUpload) { - const publicUrl = mxUtils.getPublicUrlForMxc(e.url) // @ts-ignore - const resizeInput = await fetch(publicUrl, {agent: false}).then(res => res.arrayBuffer()) + const resizeInput = await api.getMedia(e.url, {agent: false}).then(res => res.arrayBuffer()) const resizeOutput = await sharp(resizeInput) .resize(EMOJI_SIZE, EMOJI_SIZE, {fit: "inside", withoutEnlargement: true, background: {r: 0, g: 0, b: 0, alpha: 0}}) .png()