📝 DAY 04- 220521 (2)
- 포스트 조회 기능 구현
- html 필터링 (sanitize-html 라이브러리)
- 페이지네이션
❗️ HTML 필터링 과정은 백엔드 (서버)에서 진행한다!
sanitize-html
이라는 라이브러리를 사용하여 html 필터링 가능.
HTML을 제거 및 허용을 할 수 있기 때문에 매우 유용.
-> 악성 스크립트 삽입 방지도 가능.
$ cd ../blog-backend
$ yarn add sanitize-html
api/posts/posts.ctrl.js 수정
import sanitizeHtml from '../../../node_modules/sanitize-html/index';
...
// html을 없애고, 글자수 200자로 제한하는 함수 선언
const removeHtmlAndShorten = (body) => {
const filtered = sanitizeHtml(body, {
allowedTags: [],
});
return filtered.length < 200 ? filtered : `${filtered.slice(0, 200)}...`;
};
sanitizeHtml(body, { allowedTags: [], })
// 수정 전 코드
export const list = async (ctx) => {
...
.map((post) => ({
...post,
body:
post.body.length < 200 ? post.body : `${post.body.slice(0, 200)}...`,
}));
}
🔻 수정 후
.map((post) => ({
...post,
body:
removeHtmlAndShorten(post.body)
}));
api/posts/posts.ctrl.js 수정
const sanitizeOption = {
allowedTags: [
'h1',
'h2',
'b',
'i',
'u',
's',
'p',
'ul',
'ol',
'li',
'blockquote',
'a',
'img',
],
allowedAttributes: {
a: ['href', 'name', 'target'],
img: ['src'],
li: ['class'],
},
allowedSchemes: ['data', 'http'],
};
// write 함수
export const write = async (ctx) => {
...
const post = new Post({
title,
body: sanitizeHtml(body, sanitizeOption),
tags,
user: ctx.state.user,
});
}
// update 함수
export const update = async (ctx) => {
...
// 🔻 ctx.request 객체 복사하기
const nextData = { ...ctx.request.body };
// sanitizeHtml
if (nextData.body) {
nextData.body = sanitizeHtml(nextData.body, sanitizeOption);
}
try {
const post = await Post.findByIdAndUpdate(id, nextData, {
new: true,
}).exec();
}
<p>
같은 HTML 태그들이 모두 사라진 것을 볼 수 있다.// 문서 수 몇개인지 가져옴
const postCount = await Post.countDocuments(query).exec();
// 페이지 수 카운트하여 ctx.set으로 넘겨줌.
ctx.set('Last-page', Math.ceil(postCount / 10));
// 🔺 response의 'Last-page' 필드로 마지막 페이지를 전달한 것임. (전체 포스트수 / 10 을 올림한 값)
createRequestSaga
는response.data
만 넣어주므로, 헤더를 확인할수 없음.-> createRequestSaga를 일부 수정 필요.
lib/createRequestSaga.js 수정
export default function createRequestSaga(type, request) {
const SUCCESS = `${type}_SUCCESS`;
const FAILURE = `${type}_FAILURE`;
return function* (action) {
yield put(startLoading(type));
try {
const response = yield call(request, action.payload);
yield put({
// 성공 시 - SUCESS 액션 발생
type: SUCCESS,
payload: response.data,
// 🔻 meta 값을 response로 넣어줌.
meta: response,
});
} catch (e) {
yield put({
type: FAILURE,
payload: e,
error: true,
});
}
yield put(finishLoading(type));
};
}
-> meta 값을 response
로 넣어주면 나중에 http 헤더 / 상태 코드를 쉽게 조회 가능.
-> response 는 API 요청 후 전달받은 response 임.
modules/posts.js 수정
const initialState = {
posts: null,
error: null,
// 🔻 state 추가
lastPage: 1,
};
const posts = handleActions(
{
// 🔻 payload에 meta: response 추가
[LIST_POSTS_SUCCESS]: (state, { payload: posts, meta: response }) => ({
...state,
posts,
// 🔻 response.headers에서 last-page 필드의 값 불러옴 (숫자로 변경)
lastPage: parseInt(response.headers['last-page']),
}),
[LIST_POSTS_FAILURE]: (state, { payload: error }) => ({
...state,
error,
}),
},
initialState,
);
-> state에 LastPage를 추가하여, 리덕스 스토어 안에 마지막 페이지 값을 저장할 수 있음.
✅ 과정
서버측
load API
- 전체 페이지수 / 10을 반올림 한 값을 ctx.set('last-page') 해줌.
createRequestSaga
함수 - SUCCESS시 meta를 reponse에 담아 저장. -> 헤더 값 얻을수 있음
posts
모듈 - LIST_POSTS_SUCCESS 액션이 실행되면 -> lastPage: parseInt(response.headers['last-page'])로 마지막 페이지를 담은 state를 저장함.
import styled from 'styled-components';
import qs from 'qs';
import Button from '../common/Button';
const PaginationBlock = styled.div`
width: 320px;
margin: 0 auto;
display: flex;
justify-content: space-between;
margin-bottom: 3rem;
`;
const PageNumber = styled.div``;
// 🔻 qs 모듈을 이용한 path를 생성해주는 함수
const buildLink = ({ username, tag, page }) => {
const query = qs.stringify({ tag, page });
// username이 있는지 여부에 따라 달라짐
return username ? `/@${username}?${query}` : `/?${query}`;
};
const Pagination = ({ page, lastPage, username, tag }) => {
// 🔺 username은 있을수도, 없을수도 있음.
return (
<PaginationBlock>
<Button
disabled={page === 1}
to={
page === 1 ? undefined : buildLink({ username, tag, page: page - 1 })
}
>
이전
</Button>
<PageNumber>{page}</PageNumber>
<Button
disabled={page === lastPage}
to={
page === lastPage
? undefined
: buildLink({ username, tag, page: page + 1 })
}
>
다음
</Button>
</PaginationBlock>
);
};
export default Pagination;
disabled
속성<Button disabled={page === 1} ... >
// page가 1이면 (true) -> disabled (버튼 못누르는 속성)
containers/posts/PaginationContainer.js 생성
import React from 'react';
import Pagination from '../../components/posts/Pagination';
import { useDispatch, useSelector } from 'react-redux';
import { useParams, useSearchParams } from 'react-router-dom';
//page, lastPage, username, tag
const PaginationContainer = () => {
const dispatch = useDispatch();
const { username } = useParams();
const [searchParams] = useSearchParams();
const tag = searchParams.get('tag');
const page = parseInt(searchParams.get('page', 10)) || 1;
const { lastPage, posts, loading } = useSelector(({ posts, loading }) => ({
lastPage: posts.lastPage,
loading: loading['posts/LIST_POSTS'],
posts: posts.posts,
}));
if (!posts || loading) return null;
return (
<Pagination
page={parseInt(page)}
lastPage={lastPage}
username={username}
tag={tag}
/>
);
};
export default PaginationContainer;
// App.js
<Route path="/post/:username" element="<PostViewer />" />
// PostViewer.js
const { username } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
console.log(searchParams.page); // ?page=3 일때 -> 3
setSearchParams(4); // ?page=4로 변경함
다음 포스팅
- 포스트 수정(update)
- 포스트 삭제(delete)
- 프로젝트 마무리!