security: 보안 강제 시스템 구축 + 하드코딩 비밀번호 제거

보안 감사 결과 CRITICAL 2건, HIGH 5건 발견 → 수정 완료 + 자동화 구축.

[보안 수정]
- issue-view.js: 하드코딩 비밀번호 → crypto.getRandomValues() 랜덤 생성
- pushSubscriptionController.js: ntfy 비밀번호 → process.env.NTFY_SUB_PASSWORD
- DEPLOY-GUIDE.md/PROGRESS.md/migration SQL: 평문 비밀번호 → placeholder
- docker-compose.yml/.env.example: NTFY_SUB_PASSWORD 환경변수 추가

[보안 강제 시스템 - 신규]
- scripts/security-scan.sh: 8개 규칙 (CRITICAL 2, HIGH 4, MEDIUM 2)
  3모드(staged/all/diff), severity, .securityignore, MEDIUM 임계값
- .githooks/pre-commit: 로컬 빠른 피드백
- .githooks/pre-receive-server.sh: Gitea 서버 최종 차단
  bypass 거버넌스([SECURITY-BYPASS: 사유] + 사용자 제한 + 로그)
- SECURITY-CHECKLIST.md: 10개 카테고리 자동/수동 구분
- docs/SECURITY-GUIDE.md: 운영자 가이드 (워크플로우, bypass, FAQ)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-10 09:44:21 +09:00
parent bbffa47a9d
commit ba9ef32808
257 changed files with 786 additions and 18 deletions

View File

