cetirizine

An experimental matrix client written in reactjs utilizing tailwind and storybook
git clone git://archive.git.mtrnord.blog/MTRNord/cetirizine.git
Log | Files | Refs | README | LICENSE

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 }