웹에디터

https://www.npmjs.com/package/react-quill

https://www.npmjs.com/package/react-draft-wysiwyg

대표적인 두가지 웹에디터다

여기서는 react-quill을 써보자

yarn add react-quill

그리고 페이지를 한번 만들어보자

컴포넌트를 import 해와야한다

import ReactQuill from 'react-quill'; // ES6
import * as ReactQuill from 'react-quill'; // Typescript
const ReactQuill = require('react-quill'); // CommonJS

css부분도 해야한다

require('react-quill/dist/quill.snow.css'); // CommonJS
import 'react-quill/dist/quill.snow.css'; // ES6

import ReactQuill from "react-quill";
import "react-quill/dist/quill.snow.css";

export default function WebEditorPage() {
  function handlechange(value: string) {
    console.log(value);
  }
  return (
    <>
      작성자
      <input type="text" />
      <br />
      비밀번호: <input type="password" />
      <br />
      제목 <input type="text" />
      <br />
      내용 <ReactQuill onChange={handlechange} />
      <br />
      <button>등록하기</button>
    </>
  );
}

이렇게 사용법을 보고 만들어 볼 수 있다
그럼 작동되나 한번 보자

document is not defined 라고 나온다
그런데 reactquill부분을 주석처리하고 나면 너무나도 잘뜬다

  if(process.browser){
      console.log("나는 브라우저다!")
  } else {
      console.log("나는 프론트엔드 서버다!")
  }

이렇게 추가해보면

나는 브라우저라고 잘 뜬다

나는 프론트엔드 서버다는 어딨는가?

터미널에 남아있다.

웹 에디터를 쓰면 html을 건드려서 텍스트를 이쁘게 만들어주지만
서버에서는 그릴 수 없다. 서버에서는 그런 기능을 가지고 있지 않기 때문이다.

그럼 어떻게 해야되나?
프로세스가 브라우저일때 사용해 근데 브라우저일때 사용한다고 해도 안되는 이유가
import자체를 못해오기 때문이다

(왜 지금 이러고있는가? next를 쓰고 있어서 그렇다.react+next)

다이나믹 임포트

import도 다이나믹하게 해야한다.

import dynamic from 'next/dynamic'
///
const ReactQuill = dynamic(() => import("react-quill"), { ssr: false });

ssr :(server side rendering)을 false.
이후 원래 있던 import를 삭제
동적으로 브라우저에서만 가져오기로 한거다

그렇게하면 잘 나온다 + 기본적인 html기능을 갖춘 입력창이 생성되었다. 우리 대신 태그를 알아서 붙여주는 것 같다.

서버사이드 렌더링이 뭔데 이렇게 귀찮게 하냐는 배포를 배울 때쯤 배울 예정이다

이걸 활용하기 위해서 react-hook-form과 연계해서 쓰는 것이 좋다

register를 이용해서 writer, password, title이라는 state를 만들었다 .
(reactQuill의 onchange는 reactQuill의 props지 같은 onChange가 아님)

강제로라도 넣어줘야한다. hook-form의 setValue을 이용한다.

const { handleSubmit, register, setValue } = useForm({
mode: "onchange",
});
function handlechange(value: string) {
console.log(value);
setValue("contents", value)
}

register로 등록하지 않고, 강제로 값을 넣어주는 기능이다.

그럼 이러면 끝인가?

값이 변경될때마다 감지를 하는 기능 (mode :"onchange")를 달아놨다
그렇데 setValue는 값을 넣어만 주지 변경되는 기능은 들어가있지 않음

그럼 값을 강제로 변경했을때 change도 변경됐다고 알려줘야함
trigger 기능을 가져온다

