Files

88 lines
6.8 KiB
JavaScript
Raw Permalink Normal View History

#!/usr/bin/env node
/**
* 웹메이커(WebHosting_AI_agent, 이하 webmaker) 데이터 디렉터리를 읽기 전용으로 읽어 "계정 스냅샷" 하나로 만듭니다(1.0.8, 요구사항 1).
*
* 이 파일은 두 가지로 쓰입니다.
* 1) 모듈: server/webmaker.mjs 가 readSnapshotSync(dir) 를 불러 같은 프로세스에서 바로 읽습니다(파일을 읽을 권한이 있을 때, 시험에서도 이 길).
* 2) 명령: `node webmaker-snapshot.mjs <DATA_ROOT>` 가 스냅샷을 JSON 으로 표준 출력에 씁니다. webmaker 의 데이터는 보통 그 서비스 계정(예: master)만
* 읽을 수 있는 0600/0700 이라, AriMail 실행 계정은 sudo 로 그 계정이 되어 이 명령을 부릅니다(deploy/webmaker-access.sh 가 root 소유 사본과 sudoers 규칙을 설치).
* 그래서 이 파일은 다른 모듈을 import 하지 않고 node 내장 모듈만 씁니다(root 소유 사본 하나로 혼자 돌아가야 함).
*
* webmaker 의 저장 형식(그 저장소 lib/auth.js · lib/store.js · lib/modules.js · lib/domains.js 기준)
* - <DATA_ROOT>/users.json : { <아이디>: { id, name, salt, passHash, role:'admin'|'user', accountType, … } } — 플랫폼 회원. 비밀번호는 scryptSync(비밀번호, salt, 64) 의 16진수.
* - <DATA_ROOT>/sites/<32hex>.json : { id, ownerId, title, domains:[…], webmail:{ enabled, domain }, … } — 사이트. domains 는 사이트에 연결한 도메인 목록.
* - <DATA_ROOT>/modules/<32hex>.json : { members:[ { id, loginId?, email?, name, role:'admin'|'member', approved, salt, hash, mail, mailAddress } ], … } — 사이트 회원.
* loginId 가 있는 회원이 "사이트 관리자 계정"(사이트 관리자 페이지 › 계정 추가)이고, 비밀번호는 같은 scrypt 형식(hash 필드).
* - <DATA_ROOT>/domains.json : { domains:[ { name, type:'subdomain'|'registered', siteId, userId, … } ] } — 서브 도메인·구입 도메인 등록부(사이트 연결 정보).
*
* 스냅샷에는 인증에 필요한 값(salt·해시)이 들어 있으므로 파일로 남기지 않고 메모리에서만 씁니다. 게시글·문의 같은 다른 자료는 읽지 않습니다.
*/
import { readFileSync, readdirSync, realpathSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
export const SNAPSHOT_FORMAT = 1;
const ID_RE = /^[a-f0-9]{32}$/;
const str = (v, max = 200) => String(v ?? '').trim().slice(0, max);
/**
* JSON 파일 읽기. 파일이 없으면 null(아직 만들지 않은 것), 그 밖의 실패(권한 없음, 쓰는 중이라 반쪽인 JSON 등)는 예외입니다.
* 왜 구분하나: 깨진 파일을 "회원 없음" 으로 읽으면 그 순간 모든 웹메이커 사용자의 세션이 끊깁니다. 예외로 두면 webmaker.mjs 가 마지막 정상 스냅샷으로 버팁니다.
*/
function readJson(file) {
let text; try { text = readFileSync(file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return null; throw new Error(`${e.code || 'read_error'}: ${file}`); }
try { return JSON.parse(text); } catch { throw new Error(`invalid_json: ${file}`); }
}
/** 디렉터리의 <32hex>.json 파일 이름(확장자 뺀 id)들. 디렉터리가 없으면 빈 목록. */
function idsIn(dir) {
try { return readdirSync(dir).filter((f) => f.endsWith('.json') && ID_RE.test(f.slice(0, -5))).map((f) => f.slice(0, -5)); } catch { return []; }
}
/**
* webmaker 데이터 디렉터리를 읽어 스냅샷을 만듭니다.
* @param {string} dir webmaker 의 DATA_ROOT(예: /data/webmaker)
* @returns {{format:number, readAt:string, dir:string, users:object, sites:object, members:object, domainSites:object}}
* users : 아이디 → { id, name, role, salt, passHash, mustChangePassword }
* sites : 사이트 id → { id, ownerId, title, domains:string[], webmailDomain:string, webmailEnabled:boolean }
* members : 사이트 id → [ { id, loginId, email, name, role, approved, salt, hash, mail, mailAddress } ] (loginId 또는 email 이 있는 회원만)
* domainSites : 도메인 이름 → 사이트 id (domains.json 에서 사이트에 연결된 것만)
*/
export function readSnapshotSync(dir) {
const root = String(dir || '').trim(); if (!root) throw new Error('webmaker_dir_missing');
const users = {};
const rawUsers = readJson(join(root, 'users.json'));
if (rawUsers && typeof rawUsers === 'object') {
for (const [id, u] of Object.entries(rawUsers)) {
if (!u || typeof u !== 'object' || !u.salt || !u.passHash) continue;
const key = str(u.id || id, 64).toLowerCase();
users[key] = { id: key, name: str(u.name, 80), role: u.role === 'admin' ? 'admin' : 'user', salt: String(u.salt), passHash: String(u.passHash), mustChangePassword: !!u.mustChangePassword };
}
}
const sites = {}, members = {};
for (const id of idsIn(join(root, 'sites'))) {
const s = readJson(join(root, 'sites', id + '.json')); if (!s || typeof s !== 'object') continue;
const domains = (Array.isArray(s.domains) ? s.domains : []).map((d) => str(d, 253).toLowerCase().replace(/^www\./, '')).filter(Boolean);
sites[id] = { id, ownerId: str(s.ownerId, 64).toLowerCase(), title: str(s.title, 120), domains, webmailDomain: str(s.webmail?.domain, 253).toLowerCase(), webmailEnabled: !!(s.webmail && s.webmail.enabled) };
const m = readJson(join(root, 'modules', id + '.json'));
const list = m && Array.isArray(m.members) ? m.members : [];
members[id] = list.filter((x) => x && typeof x === 'object' && (x.loginId || x.email) && x.salt && x.hash).map((x) => ({
id: str(x.id, 64), loginId: str(x.loginId, 64).toLowerCase(), email: str(x.email, 200).toLowerCase(), name: str(x.name, 80), role: x.role === 'admin' ? 'admin' : 'member',
approved: x.approved !== false, salt: String(x.salt), hash: String(x.hash), mail: !!x.mail, mailAddress: str(x.mailAddress, 253).toLowerCase(),
}));
}
const domainSites = {};
const rawDomains = readJson(join(root, 'domains.json'));
for (const d of Array.isArray(rawDomains?.domains) ? rawDomains.domains : []) {
const name = str(d?.name, 253).toLowerCase(), siteId = str(d?.siteId, 32);
if (name && ID_RE.test(siteId)) domainSites[name] = siteId;
}
return { format: SNAPSHOT_FORMAT, readAt: new Date().toISOString(), dir: root, users, sites, members, domainSites };
}
// 명령줄로 직접 실행했을 때(sudo 도우미): 첫 인수가 데이터 디렉터리. 실패하면 종료 코드 2 와 오류 한 줄(표준 오류).
const samePath = (a, b) => { try { return realpathSync(a) === realpathSync(b); } catch { return a === b; } };
if (process.argv[1] && samePath(fileURLToPath(import.meta.url), process.argv[1])) {
try { process.stdout.write(JSON.stringify(readSnapshotSync(process.argv[2]))); }
catch (e) { process.stderr.write(`webmaker-snapshot: ${e.message}\n`); process.exit(2); }
}