[슬랙 클론코딩] 에디터 붙이기 1-2 | dangerouslySetInnerHTML

jung moon chai·2022년 11월 16일
1

시작에 앞서서 textarea안에 이모티콘을 보여줄 목적으로 시작 하였으나, textarea안에서 어떻게 react-icons의 태그들을 보여줄 수 있을지 고민하다가, 아직 크게 아이디어가 떠오르지 않아 일단 에디터를 붙여보기로 했다.


1. react html parsor

에디터에서 작성한 html태그들을 string이 아닌 html로 파싱하는 작업을 진행해보자.

1-1. dangerouslySetInnerHTML

리액트에서 스트링형태의 html은 그냥 그대로 스트링으로 출력하게 되어있다. 그래서 에디터에서 씌운 태그들이 그대로 스트링으로 출력 되는 것이다.
리액트 공식문서에 의하면 innerHTML의 대체 방안으로 dangerouslySetInnerHTML을 제안 하고 있으나, 애초에 이렇게 만들어놓은 이유는 cross-site scripting(XSS) 때문이다. 이름만봐도 위험하다고 경고하고있다...일단 dangerouslySetInnerHTML를 사용해 출력을 해보자.
사용법은 __html키로 객체를 전달하면 된다.

// components/chat/index.tsx
import { ChatWrapper } from '@components/Chat/styles';
import { IDM, IChat } from '@typings/db';
import React, { VFC, memo, useMemo } from 'react';
import gravatar from 'gravatar';
import dayjs from 'dayjs';
import regexifyString from 'regexify-string';
import { Link, useParams } from 'react-router-dom';

interface Props {
  data: IDM | IChat;
}

const BACK_URL = process.env.NODE_ENV === 'development' ? 'http://localhost:3095' : 'https://sleact.nodebird.com';
const Chat: VFC<Props> = ({ data }) => {
  const { workspace } = useParams<{ workspace: string; channel: string }>();
  const user = 'Sender' in data ? data.Sender : data.User;

  const result = useMemo(
    () =>
      // uploads\\서버주소
      data.content.startsWith('uploads\\') || data.content.startsWith('uploads/') ? (
        `<img src="${BACK_URL}/${data.content}" style='max-height:200px' />`
      ) : (
        regexifyString({
          input: data.content,
          pattern: /@\[(.+?)]\((\d+?)\)|\n/g,
          decorator(match, index) {
            const arr: string[] | null = match.match(/@\[(.+?)]\((\d+?)\)/)!;
            if (arr) {
              return (
                <Link key={match + index} to={`/workspace/${workspace}/dm/${arr[2]}`}>
                  @{arr[1]}
                </Link>
              );
            }
            return <br key={index} />;
          },
        })
      ),
    [workspace, data.content],
  );

  return (
    <ChatWrapper>
      <div className="chat-img">
        <img src={gravatar.url(user.email, { s: '36px', d: 'retro' })} alt={user.nickname} />
      </div>
      <div className="chat-text">
        <div className="chat-user">
          <b>{user.nickname}</b>
          <span>{dayjs(data.createdAt).format('h:mm A')}</span>
        </div>
        <p dangerouslySetInnerHTML={{ __html: result.toString() }}></p>
      </div>
    </ChatWrapper>
  );
};

export default memo(Chat);

기존에 제로초님께서 드래그앤드랍 이미지들은 스트링이 아니라 dom객체로 만들어 두신게 toString()으로 문자화 시켰을때 [object, object]로 나오고 있었다. 그래서 result에서 이미지 처리 해주신부분도 스트링으로 바꿔주었다.

이제 에디터로 작성한 이미지와 제로초님께서 만든 드래그앤 드랍의 이미지 출력 충돌 부분은 없어졌다.

근데 왠지 모르게 dangerouslySetInnerHTML이 조금 걸린다. xss에 최소한의 방어기능을 할 수 있는 라이브러리를 찾아 적용해보자.


1-2. dompurify

