#!/usr/bin/env node /** * 웹메이커(WebHosting_AI_agent, 이하 webmaker) 데이터 디렉터리를 읽기 전용으로 읽어 "계정 스냅샷" 하나로 만듭니다(1.0.8, 요구사항 1). * * 이 파일은 두 가지로 쓰입니다. * 1) 모듈: server/webmaker.mjs 가 readSnapshotSync(dir) 를 불러 같은 프로세스에서 바로 읽습니다(파일을 읽을 권한이 있을 때, 시험에서도 이 길). * 2) 명령: `node webmaker-snapshot.mjs ` 가 스냅샷을 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 기준) * - /users.json : { <아이디>: { id, name, salt, passHash, role:'admin'|'user', accountType, … } } — 플랫폼 회원. 비밀번호는 scryptSync(비밀번호, salt, 64) 의 16진수. * - /sites/<32hex>.json : { id, ownerId, title, domains:[…], webmail:{ enabled, domain }, … } — 사이트. domains 는 사이트에 연결한 도메인 목록. * - /modules/<32hex>.json : { members:[ { id, loginId?, email?, name, role:'admin'|'member', approved, salt, hash, mail, mailAddress } ], … } — 사이트 회원. * loginId 가 있는 회원이 "사이트 관리자 계정"(사이트 관리자 페이지 › 계정 추가)이고, 비밀번호는 같은 scrypt 형식(hash 필드). * - /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); } }