This commit is contained in:
lunaticbum 2025-09-08 18:21:57 +09:00
parent 51b97e2422
commit d62a4a3c15
5 changed files with 389 additions and 29 deletions

View File

@ -75,17 +75,22 @@ class SecurityConfig(
"/user/login.bjx", "/user/joinUser.bjx","/tlg/repotToMe.bjx", "/user/login.bjx", "/user/joinUser.bjx","/tlg/repotToMe.bjx",
"/blog/post/imageUpload.bjx", "/blog/post.bjx", "/blog/post/imageUpload.bjx", "/blog/post.bjx",
// "/blog/post/images/**", // WebSecurityCustomizer에서 이미 ignoring 처리됨 // "/blog/post/images/**", // WebSecurityCustomizer에서 이미 ignoring 처리됨
"/puzzle/**","/puzzle/play/**", "/puzzle/**","/puzzle/play/**","/bums/save/**",
"/rank/**","/spider/**", "/rank/**","/spider/**",
"/sudoku/**", "/sudoku/**",
) )
}.authorizeHttpRequests { auth -> }.authorizeHttpRequests { auth ->
auth auth
// [정상 유지] 이 두 엔드포인트는 인증(로그인)이 필요하며 CSRF 보호를 받아야 합니다. // [정상 유지] 이 두 엔드포인트는 인증(로그인)이 필요하며 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}/like.bjx").permitAll()
.requestMatchers(HttpMethod.POST, "/blog/post/{postId}/unlike.bjx").permitAll() .requestMatchers(HttpMethod.POST, "/blog/post/{postId}/unlike.bjx").permitAll()
.requestMatchers(HttpMethod.POST, "/bums/save/loc.api").permitAll()
// permitAll() 목록 // permitAll() 목록
.requestMatchers( .requestMatchers(
"/", "/",
"/home.bs", "/home.bs",
"/bums/where.bs" , "/bums/where.bs" ,

View File

@ -118,6 +118,7 @@ class BlogController(private val commentService : CommentService) {
try { try {
val authentication = SecurityContextHolder.getContext().authentication val authentication = SecurityContextHolder.getContext().authentication
val username = (authentication.principal as? UserDetails)?.username ?: authentication.name val username = (authentication.principal as? UserDetails)?.username ?: authentication.name
logService.log(username)
target.writer = username target.writer = username
} catch (e: Exception) { } catch (e: Exception) {
target.writer = "Anonymous" // 인증 정보 가져오기 실패 시 target.writer = "Anonymous" // 인증 정보 가져오기 실패 시
@ -144,12 +145,23 @@ class BlogController(private val commentService : CommentService) {
} else { } else {
// === B. 기존 게시물 수정 (새 버전 생성) 로직 === // === B. 기존 게시물 수정 (새 버전 생성) 로직 ===
// (글쓴이는 client-sent 'target' 객체에 이미 포함되어 있으므로 별도 설정 필요 없음) // (글쓴이는 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. (정상 로직) 이 객체에 "수정 시간"을 설정합니다. // 3. (정상 로직) 이 객체에 "수정 시간"을 설정합니다.
target.modifyTime = System.currentTimeMillis() target.modifyTime = System.currentTimeMillis()
// 4. 이 게시물이 "버전 기록용 사본"임을 설정합니다. // 4. 이 게시물이 "버전 기록용 사본"임을 설정합니다.
target.originId = target.id // 원본 마스터 ID를 originId 필드에 저장 if (target.originId == null) {
target.originId = target.id
}
target.id = null // Mongo가 새 ID를 생성하도록 ID를 null로 변경 target.id = null // Mongo가 새 ID를 생성하도록 ID를 null로 변경
// 5. 모든 데이터(새 카테고리, 태그, modifyTime 포함)가 담긴 "새 버전 문서"를 저장합니다. // 5. 모든 데이터(새 카테고리, 태그, modifyTime 포함)가 담긴 "새 버전 문서"를 저장합니다.
@ -613,30 +625,66 @@ class BlogController(private val commentService : CommentService) {
@PostMapping("posts/{postId}/comments.bjx") @PostMapping("posts/{postId}/comments.bjx")
fun addComment(@PathVariable postId: String, @RequestBody jsonString: String): Mono<ResponseEntity<ResponceResult>> { fun addComment(@PathVariable postId: String, @RequestBody jsonString: String): Mono<ResponseEntity<ResponceResult>> {
try { try {
// 1. Decode the Base64 string to get the envelope JSON
val decodedBytes: ByteArray = Base64.getDecoder().decode(jsonString) 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) // 2. Parse the ENVELOPE (RequestModel), just like in your "post.bjx" endpoint
.map { Gson().fromJson(String(decodedBytes), RequestModel::class.java)?.let { model ->
ResponseEntity.ok().body(ResponceResult().apply { model.data?.let { innerJsonString ->
resultCode = 0 try {
resultMsg = "Comment submitted successfully" // 3. COPY the EXACT decryption/un-formatting logic from "post.bjx"
}) val reqString = innerJsonString.split(GlobalEnvironment.padding(model.getKeyword()))
val nb = arrayListOf<String>()
val na = arrayListOf<String>()
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<String>()
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 { // This catches envelope parsing errors
resultCode = 500
resultMsg = "Error submitting comment: ${e.message}"
}))
}
} catch (e: Exception) {
return Mono.just(ResponseEntity.status(400).body(ResponceResult().apply { return Mono.just(ResponseEntity.status(400).body(ResponceResult().apply {
resultCode = 400 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)"
})) }))
} }
} }

View File

@ -462,4 +462,77 @@ a.btn_layerClose:hover {
} }
#selected-category-area i, #selected-hashtags-area i { #selected-category-area i, #selected-hashtags-area i {
color: #999; 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;
} }

View File

@ -10,23 +10,31 @@
var stagedCategory = 'none'; var stagedCategory = 'none';
var stagedHashtags = []; // 해시태그는 배열로 관리 var stagedHashtags = []; // 해시태그는 배열로 관리
var currentReplyParentId = null;
// 전역 변수: Quill 에디터 인스턴스와 게시물 기본 데이터를 저장합니다. // 전역 변수: Quill 에디터 인스턴스와 게시물 기본 데이터를 저장합니다.
var quill = null; var quill = null;
var currentLat = 0.0; var currentLat = 0.0;
var currentLon = 0.0; var currentLon = 0.0;
var baseData = { var baseData = {
'id': "", 'id': "",
'originId': "",
'title': "", 'title': "",
'content': "", 'content': "",
'category': "none", 'category': "none",
'tags': "", 'tags': "",
'writeTime': 0,
'modifyTime': 0,
'firstPostLat': 0.0, 'firstPostLat': 0.0,
'firstPostLon': 0.0, 'firstPostLon': 0.0,
'firstAddress': "",
'modifyLat': 0.0, 'modifyLat': 0.0,
'modifyLon': 0.0, 'modifyLon': 0.0,
'originId': "", 'modifyAddress': "",
'writeTime': 0, 'writer': "",
'posting': false,
'readCount': 0,
'voteCount': 0,
'unlikeCount': 0
}; };
// jQuery를 사용하여 문서가 완전히 로드된 후에 함수를 실행합니다. // jQuery를 사용하여 문서가 완전히 로드된 후에 함수를 실행합니다.
@ -59,7 +67,11 @@ $(document).ready(function() {
openPopup(this); openPopup(this);
}); });
/* === (대규모 수정) 팝업 입력/적용/취소 버튼 로직 === */ /* === (대규모 수정) 팝업 입력/적용/취소 버튼 로직 === */
/* === (신규 추가) 댓글 등록 버튼 이벤트 리스너 === */
$('#submit-comment').on('click', function(e) {
e.preventDefault(); // 기본 버튼 동작 방지
submitComment(); // 아래에 정의된 새 함수 호출
});
// --- 1. Category Popup Logic --- // --- 1. Category Popup Logic ---
const categoryInput = document.getElementById('category-input'); const categoryInput = document.getElementById('category-input');
const addCategoryBtn = document.getElementById('add-category-btn'); const addCategoryBtn = document.getElementById('add-category-btn');
@ -159,6 +171,19 @@ function initEditor(useEditor = false) {
baseData.firstPostLon = serverData.firstPostLon; baseData.firstPostLon = serverData.firstPostLon;
baseData.writeTime = serverData.writeTime; baseData.writeTime = serverData.writeTime;
baseData.originId = serverData.originId; 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(); getLocation();
@ -718,9 +743,26 @@ function post(target, type, data, key, callBackResult) {
}; };
httpRequest.open('POST', target, true); httpRequest.open('POST', target, true);
httpRequest.setRequestHeader("Content-Type", "text/plain"); 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, '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) { function postLogin(target, type, data, key, callBackResult) {
@ -924,4 +966,189 @@ function handleVote(buttonElement, voteType) {
controls.querySelectorAll('button').forEach(btn => btn.disabled = false); 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 = '<p style="text-align: center; color: #888;">댓글 목록을 불러오는 중...</p>';
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 = '<p style="text-align: center; color: #888;">아직 댓글이 없습니다. 첫 댓글을 작성해보세요.</p>';
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 = '<p>댓글 로딩 중 오류가 발생했습니다.</p>';
}
}
/**
* [신규 추가] 댓글 객체를 받아 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, "&lt;").replace(/>/g, "&gt;").replace(/\n/g, "<br>");
const writerNameForReply = String(writerName).replace(/'/g, "\\'"); // ' 문자가 JS를 깨트리는 것을 방지
// 참고: 현재 로직은 대댓글의 대댓글(3단계)은 지원하지 않습니다. (isReply = true이면 답글 버튼 생성 안 함)
// 3단계 이상을 지원하려면 isReply 체크를 제거하고, 답글 API가 대댓글도 정상적으로 가져오는지 확인해야 합니다.
const replyButtonHTML = !isReply
? `<button class="btn-reply" onclick="setReplyTarget('${comment.id}', '${writerNameForReply}')">답글</button>`
: ''; // 대댓글에는 "답글" 버튼 표시 안 함
return `
<div class="comment-header">
<div class="comment-author-info">
<strong>${writerName}</strong>
<span class="comment-date">${formattedDate}</span>
</div>
${replyButtonHTML}
</div>
<p style="padding: 5px 0 15px 5px; min-height: 2em;">${safeContent}</p>
`;
}
/**
* [신규 추가] "답글 달기" 버튼 클릭 호출되는 헬퍼 함수
* @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'; // 상태바 숨기기
}
}
/* ============================================= */ /* ============================================= */

View File

@ -9,7 +9,10 @@
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" /> <link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script> <script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script> <script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
<script>document.addEventListener('DOMContentLoaded', function() {initEditor(false)});</script> <script>document.addEventListener('DOMContentLoaded', function() {
initEditor(false)
fetchComments(serverData.id);
});</script>
</th:block> </th:block>
<th:block layout:fragment="content" id="content"> <th:block layout:fragment="content" id="content">
@ -85,6 +88,10 @@
<section class="comment-section"> <section class="comment-section">
<h2>Comments</h2> <h2>Comments</h2>
<div id="comment-form-container"> <div id="comment-form-container">
<div id="reply-status-bar" style="display: none;">
<span id="reply-status-text"></span>
<button id="btn-cancel-reply" onclick="cancelReply()">X 취소</button>
</div>
<textarea id="comment-input" placeholder="댓글을 입력하세요..."></textarea> <textarea id="comment-input" placeholder="댓글을 입력하세요..."></textarea>
<button id="submit-comment" class="button">등록</button> <button id="submit-comment" class="button">등록</button>
</div> </div>