'use strict'; // ============================================================================ // 관리자 작업 기록 (v1.6.31, 요구 21) — 누가·언제·무엇을(대상 사용자/사이트) 바꿨는지. 비밀값은 절대 넣지 않는다. // // 기록 대상: 설정 변경, 계정 생성·삭제·승인·역할, 비밀번호 재설정(값 없음), API 키·접속 정보 저장/삭제(종류만), 메일 계정 생성·삭제, // 비용 발생 작업(캐시 충전·차감·도메인 구매), 배포·발행 취소, DNS 변경(lib/dnszone 도 같은 파일에 남긴다). // 저장: data/audit.json (최근 5000건, 원자적 쓰기). 조회: 관리자 콘솔 › 작업 기록 (사용자·사이트·종류·기간 필터). // redact(): 기록에 들어갈 값에서 비밀로 보이는 키(password, secret, token, apiKey, key …)를 지운다. // ============================================================================ const path = require('path'); const js = require('./jsonstore'); const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data'); const FILE = path.join(DATA_ROOT, 'audit.json'); const MAX = 5000; const SECRET_KEY = /pass(word|hash)?|secret|token|api[_-]?key|private|credential|authorization|cookie|salt|oauth/i; /** 비밀로 보이는 값 지우기 (깊이 4까지). 문자열이면 비밀 형태(sk-…, re_…)를 가린다 */ function redact(v, depth = 0) { if (v == null) return v; if (typeof v === 'string') return v.replace(/\b(sk-[A-Za-z0-9_-]{8,}|re_[A-Za-z0-9_-]{8,}|wmk_[a-f0-9]{8,}|wmb_[a-f0-9]{8,}|AKIA[A-Z0-9]{12,})\b/g, (m) => m.slice(0, 4) + '…').slice(0, 500); if (Array.isArray(v)) return depth > 4 ? '[…]' : v.slice(0, 50).map((x) => redact(x, depth + 1)); if (typeof v === 'object') { if (depth > 4) return '{…}'; const o = {}; for (const [k, x] of Object.entries(v)) o[k] = SECRET_KEY.test(k) ? (x ? '(비밀값 생략)' : x) : redact(x, depth + 1); return o; } return v; } /** * 기록 남기기. * @param {{by:string, action:string, target?:string, targetType?:'user'|'site'|'setting'|'vault'|'domain'|'mail'|'deploy'|'credit'|'dns', siteId?:string, userId?:string, detail?:any, ok?:boolean, ip?:string}} e */ function log(e) { try { const d = js.readJson(FILE, [], {}); const list = Array.isArray(d) ? d : []; list.push({ at: new Date().toISOString(), by: e.by || 'system', action: String(e.action || '').slice(0, 60), targetType: e.targetType || null, target: e.target != null ? String(e.target).slice(0, 120) : null, siteId: e.siteId || null, userId: e.userId || null, ok: e.ok !== false, ip: e.ip || null, detail: redact(e.detail) }); if (list.length > MAX) list.splice(0, list.length - MAX); js.writeJsonAtomic(FILE, list, { mode: 0o600 }); } catch (_) { /* 기록 실패가 본 작업을 막지 않게 */ } } /** 조회 (최신순). filter: {userId, siteId, action, since, q, limit} */ function list(filter = {}) { const d = js.readJson(FILE, [], {}); let out = Array.isArray(d) ? d.slice().reverse() : []; if (filter.userId) out = out.filter((x) => x.userId === filter.userId || x.by === filter.userId || x.target === filter.userId); if (filter.siteId) out = out.filter((x) => x.siteId === filter.siteId || x.target === filter.siteId); if (filter.action) out = out.filter((x) => String(x.action).startsWith(filter.action)); if (filter.since) { const t = Date.parse(filter.since); if (t) out = out.filter((x) => Date.parse(x.at) >= t); } if (filter.q) { const q = String(filter.q).toLowerCase(); out = out.filter((x) => JSON.stringify(x).toLowerCase().includes(q)); } return out.slice(0, Math.min(1000, filter.limit || 200)); } module.exports = { log, list, redact, FILE };