From d62a4a3c15ce471537235ef12e520db5d27dfa97 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Mon, 8 Sep 2025 18:21:57 +0900 Subject: [PATCH] ... --- .../back/lun/configs/SecurityConfig.kt | 7 +- .../back/lun/controllers/BlogController.kt | 90 +++++-- src/main/resources/static/css/common.css | 73 ++++++ src/main/resources/static/js/common.js | 239 +++++++++++++++++- .../templates/content/blog/viewer.html | 9 +- 5 files changed, 389 insertions(+), 29 deletions(-) diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt index ea0de1f..70dc54c 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt @@ -75,17 +75,22 @@ class SecurityConfig( "/user/login.bjx", "/user/joinUser.bjx","/tlg/repotToMe.bjx", "/blog/post/imageUpload.bjx", "/blog/post.bjx", // "/blog/post/images/**", // WebSecurityCustomizer에서 이미 ignoring 처리됨 - "/puzzle/**","/puzzle/play/**", + "/puzzle/**","/puzzle/play/**","/bums/save/**", "/rank/**","/spider/**", "/sudoku/**", ) }.authorizeHttpRequests { auth -> auth // [정상 유지] 이 두 엔드포인트는 인증(로그인)이 필요하며 CSRF 보호를 받아야 합니다. + .requestMatchers(HttpMethod.GET, "/blog/comments/{commentId}/replies.bjx").permitAll() + .requestMatchers(HttpMethod.GET, "/blog/posts/{postId}/comments.bjx").permitAll() + .requestMatchers(HttpMethod.POST, "/blog/posts/{postId}/comments.bjx").permitAll() .requestMatchers(HttpMethod.POST, "/blog/post/{postId}/like.bjx").permitAll() .requestMatchers(HttpMethod.POST, "/blog/post/{postId}/unlike.bjx").permitAll() + .requestMatchers(HttpMethod.POST, "/bums/save/loc.api").permitAll() // permitAll() 목록 .requestMatchers( + "/", "/home.bs", "/bums/where.bs" , diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt index cd09fa9..6120884 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt @@ -118,6 +118,7 @@ class BlogController(private val commentService : CommentService) { try { val authentication = SecurityContextHolder.getContext().authentication val username = (authentication.principal as? UserDetails)?.username ?: authentication.name + logService.log(username) target.writer = username } catch (e: Exception) { target.writer = "Anonymous" // 인증 정보 가져오기 실패 시 @@ -144,12 +145,23 @@ class BlogController(private val commentService : CommentService) { } else { // === B. 기존 게시물 수정 (새 버전 생성) 로직 === // (글쓴이는 client-sent 'target' 객체에 이미 포함되어 있으므로 별도 설정 필요 없음) - + if (target.writer.isNullOrEmpty()) { + try { + val authentication = SecurityContextHolder.getContext().authentication + val username = (authentication.principal as? UserDetails)?.username ?: authentication.name + logService.log(username) + target.writer = username + } catch (e: Exception) { + target.writer = "Anonymous" // 인증 정보 가져오기 실패 시 + } + } // 3. (정상 로직) 이 객체에 "수정 시간"을 설정합니다. target.modifyTime = System.currentTimeMillis() // 4. 이 게시물이 "버전 기록용 사본"임을 설정합니다. - target.originId = target.id // 원본 마스터 ID를 originId 필드에 저장 + if (target.originId == null) { + target.originId = target.id + } target.id = null // Mongo가 새 ID를 생성하도록 ID를 null로 변경 // 5. 모든 데이터(새 카테고리, 태그, modifyTime 포함)가 담긴 "새 버전 문서"를 저장합니다. @@ -613,30 +625,66 @@ class BlogController(private val commentService : CommentService) { @PostMapping("posts/{postId}/comments.bjx") fun addComment(@PathVariable postId: String, @RequestBody jsonString: String): Mono> { try { + // 1. Decode the Base64 string to get the envelope JSON val decodedBytes: ByteArray = Base64.getDecoder().decode(jsonString) - val requestData = String(decodedBytes) - val comment = Gson().fromJson(requestData, Comment::class.java).apply { - this.postId = postId - this.writeTime = System.currentTimeMillis() - } - return commentService.addComment(comment) - .map { - ResponseEntity.ok().body(ResponceResult().apply { - resultCode = 0 - resultMsg = "Comment submitted successfully" - }) + // 2. Parse the ENVELOPE (RequestModel), just like in your "post.bjx" endpoint + Gson().fromJson(String(decodedBytes), RequestModel::class.java)?.let { model -> + model.data?.let { innerJsonString -> + try { + // 3. COPY the EXACT decryption/un-formatting logic from "post.bjx" + val reqString = innerJsonString.split(GlobalEnvironment.padding(model.getKeyword())) + val nb = arrayListOf() + val na = arrayListOf() + reqString[0].replace(GlobalEnvironment.padding(model.getKeyword()),"").split("").toList().let { na.addAll(it) } + reqString[1].replace(GlobalEnvironment.padding(model.getKeyword()),"").split("").toList().let { nb.addAll(it) } + var max = nb.size + na.size + var fullData = arrayListOf() + for (idx in 0..max) { if (idx % 2 == 0) { if (nb.size > 0) { fullData.add(nb.removeLast()) } } else { if (na.size > 0) { fullData.add(na.removeLast()) } } } + + val jsonFromClient = fullData.joinToString("") // This is the REAL comment JSON + + // 4. NOW, parse the decrypted JSON into the Comment object + val comment = Gson().fromJson(jsonFromClient, Comment::class.java).apply { + this.postId = postId + this.writeTime = System.currentTimeMillis() + } + + // 5. Save the valid comment object + return commentService.addComment(comment) + .map { + ResponseEntity.ok().body(ResponceResult().apply { + resultCode = 0 + resultMsg = "Comment submitted successfully" + }) + } + .onErrorResume { e -> + Mono.just(ResponseEntity.status(500).body(ResponceResult().apply { + resultCode = 500 + resultMsg = "Error submitting comment: ${e.message}" + })) + } + + } catch (e: Exception) { + // This catches decryption errors + return Mono.just(ResponseEntity.status(400).body(ResponceResult().apply { + resultCode = 400 + resultMsg = "Invalid request data (decryption fail)" + })) + } } - .onErrorResume { e -> - Mono.just(ResponseEntity.status(500).body(ResponceResult().apply { - resultCode = 500 - resultMsg = "Error submitting comment: ${e.message}" - })) - } - } catch (e: Exception) { + } + // This catches envelope parsing errors return Mono.just(ResponseEntity.status(400).body(ResponceResult().apply { resultCode = 400 - resultMsg = "Invalid request data" + resultMsg = "Invalid request data (model fail)" + })) + + } catch (e: Exception) { + // This catches Base64 decoding errors + return Mono.just(ResponseEntity.status(400).body(ResponceResult().apply { + resultCode = 400 + resultMsg = "Invalid request data (base64 fail)" })) } } diff --git a/src/main/resources/static/css/common.css b/src/main/resources/static/css/common.css index 247779e..798470c 100644 --- a/src/main/resources/static/css/common.css +++ b/src/main/resources/static/css/common.css @@ -462,4 +462,77 @@ a.btn_layerClose:hover { } #selected-category-area i, #selected-hashtags-area i { color: #999; +} + +/* === 대댓글 기능 CSS (신규 추가) === */ + +/* 1. 대댓글 목록 컨테이너 (들여쓰기) */ +.reply-list { + margin-left: 40px; + padding-left: 15px; + border-left: 2px solid #eee; +} + +/* 2. 댓글 헤더 (작성자/날짜/답글 버튼) */ +.comment-header { + border-bottom: 1px dotted #ddd; + padding-bottom: 5px; + margin-bottom: 10px; + /* Flexbox를 사용해 요소를 양쪽으로 정렬 */ + display: flex; + justify-content: space-between; + align-items: center; +} + +.comment-author-info { + font-size: 0.9em; +} + +.comment-author-info strong { + margin-right: 8px; +} + +.comment-author-info .comment-date { + font-size: 0.9em; + color: #888; +} + +/* 3. 답글 달기 버튼 */ +.btn-reply { + font-size: 0.8em; + padding: 3px 8px; + cursor: pointer; + border: 1px solid #ccc; + background: #f9f9f9; + border-radius: 4px; + color: #555; +} +.btn-reply:hover { + background: #eee; +} + +/* 4. 답글 상태 표시줄 (숨겨진 UI) */ +#reply-status-bar { + display: none; /* JS로 제어 */ + background: #f0f8ff; + border: 1px solid #bde0ff; + padding: 8px 12px; + margin-bottom: 10px; + border-radius: 4px; + display: flex; + justify-content: space-between; + align-items: center; +} +#reply-status-text { + font-size: 0.9em; + color: #333; + font-weight: 500; +} +#btn-cancel-reply { + background: none; + border: none; + color: #E63946; /* 빨간색 계열 */ + cursor: pointer; + font-size: 0.85em; + font-weight: bold; } \ No newline at end of file diff --git a/src/main/resources/static/js/common.js b/src/main/resources/static/js/common.js index 017ec43..b34d1ab 100644 --- a/src/main/resources/static/js/common.js +++ b/src/main/resources/static/js/common.js @@ -10,23 +10,31 @@ var stagedCategory = 'none'; var stagedHashtags = []; // 해시태그는 배열로 관리 - +var currentReplyParentId = null; // 전역 변수: Quill 에디터 인스턴스와 게시물 기본 데이터를 저장합니다. var quill = null; var currentLat = 0.0; var currentLon = 0.0; var baseData = { 'id': "", + 'originId': "", 'title': "", 'content': "", 'category': "none", 'tags': "", + 'writeTime': 0, + 'modifyTime': 0, 'firstPostLat': 0.0, 'firstPostLon': 0.0, + 'firstAddress': "", 'modifyLat': 0.0, 'modifyLon': 0.0, - 'originId': "", - 'writeTime': 0, + 'modifyAddress': "", + 'writer': "", + 'posting': false, + 'readCount': 0, + 'voteCount': 0, + 'unlikeCount': 0 }; // jQuery를 사용하여 문서가 완전히 로드된 후에 함수를 실행합니다. @@ -59,7 +67,11 @@ $(document).ready(function() { openPopup(this); }); /* === (대규모 수정) 팝업 입력/적용/취소 버튼 로직 === */ - + /* === (신규 추가) 댓글 등록 버튼 이벤트 리스너 === */ + $('#submit-comment').on('click', function(e) { + e.preventDefault(); // 기본 버튼 동작 방지 + submitComment(); // 아래에 정의된 새 함수 호출 + }); // --- 1. Category Popup Logic --- const categoryInput = document.getElementById('category-input'); const addCategoryBtn = document.getElementById('add-category-btn'); @@ -159,6 +171,19 @@ function initEditor(useEditor = false) { baseData.firstPostLon = serverData.firstPostLon; baseData.writeTime = serverData.writeTime; baseData.originId = serverData.originId; + + // === [버그 수정] 누락된 메타데이터 복사 로직 추가 === + baseData.modifyTime = serverData.modifyTime; + baseData.firstAddress = serverData.firstAddress; + baseData.modifyLat = serverData.modifyLat; + baseData.modifyLon = serverData.modifyLon; + baseData.modifyAddress = serverData.modifyAddress; + baseData.writer = serverData.writer; + baseData.posting = serverData.posting; + baseData.readCount = serverData.readCount; + baseData.voteCount = serverData.voteCount; + baseData.unlikeCount = serverData.unlikeCount; + // =============================================== } getLocation(); @@ -718,9 +743,26 @@ function post(target, type, data, key, callBackResult) { }; httpRequest.open('POST', target, true); httpRequest.setRequestHeader("Content-Type", "text/plain"); - httpRequest.send(btoa(JSON.stringify({ + + const csrfMeta = document.querySelector('meta[name="_csrf"]'); + if (csrfMeta) { + const csrfToken = csrfMeta.getAttribute('content'); + if (csrfToken) { + // Spring Security는 이 헤더를 확인합니다. + httpRequest.setRequestHeader("X-CSRF-TOKEN", csrfToken); + } + } + // 1. Create the complete JSON string payload + const jsonPayloadString = JSON.stringify({ 'data': unformat(type, data, key), 'key': key, 'type': type, - }))); + }); + + // 2. [FIX] Convert the Unicode JSON string (which may contain Korean) into + // a UTF-8 byte stream that btoa() can safely handle. + const utf8SafePayload = unescape(encodeURIComponent(jsonPayloadString)); + + // 3. Send the result after running btoa() on the safe string. + httpRequest.send(btoa(utf8SafePayload)); } function postLogin(target, type, data, key, callBackResult) { @@ -924,4 +966,189 @@ function handleVote(buttonElement, voteType) { controls.querySelectorAll('button').forEach(btn => btn.disabled = false); }); } + +/** + * [수정됨] 댓글 등록 처리 함수 (대댓글 지원) + */ +function submitComment() { + + const commentInput = document.getElementById('comment-input'); + if (!commentInput) return; + + const content = commentInput.value.trim(); + + if (content.length === 0) { + alert('댓글 내용을 입력하세요.'); + commentInput.focus(); + return; + } + + // [수정] parentId를 하드코딩(null)하는 대신 전역 변수에서 읽어옴 + const commentData = { + content: content, + writer: null, + parentId: currentReplyParentId, // currentReplyParentId 값 (null 또는 댓글ID) 사용 + postId: null, + id: null + }; + + const postId = serverData.id; + if (!postId) { + alert("게시물 ID를 찾을 수 없어 댓글을 등록할 수 없습니다."); + return; + } + + const uploadUrl = `${getMainPath()}/blog/posts/${postId}/comments.bjx`; + const encType = serverData.enc; + const keyword = serverData.keyword; + + try { + post(uploadUrl, encType, JSON.stringify(commentData), keyword, function(resultData) { + try { + const response = JSON.parse(resultData); + + if (response.resultCode === 0) { + alert('댓글이 성공적으로 등록되었습니다.'); + commentInput.value = ''; // 입력창 초기화 + + cancelReply(); // [신규 추가] 답글 상태 초기화 + + fetchComments(postId); // 목록 새로고침 + } else { + alert('댓글 등록 실패: ' + (response.resultMsg || '알 수 없는 오류')); + } + } catch (e) { + console.error('Failed to parse comment submission response:', e, resultData); + alert('댓글 등록 후 서버 응답을 처리하는 중 오류가 발생했습니다.'); + } + }); + } catch (err) { + console.error('Error during comment submission:', err); + alert('댓글 전송 중 예외가 발생했습니다.'); + } +} + +async function fetchComments(postId) { + if (!postId) return; + + const commentsListContainer = document.getElementById('comments-list'); + if (!commentsListContainer) return; + + commentsListContainer.innerHTML = '

댓글 목록을 불러오는 중...

'; + + try { + // 1. 최상위 댓글 목록 (ParentId = null)을 먼저 가져옵니다. + const parentResponse = await fetch(`${getMainPath()}/blog/posts/${postId}/comments.bjx`); + const parentData = await parentResponse.json(); + + if (parentData.resultCode !== 0 || !parentData.comments) { + throw new Error(parentData.resultMsg || 'Failed to load parent comments'); + } + + if (parentData.comments.length === 0) { + commentsListContainer.innerHTML = '

아직 댓글이 없습니다. 첫 댓글을 작성해보세요.

'; + return; + } + + // 컨테이너 비우기 + commentsListContainer.innerHTML = ''; + + // 2. 각 최상위 댓글을 순회합니다 (for...of 루프 사용 필수) + for (const parentComment of parentData.comments) { + + // 2-1. 최상위 댓글 HTML 생성 및 DOM에 추가 + const parentElement = document.createElement('article'); + parentElement.className = 'comment-item'; + parentElement.id = `comment-${parentComment.id}`; + parentElement.innerHTML = createCommentHTML(parentComment); // 헬퍼 함수 사용 + commentsListContainer.appendChild(parentElement); + + // 2-2. 해당 댓글의 "답글 API"를 호출합니다. + const replyResponse = await fetch(`${getMainPath()}/blog/comments/${parentComment.id}/replies.bjx`); + const replyData = await replyResponse.json(); + + if (replyData.resultCode === 0 && replyData.comments && replyData.comments.length > 0) { + // 2-3. 답글 컨테이너(div) 생성 + const replyListContainer = document.createElement('div'); + replyListContainer.className = 'reply-list'; // CSS 들여쓰기 적용 + + // 2-4. 모든 답글(대댓글)을 순회하며 HTML 추가 + replyData.comments.forEach(childComment => { + replyListContainer.innerHTML += createCommentHTML(childComment, true); // 헬퍼 함수 사용 (isReply=true) + }); + + // 2-5. 답글 컨테이너를 부모 댓글(parentElement)의 자식으로 삽입 + parentElement.appendChild(replyListContainer); + } + } // end for loop + + } catch (err) { + console.error('Failed to fetch comments hierarchicaly:', err); + commentsListContainer.innerHTML = '

댓글 로딩 중 오류가 발생했습니다.

'; + } +} + +/** + * [신규 추가] 댓글 객체를 받아 HTML 문자열을 생성하는 헬퍼 함수 + * @param {object} comment - 댓글 객체 + * @param {boolean} isReply - 대댓글 여부 (대댓글에는 "답글달기" 버튼 숨김. 원한다면 true로 변경) + * @returns {string} - 완성된 HTML 문자열 + */ +function createCommentHTML(comment, isReply = false) { + const writerName = comment.writer || 'Anonymous'; + const date = new Date(comment.writeTime); + const formattedDate = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; + + // JS에서 XSS를 방지하기 위해 특수문자를 HTML 엔티티로 치환 (간단 버전) + const safeContent = String(comment.content).replace(//g, ">").replace(/\n/g, "
"); + const writerNameForReply = String(writerName).replace(/'/g, "\\'"); // ' 문자가 JS를 깨트리는 것을 방지 + + // 참고: 현재 로직은 대댓글의 대댓글(3단계)은 지원하지 않습니다. (isReply = true이면 답글 버튼 생성 안 함) + // 3단계 이상을 지원하려면 isReply 체크를 제거하고, 답글 API가 대댓글도 정상적으로 가져오는지 확인해야 합니다. + const replyButtonHTML = !isReply + ? `` + : ''; // 대댓글에는 "답글" 버튼 표시 안 함 + + return ` +
+
+ ${writerName} + ${formattedDate} +
+ ${replyButtonHTML} +
+

${safeContent}

+ `; +} +/** + * [신규 추가] "답글 달기" 버튼 클릭 시 호출되는 헬퍼 함수 + * @param {string} commentId - 부모가 될 댓글의 ID + * @param {string} writerName - 부모 댓글 작성자명 + */ +function setReplyTarget(commentId, writerName) { + currentReplyParentId = commentId; // 전역 변수(상태) 설정 + + // UI 업데이트 + const statusBar = document.getElementById('reply-status-bar'); + const statusText = document.getElementById('reply-status-text'); + const commentInput = document.getElementById('comment-input'); + + if (statusBar && statusText) { + statusText.innerText = `@${writerName} 님에게 답글 다는 중...`; + statusBar.style.display = 'flex'; // 숨겨둔 상태바 표시 + } + commentInput.focus(); // 입력창으로 포커스 이동 +} + +/** + * [신규 추가] 답글 달기 "취소" 시 호출되는 헬퍼 함수 + */ +function cancelReply() { + currentReplyParentId = null; // 상태 초기화 + const statusBar = document.getElementById('reply-status-bar'); + if (statusBar) { + statusBar.style.display = 'none'; // 상태바 숨기기 + } +} + /* ============================================= */ diff --git a/src/main/resources/templates/content/blog/viewer.html b/src/main/resources/templates/content/blog/viewer.html index 71e7e1c..300cfcb 100644 --- a/src/main/resources/templates/content/blog/viewer.html +++ b/src/main/resources/templates/content/blog/viewer.html @@ -9,7 +9,10 @@ - + @@ -85,6 +88,10 @@

Comments

+