diff --git a/backend-board/src/main/java/com/backendboard/domain/user/controller/UserController.java b/backend-board/src/main/java/com/backendboard/domain/user/controller/UserController.java index 06ae6ac..e7145e7 100644 --- a/backend-board/src/main/java/com/backendboard/domain/user/controller/UserController.java +++ b/backend-board/src/main/java/com/backendboard/domain/user/controller/UserController.java @@ -9,9 +9,9 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.backendboard.domain.user.dto.UserInfoResponse; import com.backendboard.domain.user.dto.UserNicknameUpdateRequest; import com.backendboard.domain.user.dto.UserNicknameUpdateResponse; -import com.backendboard.domain.user.dto.UserReadResponse; import com.backendboard.domain.user.service.UserService; import com.backendboard.global.error.dto.ErrorResponse; import com.backendboard.global.security.dto.CustomUserDetails; @@ -33,20 +33,18 @@ public class UserController { private final UserService userService; @Operation( - summary = "유저 보기 API", - description = "유저의 정보를 보여줍니다.", + summary = "내 정보 보기 API", + description = "내 정보를 보여줍니다.", security = {} ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "200 성공", content = @Content( - mediaType = "application/json", schema = @Schema(implementation = UserReadResponse.class))), - @ApiResponse(responseCode = "404", description = "유저를 찾을 수 없습니다.", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))), + mediaType = "application/json", schema = @Schema(implementation = UserInfoResponse.class))), }) - @GetMapping("/{userId}") - public ResponseEntity readUser(@PathVariable Long userId) { - UserReadResponse response = userService.getUser(userId); + @GetMapping + public ResponseEntity getInfo(@AuthenticationPrincipal CustomUserDetails customUserDetails) { + UserInfoResponse response = userService.getInfo(customUserDetails.getId()); return ResponseEntity.status(HttpStatus.OK).body(response); } diff --git a/backend-board/src/main/java/com/backendboard/domain/user/dto/UserReadResponse.java b/backend-board/src/main/java/com/backendboard/domain/user/dto/UserInfoResponse.java similarity index 86% rename from backend-board/src/main/java/com/backendboard/domain/user/dto/UserReadResponse.java rename to backend-board/src/main/java/com/backendboard/domain/user/dto/UserInfoResponse.java index b9122c7..de3b5ea 100644 --- a/backend-board/src/main/java/com/backendboard/domain/user/dto/UserReadResponse.java +++ b/backend-board/src/main/java/com/backendboard/domain/user/dto/UserInfoResponse.java @@ -8,7 +8,7 @@ @Schema(description = "유저 보기 응답 DTO") @Getter -public class UserReadResponse { +public class UserInfoResponse { @Schema(description = "회원 고유 번호", example = "1") private final Long id; @@ -22,14 +22,14 @@ public class UserReadResponse { private final String nickname; @Builder - private UserReadResponse(Long id, String loginId, String username, String nickname) { + private UserInfoResponse(Long id, String loginId, String username, String nickname) { this.id = id; this.loginId = loginId; this.username = username; this.nickname = nickname; } - public static UserReadResponse toDto(User user) { + public static UserInfoResponse toDto(User user) { return builder() .id(user.getId()) .loginId(user.getAuthUser().getUsername()) diff --git a/backend-board/src/main/java/com/backendboard/domain/user/service/UserService.java b/backend-board/src/main/java/com/backendboard/domain/user/service/UserService.java index 213824d..45be138 100644 --- a/backend-board/src/main/java/com/backendboard/domain/user/service/UserService.java +++ b/backend-board/src/main/java/com/backendboard/domain/user/service/UserService.java @@ -1,11 +1,11 @@ package com.backendboard.domain.user.service; +import com.backendboard.domain.user.dto.UserInfoResponse; import com.backendboard.domain.user.dto.UserNicknameUpdateRequest; import com.backendboard.domain.user.dto.UserNicknameUpdateResponse; -import com.backendboard.domain.user.dto.UserReadResponse; public interface UserService { - UserReadResponse getUser(Long userId); + UserInfoResponse getInfo(Long authUserId); UserNicknameUpdateResponse updateNickname(UserNicknameUpdateRequest request, Long userId, Long authUserId); } diff --git a/backend-board/src/main/java/com/backendboard/domain/user/service/UserServiceImpl.java b/backend-board/src/main/java/com/backendboard/domain/user/service/UserServiceImpl.java index 84e7a93..19e1ac0 100644 --- a/backend-board/src/main/java/com/backendboard/domain/user/service/UserServiceImpl.java +++ b/backend-board/src/main/java/com/backendboard/domain/user/service/UserServiceImpl.java @@ -3,9 +3,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.backendboard.domain.user.dto.UserInfoResponse; import com.backendboard.domain.user.dto.UserNicknameUpdateRequest; import com.backendboard.domain.user.dto.UserNicknameUpdateResponse; -import com.backendboard.domain.user.dto.UserReadResponse; import com.backendboard.domain.user.entity.User; import com.backendboard.domain.user.repository.UserRepository; import com.backendboard.global.error.CustomError; @@ -20,9 +20,9 @@ public class UserServiceImpl implements UserService { private final UserRepository userRepository; @Override - public UserReadResponse getUser(Long userId) { - User user = userRepository.findById(userId).orElseThrow(() -> new CustomException(CustomError.USER_NOT_FOUND)); - return UserReadResponse.toDto(user); + public UserInfoResponse getInfo(Long authUserId) { + User user = userRepository.getByAuthUserId(authUserId); + return UserInfoResponse.toDto(user); } @Transactional diff --git a/backend-board/src/main/java/com/backendboard/global/config/WebConfig.java b/backend-board/src/main/java/com/backendboard/global/config/WebConfig.java new file mode 100644 index 0000000..edca85a --- /dev/null +++ b/backend-board/src/main/java/com/backendboard/global/config/WebConfig.java @@ -0,0 +1,22 @@ +package com.backendboard.global.config; + +import java.util.concurrent.TimeUnit; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.CacheControl; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + @Value("${spring.file.upload.directory}") + private String uploadDirectory; + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/images/**") + .addResourceLocations("file:" + uploadDirectory + "/") + .setCacheControl(CacheControl.maxAge(1, TimeUnit.HOURS)); + } +} diff --git a/backend-board/src/test/java/com/backendboard/global/config/WebConfigTest.java b/backend-board/src/test/java/com/backendboard/global/config/WebConfigTest.java new file mode 100644 index 0000000..6eda97e --- /dev/null +++ b/backend-board/src/test/java/com/backendboard/global/config/WebConfigTest.java @@ -0,0 +1,105 @@ +package com.backendboard.global.config; + +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.junit.jupiter.api.Assumptions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; + +import javax.imageio.ImageIO; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +@SpringBootTest +@AutoConfigureMockMvc +class WebConfigTest { + @Autowired + private MockMvc mockMvc; + + @Value("${spring.file.upload.directory}") + private String uploadDirectory; + + private static final String TEST_IMAGE_NAME = "test-image.jpg"; + + @BeforeEach + public void setup() throws IOException { + File directory = new File(uploadDirectory); + if (!directory.exists()) { + directory.mkdirs(); + } + + File testImage = new File(directory, TEST_IMAGE_NAME); + if (!testImage.exists()) { + BufferedImage image = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + graphics.setColor(Color.RED); + graphics.fillRect(0, 0, 100, 100); + graphics.dispose(); + ImageIO.write(image, "jpg", testImage); + } + } + + @AfterEach + public void cleanup() { + File testImage = new File(uploadDirectory, TEST_IMAGE_NAME); + if (testImage.exists()) { + testImage.delete(); + } + } + + @DisplayName("이미지 파일이 존재하는지 테스트") + @Test + public void resourceHandlerConfiguration() throws Exception { + File imageFile = new File(uploadDirectory, TEST_IMAGE_NAME); + assumeTrue(imageFile.exists(), "테스트 이미지 파일이 존재해야 합니다: " + imageFile.getAbsolutePath()); + + mockMvc.perform(get("/images/" + TEST_IMAGE_NAME)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(header().exists(HttpHeaders.CONTENT_TYPE)); + } + + @DisplayName("캐시가 제대로 적용 되었는지 테스트") + @Test + public void cacheControlHeader() throws Exception { + File imageFile = new File(uploadDirectory, TEST_IMAGE_NAME); + assumeTrue(imageFile.exists(), "테스트 이미지 파일이 존재해야 합니다: " + imageFile.getAbsolutePath()); + + MvcResult result = mockMvc.perform(get("/images/" + TEST_IMAGE_NAME)) + .andExpect(status().isOk()) + .andExpect(header().exists(HttpHeaders.CACHE_CONTROL)) + .andReturn(); + + String cacheControl = result.getResponse().getHeader(HttpHeaders.CACHE_CONTROL); + assertThat(cacheControl).contains("max-age=3600"); + } + + @DisplayName("이미지 파일이 존재하지 않은 경우") + @Test + public void nonExistentResource() throws Exception { + mockMvc.perform(get("/images/non-existent-image.jpg")) + .andExpect(status().isNotFound()); + } + + @DisplayName("상위 경로로 접근하는 경우") + @Test + public void directoryTraversalProtection() throws Exception { + mockMvc.perform(get("/images/../../../etc/passwd")) + .andExpect(status().isBadRequest()); + } +} diff --git a/frontend/package.json b/frontend/package.json index 8cf90fd..6664f47 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,7 +15,9 @@ "bootstrap": "^5.3.5", "bootstrap-icons": "^1.11.3", "prismjs": "^1.30.0", - "quill": "^2.0.3", + "quill": "^1.3.7", + "quill-drag-and-drop-module": "^0.3.0", + "quill-image-resize-module-react": "^3.0.0", "react": "^18.2.0", "react-bootstrap": "^2.10.9", "react-dom": "^18.2.0", diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index da0eafc..7fa6d79 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -3,6 +3,7 @@ import React, {useState} from 'react'; import {Alert, Button, Card, Col, Container, Form, Row} from 'react-bootstrap'; import LogoLink from "../components/LogoLink.jsx"; import {useNavigate} from "react-router-dom"; +import api from "../api/axiosInstance.jsx"; const Login = () => { const [email, setEmail] = useState(''); @@ -31,10 +32,22 @@ const Login = () => { // 브라우저가 자동으로 multipart/form-data로 설정합니다. }); if (response.ok) { - console.log('로그인 성공:'); - localStorage.setItem('user', JSON.stringify({email, name: '감자 사용자'})); localStorage.setItem('accessToken', response.headers.get('Authorization')); - navigate('/'); + api.get('users').then(response => { + if (response.status === 200) { + console.log("성공적으로 데이터를 가져왔습니다."); + localStorage.setItem('user', JSON.stringify({ + id: response.data.id, + email, + name: response.data.name, + nickname: response.data.nickname + })); + console.log('로그인 성공:'); + navigate('/'); + } else { + console.log(`오류 발생: ${response.status}`); + } + }); } else { console.error('로그인 실패'); setShowError(true); diff --git a/frontend/src/pages/post/PostDetail.jsx b/frontend/src/pages/post/PostDetail.jsx index 75c12ec..0e6bc93 100644 --- a/frontend/src/pages/post/PostDetail.jsx +++ b/frontend/src/pages/post/PostDetail.jsx @@ -5,6 +5,7 @@ import '../../style/PostDetil.css' const PostDetail = () => { const {id} = useParams(); + const [nickname, setNickname] = useState(''); const [post, setPost] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -21,10 +22,15 @@ const PostDetail = () => { const [hasMore, setHasMore] = useState(true); const [editingComment, setEditingComment] = useState(null); const [editCommentText, setEditCommentText] = useState(''); + const commentsPerPage = 5; + useEffect(() => { const user = localStorage.getItem('user'); + if (user) { + setNickname(JSON.parse(user).nickname); + } const accessToken = localStorage.getItem('accessToken'); setIsLoggedIn(!!user && !!accessToken); }, []); @@ -38,10 +44,21 @@ const PostDetail = () => { }) .then(data => { setPost(data); - setLikeCount(data.likeCount || 0); + api.get(`/post-likes/${id}/count`).then(res => { + if (res.status !== 200) { + throw new Error('좋아요 상태를 불러올 수 없습니다.'); + } + setLikeCount(res.data.count); + }) // 로그인한 경우 좋아요 상태 확인 if (isLoggedIn) { + api.get(`/post-likes/${id}/status`).then(res => { + if (res.status !== 200) { + throw new Error('좋아요 상태를 불러올 수 없습니다.'); + } + setIsLiked(res.data.liked); + }) // 좋아요 상태 확인 로직 추가 } }) @@ -52,7 +69,7 @@ const PostDetail = () => { // 댓글 불러오기 const fetchComments = (page = 0) => { setCommentLoading(true); - api.get(`/comments/posts/${id}?page=${page}&size=${commentsPerPage}`) + api.get(`/comments/posts/${id}?page=${page}&size=${commentsPerPage}&sort=lastModifiedDate,desc`) .then(res => { // 응답 데이터 구조 확인 const responseData = res.data; @@ -65,7 +82,7 @@ const PostDetail = () => { } // 더 불러올 댓글이 있는지 확인 (페이지네이션 정보 활용) - setHasMore(!res.data.empty); + setHasMore(!res.data.last); }) .catch(err => { console.error('댓글을 불러오는데 실패했습니다:', err); @@ -84,7 +101,7 @@ const PostDetail = () => { // 작성자와 현재 사용자가 일치하는지 확인하는 함수 const isPostAuthor = () => { - return true; + return post.author === nickname; }; // 수정 페이지로 이동하는 핸들러 @@ -209,8 +226,7 @@ const PostDetail = () => { // 댓글 작성자인지 확인 const isCommentAuthor = (comment) => { - // 실제 구현에서는 현재 로그인한 사용자와 댓글 작성자를 비교해야 함 - return true; // 테스트를 위해 항상 true 반환 + return comment.author === nickname; }; if (loading) return
로딩 중...
; @@ -223,12 +239,14 @@ const PostDetail = () => {

{post.title}

-
- 작성자: {post.author} | 좋아요: {post.likeCount} | 조회수: {post.viewCount} +
+ 작성자: {post.author} | 좋아요: {post.likeCount} | + 조회수: {post.viewCount} + {new Date(post.lastModifiedDate).toLocaleString()}
{/* content가 HTML일 경우 안전하게 렌더링 */}
@@ -296,7 +314,7 @@ const PostDetail = () => {
{comment.author} - {new Date(comment.createdAt).toLocaleString()} + {new Date(comment.lastModifiedDate).toLocaleString()}
diff --git a/frontend/src/pages/post/PostForm.jsx b/frontend/src/pages/post/PostForm.jsx index 8f5d5eb..0b15806 100644 --- a/frontend/src/pages/post/PostForm.jsx +++ b/frontend/src/pages/post/PostForm.jsx @@ -1,10 +1,46 @@ // PostForm.jsx - 공통 컴포넌트 import React, {useEffect, useMemo, useRef, useState} from 'react'; -import ReactQuill from 'react-quill-new'; +import ReactQuill, {Quill} from 'react-quill-new'; import 'react-quill-new/dist/quill.snow.css'; import '../../style/PostCreate.css'; import api from '../../api/axiosInstance.jsx'; import {useNavigate} from 'react-router-dom'; +import ImageResize from 'quill-image-resize-module-react'; + +const BaseImage = Quill.import('formats/image'); + +const ATTRIBUTES = ['alt', 'height', 'width', 'style', 'class']; + +class CustomImage extends BaseImage { + static formats(domNode) { + return ATTRIBUTES.reduce((formats, attribute) => { + if (domNode.hasAttribute(attribute)) { + formats[attribute] = domNode.getAttribute(attribute); + } + return formats; + }, {}); + } + + format(name, value) { + if (ATTRIBUTES.indexOf(name) > -1) { + if (value) { + this.domNode.setAttribute(name, value); + } else { + this.domNode.removeAttribute(name); + } + } else { + super.format(name, value); + } + } +} + +Quill.register('formats/image', CustomImage, true); + +if (typeof window !== 'undefined') { + window.Quill = Quill; + Quill.register('modules/imageResize', ImageResize); +} + const PostForm = ({initialData = null, mode = 'create'}) => { const [title, setTitle] = useState(initialData?.title || ''); @@ -15,11 +51,49 @@ const PostForm = ({initialData = null, mode = 'create'}) => { const navigate = useNavigate(); const [uploadedImages, setUploadedImages] = useState([]); - // 수정 모드일 경우 초기 이미지 추출 + const addImagePrefix = (url) => { + if (url.startsWith('/images/') || url.startsWith('http')) { + return url; + } + if (url.startsWith('/')) { + return `/images${url}`; + } + return `/images/${url}`; + }; + useEffect(() => { - if (mode === 'edit' && initialData?.content) { - const imgs = extractImagesFromHTML(initialData.content); - setUploadedImages(imgs.filter(img => img.id)); + if (mode === 'edit' && initialData?.id) { + // 게시글 관련 이미지 정보 불러오기 + const fetchPostImages = async () => { + try { + const response = await api.get(`/post-images/posts/${initialData.id}`); + const imageData = response.data.content; + + const processedImageData = imageData.map(image => ({ + ...image, + fileUrl: addImagePrefix(image.fileUrl) + })); + + setUploadedImages(processedImageData); + + // 에디터 로드 후 이미지에 data-id 속성 추가 + setTimeout(() => { + if (quillRef.current) { + const editor = quillRef.current.getEditor(); + imageData.forEach(img => { + if (img.fileUrl) { + const imgElements = editor.root.querySelectorAll(`img[src="${img.fileUrl}"]`); + imgElements.forEach(el => el.setAttribute('data-id', img.id)); + } + }); + } + }, 100); + } catch (err) { + console.error('이미지 정보를 불러오는데 실패했습니다:', err); + } + }; + + fetchPostImages(); } }, [initialData, mode]); @@ -33,7 +107,7 @@ const PostForm = ({initialData = null, mode = 'create'}) => { const response = await api.post('/post-images', formData, { headers: {'Content-Type': 'multipart/form-data'} }); - const fileUrl = response.data.fileUrl; + const fileUrl = "/images/" + response.data.fileUrl; const imageId = response.data.id; // 에디터에 이미지 삽입 @@ -41,7 +115,7 @@ const PostForm = ({initialData = null, mode = 'create'}) => { const range = quill.getSelection() || {index: quill.getLength()}; quill.insertEmbed(range.index, 'image', fileUrl); - setUploadedImages(prev => [...prev, {id: imageId, url: fileUrl}]); + setUploadedImages(prev => [...prev, {id: imageId, fileUrl: fileUrl}]); setTimeout(() => { const editor = quillRef.current.getEditor(); const imgs = editor.root.querySelectorAll(`img[src="${fileUrl}"]`); @@ -108,6 +182,7 @@ const PostForm = ({initialData = null, mode = 'create'}) => { const modules = useMemo(() => ({ toolbar: { container: [ + [{'align': [false, 'center', 'right', 'justify']}], [{'header': [1, 2, 3, false]}], ['bold', 'italic', 'underline', 'strike', 'blockquote'], [{'list': 'ordered'}, {'list': 'bullet'}, {'indent': '-1'}, {'indent': '+1'}], @@ -120,14 +195,19 @@ const PostForm = ({initialData = null, mode = 'create'}) => { }, clipboard: { matchVisual: false - } + }, + imageResize: { + parchment: Quill.import('parchment'), + modules: ['Resize', 'DisplaySize'] + }, }), []); const formats = [ 'header', 'bold', 'italic', 'underline', 'strike', 'blockquote', 'list', 'indent', - 'link', 'image' + 'link', 'image', + 'align' ]; // 폼 제출 핸들러 (모드에 따라 다른 동작) @@ -142,7 +222,8 @@ const PostForm = ({initialData = null, mode = 'create'}) => { setError(''); try { - const postData = {title, content}; + const imageIds = uploadedImages.map(img => img.id); + const postData = {title, content, imageIds}; if (mode === 'edit' && initialData?.id) { // 수정 모드 @@ -157,6 +238,7 @@ const PostForm = ({initialData = null, mode = 'create'}) => { } } catch (err) { setError(`게시글 ${mode === 'edit' ? '수정' : '등록'}에 실패했습니다.`); + console.log(err); } finally { setLoading(false); } @@ -166,7 +248,7 @@ const PostForm = ({initialData = null, mode = 'create'}) => { const handleContentChange = (value) => { const currentImages = extractImagesFromHTML(value); const deletedImages = uploadedImages.filter( - uploadedImg => !currentImages.some(curImg => curImg.url === uploadedImg.url) + uploadedImg => !currentImages.some(curImg => curImg.url === uploadedImg.fileUrl) ); deletedImages.forEach(async (img) => { diff --git a/frontend/src/pages/post/PostList.jsx b/frontend/src/pages/post/PostList.jsx index bc1099a..0f25015 100644 --- a/frontend/src/pages/post/PostList.jsx +++ b/frontend/src/pages/post/PostList.jsx @@ -24,7 +24,7 @@ const PostList = () => { }); useEffect(() => { - api.get(`/posts?page=${page}&size=${PAGE_SIZE}`) + api.get(`/posts?page=${page}&size=${PAGE_SIZE}&sort=lastModifiedDate,desc`) .then(res => { if (res.status !== 200) throw new Error('게시글을 불러올 수 없습니다.'); @@ -110,8 +110,11 @@ const PostList = () => {
  • {post.title}

    -
    +
    작성자: {post.author} | 좋아요: {post.likeCount} | 조회수: {post.viewCount} + {new Date(post.lastModifiedDate).toLocaleString()} +
  • diff --git a/frontend/src/style/PostCreate.css b/frontend/src/style/PostCreate.css index 215a8ee..1daf48b 100644 --- a/frontend/src/style/PostCreate.css +++ b/frontend/src/style/PostCreate.css @@ -131,3 +131,21 @@ min-height: 250px; font-size: 16px; } + +.ql-align-center { + text-align: center; +} + +.ql-align-right { + text-align: right; +} + +.ql-align-justify { + text-align: justify; +} + +/* 이미지 스타일 */ +.ql-editor img { + max-width: 100%; + height: auto; +}