fix: enforce private feedback access across list and detail
Deploy EG-BIM QA Gateway / deploy (push) Successful in 50s

This commit is contained in:
root committed 2026-11-13 10:28:00 +09:00
1 parent e2e70c13ac
commit a5a5f24f92
10 files changed
+379 -24

No files matched your search

+45 -14
View File
@@ -129,6 +129,7 @@
createdAt: createdAt,
status: normalizeStatus(value(["feedback_status", "feedbackStatus", "status_code", "status"])),
secret: toBoolean(secret),
accessRestricted: toBoolean(item.access_restricted || item.accessRestricted),
completionRequested: toBoolean(completionRequested) || Boolean(completionRequestedAt),
completionRequestedAt: completionRequestedAt,
completionConfirmed: toBoolean(completionConfirmed) || Boolean(completionConfirmedAt),
@@ -249,12 +250,14 @@
}
function savePost(post) {
if (post.secret) return;
const posts = readJson(STORAGE_KEY, []);
posts.unshift(post);
localStorage.setItem(STORAGE_KEY, JSON.stringify(posts));
}
function updateStoredPost(post) {
if (post.secret) { removeStoredPost(post.id); return; }
const posts = readJson(STORAGE_KEY, []);
const index = posts.findIndex(function (item) { return item.id === post.id; });
if (index < 0) return;
@@ -279,6 +282,10 @@
return Boolean(post) && matchesAuthorId(post.requesterId || post.requesterUuid, user);
}
function isRestrictedPost(post, user) {
return post.accessRestricted || (post.secret && !isPostOwner(post, user));
}
function normalizeStatus(value) {
if (value && typeof value === "object") value = value.code || value.key || value.value || value.name || "";
const raw = String(value || "").trim();
@@ -496,6 +503,8 @@
}
function bindGlobal() {
// Discard legacy snapshots that could have been saved under another account.
try { localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(COMMENTS_KEY); } catch (error) {}
renderAuth(getAuthUser());
authReady = loadAuthUser().catch(function (error) {
console.error('[QA] SSO session load failed:', error);
@@ -544,7 +553,9 @@
const response = await fetch(target, Object.assign({ credentials: 'include' }, options));
if (!response.ok) {
const errorBody = await response.json().catch(function () { return {}; });
throw new Error(errorBody.message || errorBody.error || 'API 요청에 실패했습니다. (' + response.status + ')');
const error = new Error(errorBody.message || errorBody.error || 'API 요청에 실패했습니다. (' + response.status + ')');
error.status = response.status;
throw error;
}
return response.json().catch(function () { return {}; });
}
@@ -598,7 +609,8 @@
const resultCount = qs("#resultCount");
const pagination = qs("#pagination");
let page = 1;
let posts = getPosts();
// A previous account's browser cache is never an authorization source.
let posts = [];
let remoteLoaded = false;
let loadFailed = false;
let receivedRemotePage = false;
@@ -620,7 +632,11 @@
const categoryFilter = selectedCategory ? selectedCategory.value : "전체";
const onlyMine = qs("#onlyMine").checked;
const user = getAuthUser();
const filtered = searchPosts(posts.filter(function (post) {
const visiblePosts = posts.map(function (post) {
if (!isRestrictedPost(post, user)) return post;
return Object.assign({}, post, { title: '비밀글입니다.', content: '', company: '-', department: '-', author: '-', requesterEmail: '', raw: {}, attachments: [] });
});
const filtered = searchPosts(visiblePosts.filter(function (post) {
const matchesCategory = categoryFilter === "전체" || categoryFilter === post.category;
const matchesMine = !onlyMine || isPostOwner(post, user);
return matchesCategory && matchesMine;
@@ -644,13 +660,17 @@
const status = normalizeStatus(post.status);
const statusClass = status === '완료' ? 'done' : status === '신규' ? 'new' : status === '보류' ? 'hold' : status === '진행중' ? 'patch' : 'review';
const company = getPostCompany(post, user);
const restricted = isRestrictedPost(post, user);
const subject = restricted ? '<span class="subject-link" aria-disabled="true"><span class="lock" aria-label="비밀글">🔒</span> 비밀글입니다.</span>' :
'<a class="subject-link" title="' + escapeHtml(post.title) + '" href="detail.html?id=' + encodeURIComponent(post.id) + '">' + (post.secret ? '<span class="lock" aria-label="비밀글">🔒</span>' : '') + '<span>' + escapeHtml(post.title) + '</span></a>';
return "<tr class=\"" + (isNotice ? "notice-row" : "") + "\" data-id=\"" + escapeHtml(post.id) + "\">" +
"<td>" + number + "</td><td class=\"category-cell\">" + escapeHtml(post.category.replace("문의", "")) + "</td>" +
'<td title="' + escapeHtml(company) + '">' + escapeHtml(company) + '</td><td title="' + escapeHtml(post.department) + '">' + escapeHtml(post.department) + '</td><td>' + escapeHtml(post.author) + '</td>' +
'<td class="inquiry-subject"><a class="subject-link" title="' + escapeHtml(post.title) + '" href="detail.html?id=' + encodeURIComponent(post.id) + '">' + (post.secret ? '<span class="lock" aria-label="비밀글">🔒</span>' : '') + '<span>' + escapeHtml(post.title) + '</span></a></td>' +
'<td class="inquiry-subject">' + subject + '</td>' +
'<td title="' + escapeHtml(post.dateTime || post.date) + '">' + escapeHtml(post.dateTime || post.date) + '</td><td>' + (status ? '<span class="inquiry-status inquiry-status--' + statusClass + '">' + escapeHtml(status) + '</span>' : '-') + '</td></tr>';
}).join("");
qsa("#qaRows tr").forEach(function (row) { row.addEventListener("click", function (event) {
if (!row.querySelector('a')) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
if (inquiryUI) { event.preventDefault(); inquiryUI.open('detail', row.dataset.id, row.querySelector('a')); }
else window.location.href = 'detail.html?id=' + encodeURIComponent(row.dataset.id);
@@ -745,22 +765,32 @@
const bottomBackButton = find('#backButton', detail);
if (bottomBackButton && bottomBackButton.parentElement) bottomBackButton.parentElement.remove();
const id = requestedId || new URLSearchParams(window.location.search).get("id") || "EG-1048";
const storedPost = getPosts().find(function (item) { return item.id === id; });
let post = storedPost;
let post;
let loadError;
try {
const result = await requestJson("/api/feedbacks/" + encodeURIComponent(id), { method: "GET" });
const remotePost = normalizeRemotePost(result.feedback || result.data || result);
if (remotePost.id) post = Object.assign({}, storedPost || {}, remotePost);
if (remotePost.id) post = remotePost;
} catch (error) {
loadError = error;
console.warn("[QA] feedback detail load failed:", error.message);
}
const showUnavailable = function (restricted) {
find('[data-detail-title]').textContent = restricted ? '비밀글입니다.' : '문의 내용을 찾을 수 없습니다.';
find('[data-detail-content]').textContent = restricted ? '비밀글은 작성자만 조회할 수 있습니다.' : '문의 내용을 불러오지 못했습니다. 다시 시도해 주세요.';
['.comment-box', '#commentForm', '#comments', '#editButton', '#deleteButton', '#editForm', '[data-detail-attachments]'].forEach(function (selector) {
const element = find(selector);
if (element) { element.hidden = true; }
});
};
if (!post) {
find('[data-detail-title]').textContent = '문의 내용을 찾을 수 없습니다.';
find('[data-detail-content]').textContent = '목록에 등록된 문의가 없습니다.';
const commentBox = find('.comment-box');
if (commentBox) commentBox.hidden = true;
showUnavailable(loadError && loadError.status === 403);
return;
}
// Private data must wait for verified identity before touching the DOM.
if (!post.secret && !post.accessRestricted) find('[data-detail-title]').textContent = post.title;
const detailUser = await (authReady || Promise.resolve(getAuthUser()));
if (isRestrictedPost(post, detailUser)) { showUnavailable(true); return; }
find('[data-detail-category]').textContent = post.category;
find('[data-detail-title]').textContent = post.title;
const renderDetailStatus = function () {
@@ -775,7 +805,6 @@
renderDetailStatus();
find('[data-detail-date]').textContent = post.dateTime || post.date;
find('[data-detail-author]').textContent = post.author;
const detailUser = await (authReady || Promise.resolve(getAuthUser()));
find('[data-detail-company]').textContent = getPostCompanyLabel(post, detailUser);
find('[data-detail-department]').textContent = post.department;
find('[data-detail-content]').textContent = post.content;
@@ -979,11 +1008,13 @@
return '<div class="comment" data-comment-id="' + escapeHtml(comment.id) + '"><div class="comment-meta"><strong>' + escapeHtml(comment.author) + '</strong><span>' + escapeHtml(comment.date) + '</span></div>' + content + (comment.attachments.length ? '<div class="comment-attachments-list">' + renderCommentAttachments(comment.attachments) + '</div>' : '') + (editing ? '' : actions) + '</div>';
}).join('') : '<div class="comment-empty">등록된 답변이 없습니다.</div>';
const storedComments = getComments();
storedComments[post.id] = comments;
if (post.secret) delete storedComments[post.id];
else storedComments[post.id] = comments;
localStorage.setItem(COMMENTS_KEY, JSON.stringify(storedComments));
return comments;
}
let comments = renderComments(getComments()[post.id] || []);
// Refresh access on every open; cached comments may belong to another login.
let comments = renderComments([]);
const commentsPath = '/api/feedbacks/' + encodeURIComponent(post.id) + '/comments';
requestJson(commentsPath, { method: 'GET' }).then(function (result) {
comments = renderComments(result.comments || result.items || []);