47 lines
3.6 KiB
JavaScript
47 lines
3.6 KiB
JavaScript
'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 };
|