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:
219
system3-nonconformance/web/public/static/js/m/m-common.js
Normal file
219
system3-nonconformance/web/public/static/js/m/m-common.js
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* m-common.js — TKQC 모바일 공통 JS
|
||||
* 바텀 네비게이션, 바텀시트 엔진, 인증, 뷰포트 가드, 토스트
|
||||
*/
|
||||
|
||||
/* ===== Viewport Guard: 데스크탑이면 리다이렉트 ===== */
|
||||
(function () {
|
||||
if (window.innerWidth > 768) {
|
||||
var page = location.pathname.replace('/m/', '').replace('.html', '');
|
||||
var map = { dashboard: '/issues-dashboard.html', inbox: '/issues-inbox.html', management: '/issues-management.html' };
|
||||
window.location.replace(map[page] || '/issues-dashboard.html');
|
||||
}
|
||||
})();
|
||||
|
||||
/* ===== KST Date Utilities ===== */
|
||||
// DB에 KST로 저장된 naive datetime을 그대로 표시 (이중 변환 방지)
|
||||
function getKSTDate(date) {
|
||||
return new Date(date);
|
||||
}
|
||||
function formatKSTDate(date) {
|
||||
return new Date(date).toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' });
|
||||
}
|
||||
function formatKSTTime(date) {
|
||||
return new Date(date).toLocaleTimeString('ko-KR', { timeZone: 'Asia/Seoul', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
function formatKSTDateTime(date) {
|
||||
return new Date(date).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
function getKSTToday() {
|
||||
var kst = new Date(new Date().toLocaleString('en-US', { timeZone: 'Asia/Seoul' }));
|
||||
return new Date(kst.getFullYear(), kst.getMonth(), kst.getDate());
|
||||
}
|
||||
function getTimeAgo(date) {
|
||||
var now = new Date();
|
||||
var d = new Date(date);
|
||||
var diff = now - d;
|
||||
var mins = Math.floor(diff / 60000);
|
||||
var hours = Math.floor(diff / 3600000);
|
||||
var days = Math.floor(diff / 86400000);
|
||||
if (mins < 1) return '방금 전';
|
||||
if (mins < 60) return mins + '분 전';
|
||||
if (hours < 24) return hours + '시간 전';
|
||||
if (days < 7) return days + '일 전';
|
||||
return formatKSTDate(date);
|
||||
}
|
||||
|
||||
/* ===== Bottom Navigation ===== */
|
||||
function renderBottomNav(activePage) {
|
||||
var nav = document.createElement('nav');
|
||||
nav.className = 'm-bottom-nav';
|
||||
var items = [
|
||||
{ icon: 'fa-chart-line', label: '현황판', href: '/m/dashboard.html', page: 'dashboard' },
|
||||
{ icon: 'fa-inbox', label: '수신함', href: '/m/inbox.html', page: 'inbox' },
|
||||
{ icon: 'fa-tasks', label: '관리함', href: '/m/management.html', page: 'management' },
|
||||
{ icon: 'fa-bullhorn', label: '신고', href: (location.hostname.includes('technicalkorea.net') ? 'https://tkreport.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30180'), page: 'report', external: true, highlight: true }
|
||||
];
|
||||
items.forEach(function (item) {
|
||||
var a = document.createElement('a');
|
||||
a.href = item.href;
|
||||
a.className = 'm-nav-item';
|
||||
if (item.page === activePage) a.classList.add('active');
|
||||
if (item.highlight) a.classList.add('highlight');
|
||||
if (item.external) { a.target = '_blank'; a.rel = 'noopener'; }
|
||||
a.innerHTML = '<i class="fas ' + item.icon + '"></i><span>' + item.label + '</span>';
|
||||
nav.appendChild(a);
|
||||
});
|
||||
document.body.appendChild(nav);
|
||||
}
|
||||
|
||||
/* ===== Bottom Sheet Engine ===== */
|
||||
var _activeSheets = [];
|
||||
|
||||
function openSheet(sheetId) {
|
||||
var overlay = document.getElementById(sheetId + 'Overlay');
|
||||
var sheet = document.getElementById(sheetId + 'Sheet');
|
||||
if (!overlay || !sheet) return;
|
||||
overlay.classList.add('open');
|
||||
sheet.classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
_activeSheets.push(sheetId);
|
||||
}
|
||||
|
||||
function closeSheet(sheetId) {
|
||||
var overlay = document.getElementById(sheetId + 'Overlay');
|
||||
var sheet = document.getElementById(sheetId + 'Sheet');
|
||||
if (!overlay || !sheet) return;
|
||||
overlay.classList.remove('open');
|
||||
sheet.classList.remove('open');
|
||||
_activeSheets = _activeSheets.filter(function (id) { return id !== sheetId; });
|
||||
if (_activeSheets.length === 0) document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
function closeAllSheets() {
|
||||
_activeSheets.slice().forEach(function (id) { closeSheet(id); });
|
||||
}
|
||||
|
||||
// ESC key closes topmost sheet
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && _activeSheets.length) {
|
||||
closeSheet(_activeSheets[_activeSheets.length - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
/* ===== Toast ===== */
|
||||
var _toastTimer = null;
|
||||
function showToast(message, type, duration) {
|
||||
type = type || 'info';
|
||||
duration = duration || 3000;
|
||||
var existing = document.querySelector('.m-toast');
|
||||
if (existing) existing.remove();
|
||||
clearTimeout(_toastTimer);
|
||||
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'm-toast';
|
||||
if (type !== 'info') toast.classList.add(type);
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
requestAnimationFrame(function () { toast.classList.add('show'); });
|
||||
_toastTimer = setTimeout(function () {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(function () { toast.remove(); }, 300);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
/* ===== Photo Modal ===== */
|
||||
function openPhotoModal(src) {
|
||||
var modal = document.getElementById('photoModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'photoModal';
|
||||
modal.className = 'm-photo-modal';
|
||||
modal.innerHTML = '<button class="m-photo-modal-close" onclick="closePhotoModal()"><i class="fas fa-times"></i></button><img>';
|
||||
modal.addEventListener('click', function (e) { if (e.target === modal) closePhotoModal(); });
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
modal.querySelector('img').src = src;
|
||||
modal.classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closePhotoModal() {
|
||||
var modal = document.getElementById('photoModal');
|
||||
if (modal) {
|
||||
modal.classList.remove('open');
|
||||
if (!_activeSheets.length) document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Auth Helper (authManager 위임 + 루프 방지) ===== */
|
||||
var _mRedirectKey = '_sso_redirect_ts';
|
||||
var _mRedirectCooldown = 5000; // 5초 내 재리다이렉트 방지
|
||||
|
||||
function _mSafeRedirectToLogin() {
|
||||
var last = parseInt(sessionStorage.getItem(_mRedirectKey) || '0', 10);
|
||||
if (Date.now() - last < _mRedirectCooldown) {
|
||||
console.warn('[TKQC-M] 리다이렉트 루프 감지 — 로그인 페이지로 이동하지 않음');
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(_mRedirectKey, String(Date.now()));
|
||||
window.location.href = _getLoginUrl();
|
||||
}
|
||||
|
||||
async function mCheckAuth() {
|
||||
// authManager가 있으면 위임 (SW 정리 + 캐시 관리 포함)
|
||||
if (window.authManager && typeof window.authManager.checkAuth === 'function') {
|
||||
var user = await window.authManager.checkAuth();
|
||||
if (user) {
|
||||
sessionStorage.removeItem(_mRedirectKey);
|
||||
return user;
|
||||
}
|
||||
_mSafeRedirectToLogin();
|
||||
return null;
|
||||
}
|
||||
// 폴백: authManager 없는 경우
|
||||
var token = TokenManager.getToken();
|
||||
if (!token) {
|
||||
_mSafeRedirectToLogin();
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
var user = await AuthAPI.getCurrentUser();
|
||||
sessionStorage.removeItem(_mRedirectKey);
|
||||
return user;
|
||||
} catch (e) {
|
||||
TokenManager.removeToken();
|
||||
TokenManager.removeUser();
|
||||
_mSafeRedirectToLogin();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Loading Overlay ===== */
|
||||
function hideLoading() {
|
||||
var el = document.getElementById('loadingOverlay');
|
||||
if (el) { el.classList.add('hide'); setTimeout(function () { el.remove(); }, 300); }
|
||||
}
|
||||
|
||||
/* ===== Helpers ===== */
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function getPhotoPaths(issue) {
|
||||
return [issue.photo_path, issue.photo_path2, issue.photo_path3, issue.photo_path4, issue.photo_path5].filter(Boolean);
|
||||
}
|
||||
|
||||
function getCompletionPhotoPaths(issue) {
|
||||
return [issue.completion_photo_path, issue.completion_photo_path2, issue.completion_photo_path3, issue.completion_photo_path4, issue.completion_photo_path5].filter(Boolean);
|
||||
}
|
||||
|
||||
function renderPhotoThumbs(photos) {
|
||||
if (!photos || !photos.length) return '';
|
||||
return '<div class="m-photo-row">' + photos.map(function (p, i) {
|
||||
return '<img src="' + p + '" class="m-photo-thumb" onclick="openPhotoModal(\'' + p + '\')" alt="사진 ' + (i + 1) + '">';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
Reference in New Issue
Block a user