import { MatrixClient, MatrixEvent } from 'matrix-js-sdk'; import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room'; import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix'; import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes'; import { IEncryptedFile } from '../../../../types/matrix/common'; /** * Build the content to forward: * - undecryptable events are refused (would forward `m.bad.encrypted` junk) * - edited messages forward the LATEST edit (`m.new_content`), not the * original pre-edit body * - reply fallbacks (`> <@user> …` quote + `` block) are stripped * along with the `m.relates_to` reply/thread relation, so the forwarded * message stands alone in the target room */ export function buildForwardContent( mx: MatrixClient, mEvent: MatrixEvent, ): Record | undefined { if (mEvent.isDecryptionFailure()) return undefined; let content = { ...mEvent.getContent() }; const eventId = mEvent.getId(); const room = mx.getRoom(mEvent.getRoomId()); if (eventId && room) { const editedEvent = getEditedEvent(eventId, mEvent, room.getUnfilteredTimelineSet()); const newContent = editedEvent?.getContent()['m.new_content']; if (newContent && typeof newContent === 'object') { content = { ...(newContent as Record) }; } } delete content['m.relates_to']; // Drop intentional mentions so forwarding a message doesn't re-ping the // originally-mentioned users (they're not in the destination room's context). delete content['m.mentions']; if (typeof content.body === 'string') { content.body = trimReplyFromBody(content.body); } if (typeof content.formatted_body === 'string') { content.formatted_body = trimReplyFromFormattedBody(content.formatted_body); } return content; } /** * `content.file` on an encrypted attachment carries the AES key/iv/hashes * needed to decrypt it. Forwarding that content verbatim into a room that * isn't itself encrypted would publish the key in plaintext to anyone who can * read the destination room (Gitea #63). Re-encrypting for the destination is * out of scope, so instead download+decrypt the attachment here and re-upload * it as a plain (unencrypted) upload, sending `url` in place of `file`. * * Throws if the attachment can't be fetched/decrypted — callers must treat * that as a hard failure for this forward rather than falling back to * sending the encrypted `file` block into the plaintext room. */ export async function buildPlaintextAttachmentContent( mx: MatrixClient, content: Record, useAuthentication: boolean, ): Promise> { const file = content.file as IEncryptedFile; const info = content.info as Record | undefined; const mimeType = (info?.mimetype as string | undefined) ?? FALLBACK_MIMETYPE; const mediaUrl = mxcUrlToHttp(mx, file.url, useAuthentication); if (!mediaUrl) throw new Error('Invalid attachment URL'); const blob = await downloadEncryptedMedia(mediaUrl, (buf) => decryptFile(buf, mimeType, file)); const uploadResult = await mx.uploadContent(blob, { type: mimeType }); const plainContent = { ...content }; delete plainContent.file; plainContent.url = uploadResult.content_uri; // The thumbnail can carry its own encryption key (`thumbnail_file`); drop it // rather than leak it too — the full attachment still forwards fine without // a thumbnail. if (info && (info.thumbnail_file || info.thumbnail_url)) { const { thumbnail_file: _tf, thumbnail_url: _tu, ...restInfo } = info; plainContent.info = restInfo; } return plainContent; }