@@ -0,0 +1,358 @@
/* meeting-detail.js — 회의록 상세/작성 */
let meetingId = null;
let meetingData = null;
let selectedAttendees = []; // [{user_id, name, username}]
let projects = [];
let users = [];
let canEdit = false;
let isAdmin = false;
let isPublished = false;
document.addEventListener('DOMContentLoaded', async () => {
const ok = await initAuth();
if (!ok) return;
document.querySelector('.fade-in').classList.add('visible');
const role = currentUser?.role || '';
canEdit = ['support_team', 'admin', 'system', 'system admin'].includes(role);
isAdmin = ['admin', 'system', 'system admin'].includes(role);
// Parse URL
const params = new URLSearchParams(location.search);
meetingId = params.get('id');
// Load master data
try {
const [projRes, userRes] = await Promise.all([
api('/projects'),
api('/users')
]);
projects = projRes.data || [];
users = (userRes.data || []).filter(u => u.is_active !== 0);
} catch {}
// Populate project select in item modal
const projSel = document.getElementById('itemProject');
projects.forEach(p => {
projSel.innerHTML += `<option value="${p.project_id}">${escapeHtml(p.job_no)} ${escapeHtml(p.project_name)}</option>`;
});
// Populate responsible user select
const respSel = document.getElementById('itemResponsible');
users.forEach(u => {
respSel.innerHTML += `<option value="${u.user_id}">${escapeHtml(u.name)} (${escapeHtml(u.username)})</option>`;
});
// Attendee search
const searchInput = document.getElementById('attendeeSearch');
const resultsDiv = document.getElementById('attendeeResults');
searchInput.addEventListener('input', debounce(() => {
const q = searchInput.value.trim().toLowerCase();
if (q.length < 1) { resultsDiv.classList.add('hidden'); return; }
const matches = users.filter(u =>
!selectedAttendees.some(a => a.user_id === u.user_id) &&
(u.name?.toLowerCase().includes(q) || u.username?.toLowerCase().includes(q))
).slice(0, 10);
if (matches.length === 0) { resultsDiv.classList.add('hidden'); return; }
resultsDiv.innerHTML = matches.map(u =>
`<div class="user-search-item" onclick="addAttendee(${u.user_id}, '${escapeHtml(u.name)}', '${escapeHtml(u.username)}')">${escapeHtml(u.name)} <span class="text-gray-400">(${escapeHtml(u.username)})</span></div>`
).join('');
resultsDiv.classList.remove('hidden');
}, 200));
searchInput.addEventListener('blur', () => setTimeout(() => resultsDiv.classList.add('hidden'), 200));
if (meetingId) {
await loadMeeting();
} else {
// New meeting
document.getElementById('meetingDate').value = new Date().toISOString().split('T')[0];
updateUI();
}
});
async function loadMeeting() {
try {
const res = await api(`/meetings/${meetingId}`);
meetingData = res.data;
isPublished = meetingData.status === 'published';
document.getElementById('pageTitle').textContent = meetingData.title;
document.getElementById('meetingDate').value = formatDate(meetingData.meeting_date);
document.getElementById('meetingTime').value = meetingData.meeting_time || '';
document.getElementById('meetingTitle').value = meetingData.title;
document.getElementById('meetingLocation').value = meetingData.location || '';
document.getElementById('meetingSummary').value = meetingData.summary || '';
// Status badge
const badge = document.getElementById('statusBadge');
badge.classList.remove('hidden');
if (isPublished) {
badge.className = 'badge badge-green';
badge.textContent = '발행';
} else {
badge.className = 'badge badge-gray';
badge.textContent = '초안';
}
// Attendees
selectedAttendees = (meetingData.attendees || []).map(a => ({
user_id: a.user_id, name: a.name, username: a.username
}));
renderAttendees();
// Agenda items
renderAgendaItems(meetingData.items || []);
updateUI();
} catch (err) {
showToast('회의록 로드 실패: ' + err.message, 'error');
}
}
function updateUI() {
const editable = canEdit && (!isPublished || isAdmin);
// Fields
['meetingDate', 'meetingTime', 'meetingTitle', 'meetingLocation', 'meetingSummary', 'attendeeSearch'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.disabled = !editable; if (!editable) el.classList.add('bg-gray-100'); }
});
// Buttons
document.getElementById('btnSave').classList.toggle('hidden', !editable);
document.getElementById('btnAddItem').classList.toggle('hidden', !editable);
document.getElementById('btnPublish').classList.toggle('hidden', !canEdit || isPublished || !meetingId);
document.getElementById('btnUnpublish').classList.toggle('hidden', !isAdmin || !isPublished);
document.getElementById('btnDelete').classList.toggle('hidden', !isAdmin || !meetingId);
}
/* ===== Attendees ===== */
function addAttendee(userId, name, username) {
if (selectedAttendees.some(a => a.user_id === userId)) return;
selectedAttendees.push({ user_id: userId, name, username });
renderAttendees();
document.getElementById('attendeeSearch').value = '';
document.getElementById('attendeeResults').classList.add('hidden');
}
function removeAttendee(userId) {
selectedAttendees = selectedAttendees.filter(a => a.user_id !== userId);
renderAttendees();
}
function renderAttendees() {
const container = document.getElementById('attendeeTags');
const editable = canEdit && (!isPublished || isAdmin);
container.innerHTML = selectedAttendees.map(a =>
`<span class="attendee-tag">${escapeHtml(a.name)}${editable ? ` <span class="remove-btn" onclick="removeAttendee(${a.user_id})">×</span>` : ''}</span>`
).join('');
}
/* ===== Agenda Items ===== */
function renderAgendaItems(items) {
const list = document.getElementById('agendaList');
const empty = document.getElementById('agendaEmpty');
if (items.length === 0) {
list.innerHTML = '';
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
const typeLabels = { schedule_update: '공정현황', issue: '이슈', decision: '결정사항', action_item: '조치사항', other: '기타' };
const typeColors = { schedule_update: 'badge-blue', issue: 'badge-red', decision: 'badge-green', action_item: 'badge-amber', other: 'badge-gray' };
const statusLabels = { open: '미처리', in_progress: '진행중', completed: '완료', cancelled: '취소' };
const statusColors = { open: 'badge-amber', in_progress: 'badge-blue', completed: 'badge-green', cancelled: 'badge-gray' };
const editable = canEdit && (!isPublished || isAdmin);
const canUpdateStatus = ['group_leader', 'support_team', 'admin', 'system', 'system admin'].includes(currentUser?.role || '');
list.innerHTML = items.map(item => `
<div class="border rounded-lg p-4">
<div class="flex items-start justify-between gap-2 mb-2">
<div class="flex items-center gap-2 flex-wrap">
<span class="badge ${typeColors[item.item_type] || 'badge-gray'}">${typeLabels[item.item_type] || item.item_type}</span>
<span class="badge ${statusColors[item.status] || 'badge-gray'}">${statusLabels[item.status] || item.status}</span>
${item.project_code ? `<span class="text-xs text-gray-400">${escapeHtml(item.project_code)}</span>` : ''}
${item.milestone_name ? `<span class="text-xs text-purple-500">◆ ${escapeHtml(item.milestone_name)}</span>` : ''}
</div>
<div class="flex items-center gap-1 flex-shrink-0">
${canUpdateStatus && item.status !== 'completed' ? `<select class="text-xs border rounded px-1 py-0.5" onchange="updateItemStatus(${item.item_id}, this.value)">
<option value="">상태변경</option>
<option value="in_progress">진행중</option>
<option value="completed">완료</option>
</select>` : ''}
${editable ? `<button onclick="openItemModal(${item.item_id})" class="text-gray-400 hover:text-orange-600 text-xs px-1"><i class="fas fa-edit"></i></button>
<button onclick="deleteItem(${item.item_id})" class="text-gray-400 hover:text-red-600 text-xs px-1"><i class="fas fa-trash"></i></button>` : ''}
</div>
</div>
<p class="text-sm text-gray-800 mb-1">${escapeHtml(item.content)}</p>
${item.decision ? `<p class="text-sm text-green-700 bg-green-50 rounded p-2 mb-1"><strong>결정:</strong> ${escapeHtml(item.decision)}</p>` : ''}
${item.action_required ? `<p class="text-sm text-amber-700 bg-amber-50 rounded p-2 mb-1"><strong>조치:</strong> ${escapeHtml(item.action_required)}</p>` : ''}
<div class="flex items-center gap-4 text-xs text-gray-400 mt-2">
${item.responsible_name ? `<span><i class="fas fa-user mr-1"></i>${escapeHtml(item.responsible_name)}</span>` : ''}
${item.due_date ? `<span class="${new Date(item.due_date) < new Date() && item.status !== 'completed' ? 'text-red-500 font-semibold' : ''}"><i class="fas fa-clock mr-1"></i>${formatDate(item.due_date)}</span>` : ''}
${item.milestone_name ? `<a href="/pages/work/schedule.html?highlight=${item.milestone_id}" class="text-purple-500 hover:text-purple-700"><i class="fas fa-calendar-alt mr-1"></i>공정표 보기</a>` : ''}
</div>
</div>
`).join('');
}
/* ===== Save Meeting ===== */
async function saveMeeting() {
const title = document.getElementById('meetingTitle').value.trim();
const meetingDate = document.getElementById('meetingDate').value;
if (!title || !meetingDate) { showToast('날짜와 제목은 필수입니다.', 'error'); return; }
const data = {
meeting_date: meetingDate,
meeting_time: document.getElementById('meetingTime').value || null,
title,
location: document.getElementById('meetingLocation').value || null,
summary: document.getElementById('meetingSummary').value || null,
attendees: selectedAttendees.map(a => a.user_id)
};
try {
if (meetingId) {
await api(`/meetings/${meetingId}`, { method: 'PUT', body: JSON.stringify(data) });
showToast('회의록이 저장되었습니다.');
} else {
const res = await api('/meetings', { method: 'POST', body: JSON.stringify(data) });
meetingId = res.data.meeting_id;
history.replaceState(null, '', `?id=${meetingId}`);
showToast('회의록이 생성되었습니다.');
}
await loadMeeting();
} catch (err) { showToast(err.message, 'error'); }
}
async function publishMeeting() {
if (!confirm('회의록을 발행하시겠습니까? 발행 후 일반 사용자는 수정할 수 없습니다.')) return;
try {
await api(`/meetings/${meetingId}/publish`, { method: 'PUT' });
showToast('회의록이 발행되었습니다.');
await loadMeeting();
} catch (err) { showToast(err.message, 'error'); }
}
async function unpublishMeeting() {
if (!confirm('발행을 취소하시겠습니까?')) return;
try {
await api(`/meetings/${meetingId}/unpublish`, { method: 'PUT' });
showToast('발행이 취소되었습니다.');
await loadMeeting();
} catch (err) { showToast(err.message, 'error'); }
}
async function deleteMeeting() {
if (!confirm('회의록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) return;
try {
await api(`/meetings/${meetingId}`, { method: 'DELETE' });
showToast('회의록이 삭제되었습니다.');
location.href = '/pages/work/meetings.html';
} catch (err) { showToast(err.message, 'error'); }
}
/* ===== Agenda Item Modal ===== */
function openItemModal(editItemId) {
const modal = document.getElementById('itemModal');
const isEdit = !!editItemId;
document.getElementById('itemModalTitle').textContent = isEdit ? '안건 수정' : '안건 추가';
if (isEdit && meetingData) {
const item = meetingData.items.find(i => i.item_id === editItemId);
if (!item) return;
document.getElementById('itemId').value = editItemId;
document.getElementById('itemType').value = item.item_type;
document.getElementById('itemProject').value = item.project_id || '';
loadItemMilestones(item.milestone_id);
document.getElementById('itemContent').value = item.content;
document.getElementById('itemDecision').value = item.decision || '';
document.getElementById('itemAction').value = item.action_required || '';
document.getElementById('itemResponsible').value = item.responsible_user_id || '';
document.getElementById('itemDueDate').value = item.due_date ? formatDate(item.due_date) : '';
document.getElementById('itemStatus').value = item.status;
} else {
document.getElementById('itemId').value = '';
document.getElementById('itemType').value = 'schedule_update';
document.getElementById('itemProject').value = '';
document.getElementById('itemMilestone').innerHTML = '<option value="">선택안함</option>';
document.getElementById('itemContent').value = '';
document.getElementById('itemDecision').value = '';
document.getElementById('itemAction').value = '';
document.getElementById('itemResponsible').value = '';
document.getElementById('itemDueDate').value = '';
document.getElementById('itemStatus').value = 'open';
}
modal.classList.remove('hidden');
}
function closeItemModal() { document.getElementById('itemModal').classList.add('hidden'); }
async function loadItemMilestones(selectedId) {
const projectId = document.getElementById('itemProject').value;
const sel = document.getElementById('itemMilestone');
sel.innerHTML = '<option value="">선택안함</option>';
if (!projectId) return;
try {
const res = await api(`/schedule/milestones?project_id=${projectId}`);
(res.data || []).forEach(m => {
const opt = document.createElement('option');
opt.value = m.milestone_id;
opt.textContent = `${m.milestone_name} (${formatDate(m.milestone_date)})`;
if (selectedId && m.milestone_id === selectedId) opt.selected = true;
sel.appendChild(opt);
});
} catch {}
}
async function saveItem() {
const content = document.getElementById('itemContent').value.trim();
if (!content) { showToast('안건 내용을 입력해주세요.', 'error'); return; }
if (!meetingId) { showToast('회의록을 먼저 저장해주세요.', 'error'); return; }
const itemId = document.getElementById('itemId').value;
const data = {
item_type: document.getElementById('itemType').value,
project_id: document.getElementById('itemProject').value || null,
milestone_id: document.getElementById('itemMilestone').value || null,
content,
decision: document.getElementById('itemDecision').value || null,
action_required: document.getElementById('itemAction').value || null,
responsible_user_id: document.getElementById('itemResponsible').value || null,
due_date: document.getElementById('itemDueDate').value || null,
status: document.getElementById('itemStatus').value
};
try {
if (itemId) {
await api(`/meetings/${meetingId}/items/${itemId}`, { method: 'PUT', body: JSON.stringify(data) });
showToast('안건이 수정되었습니다.');
} else {
await api(`/meetings/${meetingId}/items`, { method: 'POST', body: JSON.stringify(data) });
showToast('안건이 추가되었습니다.');
}
closeItemModal();
await loadMeeting();
} catch (err) { showToast(err.message, 'error'); }
}
async function deleteItem(itemId) {
if (!confirm('안건을 삭제하시겠습니까?')) return;
try {
await api(`/meetings/${meetingId}/items/${itemId}`, { method: 'DELETE' });
showToast('안건이 삭제되었습니다.');
await loadMeeting();
} catch (err) { showToast(err.message, 'error'); }
}
async function updateItemStatus(itemId, status) {
if (!status) return;
try {
await api(`/meetings/items/${itemId}/status`, { method: 'PUT', body: JSON.stringify({ status }) });
showToast('상태가 업데이트되었습니다.');
await loadMeeting();
} catch (err) { showToast(err.message, 'error'); }
}