const { handleSubmit, register, setValue, trigger } = useForm({
mode: "onchange",
});
function handlechange(value: string) {
console.log(value);
// register로 등록하지 않고, 강제로 값을 넣어주는 기능!!
setValue("contents", value);
// onChange 됐는지 안됐는지 react-hook-form에 알려주는 기능
trigger("contents");

그런데 이렇게 한다고해도 남아있는 값들은 남

비었을때는 빈칸세팅, 차있을때는 value를 넣어주는 세팅을 해야한다.

setValue("contents", value === "


" ? "" : value);

이러면 기본적인 세팅이 끝난다. 그럼 handleSubmit을 사용해보자

핵심은 form 으로 싼 이후 handlesubmit을 이용하여 react-hook-form에 담겨있는 onCluckSubmit을 가져오는거다

이후에

  function onClickSumbit(data) {
    // createBoard 뮤테이션 요청
    createBoard({
      variables: {
        createBoardInput: {
          writer: data.writer,
          password: data.password,
          title: data.title,
          contents: data.contents,
        },
      },
    });
  }

이런식으로 아까 강제로 넣어줬던 contents까지 이런식으로 요청할 수 있다.

작성완료 페이지로 이동하기까지 만들어보자

import { useForm } from "react-hook-form";
import { gql, useMutation } from "@apollo/client";
// import ReactQuill from "react-quill";   <- 애가 dynamic import임
import "react-quill/dist/quill.snow.css";

// react를 next에서 쓰기위해

import dynamic from "next/dynamic";
import { useRouter } from "next/router";
import { Modal } from "antd";
const ReactQuill = dynamic(() => import("react-quill"), { ssr: false });

export const CREATE_BOARD = gql`
  mutation createBoard($createBoardInput: CreateBoardInput!) {
    createBoard(createBoardInput: $createBoardInput) {
      _id
    }
  }
`;

interface FormValues {
  writer: string;
  password: string;
  title: string;
  contents: string;
}

export default function WebEditorReactHookFormSubmitPage() {
  const router = useRouter();
  const [createBoard] = useMutation(CREATE_BOARD);
  const { handleSubmit, register, setValue, trigger } = useForm({
    mode: "onChange",
  });
  function handleChange(value: string) {
    console.log(value);

    // register로 등록하지 않고, 강제로 값을 넣어주는 기능 !!
    setValue("contents", value === "<p><br></p>" ? "" : value);
    // onChange 됐는지 안됐는지 react-hook-form에 알려주는 기능 !!
    trigger("contents");
  }

  async function onClickSubmit(data: FormValues) {
    // createBoard Mutation 요청!!
    try {
      const result = await createBoard({
        variables: {
          createBoardInput: {
            writer: data.writer,
            password: data.password,
            title: data.title,
            contents: data.contents,
          },
        },
      });
      console.log(result);
      router.push(`27-04-web-editor-detail/${result.data?.createBoard._id}`);
    } catch (error) {
      Modal.error(error.message);
    }
  }
  return (
    <form onSubmit={handleSubmit(onClickSubmit)}>
      작성자:
      <input type="text" {...register("writer")} />
      <br />
      비밀번호:
      <input type="password" {...register("password")} />
      <br />
      제목:
      <input type="text" {...register("title")} />
      <br />
      내용:
      <ReactQuill onChange={handleChange} />
      <br />
      <button>등록하기</button>
    </form>
  );
}

그리고 이동한 이후의 페이지 까지 만들어보자

import { useQuery, gql } from "@apollo/client";
import { useRouter } from "next/router";
import {
  IQuery,
  IQueryFetchBoardArgs,
} from "../../../src/commons/types/generated/types";
const FETCH_BOARD = gql`
  query fetchBoard($boardId: ID!) {
    fetchBoard(boardId: $boardId) {
      writer
      title
      contents
    }
  }
`;

export default function WebEditorDetailPage() {
  const router = useRouter();
  //   const { data } = useQuery<Pick<IQuery, "fetchBoard">, IQueryFetchBoardArgs>(
  //     FETCH_BOARD,
  //     {
  //       variables: { boardId: String(router.query.id) },
  //     }
  //   );
  const { data } = useQuery(FETCH_BOARD, {
    variables: {
      boardId: String(router.query.id),
    },
  });
  console.log(router.query.id);
  console.log(data);
  return (
    <>
      <div>작성자 : {data?.fetchBoard.writer}</div>
      <div>제목 : {data?.fetchBoard.title}</div>
      <div>내용 : {data?.fetchBoard.contents}</div>
    </>
  );
}

잘된다

근데 태그가 수정되어서 나오지 않고 그냥 나온다

html이 태그대로 입력되게 해보자

    <div
      dangerouslySetInnerHTML={{
        __html: String(data?.fetchBoard.contents),
      }}

이렇게 바꿔야한다.

다양한 웹 공격

그런데 3번 페이지에서 그대로 입력됐던 태그를 볼 수 있다.

만약에 입력창에 같은걸 입력하면 어떻게될까??

다행히도 reactQuill에서는 본문의 글자들을 그냥 글자로 받아들이게 script를 다른 글자로 치환해서 잘 받아들이게됐다.

그럼 그런 방어기능이 없는곳에 이런식으로 쓰게 되면?

나도 모르게 토큰이 뺏어갈 수 있게된다.
그럼 이렇게 뺏은 토큰을 사용해서 다른곳에 전송하거나 이용할 수 있다. 이런방식을 XSS라고한다.
(cross site scripting)

굉장히 기초적인 방법이고 얼마든지 응용된다.

https://www.npmjs.com/package/dompurify

다행히 이런 시도를 방지하기 위한 라이브러리가 존재한다 - dompurify

yarn add dompurify
yarn add -D @types/dompurify

{process.browser && (
      <div
        dangerouslySetInnerHTML={{
          __html: Dompurify.sanitize(String(data?.fetchBoard.contents)),
        }}
      />
    )}

이렇게 브라우저에서만 작동되게하고, Dompurify.sanitize를 사용하면 글 안의 스크립트는 작동하지 않는다

OWASP 위키

이렇게 이런 The Open Web Application Security Project) 오픈소스 웹 어플리케이션 보안 프로젝트로 다양한 보안 사례를 소개하고 있다. (오늘 봤던 XSS도 있다)

정리
웹에디터, 웹에디터를 구현함에 있어서 생기는 보안문제
(강제로 값을 넣어주는 과정에서)
모두 브라우저에서 실행되기 때문에 서버에서는 그려주면 안됨
조건부 렌더링이 있으면 hydration이슈가 next에 있기 때문에 이부분 또한 조심해야함
그래서 삼항연산자를 사용해서 빈 태그를 채워줘야한다.

개념을 잘 알고 있어야 한다.

profile
개발자 새싹🌱 The only constant is change.

0개의 댓글