글쓰기 상세페이지에 삭제 버튼을 추가해주자.
수정 버튼은 수정 페이지 이동이니까 get요청 -> 하이퍼링크
삭제는 get 요청으로 할 수 없으니까 버튼으로 만들어준다.
현재 상태는 다른 아이디로 로그인해도 글 상세보기 페이지에서
수정과 삭제 버튼이 보이게 되어있다.
해당 게시글의 userId(FK)와 세션의 principal id를 비교해서 권한을 체크해준다.
동일하면 글 삭제 가능, 같지 않으면 글 삭제 불가능하게!
delete 요청에는 body가 없기 때문에
게시글의 id로 한번 더 SELECT 해서 userid를 확인해야 한다.
<script>
async function deleteById () {
let id = $("#id").val();
// 무조건 서버는 json 응답! 생긴건 json 모양이지만 결국 통신은 문자열
let response = await fetch("/s/post/" + id, {
method: "DELETE"
});
let responseObject = await response.json();
if(responseObject.code == 1) { // 통신 성공
alert("삭제 성공");
location.href = "/";
} else { // 통신 실패 code = -1
alert("삭제 실패");
console.log("삭제 실패 : " + responseObject.msg);
}
}
$("#btn-delete").click((event) => {
// DELETE:/post/{id}
deleteById();
});
</script>
// DELETE 글 삭제 /post/{id} -> 글 목록으로 가기 - 인증 O
@DeleteMapping("/s/post/{id}")
public @ResponseBody ResponseDto<String> delete(@PathVariable Integer id) {
// 인증
User principal = (User) session.getAttribute("principal");
if (principal == null) { // 로그인 안됨
return new ResponseDto<String>(-1, "로그인에 실패하였습니다.", null);
}
// 권한
Post postEntity = postService.글상세보기(id); // 재활용
if(principal.getId() != postEntity.getUser().getId()) {
return new ResponseDto<String>(-1, "해당 글을 삭제할 권한이 없습니다.", null);
}
postService.글삭제하기(id); // 내부적으로 exception이 터지면 무조건 stackTrace를 리턴
return new ResponseDto<String>(1, "성공", null);
}
@Transactional
public void 글삭제하기(Integer id) {
postRepository.deleteById(id); // 실패했을 때 내부적으로 exception 터짐
}
cos로 로그인했는데 ssar이 게시한 글의 수정, 삭제 버튼이 보인다.
권한 체크해주자.
포스트 컨트롤러에서 모델에 값 하나 더 보낼 것이다.
// 글 상세보기 페이지 /post/{id} (삭제버튼 만들어두면 되니까 삭제페이지 필요 X)
@GetMapping("/post/{id}") // Get요청에 /post 제외시키기(인증 X)
public String detail(@PathVariable Integer id, Model model) { // int는 null이 없음, 초기값이 0
// Integer는 초기값이 null
User principal = (User) session.getAttribute("principal");
// 권한
Post postEntity = postService.글상세보기(id); // 재활용, EAGER니까 user 가지고있음
// 게시물이 없으면 error 페이지 이동
if(postEntity == null){
return "error/page1";
}
// 로그인 안했을때 터짐 null.getId했던것
if(principal != null) {
// 권한 확인해서 view로 값 넘김
if(principal.getId() == postEntity.getUser().getId()) { // 권한이 있다는 뜻
model.addAttribute("pageOwner", true);
} else {
model.addAttribute("pageOwner", false);
}
}
model.addAttribute("post", postEntity);
return "post/detail";
}
{{#pageOwner}} <!--true이면 실행-->
<a href="/s/post/{{post.id}}/updateForm" class="btn btn-secondary">수정</a>
<button class="btn btn-danger" id="btn-delete">삭제</button>
{{/pageOwner}}
글 수정해보자.
수정 버튼을 누르면 해당 주소로 가고 404 에러가 뜬다.
해당 페이지는 우리가 만든 적 없는 페이지 -> 스프링이 제공해준 것
저 화면보다는 좀 더 예쁜 페이지를 리턴해서 이전 화면으로 돌아가 주게 하는 게 UX가 좋겠다.
이건 버전 2에서 만들어보자.
404 에러는 Not Found 상태 코드 확인해보자.
컨트롤러 자체를 못 찾았던지 컨트롤러가 리턴하는 파일이 없는 것이다.
우리는 컨트롤러는 있는데 /post/updateForm이 없다.
writeForm 파일을 복사하여 만들어주면 되겠다.
수정은 put 요청이라서 form 태그를 사용할 수 없다. fetch 사용!
name은 필요 없다. 자바스크립트로 찾아야 하니까 id로 변경해주자.
다 수정하면 submit이 아니라 button 타입이여야한다.
버튼은 원래 타입을 안 적으면 디폴트가 button 타입이다.
근데 폼 태그 내부에 있는 버튼은 디폴트가 submit 타입이다.
헷갈리니까 웬만하면 버튼의 타입을 적어주는 것을 습관 하면 좋겠다.
게시글 수정하는 거니까 원래 적혀있던 글 내용(value)이 있어야겠다.
이제 원래 적혀있던 글 내용을 가져와보자.
글 수정 페이지 컨트롤러로 갈 때 모델을 하나 가져가야 한다.
그리고 인증과 권한 체크도 필요하다.
다이렉트하게 주소로 접근할 수 있기 때문이다.
// 글 수정 페이지 /post/{id}/updateForm - 인증 O
@GetMapping("/s/post/{id}/updateForm")
public String updateForm(@PathVariable Integer id, Model model) {
// 인증
User principal = (User) session.getAttribute("principal");
if(principal == null) {
return "error/page1";
}
// 권한
Post postEntity = postService.글상세보기(id);
if(postEntity.getUser().getId() != principal.getId()) {
return "error/page1";
}
model.addAttribute("post", postEntity);
return "post/updateForm"; // ViewResolver 도움 받음
}
{{> /layout/header}}
<!-- 컨테이너 시작 -->
<div class="container mt-3"> <!-- container, mt-3은 css 클래스임!! -->
<!-- 글쓰기 수정 폼 시작 -->
<form>
<div class="mb-3 mt-3">
<input type="text" class="form-control" placeholder="Enter title" id="title" value="{{post.title}}">
</div>
<div class="mb-3">
<textarea class="form-control" id="content" rows="10">{{post.content}}</textarea>
</div>
<button type="submit" class="btn btn-secondary" id="btn-update">글쓰기 수정완료</button>
<!-- 글쓰기 수정 폼 끝 -->
</form>
</div>
<!-- 컨테이너 끝 -->
{{> /layout/footer}}
이제 수정 완료 버튼을 눌리면 fetch로 put요청해야 한다.
<script>
async function update() {
let post = {
title: $("#title").val(),
content: $("#content").val()
}
console.log("title : " + post.title);
console.log("content : " + post.content);
let postJson = JSON.stringify(post);
console.log("postJson : " + postJson);
let id = $("#id").val();
let response = await fetch("/s/post/" + id, {
headers: {
'Content-Type': 'application/json;charset=utf-8'
},
method: 'PUT',
body: postJson
});
console.log("response : " + response);
let responseObject = await response.json();
console.log("responseObject.code : " + responseObject.code);
if(responseObject.code == 1) {
alert("수정이 정상적으로 완료되었습니다.")
location.href = "/post/" + id;
}
}
$("#btn-update").click((event) => {
update();
});
</script>
자바스크립트로 요청할 때는 반드시 데이터로 응답받아야 한다.
// UPDATE 글 수정 /post/{id} -> 글 상세보기 페이지 가기 - 인증 O
@PutMapping("/s/post/{id}")
public @ResponseBody ResponseDto<String> update(@PathVariable Integer id, @RequestBody Post post) {
// 인증
User principal = (User) session.getAttribute("principal");
if(principal == null) {
return new ResponseDto<String>(-1, "로그인에 실패하였습니다.", null);
}
// 권한
Post postEntity = postService.글상세보기(id);
if(postEntity.getUser().getId() != principal.getId()) {
return new ResponseDto<String>(-1, "해당 게시글을 수정할 권한이 없습니다.", null);
}
postService.글수정하기(post, id);
return new ResponseDto<String>(1, "성공", null);
}
@Transactional
public void 글수정하기(Post post, Integer id) {
// 영속화
Optional<Post> postOp = postRepository.findById(id);
// 변경감지
if(postOp.isPresent()) {
Post postEntity = postOp.get();
postEntity.setTitle(post.getTitle());
postEntity.setContent(post.getContent());
}
} // 더티체킹 완료 (수정됨)
지금까지 배운 내용이 CRUD 기본이다.
모든 웹이 CRUD의 반복이다.
글 content가 담기는 textarea가
UX가 좋고 텍스트 편집하기 좋게 만들어주자.
cdn 헤더에 추가해주면 사용이 가능하다.
분명 cdn이 중복되는 게 있을 텐데 중복되는 건 지워줘야 한다.
<!-- include summernote css/js -->
<link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote.min.js"></script>
<div id="summernote"><p>Hello Summernote</p></div>
<script>
$(document).ready(function() {
$('#summernote').summernote();
});
</script>
글쓰기 textarea에 걸어줘야 한다.
{{> /layout/header}}
<!-- 컨테이너 시작 -->
<div class="container mt-3"> <!-- container, mt-3은 css 클래스임!! -->
<!-- 글쓰기 폼 시작 -->
<form action="/s/post" method="post">
<div class="mb-3 mt-3">
<input type="text" class="form-control" placeholder="Enter title" name="title">
</div>
<div class="mb-3">
<textarea class="form-control" name="content" rows="10" id="summernote"></textarea>
</div>
<button type="submit" class="btn btn-secondary">글쓰기</button>
</form>
<!-- 글쓰기 폼 끝 -->
</div>
<!-- 컨테이너 끝 -->
<script>
$("#summernote").summernote();
</script>
{{> /layout/footer}}
비교 표현식
content자리에 <script>alert("안녕");</script>를 넣으면
섬머 노트가 디비에 넣을 때 꺽쇠를 비교 표현식으로 치환해서 들어간다.
<script> 이런 식으로!
그냥 브라우저에서 열면 알아서 치환되어 잘 나오는데
섬머 노트가 아닌 포스트맨으로 접근하면 치환되지 않고 꺽쇠가 그대로 디비에 들어가게 된다.
그리고 브라우저에서 들어가 보면 공격이 정상적으로 작동된다.
이를 막기 위해 디비에 넣을 때 치환해서 넣거나 디비에서 당겨올 때 치환해서 가져와야 한다.
자동으로 치환해서 디비에 저장된 게 아니라 섬머 노트가 해준 것이었다.
우리는 디비에서 빼올 때 막아보도록 하자.
// <> -> <> 치환해주기
String rawContent = postEntity.getContent();
String encContent = rawContent
.replaceAll("<script>", "<script>")
.replaceAll("</script>", "</script>");
postEntity.setContent(encContent);
치환해주는 게 비즈니스 로직은 아니니까 컨트롤러에서 만들어준다.
컨트롤러의 핵심 로직은 2줄밖에 안되는데 부가 로직이 더 많다.
이거 말고도 막아야 할게 진짜 많다.
lucy filter 같은 라이브러리(방어 코드)를 쓰면 한방에 해결 가능하다.
여기까지 블로그 프로젝트 버전 1이 끝났다.
자바 코드는 총 9개가 나왔다.
코드를 다시 반복하면서 리팩토링 해보자.
이제 x-www-form-urlencoded 안 쓰고 전부 fetch 요청할 것이다.
깔끔하고 심플하게 통일하자.
[출처]
https://cafe.naver.com/metacoding
메타 코딩 유튜브
https://www.youtube.com/c/%EB%A9%94%ED%83%80%EC%BD%94%EB%94%A9