messageEvent.tsx (17591B)
1 import { memo, useContext, useEffect, useState } from "react"; 2 import { IRoomEvent, isRoomMessageAudioEvent, isRoomMessageImageEvent, isRoomMessageNoticeEvent, isRoomMessageTextEvent, isRoomMessageVideoEvent } from "../../app/sdk/api/events"; 3 import { FC } from "react"; 4 import { MatrixClient, MatrixContext } from "../../app/sdk/client"; 5 import Linkify from "linkify-react"; 6 import linkifyHtml from 'linkify-html'; 7 import DOMPurify from "dompurify"; 8 import { UndecryptableEvent } from "./unknownEvent"; 9 import { decryptAttachment } from "matrix-encrypt-attachment"; 10 import Waveform from './helpers/Waveform'; 11 import hljs from 'highlight.js'; 12 import 'highlight.js/styles/base16/solarized-dark.css'; 13 import { OnlineState } from "../../app/sdk/api/otherEnums"; 14 import { Room } from "../../app/sdk/room"; 15 import { MessageWrapper } from "./wrapper"; 16 import './messageEvent.scss'; 17 18 type MessageEventProps = { 19 /** 20 * The event to render 21 */ 22 event: IRoomEvent; 23 /** 24 * The room of the event to display 25 */ 26 room?: Room; 27 /** 28 * If the previous event was sent by the same user 29 */ 30 hasPreviousEvent?: boolean; 31 /** 32 * Reactions to the event 33 */ 34 reactions?: IRoomEvent[]; 35 }; 36 37 const linkifyOptions = { 38 defaultProtocol: "https", 39 rel: "noopener", 40 target: "_blank", 41 className: "text-blue-500 hover:text-blue-700 active:text-blue-700 visited:text-blue-500" 42 } 43 44 const decryptMedia = (client: MatrixClient, event: IRoomEvent, decryptedCallback: (url: string) => void, failureCallback: (error: string) => void) => { 45 console.log("Downloading media:", event.event_id); 46 fetch(client.convertMXC(event.content.file.url), { 47 headers: { 48 Authorization: `Bearer ${client.accessToken}` 49 } 50 }).then((response) => { 51 if (!response.ok) { 52 // TODO: display error? 53 console.log("Unable to decrypt media:", response.text()); 54 return; 55 } 56 console.log("Downloaded media:", event.event_id); 57 response.arrayBuffer().then((responseData) => { 58 // Decrypt the array buffer using the information taken from the event content. 59 decryptAttachment(responseData, event.content.file).then((dataArray) => { 60 // Turn the array into a Blob and give it the correct MIME-type. 61 62 // IMPORTANT: we must not allow scriptable mime-types into Blobs otherwise 63 // they introduce XSS attacks if the Blob URI is viewed directly in the 64 // browser (e.g. by copying the URI into a new tab or window.) 65 // See warning at top of file. 66 let mimetype = event.content.info?.mimetype ? event.content.info.mimetype.split(";")[0].trim() : ""; 67 mimetype = getBlobSafeMimeType(mimetype); 68 69 const blob = new Blob([dataArray], { type: mimetype }); 70 // TODO: Cache media in indexeddb 71 decryptedCallback(URL.createObjectURL(blob)); 72 console.log("Decrypted media:", event.event_id); 73 }).catch((e: any) => { 74 console.log("Unable to decrypt media due to decryption error:", e); 75 failureCallback(`Unable to decrypt media due to decryption error: ${e}`); 76 }); 77 }); 78 }); 79 } 80 81 const MessageEvent: FC<MessageEventProps> = memo(({ event, room, hasPreviousEvent, reactions }) => { 82 const client = useContext(MatrixContext); 83 84 const renderCorrectMessage = (event: IRoomEvent) => { 85 if (isRoomMessageTextEvent(event)) { 86 return (<TextMessage event={event} room={room} hasPreviousEvent={hasPreviousEvent} reactions={reactions} />) 87 } else if (isRoomMessageNoticeEvent(event)) { 88 return (<TextMessage event={event} room={room} hasPreviousEvent={hasPreviousEvent} reactions={reactions} message_type={MessageType.Notice} />) 89 } else if (isRoomMessageImageEvent(event)) { 90 const [url, setUrl] = useState<string | undefined>(undefined); 91 const [unableToDecrypt, setUnableToDecrypt] = useState<boolean>(event.content.file !== undefined); 92 93 useEffect(() => { 94 if (isRoomMessageImageEvent(event)) { 95 if (event.content.url) { 96 setUrl(client.convertMXC(event.content.url)); 97 } else { 98 // Image is encrypted and we need to download and decrypt it 99 if (event.content.file) { 100 decryptMedia( 101 client, 102 event, 103 (url) => { 104 setUrl(url); 105 setUnableToDecrypt(false); 106 }, 107 (_error) => { 108 setUnableToDecrypt(true); 109 } 110 ); 111 } 112 } 113 } 114 }, [event]) 115 116 if (unableToDecrypt) { 117 return (<UndecryptableEvent event={event} room={room} hasPreviousEvent={hasPreviousEvent} />) 118 } 119 120 return ( 121 <MessageWrapper 122 displayname={room?.getMemberName(event.sender) || event.sender} 123 avatar_url={room?.getMemberAvatar(event.sender) || ""} 124 onlineState={room?.presence || OnlineState.Unknown} 125 isBot={room?.isBot(event.sender) || false} 126 dm={room?.isDM() || false} 127 hasPreviousEvent={hasPreviousEvent} 128 > 129 {/* TODO: Loading circle while image is loading */} 130 <img 131 src={url} 132 alt={event.content.body} 133 title={event.content.body} 134 className="rounded-md object-cover border-slate-400 border-2 max-h-[50rem] max-w-[50rem] h-[unset]" 135 /> 136 </MessageWrapper> 137 ) 138 } else if (isRoomMessageAudioEvent(event)) { 139 const [url, setUrl] = useState<string | undefined>(undefined); 140 const [unableToDecrypt, setUnableToDecrypt] = useState<boolean>(event.content.file !== undefined); 141 142 143 useEffect(() => { 144 if (isRoomMessageAudioEvent(event)) { 145 if (event.content.url) { 146 setUrl(client.convertMXC(event.content.url)); 147 } else { 148 // Audio is encrypted and we need to download and decrypt it 149 if (event.content.file) { 150 decryptMedia( 151 client, 152 event, 153 (url) => { 154 setUrl(url); 155 setUnableToDecrypt(false); 156 }, 157 (_error) => { 158 setUnableToDecrypt(true); 159 } 160 ); 161 } 162 } 163 } 164 }, [event]) 165 166 if (unableToDecrypt) { 167 return (<UndecryptableEvent event={event} room={room} hasPreviousEvent={hasPreviousEvent} />) 168 } 169 170 return ( 171 <MessageWrapper 172 displayname={room?.getMemberName(event.sender) || event.sender} 173 avatar_url={room?.getMemberAvatar(event.sender) || ""} 174 onlineState={room?.presence || OnlineState.Unknown} 175 isBot={room?.isBot(event.sender) || false} 176 dm={room?.isDM() || false} 177 hasPreviousEvent={hasPreviousEvent} 178 > 179 {url && <Waveform src_url={url} />} 180 </MessageWrapper> 181 ) 182 } else if (isRoomMessageVideoEvent(event)) { 183 const [url, setUrl] = useState<string | undefined>(undefined); 184 const [unableToDecrypt, setUnableToDecrypt] = useState<boolean>(event.content.file !== undefined); 185 186 useEffect(() => { 187 if (isRoomMessageVideoEvent(event)) { 188 if (event.content.url) { 189 setUrl(client.convertMXC(event.content.url)); 190 } else { 191 // Image is encrypted and we need to download and decrypt it 192 if (event.content.file) { 193 decryptMedia( 194 client, 195 event, 196 (url) => { 197 setUrl(url); 198 setUnableToDecrypt(false); 199 }, 200 (_error) => { 201 setUnableToDecrypt(true); 202 } 203 ); 204 } 205 } 206 } 207 }, [event]) 208 209 if (unableToDecrypt) { 210 return (<UndecryptableEvent event={event} room={room} hasPreviousEvent={hasPreviousEvent} />) 211 } 212 213 return ( 214 <MessageWrapper 215 displayname={room?.getMemberName(event.sender) || event.sender} 216 avatar_url={room?.getMemberAvatar(event.sender) || ""} 217 onlineState={room?.presence || OnlineState.Unknown} 218 isBot={room?.isBot(event.sender) || false} 219 dm={room?.isDM() || false} 220 hasPreviousEvent={hasPreviousEvent} 221 > 222 {/* TODO: Loading circle while video is loading */} 223 <video controls preload="metadata" 224 title={event.content.body} 225 className="rounded-md border-slate-400 border-2 max-h-[40rem] max-w-[40rem] h-[unset]" 226 > 227 <source src={url} /> 228 <meta name="description" content={event.content.body} /> 229 </video> 230 </MessageWrapper> 231 ) 232 } else { 233 return ( 234 <MessageWrapper 235 displayname={room?.getMemberName(event.sender) || event.sender} 236 avatar_url={room?.getMemberAvatar(event.sender) || ""} 237 onlineState={room?.presence || OnlineState.Unknown} 238 isBot={room?.isBot(event.sender) || false} 239 dm={room?.isDM() || false} 240 hasPreviousEvent={hasPreviousEvent} 241 > 242 <Linkify options={linkifyOptions} as='p' className="text-black text-base font-normal">{event.content.body}</Linkify> 243 </MessageWrapper > 244 ) 245 } 246 } 247 248 return renderCorrectMessage(event); 249 }); 250 251 export default MessageEvent; 252 253 enum MessageType { 254 Text, 255 Notice, 256 Emote 257 } 258 259 interface TextMessage extends MessageEventProps { 260 /** 261 * Which type the message is 262 */ 263 message_type?: MessageType; 264 } 265 266 const TextMessage: FC<TextMessage> = memo(({ event, room, hasPreviousEvent, message_type = MessageType.Text }) => { 267 let text_color = "text-black"; 268 if (message_type === MessageType.Notice) { 269 text_color = "text-slate-500" 270 } 271 272 if (event.content.format === "org.matrix.custom.html") { 273 if (event.content.formatted_body.includes("<a")) { 274 console.log(event) 275 } 276 let sanitized = DOMPurify.sanitize(event.content.formatted_body!, { 277 ADD_TAGS: [ 278 "font", 279 "del", 280 "h1", 281 "h2", 282 "h3", 283 "h4", 284 "h5", 285 "h6", 286 "blockquote", 287 "p", 288 "a", 289 "ul", 290 "ol", 291 "sup", 292 "sub", 293 "li", 294 "b", 295 "i", 296 "u", 297 "strong", 298 "em", 299 "strike", 300 "code", 301 "hr", 302 "br", 303 "div", 304 "table", 305 "thead", 306 "tbody", 307 "tr", 308 "th", 309 "td", 310 "caption", 311 "pre", 312 "span", 313 "img", 314 "details", 315 "summary" 316 ], 317 ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i 318 }) 319 // Extract code and language from the html 320 const codeRegex = /<pre><code (?:class="language-(?<language>.*?)")?.*?>(?<code>[\s\S]*?)<\/code><\/pre>/; 321 const code = codeRegex.exec(sanitized); 322 323 if (code?.groups?.["code"]) { 324 if (code.groups?.["language"]) { 325 // Highlight the code 326 const highlighted = hljs.highlight(code.groups?.["code"], { language: code.groups?.["language"] }).value; 327 sanitized = sanitized.replace(code.groups?.["code"], `${highlighted}`); 328 } else { 329 // Highlight the code 330 const highlighted = hljs.highlightAuto(code.groups?.["code"]).value; 331 sanitized = sanitized.replace(code[0], `${highlighted}`); 332 } 333 } 334 const linkified = linkifyHtml(sanitized, linkifyOptions); 335 // TODO: sanitize the attributes allowed by matrix spec 336 337 return ( 338 <MessageWrapper 339 displayname={room?.getMemberName(event.sender) || event.sender} 340 avatar_url={room?.getMemberAvatar(event.sender) || ""} 341 onlineState={room?.presence || OnlineState.Unknown} 342 isBot={room?.isBot(event.sender) || false} 343 dm={room?.isDM() || false} 344 hasPreviousEvent={hasPreviousEvent} 345 > 346 {/* TODO: Fixme */} 347 <p id="text-event" className={`${text_color} text-base font-normal`} dangerouslySetInnerHTML={{ __html: linkified }}></p> 348 </MessageWrapper> 349 ) 350 } else { 351 return ( 352 <MessageWrapper 353 displayname={room?.getMemberName(event.sender) || event.sender} 354 avatar_url={room?.getMemberAvatar(event.sender) || ""} 355 onlineState={room?.presence || OnlineState.Unknown} 356 isBot={room?.isBot(event.sender) || false} 357 dm={room?.isDM() || false} 358 hasPreviousEvent={hasPreviousEvent} 359 > 360 <Linkify options={linkifyOptions} as='p' id="text-event" className={`${text_color} text-base font-normal`}>{event.content.body}</Linkify> 361 </MessageWrapper> 362 ) 363 } 364 }) 365 366 367 // WARNING: We have to be very careful about what mime-types we allow into blobs, 368 // as for performance reasons these are now rendered via URL.createObjectURL() 369 // rather than by converting into data: URIs. 370 // 371 // This means that the content is rendered using the origin of the script which 372 // called createObjectURL(), and so if the content contains any scripting then it 373 // will pose a XSS vulnerability when the browser renders it. This is particularly 374 // bad if the user right-clicks the URI and pastes it into a new window or tab, 375 // as the blob will then execute with access to Element's full JS environment(!) 376 // 377 // See https://github.com/matrix-org/matrix-react-sdk/pull/1820#issuecomment-385210647 378 // for details. 379 // 380 // We mitigate this by only allowing mime-types into blobs which we know don't 381 // contain any scripting, and instantiate all others as application/octet-stream 382 // regardless of what mime-type the event claimed. Even if the payload itself 383 // is some malicious HTML, the fact we instantiate it with a media mimetype or 384 // application/octet-stream means the browser doesn't try to render it as such. 385 // 386 // One interesting edge case is image/svg+xml, which empirically *is* rendered 387 // correctly if the blob is set to the src attribute of an img tag (for thumbnails) 388 // *even if the mimetype is application/octet-stream*. However, empirically JS 389 // in the SVG isn't executed in this scenario, so we seem to be okay. 390 // 391 // Tested on Chrome 65 and Firefox 60 392 // 393 // The list below is taken mainly from 394 // https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats 395 // N.B. Matrix doesn't currently specify which mimetypes are valid in given 396 // events, so we pick the ones which HTML5 browsers should be able to display 397 // 398 // For the record, mime-types which must NEVER enter this list below include: 399 // text/html, text/xhtml, image/svg, image/svg+xml, image/pdf, and similar. 400 401 const ALLOWED_BLOB_MIMETYPES = [ 402 "image/jpeg", 403 "image/gif", 404 "image/png", 405 "image/apng", 406 "image/webp", 407 "image/avif", 408 409 "video/mp4", 410 "video/webm", 411 "video/ogg", 412 "video/quicktime", 413 414 "audio/mp4", 415 "audio/webm", 416 "audio/aac", 417 "audio/mpeg", 418 "audio/ogg", 419 "audio/wave", 420 "audio/wav", 421 "audio/x-wav", 422 "audio/x-pn-wav", 423 "audio/flac", 424 "audio/x-flac", 425 ]; 426 427 export function getBlobSafeMimeType(mimetype: string): string { 428 if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) { 429 return "application/octet-stream"; 430 } 431 return mimetype; 432 }