innerHTML을 무작정 안쓸수는 없으니 프론트에서 최소한의 xss방어 를 위해 많이 사용하는 dompurify 라이브러리를 사용 해보도록 하겠다.

$ npm i isomorphic-dompurify

자세한 사용 방법은 npm에 올라와 있다. dompurify npm
dangerouslySetInnerHTML를 사용하는 방식은 그대로 이나 파싱 하기 전에 xss 필터를 거쳐 안전한 string을 넣어주는 것이다.

// components/chat/index.tsx
import { ChatWrapper } from '@components/Chat/styles';
import { IDM, IChat } from '@typings/db';
import React, { VFC, memo, useMemo } from 'react';
import gravatar from 'gravatar';
import dayjs from 'dayjs';
import regexifyString from 'regexify-string';
import { Link, useParams } from 'react-router-dom';
import DOMPurify from 'isomorphic-dompurify';

interface Props {
  data: IDM | IChat;
}

const BACK_URL = process.env.NODE_ENV === 'development' ? 'http://localhost:3095' : 'https://sleact.nodebird.com';
const Chat: VFC<Props> = ({ data }) => {
  const { workspace } = useParams<{ workspace: string; channel: string }>();
  const user = 'Sender' in data ? data.Sender : data.User;

  const result = useMemo(
    () =>
      // uploads\\서버주소
      data.content.startsWith('uploads\\') || data.content.startsWith('uploads/') ? (
        `<img src="${BACK_URL}/${data.content}" style='max-height:200px' />`
      ) : (
        regexifyString({
          input: data.content,
          pattern: /@\[(.+?)]\((\d+?)\)|\n/g,
          decorator(match, index) {
            const arr: string[] | null = match.match(/@\[(.+?)]\((\d+?)\)/)!;
            if (arr) {
              return (
                <Link key={match + index} to={`/workspace/${workspace}/dm/${arr[2]}`}>
                  @{arr[1]}
                </Link>
              );
            }
            return <br key={index} />;
          },
        })
      ),
    [workspace, data.content],
  );

  return (
    <ChatWrapper>
      <div className="chat-img">
        <img src={gravatar.url(user.email, { s: '36px', d: 'retro' })} alt={user.nickname} />
      </div>
      <div className="chat-text">
        <div className="chat-user">
          <b>{user.nickname}</b>
          <span>{dayjs(data.createdAt).format('h:mm A')}</span>
        </div>
        <p dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(result.toString()) }}></p>
      </div>
    </ChatWrapper>
  );
};

export default memo(Chat);

위에 dangerouslySetInnerHTML만 적용했을 때랑 모양새는 같지만 tinymce 내부에서도, 그리고 출력하는 부분에서도 xss의 최소한에 방어는 작용 한듯 보인다.


2. 기타 버그사항 정리

  1. Chat 컴포넌트에서 Link컴포넌트를 리턴하는 경우 글자 출력시 toString()을 하게 되면 [object, obejct]로 출력되는 부분, 스트링으로 a태그를 출력하게되면 history push가 되지 않고 새로 페이지를 로드하게 되는데 history.push로 해결이 될 가능성은 보이긴 한다.
  2. 기존 textarea로 되어있는 부분을 br태그로 줄넘김이 아닌 br태그 인식시 p태그로 감싸게 해야하는 부분
  3. 멘션기능 일단 tinymce는 멘션기능이 유료라서 어떻게 할지 좀 고민을 해봐야 하는부분인데 1번 버그와 어느정도 연관이 있다.
  4. 기존 슬랙에서는 챗을 쓰다가 채널및 dm을 이동하게 되면 폼이 비었다가, 다시 작성하던 채널이나 dm으로 돌아오면 작성하던 챗이 그대로 남아있다. 실제 슬랙도 뜯어본 결과 로컬스토리지에 저장하고 있는 것을 확인 했고, 이 부분은 적용 예정이다.
profile
고급개발자되기

0개의 댓글