Ariweb Suite 0.1.1 기반: 공통 라이브러리(db·http·세션·작업 엔진·할당/예약·알림·감사·보관소), 포털 뼈대, Webmaker/AriMail 도입 소스와 manifest

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:02:58 +00:00
co-authored by Claude Fable 5.1
parent 9ca4cee341
commit c7aea8d419
295 changed files with 90146 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
'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 };
+214
View File
@@ -0,0 +1,214 @@
'use strict';
// 회원(id, 비밀번호, 이름/회사명) 저장과 세션 쿠키 처리. 의존성 없음.
// v1.3.17 보안 점검(reports/code-review-2026-10-07.md) 반영:
// S01 세션 값은 JSON(아이디·만료·세대)을 서명한 형식으로 바꿔, 점(.)이 든 아이디로 다른 계정이 되는 문제를 막았다.
// S05 비밀번호를 바꾸거나 재설정하면 사용자별 세션 세대(sessionGen)가 올라가 그 전에 발급한 세션이 모두 무효가 된다.
// 비밀번호 규칙은 lib/passpolicy.js (관리자 콘솔에서 설정, 기본은 기존 규칙 "6자 이상").
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const passpolicy = require('./passpolicy');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const USERS_FILE = path.join(DATA_ROOT, 'users.json');
const SECRET_FILE = path.join(DATA_ROOT, 'session.secret');
const COOKIE = 'wh_session';
const SESSION_DAYS = 30;
const ID_RE = /^[a-z0-9][a-z0-9._-]{2,31}$/;
fs.mkdirSync(DATA_ROOT, { recursive: true });
let SECRET;
try { SECRET = fs.readFileSync(SECRET_FILE, 'utf8').trim(); } catch (_) { SECRET = ''; }
if (!SECRET) { SECRET = crypto.randomBytes(32).toString('hex'); fs.writeFileSync(SECRET_FILE, SECRET, { mode: 0o600 }); }
// v1.6.31 (요구 10): users.json 이 깨졌으면 "사용자 없음" 으로 보지 않는다(그러면 기본 관리자 생성·가입 중복 같은 2차 사고가 난다) — 예외로 멈추고 관리자 확인
const js = require('./jsonstore');
function loadUsers() { const d = js.readJson(USERS_FILE, null, { strict: true }); return d && typeof d === 'object' ? d : {}; }
function saveUsers(u) { js.writeJsonAtomic(USERS_FILE, JSON.stringify(u, null, 2), { mode: 0o600, keepBackup: true }); }
function hashPassword(pw, salt) { return crypto.scryptSync(pw, salt, 64).toString('hex'); }
function httpError(msg, status = 400) { return Object.assign(new Error(msg), { status }); }
const ADMIN_USERS = new Set((process.env.ADMIN_USERS || '').split(',').map((x) => x.trim().toLowerCase()).filter(Boolean));
function roleOf(u) { return u.role === 'admin' || ADMIN_USERS.has(u.id) ? 'admin' : 'user'; }
// v1.4.0 accountType: 'subdomain'(플랫폼 서브 도메인만) | 'domain'(자기 도메인 구매·관리). 기록이 없는 1.4.0 이전 가입자는 'domain' 으로 본다(lib/domains.accountTypeOf)
// v1.5.11 mustChangePassword: 기본 관리자(admin/admin)처럼 "알려진 비밀번호" 로 만든 계정 — 로그인 뒤 다른 일을 하기 전에 비밀번호를 바꿔야 한다(server.js 가 막는다)
// v1.5.27 (요구 6) pendingApproval: 관리자 승인 방식으로 가입해 아직 승인되지 않은 계정 — 로그인이 403 으로 막힌다. approvedAt/approvedBy 는 승인 기록
function publicUser(u) { return { id: u.id, name: u.name, role: roleOf(u), createdAt: u.createdAt, accountType: u.accountType || null, mustChangePassword: !!u.mustChangePassword, lastLoginAt: u.lastLoginAt || null, pendingApproval: !!u.pendingApproval, approvedAt: u.approvedAt || null }; }
// ============================================================================
// v1.5.11 (요구 1): 기본 관리자 계정 admin / admin
//
// 처음 설치한 서버에는 관리자 계정이 없었다(관리자는 scripts/admin.js 나 콘솔에서 따로 만들어야 했고, 설치 안내에도 없었다).
// 그래서 설치 직후 회원가입이 막혀 있으면(웹서버 규칙·설정) 아무도 들어갈 수 없었고, 운영자가 당연히 있을 거라 여긴 admin/admin 도 없었다.
// 이제 "사용자가 한 명도 없는" 서버가 기동하면 admin / admin 을 만든다(ensureBootstrapAdmin). 비밀번호 admin 은 규칙(6자 이상)에
// 맞지 않지만 일부러 그대로 두고, 대신 mustChangePassword 표시를 붙여 첫 로그인 뒤 비밀번호를 바꾸기 전에는 아무 것도 못 하게 한다.
// 이미 사용자가 있는 서버(운영 중)에는 자동으로 만들지 않는다 — 누군가 admin/admin 으로 들어올 수 있는 구멍이 되기 때문.
// 운영자가 다른 방식으로 설치해 관리자 비밀번호를 모르는 서버(관리자는 있으나 한 번도 로그인한 적이 없음)에서는
// 초기 설정 페이지(접속 토큰)나 `node scripts/admin.js bootstrap` 으로 같은 계정을 만들 수 있다(bootstrapAdmin).
// ============================================================================
const BOOTSTRAP_ID = 'admin';
const BOOTSTRAP_PASSWORD = 'admin';
/**
* admin / admin 계정을 만든다(이미 있으면 그대로). 비밀번호 규칙을 거치지 않고 바로 저장하며 mustChangePassword 를 켠다.
* @param {string} by 기록용 출처 (boot | setup | cli)
* @returns {{created:boolean, user:object}}
*/
function bootstrapAdmin(by = 'boot') {
const users = loadUsers();
if (users[BOOTSTRAP_ID]) return { created: false, user: publicUser(users[BOOTSTRAP_ID]) };
const salt = crypto.randomBytes(16).toString('hex');
// v1.6.31: 비밀번호는 설치마다 무작위(1회 표시) — 고정값 admin 은 쓰지 않는다. 첫 로그인 뒤 변경을 강제한다
const oneTime = crypto.randomBytes(9).toString('base64url');
users[BOOTSTRAP_ID] = { id: BOOTSTRAP_ID, name: '관리자', salt, passHash: hashPassword(oneTime, salt), role: 'admin', accountType: 'domain', mustChangePassword: true, bootstrappedAt: new Date().toISOString(), bootstrappedBy: by, createdAt: new Date().toISOString() };
saveUsers(users);
return { created: true, user: publicUser(users[BOOTSTRAP_ID]), password: oneTime };
}
/** 기동 때: 사용자가 한 명도 없으면 기본 관리자를 만든다. 결과 메시지(로그용) 또는 null */
/**
* v1.6.31 (요구 6, 검토 S5): 기동 때 admin/admin 을 만들던 동작을 없앴다. 사용자가 없는 새 설치는 초기 설정 페이지(접속 토큰)에서 "최초 관리자 만들기" 로
* 운영자가 아이디·비밀번호를 정한다. 이 함수는 호환을 위해 남겨 두되 아무 것도 만들지 않고 안내만 돌려준다.
*/
function ensureBootstrapAdmin() {
if (Object.keys(loadUsers()).length) return null;
return '사용자가 없습니다 — 초기 설정 페이지(설치 스크립트가 알려 준 접속 토큰 주소)에서 최초 관리자 계정을 만드세요 (v1.6.31: 고정 비밀번호 admin/admin 은 더 이상 만들지 않습니다)';
}
function _legacyEnsureBootstrapAdmin() {
const users = loadUsers();
if (Object.keys(users).length) return null;
const r = bootstrapAdmin('boot');
return r.created ? `사용자가 없어 기본 관리자 계정을 만들었습니다: ${BOOTSTRAP_ID} / ${BOOTSTRAP_PASSWORD} (첫 로그인 뒤 비밀번호를 바꿔야 합니다)` : null;
}
/**
* 관리자 계정 상태 (초기 설정 페이지·설치 점검표용).
* admins : 관리자 목록(아이디·마지막 로그인·비밀번호 변경 필요 여부)
* usable : 비밀번호가 기본값이 아닌 관리자가 하나라도 있나 (= 운영 가능한 관리자가 있나)
* defaultOpen : 기본 비밀번호(admin) 그대로인 관리자가 남아 있나 → 점검표에서 "바꾸세요"
* anyLogin : 관리자 중 한 번이라도 로그인한 계정이 있나 (운영자가 비밀번호를 아는 계정이 있다는 뜻)
*/
function adminState() {
const list = Object.values(loadUsers()).filter((u) => roleOf(u) === 'admin').map(publicUser);
return { admins: list, usable: list.some((u) => !u.mustChangePassword), defaultOpen: list.some((u) => u.mustChangePassword), anyLogin: list.some((u) => !!u.lastLoginAt), hasBootstrap: list.some((u) => u.id === BOOTSTRAP_ID), total: Object.keys(loadUsers()).length };
}
/**
* 회원가입. v1.5.27 (요구 6): opts.pending 이면 승인 대기 계정으로 만든다(관리자 승인 전까지 로그인 불가). 호출자(server.js)가 설정 signupApproval 을 보고 정한다.
*/
function signup({ id, password, name }, opts = {}) {
id = String(id || '').trim().toLowerCase(); name = String(name || '').trim(); password = String(password || '');
if (!ID_RE.test(id)) throw httpError('아이디는 영문 소문자/숫자로 시작하는 3~32자(영문, 숫자, . _ -)여야 합니다');
passpolicy.check(password, { id });
if (!name || name.length > 80) throw httpError('이름(회사명)을 입력하세요 (80자 이내)');
const users = loadUsers();
if (users[id]) throw httpError('이미 사용 중인 아이디입니다', 409);
const salt = crypto.randomBytes(16).toString('hex');
// v1.4.0 새 가입자의 계정 종류 (관리자 설정). 플랫폼 도메인이 없으면(서비스 주소가 IP 등) 서브 도메인을 쓸 수 없으므로 자체 도메인으로 둔다
let accountType = 'subdomain'; try { const dc = require('./domains').config(); accountType = dc.defaultAccountType === 'domain' || !dc.platformDomain ? 'domain' : 'subdomain'; } catch (_) {}
users[id] = { id, name, salt, passHash: hashPassword(password, salt), role: 'user', accountType, createdAt: new Date().toISOString(), ...(opts.pending ? { pendingApproval: true } : {}) };
saveUsers(users);
return publicUser(users[id]);
}
/** 관리자 생성/승격 등 내부용: 역할을 지정해 계정을 만든다(이미 있으면 역할만 갱신) */
function createUser({ id, password, name, role = 'user' }) {
const users = loadUsers(); id = String(id || '').trim().toLowerCase();
if (!users[id]) { signup({ id, password, name }); }
const u = loadUsers(); u[id].role = role; if (password && users[id]) { passpolicy.check(password, { id }); u[id].salt = crypto.randomBytes(16).toString('hex'); u[id].passHash = hashPassword(String(password), u[id].salt); u[id].sessionGen = (u[id].sessionGen || 0) + 1; }
saveUsers(u); return publicUser(u[id]);
}
/** 본인 비밀번호 변경 (현재 비밀번호 확인) */
function changePassword(id, current, next) {
const users = loadUsers(); const u = users[id]; if (!u) throw httpError('사용자가 없습니다', 404);
const h = Buffer.from(hashPassword(String(current || ''), u.salt)); const e = Buffer.from(u.passHash);
if (h.length !== e.length || !crypto.timingSafeEqual(h, e)) throw httpError('현재 비밀번호가 올바르지 않습니다', 400);
passpolicy.check(next, { id, label: '새 비밀번호' });
// v1.5.11: 기본 관리자(admin/admin)가 새 비밀번호를 "admin" 으로 다시 두는 것은 막는다 (규칙상 6자 미만이라 어차피 걸리지만 규칙을 느슨하게 바꾼 경우 대비)
if (u.mustChangePassword && String(next) === BOOTSTRAP_PASSWORD) throw httpError('기본 비밀번호(admin)는 다시 쓸 수 없습니다. 다른 비밀번호를 정하세요', 400);
// S05: 세션 세대를 올려 다른 곳에 남아 있는 세션을 끊는다 (본인 화면은 server.js 가 새 세션 쿠키를 다시 준다)
u.salt = crypto.randomBytes(16).toString('hex'); u.passHash = hashPassword(String(next), u.salt); u.passwordChangedAt = new Date().toISOString(); u.sessionGen = (u.sessionGen || 0) + 1; delete u.mustChangePassword; saveUsers(users); return publicUser(u);
}
/** 관리자용 비밀번호 재설정 (v1.5.11: 초기 설정 페이지의 "관리자 비밀번호 재설정" 도 이것을 쓴다 — 재설정하면 기본 비밀번호 표시가 풀린다) */
function resetPassword(id, next) {
const users = loadUsers(); const u = users[id]; if (!u) throw httpError('사용자가 없습니다', 404);
passpolicy.check(next, { id, label: '새 비밀번호' });
if (String(next) === BOOTSTRAP_PASSWORD) throw httpError('기본 비밀번호(admin)는 쓸 수 없습니다. 다른 비밀번호를 정하세요', 400);
u.salt = crypto.randomBytes(16).toString('hex'); u.passHash = hashPassword(String(next), u.salt); u.passwordChangedAt = new Date().toISOString(); u.sessionGen = (u.sessionGen || 0) + 1; delete u.mustChangePassword; saveUsers(users); return publicUser(u);
}
/** v1.4.0 관리자: 계정 종류 바꾸기 */
function setAccountType(id, type) { const users = loadUsers(); if (!users[id]) throw httpError('사용자가 없습니다', 404); if (!['subdomain', 'domain'].includes(type)) throw httpError('계정 종류는 subdomain(서브 도메인) 또는 domain(자기 도메인) 입니다'); users[id].accountType = type; saveUsers(users); return publicUser(users[id]); }
/** v1.5.27 (요구 6) 가입 승인: 대기 표시를 지우고 승인 기록을 남긴다. 거절(reject)은 계정을 지운다 */
function approve(id, by) { const users = loadUsers(); const u = users[id]; if (!u) throw httpError('사용자가 없습니다', 404); if (!u.pendingApproval) return publicUser(u); delete u.pendingApproval; u.approvedAt = new Date().toISOString(); u.approvedBy = by || null; saveUsers(users); return publicUser(u); }
function reject(id) { const users = loadUsers(); const u = users[id]; if (!u) throw httpError('사용자가 없습니다', 404); if (!u.pendingApproval) throw httpError('승인 대기 계정이 아닙니다', 400); delete users[id]; saveUsers(users); return { ok: true, id }; }
function pendingCount() { return Object.values(loadUsers()).filter((u) => u.pendingApproval).length; }
function setRole(id, role) { const users = loadUsers(); if (!users[id]) throw httpError('사용자가 없습니다', 404); users[id].role = role === 'admin' ? 'admin' : 'user'; saveUsers(users); return publicUser(users[id]); }
function listUsers() { return Object.values(loadUsers()).map(publicUser).sort((a, b) => a.createdAt.localeCompare(b.createdAt)); }
function login({ id, password }) {
id = String(id || '').trim().toLowerCase(); password = String(password || '');
const u = loadUsers()[id];
if (!u) throw httpError('아이디 또는 비밀번호가 올바르지 않습니다', 401);
const h = Buffer.from(hashPassword(password, u.salt)); const e = Buffer.from(u.passHash);
if (h.length !== e.length || !crypto.timingSafeEqual(h, e)) throw httpError('아이디 또는 비밀번호가 올바르지 않습니다', 401);
// v1.5.27 (요구 6): 관리자 승인 대기 계정은 비밀번호가 맞아도 들어올 수 없다 (승인되면 pendingApproval 이 지워진다)
if (u.pendingApproval) throw Object.assign(httpError('가입 신청이 접수되었습니다. 관리자가 승인하면 로그인할 수 있습니다', 403), { pendingApproval: true });
// v1.5.11: 마지막 로그인 시각 — 설치 점검표가 "운영자가 비밀번호를 아는 관리자 계정이 있는지" 판단하는 데 쓴다
try { const users = loadUsers(); if (users[id]) { users[id].lastLoginAt = new Date().toISOString(); saveUsers(users); } } catch (_) {}
return publicUser(u);
}
function getUser(id) { const u = loadUsers()[id]; return u ? publicUser(u) : null; }
/**
* v1.7.3 추가(운영자 요청): 비밀번호 "지문" — 저장된 해시(salt+passHash)의 sha256 앞 24자. 비밀번호가 바뀌면 달라지고, 이 값으로는 로그인할 수 없다.
* 메일 서버(Arimail) 관리자 비밀번호 동기화(lib/mailsys.syncServiceAdmin)가 "마지막으로 맞춘 비밀번호와 같은지" 를 판단하는 데 쓴다.
*/
function passwordFingerprint(id) { const u = loadUsers()[String(id || '').toLowerCase()]; if (!u || !u.passHash) return null; return crypto.createHash('sha256').update(`${u.salt || ''}:${u.passHash}`).digest('hex').slice(0, 24); }
// ---- 세션 쿠키 (v1.3.17): base64url(JSON {u: 아이디, e: 만료 ms, g: 세션 세대}) + '.' + HMAC ----
// 1.3.16 까지는 "아이디.만료" 를 점으로 나눠 읽어, 점이 든 아이디(victim.probe)는 앞부분을 다른 사용자로, 뒷부분을 만료로 읽었고
// 만료가 숫자가 아니면(NaN) 비교가 통과했다(S01). 이제 구조화된 값만 받고, 만료는 유한한 미래 시각이어야 하며,
// 세대가 사용자 레코드의 sessionGen 과 같아야 한다(S05). 옛 형식 쿠키는 모두 무효 → 다시 로그인.
function sign(payload) { return crypto.createHmac('sha256', SECRET).update(payload).digest('base64url'); }
/** v1.6.31 (검토 S6): HTTPS 로 들어온 요청(또는 공개 주소가 https)이면 Secure 를 붙여 평문 HTTP 로 쿠키가 새지 않게 한다 */
function makeSessionCookie(userId, opts = {}) {
const exp = Date.now() + SESSION_DAYS * 86400000; const u = loadUsers()[userId];
const payload = Buffer.from(JSON.stringify({ v: 2, u: userId, e: exp, g: (u && u.sessionGen) || 0 })).toString('base64url');
const v = `${payload}.${sign(payload)}`;
return `${COOKIE}=${v}; Path=/; HttpOnly; SameSite=Lax${opts.secure ? '; Secure' : ''}; Max-Age=${SESSION_DAYS * 86400}`;
}
function clearSessionCookie() { return `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`; }
/**
* v1.6.31 (요구 4): CSRF 토큰 — 세션(아이디·세대)에 묶인 HMAC. 빌더·관리자 화면의 fetch 가 X-WM-CSRF 헤더로 보낸다.
* 생성 사이트 페이지(같은 origin)는 이 값을 얻을 수 없다(토큰을 주는 API 는 출처가 빌더 화면일 때만 응답) → Referer 를 없애도 세션을 쓸 수 없다.
*/
function csrfToken(user) { const u = loadUsers()[user.id]; return crypto.createHmac('sha256', SECRET).update(`csrf|${user.id}|${(u && u.sessionGen) || 0}`).digest('base64url'); }
function csrfOk(user, token) { if (!user || !token) return false; const a = Buffer.from(csrfToken(user)); const b = Buffer.from(String(token)); return a.length === b.length && crypto.timingSafeEqual(a, b); }
function parseCookies(req) {
const out = {};
for (const part of (req.headers.cookie || '').split(';')) { const i = part.indexOf('='); if (i > 0) out[part.slice(0, i).trim()] = part.slice(i + 1).trim(); }
return out;
}
/** 요청의 세션 쿠키를 검증해 사용자(공개 정보)를 돌려준다. 없거나 잘못되면 null. */
function userFromRequest(req) {
const v = parseCookies(req)[COOKIE];
if (!v) return null;
const parts = v.split('.'); if (parts.length !== 2) return null; // 서명 값(base64url)에는 점이 없다 → 정확히 두 조각
const [payload, sig] = parts; if (!payload || !sig) return null;
const a = Buffer.from(sign(payload)); const b = Buffer.from(sig);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
let d; try { d = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); } catch (_) { return null; }
if (!d || d.v !== 2 || typeof d.u !== 'string' || !Number.isFinite(d.e) || d.e < Date.now()) return null;
const u = loadUsers()[d.u]; if (!u) return null;
if ((u.sessionGen || 0) !== (Number(d.g) || 0)) return null; // 비밀번호 변경·재설정 뒤의 옛 세션
return publicUser(u);
}
/** 프로필 변경(이름/회사명). 사이트의 ownerName 은 다음 저장 때 갱신되지 않으므로 표시용으로만 쓴다 */
function updateProfile(id, patch) { const users = loadUsers(); const u = users[id]; if (!u) throw Object.assign(new Error('사용자가 없습니다'), { status: 404 }); if (patch.name) u.name = String(patch.name).trim().slice(0, 80); saveUsers(users); return publicUser(u); }
/**
* v1.3.16 사용자의 화면 언어 선택 저장. at 은 고른 시각 — 관리자가 서비스 기본 언어를 "강제" 한 시각(settings.uiLang.forcedAt)보다
* 나중에 고른 경우에만 그 사용자 선택이 이긴다(server.js effectiveUiLang).
*/
function setUiLang(id, lang) { const users = loadUsers(); const u = users[id]; if (!u) throw httpError('사용자가 없습니다', 404); u.uiLang = String(lang || '').slice(0, 20); u.uiLangAt = new Date().toISOString(); saveUsers(users); return { uiLang: u.uiLang, uiLangAt: u.uiLangAt }; }
/** 사용자의 화면 언어 선택 (없으면 null) */
function uiLangOf(id) { const u = id ? loadUsers()[id] : null; return u && u.uiLang ? { lang: u.uiLang, at: u.uiLangAt || '' } : null; }
module.exports = { passwordFingerprint, csrfToken, csrfOk, approve, reject, pendingCount, setAccountType, setUiLang, uiLangOf, parseCookies, updateProfile, signup, login, getUser, createUser, setRole, listUsers, changePassword, resetPassword, makeSessionCookie, clearSessionCookie, userFromRequest, bootstrapAdmin, ensureBootstrapAdmin, adminState, BOOTSTRAP_ID, BOOTSTRAP_PASSWORD }; // v1.5.11 기본 관리자·관리자 상태
+32
View File
@@ -0,0 +1,32 @@
'use strict';
// ============================================================================
// AWS Signature Version 4 서명 (v1.5.36, 요구 5) — Route 53 API 호출에 쓴다. 외부 SDK 없이 node:crypto 만 사용.
//
// 절차(공식 문서 "Signing AWS API requests"): 정규 요청(CanonicalRequest) → 서명 문자열(StringToSign) → 서명 키(kSecret→kDate→kRegion→kService→aws4_request) → HMAC-SHA256.
// Route 53 는 글로벌 서비스라 region 은 us-east-1, host 는 route53.amazonaws.com 이다. 임시 자격 증명(STS)을 쓰면 X-Amz-Security-Token 헤더를 함께 서명한다.
// ============================================================================
const crypto = require('crypto');
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
const hmac = (k, s) => crypto.createHmac('sha256', k).update(s).digest();
/** RFC 3986 방식 인코딩 (AWS 는 '*' 등도 인코딩해야 한다) */
const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
/**
* 서명 헤더 만들기.
* @param {{method:string, host:string, path:string, query?:object, headers?:object, body?:string, service:string, region:string, accessKeyId:string, secretAccessKey:string, sessionToken?:string, date?:Date}} o
* @returns {object} 요청에 붙일 헤더 (Host·X-Amz-Date·Authorization·[X-Amz-Security-Token]·기존 headers)
*/
function sign(o) {
const date = o.date || new Date(); const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, ''); const day = amzDate.slice(0, 8);
const headers = { host: o.host, 'x-amz-date': amzDate, ...(o.sessionToken ? { 'x-amz-security-token': o.sessionToken } : {}), ...Object.fromEntries(Object.entries(o.headers || {}).map(([k, v]) => [k.toLowerCase(), String(v).trim()])) };
const signedNames = Object.keys(headers).sort(); const canonHeaders = signedNames.map((k) => `${k}:${headers[k].replace(/\s+/g, ' ')}\n`).join('');
const canonQuery = Object.entries(o.query || {}).filter(([, v]) => v !== undefined && v !== null && v !== '').map(([k, v]) => [enc(k), enc(String(v))]).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : 1)).map(([k, v]) => `${k}=${v}`).join('&');
const canonPath = o.path.split('/').map((seg) => enc(decodeURIComponent(seg))).join('/') || '/';
const payloadHash = sha256(o.body || '');
const canonical = [o.method.toUpperCase(), canonPath, canonQuery, canonHeaders, signedNames.join(';'), payloadHash].join('\n');
const scope = `${day}/${o.region}/${o.service}/aws4_request`;
const toSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256(canonical)].join('\n');
const kSigning = hmac(hmac(hmac(hmac('AWS4' + o.secretAccessKey, day), o.region), o.service), 'aws4_request');
const signature = crypto.createHmac('sha256', kSigning).update(toSign).digest('hex');
return { ...headers, authorization: `AWS4-HMAC-SHA256 Credential=${o.accessKeyId}/${scope}, SignedHeaders=${signedNames.join(';')}, Signature=${signature}`, 'x-amz-content-sha256': payloadHash };
}
module.exports = { sign, sha256 };
+51
View File
@@ -0,0 +1,51 @@
'use strict';
// 모델별 사용량, 사용자별 원화 정산·청구(마진), 선결제 충전 관리. data/billing.json
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const settings = require('./settings');
const fx = require('./fx');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const FILE = path.join(DATA_ROOT, 'billing.json');
function load() { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (_) { return { charges: [] }; } }
function save(b) { fs.mkdirSync(DATA_ROOT, { recursive: true }); const tmp = FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(b, null, 1)); fs.renameSync(tmp, FILE); }
function addCharge({ userId, amountKrw, memo, by }) {
const b = load(); const c = { id: crypto.randomBytes(6).toString('hex'), userId, amountKrw: Math.round(+amountKrw), memo: String(memo || ''), by, at: new Date().toISOString() };
if (!c.userId || !Number.isFinite(c.amountKrw) || c.amountKrw === 0) throw Object.assign(new Error('사용자와 금액을 확인하세요'), { status: 400 });
b.charges.push(c); save(b); return c;
}
function removeCharge(id) { const b = load(); const i = b.charges.findIndex((c) => c.id === id); if (i < 0) throw Object.assign(new Error('충전 내역이 없습니다'), { status: 404 }); const [c] = b.charges.splice(i, 1); save(b); return c; }
/** 모든 사이트의 명령을 모델별·사용자별·일자별로 집계 (원화는 명령 실행일 환율 적용) */
async function summarize(sites, users) {
const s = settings.load(); const margin = (s.marginPct || 0) / 100;
const byModel = {}; const byUser = {}; const rateCache = {}; const rateErrors = [];
const rateFor = async (date) => { if (rateCache[date]) return rateCache[date]; try { rateCache[date] = await fx.getRate(date); } catch (e) { rateErrors.push(`${date}: ${e.message}`); rateCache[date] = { date, rate: 0, mid: 0, spreadPct: 0, source: '조회 실패' }; } return rateCache[date]; };
for (const site of sites) {
const uid = site.ownerId || '(소유자 없음)';
byUser[uid] = byUser[uid] || { userId: uid, sites: 0, commands: 0, tokens: 0, usd: 0, krw: 0, charge: 0, byDay: {} };
byUser[uid].sites += 1;
for (const c of site.commands || []) {
if (!c.usage) continue;
const model = c.model || (c.modelUsage && Object.keys(c.modelUsage)[0]) || 'claude-opus-5-5'; // 모델 기록 이전 명령은 당시 기본 모델로 집계
// v1.4.5: 모델별 토큰은 이번 실행분 modelUsage(costSource 가 있는 기록)만 쓴다 — 그 전 기록의 modelUsage 는 세션 누적이라 c.usage 로 센다
const mu = (c.costSource && c.modelUsage && c.modelUsage[String(model).replace(/^[a-z]+:/, '')]) || null;
const m = byModel[model] = byModel[model] || { model, calls: 0, input: 0, output: 0, cacheCreate: 0, cacheRead: 0, tokens: 0, usd: 0, estimatedCalls: 0 };
m.calls += 1; if (c.userKey) m.userKeyCalls = (m.userKeyCalls || 0) + 1; m.input += mu ? mu.inputTokens : c.usage.input; m.output += mu ? mu.outputTokens : c.usage.output; m.cacheCreate += mu ? mu.cacheCreationInputTokens : c.usage.cacheCreate; m.cacheRead += mu ? mu.cacheReadInputTokens : c.usage.cacheRead; m.tokens += c.usage.total; m.usd += c.costUsd || 0; if (c.usageEstimated) m.estimatedCalls += 1;
const day = fx.kstDate(c.startedAt || c.finishedAt || Date.now()); const r = await rateFor(day);
// v1.3.17 (B28): 작업이 끝날 때 확정된 금액(chargeKrw·chargeRate)이 있으면 그 값을 쓴다 — 지금 마진·환율로 다시 계산하면
// 지난 작업 금액이 설정 변경에 따라 바뀌어 캐시 원장과 맞지 않았다. 확정 값이 없는 옛 기록만 지금 기준으로 추정한다.
const fixed = Number.isFinite(c.chargeKrw); const rate = fixed && Number.isFinite(c.chargeRate) ? c.chargeRate : r.rate;
const krw = c.userKey ? 0 : (c.costUsd || 0) * rate; const charge = c.userKey ? 0 : (fixed ? c.chargeKrw : krw * (1 + margin));
const u = byUser[uid]; u.commands += 1; u.tokens += c.usage.total; u.usd += c.costUsd || 0; u.krw += krw; u.charge += charge;
u.byDay[day] = u.byDay[day] || { day, rate: r.rate, source: r.source, usd: 0, krw: 0, charge: 0, commands: 0 }; u.byDay[day].usd += c.costUsd || 0; u.byDay[day].krw += krw; u.byDay[day].charge += charge; u.byDay[day].commands += 1;
}
}
const b = load(); const userMap = Object.fromEntries((users || []).map((u) => [u.id, u]));
for (const u of users || []) byUser[u.id] = byUser[u.id] || { userId: u.id, sites: 0, commands: 0, tokens: 0, usd: 0, krw: 0, charge: 0, byDay: {} };
const rows = Object.values(byUser).map((u) => { const charges = b.charges.filter((c) => c.userId === u.userId); const prepaid = charges.reduce((a, c) => a + c.amountKrw, 0); const info = userMap[u.userId] || {}; return { ...u, name: info.name || '', role: info.role || '', pendingApproval: !!info.pendingApproval /* v1.5.27 */, accountType: info.role ? (info.role === 'admin' || info.accountType !== 'subdomain' ? 'domain' : 'subdomain') : '', prepaid, balance: prepaid - u.charge, charges, byDay: Object.values(u.byDay).sort((a, b2) => b2.day.localeCompare(a.day)) }; }).sort((a, b2) => b2.charge - a.charge);
const today = await rateFor(fx.kstDate());
return { models: Object.values(byModel).sort((a, b2) => b2.usd - a.usd), users: rows, marginPct: s.marginPct, fx: { mode: s.fx.mode, spreadPct: s.fx.spreadPct, manualRate: s.fx.manualRate, today }, rateErrors, totals: rows.reduce((a, u) => ({ usd: a.usd + u.usd, krw: a.krw + u.krw, charge: a.charge + u.charge, prepaid: a.prepaid + u.prepaid, tokens: a.tokens + u.tokens }), { usd: 0, krw: 0, charge: 0, prepaid: 0, tokens: 0 }) };
}
module.exports = { summarize, addCharge, removeCharge, load };
+236
View File
@@ -0,0 +1,236 @@
'use strict';
// Site Brief: 기획 확정 결과(JSON). 생성 에이전트의 유일한 입력.
// 업종/지역/디자인 카탈로그, 업종 기본값, 규제 제약, 정합성 검사, 결과 검증(결정적 규칙)을 담당한다.
const fs = require('fs');
const path = require('path');
// ---------- 목표 → 기능 ----------
const GOALS = [
{ id: 'inquiry', label: '문의 유도', features: ['contactForm', 'map'] },
{ id: 'booking', label: '예약 접수', features: ['booking', 'map'] },
{ id: 'intro', label: '회사/매장 소개', features: [] },
{ id: 'portfolio', label: '포트폴리오/작업 사례', features: ['gallery'] },
{ id: 'news', label: '소식/공지', features: ['board'] },
{ id: 'sales', label: '상품/서비스 판매 안내', features: ['faq'] },
];
const FEATURES = [
{ id: 'contactForm', label: '문의 폼', section: 'contact' },
{ id: 'survey', label: '설문/참가 신청', section: 'survey' },
{ id: 'members', label: '회원 로그인', section: 'members' },
{ id: 'booking', label: '예약', section: 'booking' },
{ id: 'board', label: '게시판/소식', section: 'news' },
{ id: 'gallery', label: '갤러리', section: 'gallery' },
{ id: 'map', label: '지도/오시는 길', section: 'map' },
{ id: 'reviews', label: '고객 후기', section: 'reviews' },
{ id: 'faq', label: 'FAQ', section: 'faq' },
{ id: 'blog', label: '블로그', section: 'blog' }, // v1.4.0
{ id: 'library', label: '자료실', section: 'library' }, // v1.4.0
{ id: 'calendar', label: '일정관리(캘린더)', section: 'calendar' }, // v1.4.18
];
const SECTIONS = {
hero: { label: '히어로(대표 문구+CTA)' }, about: { label: '소개' }, services: { label: '서비스/진료/과목' }, menu: { label: '메뉴' },
products: { label: '상품/서비스 목록' }, portfolio: { label: '작업 사례' }, gallery: { label: '갤러리', feature: 'gallery' }, team: { label: '팀/구성원' },
reviews: { label: '고객 후기', feature: 'reviews' }, faq: { label: 'FAQ', feature: 'faq' }, survey: { label: '설문/신청 양식', feature: 'survey' }, members: { label: '회원 로그인', feature: 'members' }, pricing: { label: '가격표' }, hours: { label: '영업시간' },
map: { label: '오시는 길/지도', feature: 'map' }, contact: { label: '문의 폼', feature: 'contactForm' }, booking: { label: '예약', feature: 'booking' },
news: { label: '소식/공지', feature: 'board' }, blog: { label: '블로그', feature: 'blog' }, library: { label: '자료실(내려받기)', feature: 'library' }, calendar: { label: '일정표(캘린더)', feature: 'calendar' }, cta: { label: '행동 유도(CTA) 배너' }, credentials: { label: '자격/인증/수상' }, process: { label: '진행 절차' },
};
// ---------- 규제 업종 ----------
const REGULATIONS = {
medical: { label: '의료(의료광고법)', disableFeatures: ['reviews'], constraints: ['치료 효과 보장·완치·부작용 없음 등 단정 표현 금지', '환자 치료 경험담(후기) 게시 금지', '치료 전후 비교 사진 금지', '최고·유일·1위 등 최상급 표현 금지', '가격은 사실정보에 입력된 값만 표시'], patterns: ['완치', '부작용\\s*(이|가)?\\s*없', '100\\s*%', '효과\\s*보장', '치료\\s*보장', '전후\\s*사진', '최고의\\s*(병원|의원|의료진)', '유일한', '1\\s*위', '(환자|고객)\\s*후기'] },
legal: { label: '법률/세무(변호사법 등)', disableFeatures: [], constraints: ['승소·결과 보장 표현 금지', '전문 분야 표기는 등록된 전문분야에 한함', '수임료는 사실정보에 입력된 값만 표시', '타 사무소 비방·비교 금지'], patterns: ['승소\\s*보장', '결과\\s*보장', '100\\s*%', '무조건', '최고의\\s*(변호사|법무|세무)', '1\\s*위'] },
finance: { label: '금융/대부', disableFeatures: [], constraints: ['수익률·원금 보장 표현 금지', '이자율·수수료는 사실정보 값만 표시'], patterns: ['원금\\s*보장', '수익\\s*보장', '확정\\s*수익', '무조건'] },
};
// ---------- 업종 카탈로그 (대분류 → 소분류) + 기본값 ----------
function ind(major, minors, d) { return { major, minors, defaults: d }; }
const INDUSTRIES = [
ind('음식점·카페', ['한식', '중식', '일식', '양식', '카페·디저트', '술집·바', '분식·패스트푸드', '베이커리'], { goals: ['intro', 'inquiry'], features: ['map', 'reviews'], customers: ['주변 직장인', '가족 단위', '데이트 커플', '단체 모임', '관광객'], pages: [['home', ['hero', 'about', 'menu', 'reviews', 'hours', 'map']], ['menu', ['menu', 'pricing']], ['contact', ['hours', 'map', 'contact']]], tone: 'warm', voice: 'friendly' }),
ind('병원·의원', ['내과', '치과', '피부과', '한의원', '정형외과', '소아청소년과', '안과', '성형외과', '동물병원'], { regulated: 'medical', goals: ['booking', 'intro'], features: ['booking', 'map', 'faq'], customers: ['지역 주민', '직장인', '시니어', '학생·청년', '보호자'], pages: [['home', ['hero', 'about', 'services', 'team', 'hours', 'map']], ['services', ['services', 'process', 'faq']], ['booking', ['booking', 'hours', 'map']]], tone: 'minimal', voice: 'expert' }),
ind('법률·세무·행정', ['변호사', '법무사', '세무사', '회계사', '노무사', '행정사'], { regulated: 'legal', goals: ['inquiry', 'intro'], features: ['contactForm', 'map', 'faq'], customers: ['개인 의뢰인', '중소기업', '스타트업', '자영업자'], pages: [['home', ['hero', 'about', 'services', 'credentials', 'contact']], ['services', ['services', 'process', 'faq']], ['contact', ['contact', 'map']]], tone: 'luxury', voice: 'formal' }),
ind('뷰티·미용', ['헤어샵', '네일', '피부관리', '메이크업', '왁싱', '이발소'], { goals: ['booking', 'portfolio'], features: ['booking', 'gallery', 'map', 'reviews'], customers: ['20~30대 여성', '직장인', '학생', '남성 고객'], pages: [['home', ['hero', 'services', 'gallery', 'reviews', 'booking', 'map']], ['pricing', ['pricing', 'faq']]], tone: 'modern', voice: 'friendly' }),
ind('교육·학원', ['입시학원', '어학원', '코딩·IT', '예체능', '유아교육', '성인교육', '과외·스터디'], { goals: ['inquiry', 'intro'], features: ['contactForm', 'map', 'faq', 'board'], customers: ['학부모', '초·중·고 학생', '대학생·취준생', '직장인'], pages: [['home', ['hero', 'about', 'services', 'team', 'faq', 'contact']], ['courses', ['products', 'pricing', 'process']], ['news', ['news']]], tone: 'vivid', voice: 'friendly' }),
ind('부동산·건설·인테리어', ['공인중개사', '인테리어', '건축·시공', '리모델링', '청소·이사'], { goals: ['inquiry', 'portfolio'], features: ['contactForm', 'gallery', 'map'], customers: ['신혼·1인 가구', '자영업자(상가)', '건물주', '기업'], pages: [['home', ['hero', 'about', 'portfolio', 'process', 'contact']], ['portfolio', ['gallery']], ['contact', ['contact', 'map']]], tone: 'minimal', voice: 'expert' }),
ind('IT·소프트웨어·에이전시', ['SaaS·앱', 'IT 서비스·SI', '디자인 에이전시', '마케팅 에이전시', 'AI·데이터'], { goals: ['inquiry', 'sales'], features: ['contactForm', 'faq', 'board'], customers: ['스타트업', '중소기업', '대기업 담당자', '개발자'], pages: [['home', ['hero', 'services', 'products', 'process', 'faq', 'cta']], ['product', ['products', 'pricing', 'faq']], ['contact', ['contact']]], tone: 'modern', voice: 'expert' }),
ind('제조·유통·도소매', ['식품 제조', '기계·부품', '화장품·생활용품', '도매·유통', '농수산물'], { goals: ['intro', 'inquiry', 'sales'], features: ['contactForm', 'gallery'], customers: ['B2B 구매 담당자', '소매점', '일반 소비자', '해외 바이어'], pages: [['home', ['hero', 'about', 'products', 'credentials', 'contact']], ['products', ['products', 'gallery']], ['company', ['about', 'team', 'map']]], tone: 'minimal', voice: 'formal' }),
ind('쇼핑·판매', ['의류·패션', '식품·건강', '가구·인테리어 소품', '취미·수공예', '전자·IT 기기'], { goals: ['sales', 'intro'], features: ['gallery', 'faq', 'reviews'], customers: ['20~30대', '30~40대 주부', '선물 구매자', '마니아층'], pages: [['home', ['hero', 'products', 'reviews', 'faq', 'cta']], ['products', ['products', 'gallery']], ['about', ['about', 'contact']]], tone: 'vivid', voice: 'friendly' }),
ind('프리랜서·포트폴리오', ['디자이너', '개발자', '사진작가', '작가·강사', '영상·크리에이터'], { goals: ['portfolio', 'inquiry'], features: ['gallery', 'contactForm'], customers: ['기업 담당자', '개인 의뢰인', '에이전시'], pages: [['home', ['hero', 'about', 'portfolio', 'process', 'contact']], ['works', ['gallery']]], tone: 'minimal', voice: 'friendly' }),
ind('숙박·여행·레저', ['펜션·민박', '호텔·게스트하우스', '여행사', '캠핑', '체험·액티비티'], { goals: ['booking', 'intro'], features: ['booking', 'gallery', 'map', 'reviews', 'faq'], customers: ['가족 여행객', '커플', '단체·워크숍', '외국인 관광객'], pages: [['home', ['hero', 'gallery', 'about', 'reviews', 'booking', 'map']], ['rooms', ['products', 'pricing', 'faq']]], tone: 'warm', voice: 'friendly' }),
ind('스포츠·피트니스·건강', ['헬스장·PT', '필라테스·요가', '골프', '수영·구기', '재활·운동센터'], { goals: ['booking', 'inquiry'], features: ['booking', 'map', 'reviews', 'faq'], customers: ['직장인', '주부', '시니어', '학생'], pages: [['home', ['hero', 'services', 'team', 'pricing', 'reviews', 'map']], ['programs', ['products', 'faq', 'booking']]], tone: 'vivid', voice: 'friendly' }),
ind('기타', ['비영리·단체', '종교', '개인 블로그', '이벤트·행사', '기타'], { goals: ['intro', 'news'], features: ['board', 'contactForm'], customers: ['회원', '일반 방문자', '후원자'], pages: [['home', ['hero', 'about', 'news', 'contact']]], tone: 'minimal', voice: 'friendly' }),
];
const PAGE_TITLES = { home: '홈', menu: '메뉴', contact: '문의·오시는 길', services: '서비스', booking: '예약', pricing: '가격 안내', courses: '과정 안내', news: '소식', portfolio: '포트폴리오', product: '제품', products: '상품', company: '회사 소개', about: '소개', works: '작업', rooms: '객실', programs: '프로그램' };
const AGES = ['10대', '20대', '30대', '40대', '50대', '60대 이상'];
// ---------- 지역 (시·도 → 시·군·구) ----------
const REGIONS = {
'서울특별시': ['종로구', '중구', '용산구', '성동구', '광진구', '동대문구', '중랑구', '성북구', '강북구', '도봉구', '노원구', '은평구', '서대문구', '마포구', '양천구', '강서구', '구로구', '금천구', '영등포구', '동작구', '관악구', '서초구', '강남구', '송파구', '강동구'],
'부산광역시': ['중구', '서구', '동구', '영도구', '부산진구', '동래구', '남구', '북구', '해운대구', '사하구', '금정구', '강서구', '연제구', '수영구', '사상구', '기장군'],
'대구광역시': ['중구', '동구', '서구', '남구', '북구', '수성구', '달서구', '달성군', '군위군'],
'인천광역시': ['중구', '동구', '미추홀구', '연수구', '남동구', '부평구', '계양구', '서구', '강화군', '옹진군'],
'광주광역시': ['동구', '서구', '남구', '북구', '광산구'],
'대전광역시': ['동구', '중구', '서구', '유성구', '대덕구'],
'울산광역시': ['중구', '남구', '동구', '북구', '울주군'],
'세종특별자치시': ['세종시'],
'경기도': ['수원시', '성남시', '의정부시', '안양시', '부천시', '광명시', '평택시', '동두천시', '안산시', '고양시', '과천시', '구리시', '남양주시', '오산시', '시흥시', '군포시', '의왕시', '하남시', '용인시', '파주시', '이천시', '안성시', '김포시', '화성시', '광주시', '양주시', '포천시', '여주시', '연천군', '가평군', '양평군'],
'강원특별자치도': ['춘천시', '원주시', '강릉시', '동해시', '태백시', '속초시', '삼척시', '홍천군', '횡성군', '영월군', '평창군', '정선군', '철원군', '화천군', '양구군', '인제군', '고성군', '양양군'],
'충청북도': ['청주시', '충주시', '제천시', '보은군', '옥천군', '영동군', '증평군', '진천군', '괴산군', '음성군', '단양군'],
'충청남도': ['천안시', '공주시', '보령시', '아산시', '서산시', '논산시', '계룡시', '당진시', '금산군', '부여군', '서천군', '청양군', '홍성군', '예산군', '태안군'],
'전북특별자치도': ['전주시', '군산시', '익산시', '정읍시', '남원시', '김제시', '완주군', '진안군', '무주군', '장수군', '임실군', '순창군', '고창군', '부안군'],
'전라남도': ['목포시', '여수시', '순천시', '나주시', '광양시', '담양군', '곡성군', '구례군', '고흥군', '보성군', '화순군', '장흥군', '강진군', '해남군', '영암군', '무안군', '함평군', '영광군', '장성군', '완도군', '진도군', '신안군'],
'경상북도': ['포항시', '경주시', '김천시', '안동시', '구미시', '영주시', '영천시', '상주시', '문경시', '경산시', '의성군', '청송군', '영양군', '영덕군', '청도군', '고령군', '성주군', '칠곡군', '예천군', '봉화군', '울진군', '울릉군'],
'경상남도': ['창원시', '진주시', '통영시', '사천시', '김해시', '밀양시', '거제시', '양산시', '의령군', '함안군', '창녕군', '고성군', '남해군', '하동군', '산청군', '함양군', '거창군', '합천군'],
'제주특별자치도': ['제주시', '서귀포시'],
};
// ---------- 디자인 카탈로그 ----------
const TONES = [
{ id: 'minimal', label: '미니멀', desc: '여백 많고 정돈된 구성, 절제된 색', palettes: [{ name: '화이트·그레이', colors: ['#111827', '#6b7280', '#f9fafb', '#2563eb'] }, { name: '아이보리·차콜', colors: ['#1f2937', '#9ca3af', '#f5f5f0', '#0f766e'] }, { name: '모노 블루', colors: ['#0f172a', '#334155', '#f8fafc', '#1d4ed8'] }] },
{ id: 'modern', label: '모던', desc: '선명한 대비, 볼드한 타이포, 카드형 레이아웃', palettes: [{ name: '네이비·스카이', colors: ['#0b1f3a', '#1e3a8a', '#ffffff', '#38bdf8'] }, { name: '블랙·라임', colors: ['#0a0a0a', '#262626', '#fafafa', '#a3e635'] }, { name: '인디고·코랄', colors: ['#1e1b4b', '#4338ca', '#ffffff', '#fb7185'] }] },
{ id: 'warm', label: '따뜻한', desc: '베이지·브라운·오렌지 계열, 부드러운 곡선', palettes: [{ name: '베이지·브라운', colors: ['#3f2e22', '#8b5e3c', '#f7f1e8', '#d97706'] }, { name: '테라코타', colors: ['#3b1f1a', '#b45309', '#fff7ed', '#c2410c'] }, { name: '올리브·크림', colors: ['#2f3a1f', '#6b7c3a', '#fbfaf3', '#ca8a04'] }] },
{ id: 'luxury', label: '고급스러운', desc: '다크 톤과 골드·세리프 서체, 넓은 여백', palettes: [{ name: '블랙·골드', colors: ['#0f0f0f', '#3a3a3a', '#f5f3ef', '#c9a227'] }, { name: '딥그린·골드', colors: ['#0b2e2a', '#14532d', '#f6f5f0', '#b08d57'] }, { name: '버건디·아이보리', colors: ['#3b0a14', '#7f1d1d', '#faf7f2', '#a16207'] }] },
{ id: 'vivid', label: '활기찬', desc: '밝고 강한 포인트 컬러, 큼직한 요소', palettes: [{ name: '옐로·블루', colors: ['#111827', '#1d4ed8', '#ffffff', '#facc15'] }, { name: '핑크·퍼플', colors: ['#2e1065', '#7c3aed', '#fdf4ff', '#ec4899'] }, { name: '그린·오렌지', colors: ['#052e16', '#15803d', '#f0fdf4', '#f97316'] }] },
];
const FONT_SETS = [
{ id: 'sans', label: '산세리프 (Noto Sans KR)', heading: 'Noto Sans KR', body: 'Noto Sans KR', google: 'Noto+Sans+KR:wght@400;500;700' },
{ id: 'serif-mix', label: '세리프 제목 + 산세리프 본문', heading: 'Noto Serif KR', body: 'Noto Sans KR', google: 'Noto+Serif+KR:wght@500;700&family=Noto+Sans+KR:wght@400;500' },
{ id: 'rounded', label: '둥근 고딕 (Gowun Dodum)', heading: 'Gowun Dodum', body: 'Gowun Dodum', google: 'Gowun+Dodum' },
];
const DENSITY = [{ id: 'wide', label: '넓게' }, { id: 'standard', label: '표준' }, { id: 'dense', label: '조밀' }];
const IMAGERY = [{ id: 'photo', label: '사진' }, { id: 'illustration', label: '일러스트' }];
const VOICE = [{ id: 'formal', label: '격식' }, { id: 'friendly', label: '친근' }, { id: 'expert', label: '전문' }];
const LENGTH = [{ id: 'short', label: '짧게' }, { id: 'normal', label: '보통' }];
const LANGUAGE = [{ id: 'ko', label: '한국어' }, { id: 'en', label: '영어' }, { id: 'multi', label: '다국어(한/영)' }];
const FACT_FIELDS = [
{ id: 'phone', label: '전화번호' }, { id: 'address', label: '주소' }, { id: 'hours', label: '영업시간' },
{ id: 'email', label: '문의 수신 이메일' }, { id: 'prices', label: '가격표' }, { id: 'credentials', label: '자격·인증·경력' }, { id: 'reviews', label: '고객 후기(실제)' },
];
function catalog() {
return { GOALS, FEATURES, SECTIONS, REGULATIONS: Object.fromEntries(Object.entries(REGULATIONS).map(([k, v]) => [k, { label: v.label, disableFeatures: v.disableFeatures, constraints: v.constraints }])), INDUSTRIES, PAGE_TITLES, AGES, REGIONS, TONES, FONT_SETS, DENSITY, IMAGERY, VOICE, LENGTH, LANGUAGE, FACT_FIELDS };
}
// ---------- Brief 기본 구조 ----------
function emptyFact() { return { value: '', source: null, status: 'removed' }; } // v1.0.3: 값이 없는 사실정보는 홈페이지에서 항목 자체를 뺀다
function defaultBrief(mode) {
return {
version: 1, mode,
business: { name: '', industry: { major: '', minor: '' }, region: { sido: '', sigungu: '' }, description: '', existingUrl: '' },
goals: [], features: Object.fromEntries(FEATURES.map((f) => [f.id, false])),
regulated: { flag: false, category: null, constraints: [] },
customers: { types: [], ages: [] },
pages: [],
design: { tone: '', palette: null, fonts: null, density: 'standard', imagery: 'photo', recommendedTone: '' },
copy: { voice: 'friendly', length: 'normal', language: 'ko' },
facts: Object.fromEntries(FACT_FIELDS.map((f) => [f.id, emptyFact()])),
assets: { logo: '', photos: [], useStock: false },
references: [], notes: '', origin: {},
};
}
function findIndustry(major) { return INDUSTRIES.find((i) => i.major === major); }
function pagesFromDefaults(list) { return list.map(([id, secs]) => ({ id, title: PAGE_TITLES[id] || id, sections: secs.map((s) => ({ id: s, title: SECTIONS[s] ? SECTIONS[s].label : s, feature: SECTIONS[s] && SECTIONS[s].feature || null })) })); }
/** 업종 기본값을 비어 있는 항목에만 채운다 */
function applyIndustryDefaults(brief) {
const ind = findIndustry(brief.business.industry.major); if (!ind) return brief;
const d = ind.defaults;
if (!brief.goals.length) brief.goals = [...d.goals];
if (!Object.values(brief.features).some(Boolean)) { for (const f of featuresForGoals(brief.goals)) brief.features[f] = true; for (const f of d.features) brief.features[f] = true; }
if (!brief.customers.types.length) brief.customers.types = d.customers.slice(0, 2);
if (!brief.pages.length) brief.pages = pagesFromDefaults(d.pages);
if (!brief.design.tone) { brief.design.tone = d.tone; brief.design.recommendedTone = d.tone; }
if (!brief.copy.voice) brief.copy.voice = d.voice;
if (d.regulated) { brief.regulated = { flag: true, category: d.regulated, constraints: REGULATIONS[d.regulated].constraints }; }
applyRegulation(brief); reconcileFeatures(brief);
return brief;
}
function customerTypesFor(major) { const i = findIndustry(major); return i ? i.defaults.customers : ['일반 고객', '기업 고객']; }
function featuresForGoals(goals) { const out = new Set(); for (const g of goals) { const G = GOALS.find((x) => x.id === g); if (G) G.features.forEach((f) => out.add(f)); } return [...out]; }
function applyRegulation(brief) {
if (!brief.regulated.flag || !brief.regulated.category) return brief;
const reg = REGULATIONS[brief.regulated.category]; if (!reg) return brief;
brief.regulated.constraints = reg.constraints;
for (const f of reg.disableFeatures) brief.features[f] = false;
for (const p of brief.pages) p.sections = p.sections.filter((s) => !reg.disableFeatures.includes(s.feature));
return brief;
}
/** 켜진 기능에 섹션이 없으면 알맞은 페이지에 섹션을 추가한다. 반환: 추가된 섹션 라벨 목록 */
function reconcileFeatures(brief) {
const added = [];
const has = new Set(); for (const p of brief.pages) for (const s of p.sections) if (s.feature) has.add(s.feature);
for (const f of FEATURES) {
if (!brief.features[f.id] || has.has(f.id) || !brief.pages.length) continue;
const secId = f.section; const prefer = { contactForm: ['contact', 'reservation', 'booking'], map: ['contact', 'reservation', 'booking', 'home'], booking: ['booking', 'reservation', 'contact'], faq: ['services', 'programs', 'product', 'products'], gallery: ['portfolio', 'works', 'about'], board: ['news', 'notice'], reviews: ['home'], calendar: ['news', 'notice', 'programs', 'courses', 'home'] }[f.id] || [];
let page = brief.pages.find((p) => prefer.includes(p.id)) || brief.pages[brief.pages.length - 1];
page.sections.push({ id: secId, title: SECTIONS[secId].label, feature: f.id });
added.push(`${SECTIONS[secId].label} → ${page.title}`);
}
return added;
}
/** 기능↔페이지 정합성, 사실정보 부재 경고 */
function checkConsistency(brief) {
const warnings = [];
const sectionFeatures = new Set(); const sectionIds = new Set();
for (const p of brief.pages) for (const s of p.sections) { sectionIds.add(s.id); if (s.feature) sectionFeatures.add(s.feature); }
for (const f of FEATURES) {
if (brief.features[f.id] && !sectionFeatures.has(f.id)) warnings.push({ level: 'error', code: 'feature-no-page', text: `기능 "${f.label}" 이 켜져 있지만 어떤 페이지 섹션에도 배치되지 않았습니다.` });
if (!brief.features[f.id] && sectionFeatures.has(f.id)) warnings.push({ level: 'warn', code: 'section-no-feature', text: `"${f.label}" 섹션이 있지만 기능이 꺼져 있습니다.` });
}
if (!brief.pages.length) warnings.push({ level: 'error', code: 'no-pages', text: '페이지가 하나도 없습니다.' });
if (brief.pages.length && brief.pages[0].id !== 'home') warnings.push({ level: 'warn', code: 'no-home', text: '첫 페이지가 홈(home)이 아닙니다. 첫 페이지가 index.html 이 됩니다.' });
const facts = brief.facts || {};
const need = (sec, fact, label) => { if (sectionIds.has(sec) && facts[fact] && facts[fact].status === 'removed') return; if (sectionIds.has(sec) && (!facts[fact] || facts[fact].status !== 'provided')) warnings.push({ level: 'warn', code: 'fact-missing', text: `"${SECTIONS[sec].label}" 섹션에 필요한 ${label} 가 없어 "[입력 필요]" 로 표시됩니다.` }); };
need('map', 'address', '주소'); need('contact', 'phone', '전화번호'); need('hours', 'hours', '영업시간'); need('pricing', 'prices', '가격표'); need('credentials', 'credentials', '자격 정보');
if (sectionIds.has('reviews') && (!facts.reviews || (facts.reviews.status !== 'provided' && facts.reviews.status !== 'removed'))) warnings.push({ level: 'warn', code: 'fact-missing', text: '후기 섹션이 있지만 실제 후기가 입력되지 않았습니다. 가짜 후기는 만들지 않으며 "[입력 필요]" 로 표시됩니다.' });
if (brief.regulated.flag) warnings.push({ level: 'info', code: 'regulated', text: `규제 업종(${REGULATIONS[brief.regulated.category].label}) 제약이 적용됩니다: ${brief.regulated.constraints.join(' / ')}` });
return warnings;
}
/** 업종 정보가 없을 때 쓰는 기본 페이지 구성 */
function genericPages() { return pagesFromDefaults([['home', ['hero', 'about', 'services', 'contact']], ['about', ['about', 'credentials', 'map']]]); }
/** 페이지 파일명 규칙: 첫 페이지 index.html, 나머지 <id>.html */
function pageFile(brief, i) { return i === 0 ? 'index.html' : `${brief.pages[i].id}.html`; }
// ---------- 결과 검증 (결정적 규칙) ----------
const PHONE_RE = /(?:0\d{1,2}[-.\s]?\d{3,4}[-.\s]?\d{4}|1[5-9]\d{2}[-.\s]?\d{4})/g;
const PRICE_RE = /\d{1,3}(?:,\d{3})+\s*원|\d+\s*만\s*원/g;
const ADDR_RE = /(?:[가-힣]+(?:특별시|광역시|특별자치시|특별자치도|도)\s+)?[가-힣]+(?:시|군|구)\s+[가-힣0-9]+(?:로|길|동)\s*\d+/g;
function stripHtml(s) { return s.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' '); }
function listHtml(dir, base = '') {
let out = []; let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return out; }
for (const e of ents) { if (e.name.startsWith('.') || e.name === 'uploads') continue; const rel = base ? `${base}/${e.name}` : e.name; if (e.isDirectory()) out = out.concat(listHtml(path.join(dir, e.name), rel)); else if (/\.html?$/i.test(e.name)) out.push(rel); }
return out.sort();
}
/**
* 생성 결과 규칙 검사. prefix: 다국어 사이트의 기본 언어 폴더('ko/') — 그 폴더의 페이지만 검사하고 파일 이름은 폴더 기준으로 비교한다
* (다른 언어 폴더는 서비스가 번역해 만든 사본이라 검사하지 않는다).
*/
function verifyFiles(siteDir, brief, prefix = '') {
const files = prefix ? listHtml(path.join(siteDir, prefix)).map((f) => prefix + f) : listHtml(siteDir);
const report = { pages: [], placeholders: [], extraneous: [], regulated: [], mailto: [], widget: { present: false, pages: [] }, summary: {} };
const allowedPhones = new Set(); const allowedPrices = new Set(); let allowedAddr = '';
const f = brief.facts || {};
if (f.phone && f.phone.status === 'provided') (String(f.phone.value).match(PHONE_RE) || []).forEach((m) => allowedPhones.add(m.replace(/[-.\s]/g, '')));
if (f.prices && f.prices.status === 'provided') (String(f.prices.value).match(PRICE_RE) || []).forEach((m) => allowedPrices.add(m.replace(/\s/g, '')));
if (f.address && f.address.status === 'provided') allowedAddr = String(f.address.value).replace(/\s/g, '');
const regPatterns = brief.regulated.flag && REGULATIONS[brief.regulated.category] ? REGULATIONS[brief.regulated.category].patterns.map((p) => new RegExp(p, 'g')) : [];
for (let i = 0; i < (brief.pages || []).length; i++) { const pf = pageFile(brief, i); report.pages.push({ page: brief.pages[i].title, file: prefix + pf, exists: files.includes(prefix + pf) }); }
for (const rel of files) {
const raw = fs.readFileSync(path.join(siteDir, rel), 'utf8'); const text = stripHtml(raw);
const ph = (raw.match(/\[입력 필요\]/g) || []).length; if (ph) report.placeholders.push({ file: rel, count: ph });
const mt = (raw.match(/mailto:/gi) || []).length; if (mt) report.mailto.push({ file: rel, count: mt });
if (/data-wm-contact/.test(raw)) { report.widget.present = true; report.widget.pages.push(rel); }
for (const m of text.match(PHONE_RE) || []) if (!allowedPhones.has(m.replace(/[-.\s]/g, ''))) report.extraneous.push({ file: rel, type: '전화번호', value: m });
for (const m of text.match(PRICE_RE) || []) if (!allowedPrices.has(m.replace(/\s/g, ''))) report.extraneous.push({ file: rel, type: '가격', value: m });
for (const m of text.match(ADDR_RE) || []) { const nm = m.replace(/\s/g, ''); if (!allowedAddr || !(allowedAddr.includes(nm) || nm.includes(allowedAddr))) report.extraneous.push({ file: rel, type: '주소', value: m }); }
for (const re of regPatterns) for (const m of text.match(re) || []) report.regulated.push({ file: rel, value: m });
}
report.summary = { files: files.length, pagesMissing: report.pages.filter((p) => !p.exists).length, placeholders: report.placeholders.reduce((a, b) => a + b.count, 0), extraneous: report.extraneous.length, regulated: report.regulated.length, mailto: report.mailto.reduce((a, b) => a + b.count, 0), widget: report.widget.present };
return report;
}
module.exports = { INDUSTRIES, catalog, defaultBrief, applyIndustryDefaults, applyRegulation, reconcileFeatures, checkConsistency, genericPages, customerTypesFor, featuresForGoals, findIndustry, pagesFromDefaults, pageFile, verifyFiles, GOALS, FEATURES, SECTIONS, REGULATIONS, TONES, FONT_SETS, PAGE_TITLES };
+183
View File
@@ -0,0 +1,183 @@
'use strict';
// ============================================================================
// 일정관리 모듈 — 일정 데이터 규칙과 발생일 전개 (v1.4.18, 요구 1)
//
// 일정(event)은 사이트 관리자 페이지에서 등록하고 data/modules/<사이트>.json 의 events 에 저장한다(lib/modules.js).
// 이 파일은 (1) 관리자 입력을 저장 형식으로 정리(normalizeEvent)하고 (2) 캘린더가 요청한 기간 안에서 반복·음력 일정을
// 실제 날짜(발생, occurrence)로 펼치며(expand) (3) 캘린더 한 칸에 보일 음력·간지 정보(lib/lunar.js)를 붙인다(range).
//
// 저장 형식 (event)
// id, title, body(설명), location, category, color, published
// unit : 'time' 시·분 단위(한 날의 시작~종료 시각) | 'day' 일 단위(length 일) | 'week' 주 단위(length 주)
// cal : 'solar' 양력 | 'lunar' 음력 — 음력이면 lunar:{ year, month, day, leap } 가 시작일(양력 date 는 변환해 함께 저장)
// date : 양력 시작일 'YYYY-MM-DD' (cal=lunar 여도 첫 발생일을 변환해 둔다 — 목록 정렬용)
// time, endTime : unit=time 일 때 'HH:MM'
// length : unit=day → 며칠, unit=week → 몇 주 (기본 1)
// repeat : { freq:'none'|'daily'|'weekly'|'monthly'|'yearly', interval(1~99), until:'YYYY-MM-DD'|'', weekdays:[0..6] }
// 음력 일정의 monthly 는 "음력 매월 같은 날", yearly 는 "음력 매년 같은 달·날" 이다. 윤달 시작 일정은 그 해에
// 같은 윤달이 있을 때만 윤달로, 없으면 평달로 잡는다(윤달 생일을 평달에 지내는 관행). 없는 날(30일)은 그 달 마지막 날로 당긴다.
//
// 발생(occurrence) — 캘린더가 그리는 단위
// { id:'<eventId>@<YYYY-MM-DD>', eventId, title, body, location, category, color, unit, allDay, start:'YYYY-MM-DD', end:'YYYY-MM-DD'(마지막 날, 포함),
// days, time, endTime, cal, lunarText('음 8.15' 등 음력 일정일 때) }
// ============================================================================
const lunar = require('./lunar');
const sitemsg = require('./sitemsg');
const E = (id, status = 400, vars) => sitemsg.err(id, status, vars);
const pad = (n) => String(n).padStart(2, '0');
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
const UNITS = ['time', 'day', 'week'];
const FREQS = ['none', 'daily', 'weekly', 'monthly', 'yearly'];
const COLORS = ['', 'blue', 'green', 'red', 'orange', 'purple', 'gray']; // 위젯 CSS 클래스 wm-cal-c-<색>
const MAX_RANGE_DAYS = 70; // 한 번에 조회할 수 있는 최대 기간 (월간 보기 6주 = 42일 + 여유)
const MAX_OCCURRENCES = 2000; // 한 조회에서 펼치는 발생 상한 (무한 반복 방어)
const clip = (v, n) => String(v == null ? '' : v).trim().slice(0, n);
/** 'YYYY-MM-DD' → JDN (잘못된 형식이면 null) */
function jdnOf(s) { const m = DATE_RE.exec(String(s || '')); if (!m) return null; const y = +m[1], mo = +m[2], d = +m[3]; if (mo < 1 || mo > 12 || d < 1 || d > 31) return null; const j = lunar.jdn(y, mo, d); const back = lunar.dateFromJdn(j); return back.y === y && back.m === mo && back.d === d ? j : null; }
/** JDN → 'YYYY-MM-DD' */
function dateOf(j) { const { y, m, d } = lunar.dateFromJdn(j); return `${y}-${pad(m)}-${pad(d)}`; }
/** JDN → 요일 (0=일) : JDN 0 은 월요일이므로 +1 */
const weekdayOf = (j) => (j + 1) % 7;
/** 시각 'HH:MM' → 분 */
const minutesOf = (t) => { const m = TIME_RE.exec(t || ''); return m ? +m[1] * 60 + +m[2] : null; };
/**
* 관리자 입력 → 저장 형식. 잘못된 값은 오류(사이트 문구 id)로 돌려준다.
* @param {object} body 입력 (문자열 값 허용: 체크박스·숫자는 문자열로 올 수 있음)
* @param {object} [prev] 수정할 때 기존 일정 (없는 항목은 기존 값 유지)
*/
function normalizeEvent(body, prev) {
const b = body || {}; const p = prev || {};
const get = (k, def) => (b[k] !== undefined ? b[k] : (p[k] !== undefined ? p[k] : def));
const title = clip(get('title', ''), 120); if (!title) throw E('err.requiredFields');
const unit = UNITS.includes(get('unit', 'day')) ? get('unit', 'day') : 'day';
const cal = get('cal', 'solar') === 'lunar' ? 'lunar' : 'solar';
const ev = { id: p.id, title, body: clip(get('body', ''), 5000), location: clip(get('location', ''), 200), category: clip(get('category', ''), 40), color: COLORS.includes(get('color', '')) ? get('color', '') : '', unit, cal, published: !(get('published', true) === false || get('published', true) === 'false') };
// 시작일: 양력 또는 음력
if (cal === 'lunar') {
const ln = b.lunar && typeof b.lunar === 'object' ? b.lunar : (p.lunar || {});
const ly = parseInt(ln.year, 10), lm = parseInt(ln.month, 10), ld = parseInt(ln.day, 10); const leap = ln.leap === true || ln.leap === 'true' || ln.leap === 'on';
if (!(ly >= lunar.RANGE[0] && ly <= lunar.RANGE[1]) || !(lm >= 1 && lm <= 12) || !(ld >= 1 && ld <= 30)) throw E('err.eventDate');
const sol = lunar.lunarToSolar(ly, lm, ld, leap, true); if (!sol) throw E('err.eventDate');
ev.lunar = { year: ly, month: lm, day: ld, leap }; ev.date = `${sol.y}-${pad(sol.m)}-${pad(sol.d)}`;
} else {
const j = jdnOf(get('date', '')); if (!j) throw E('err.eventDate'); ev.date = dateOf(j); ev.lunar = null;
}
// 시간 단위: 시작·종료 시각
if (unit === 'time') {
const t = String(get('time', '')); const et = String(get('endTime', '')); if (!TIME_RE.test(t)) throw E('err.eventTime');
ev.time = t; ev.endTime = TIME_RE.test(et) && minutesOf(et) > minutesOf(t) ? et : '';
} else { ev.time = ''; ev.endTime = ''; }
const len = parseInt(get('length', 1), 10); ev.length = unit === 'time' ? 1 : Math.min(unit === 'week' ? 52 : 366, Math.max(1, Number.isFinite(len) ? len : 1));
// 반복
const r = b.repeat && typeof b.repeat === 'object' ? b.repeat : (p.repeat || {});
const freq = FREQS.includes(r.freq) ? r.freq : 'none'; const iv = parseInt(r.interval, 10);
const until = r.until ? (jdnOf(r.until) ? dateOf(jdnOf(r.until)) : '') : '';
const weekdays = Array.isArray(r.weekdays) ? r.weekdays.map((x) => parseInt(x, 10)).filter((x) => x >= 0 && x <= 6) : String(r.weekdays || '').split(',').map((x) => parseInt(x, 10)).filter((x) => x >= 0 && x <= 6);
ev.repeat = { freq, interval: freq === 'none' ? 1 : Math.min(99, Math.max(1, Number.isFinite(iv) ? iv : 1)), until, weekdays: freq === 'weekly' ? [...new Set(weekdays)].sort() : [] };
return ev;
}
/** 일정 한 건의 발생 시작일(JDN) 목록을 [from, to] 와 겹치도록 만든다. dur 은 발생 하나의 길이(일). */
function startsOf(ev, fromJ, toJ, dur) {
const baseJ = jdnOf(ev.date); if (baseJ == null) return [];
const untilJ = ev.repeat && ev.repeat.until ? jdnOf(ev.repeat.until) : null;
const limit = untilJ != null ? Math.min(untilJ, toJ) : toJ; // 이 날 이후에 시작하는 발생은 필요 없다
const freq = (ev.repeat && ev.repeat.freq) || 'none'; const iv = (ev.repeat && ev.repeat.interval) || 1;
const out = []; const push = (j) => { if (j >= baseJ && j <= limit && j + dur - 1 >= fromJ) out.push(j); };
if (freq === 'none') { push(baseJ); return out; }
if (freq === 'daily') { const step = iv; let j = baseJ; if (fromJ > baseJ) j = baseJ + Math.floor((fromJ - dur + 1 - baseJ) / step) * step; if (j < baseJ) j = baseJ; for (; j <= limit && out.length < MAX_OCCURRENCES; j += step) push(j); return out; }
if (freq === 'weekly') {
const wds = ev.repeat.weekdays && ev.repeat.weekdays.length ? ev.repeat.weekdays : [weekdayOf(baseJ)];
const week0 = baseJ - weekdayOf(baseJ); // 시작일이 든 주의 일요일
let n = 0; if (fromJ > baseJ) n = Math.max(0, Math.floor((fromJ - dur + 1 - week0) / (7 * iv)));
for (; week0 + 7 * iv * n <= limit && out.length < MAX_OCCURRENCES; n++) for (const wd of wds) push(week0 + 7 * iv * n + wd);
return out;
}
if (ev.cal === 'lunar' && ev.lunar) {
const { month, day, leap } = ev.lunar; const fromY = lunar.dateFromJdn(Math.max(fromJ - dur, baseJ)).y - 1; const toY = lunar.dateFromJdn(limit).y + 1;
if (freq === 'yearly') {
for (let y = ev.lunar.year; y <= toY && out.length < MAX_OCCURRENCES; y += iv) { if (y < fromY) continue; try { const s = lunar.lunarToSolar(y, month, day, leap, true); if (s) push(lunar.jdn(s.y, s.m, s.d)); } catch (_) { break; } }
return out;
}
// monthly: 음력 달을 차례로 세며 같은 날짜 (윤달도 한 달로 센다)
let count = 0;
for (let y = ev.lunar.year; y <= toY && out.length < MAX_OCCURRENCES; y++) {
let months; try { months = lunar.lunarYearMonths(y); } catch (_) { break; }
for (const mo of months) {
if (y === ev.lunar.year && (mo.month < month || (mo.month === month && !mo.leap && leap))) continue; // 시작 달 이전은 건너뜀
if (count % iv === 0) { const d = Math.min(day, mo.days); const j = lunar.jdn(mo.start.y, mo.start.m, mo.start.d) + d - 1; if (j > limit) return out; push(j); }
count += 1;
}
}
return out;
}
// 양력 monthly / yearly: 같은 날짜, 없는 날(31일·2월 29일)은 그 달 마지막 날
const base = lunar.dateFromJdn(baseJ);
const lastDay = (y, m) => lunar.jdn(y, m, 1) + (m === 12 ? lunar.jdn(y + 1, 1, 1) : lunar.jdn(y, m + 1, 1)) - lunar.jdn(y, m, 1) - 1;
if (freq === 'monthly') {
let n = 0; if (fromJ > baseJ) n = Math.max(0, Math.floor(((fromJ - dur + 1) - baseJ) / 31 / iv) * iv - iv);
for (; out.length < MAX_OCCURRENCES; n += iv) { const total = base.m - 1 + n; const y = base.y + Math.floor(total / 12); const m = total % 12 + 1; const first = lunar.jdn(y, m, 1); if (first > limit) break; push(Math.min(first + base.d - 1, lastDay(y, m))); }
return out;
}
for (let y = base.y; out.length < MAX_OCCURRENCES; y += iv) { const first = lunar.jdn(y, base.m, 1); if (first > limit) break; if (y + 1 < lunar.dateFromJdn(fromJ).y) continue; push(Math.min(first + base.d - 1, lastDay(y, base.m))); }
return out;
}
/**
* 일정 목록을 기간 [from, to] (JDN, 양끝 포함) 의 발생으로 펼친다.
* @param {object[]} events 저장된 일정
* @param {object} [opt] { category: 분류 필터, publishedOnly: 공개 일정만(방문자) }
*/
function expand(events, fromJ, toJ, opt = {}) {
const out = [];
for (const ev of events) {
if (opt.publishedOnly && ev.published === false) continue; if (opt.category && ev.category !== opt.category) continue;
const dur = ev.unit === 'week' ? 7 * (ev.length || 1) : ev.unit === 'day' ? (ev.length || 1) : 1;
for (const j of startsOf(ev, fromJ, toJ, dur)) {
const o = { id: `${ev.id}@${dateOf(j)}`, eventId: ev.id, title: ev.title, body: ev.body || '', location: ev.location || '', category: ev.category || '', color: ev.color || '', unit: ev.unit, allDay: ev.unit !== 'time', start: dateOf(j), end: dateOf(j + dur - 1), days: dur, time: ev.time || '', endTime: ev.endTime || '', cal: ev.cal || 'solar', published: ev.published !== false };
if (ev.cal === 'lunar') { try { const l = lunar.solarToLunar(...dateOf(j).split('-').map(Number)); o.lunarText = `${l.leap ? '윤' : ''}${l.month}.${l.day}`; } catch (_) {} }
out.push(o);
}
if (out.length >= MAX_OCCURRENCES) break;
}
return out.sort((a, b) => a.start.localeCompare(b.start) || (a.allDay === b.allDay ? (a.time || '').localeCompare(b.time || '') : (a.allDay ? -1 : 1)));
}
/**
* 캘린더 조회: 기간의 날짜 정보(음력·간지·절기)와 발생 일정.
* @param {object[]} events 저장된 일정
* @param {string} from 'YYYY-MM-DD'
* @param {string} to 'YYYY-MM-DD' (포함)
* @param {object} opt { category, publishedOnly, withDays }
*/
function range(events, from, to, opt = {}) {
const fj = jdnOf(from), tj = jdnOf(to); if (fj == null || tj == null || tj < fj) throw E('err.dateFormat');
if (tj - fj + 1 > MAX_RANGE_DAYS) throw E('err.rangeTooLong', 400, { n: MAX_RANGE_DAYS });
const days = [];
if (opt.withDays !== false) for (let j = fj; j <= tj; j++) { const { y, m, d } = lunar.dateFromJdn(j); try { const info = lunar.dayInfo(y, m, d); days.push({ date: info.date, lunar: { month: info.lunar.month, day: info.lunar.day, leap: info.lunar.leap, text: info.lunar.text }, ganji: { year: info.ganji.year, month: info.ganji.month, day: info.ganji.day, text: info.ganji.text, textHanja: info.ganji.textHanja }, term: info.term, termHanja: info.termHanja }); } catch (_) { days.push({ date: dateOf(j) }); } }
return { from: dateOf(fj), to: dateOf(tj), days, events: expand(events, fj, tj, opt) };
}
/** 오늘 이후 첫 발생일 (관리자 목록 표시용). 2년 안에 없으면 null */
function nextOccurrence(ev, today = new Date()) {
const t = lunar.jdn(today.getFullYear(), today.getMonth() + 1, today.getDate());
const dur = ev.unit === 'week' ? 7 * (ev.length || 1) : ev.unit === 'day' ? (ev.length || 1) : 1;
const s = startsOf(ev, t, t + 730, dur).filter((j) => j + dur - 1 >= t); return s.length ? dateOf(s[0]) : null;
}
/** 양력 한 날의 음력·간지·절기 (관리자 "날짜 조회", 위젯 일간 보기) */
function dayInfo(dateStr) { const j = jdnOf(dateStr); if (j == null) throw E('err.dateFormat'); const { y, m, d } = lunar.dateFromJdn(j); return lunar.dayInfo(y, m, d); }
/** 음력 → 양력 (관리자 입력 미리보기). 없는 날은 null */
function lunarToSolar(q) {
const y = parseInt(q.year, 10), m = parseInt(q.month, 10), d = parseInt(q.day, 10); const leap = q.leap === '1' || q.leap === 'true' || q.leap === true;
if (!(y >= lunar.RANGE[0] && y <= lunar.RANGE[1]) || !(m >= 1 && m <= 12) || !(d >= 1 && d <= 30)) throw E('err.eventDate');
const months = lunar.lunarYearMonths(y); const mo = months.find((x) => x.month === m && x.leap === leap);
const s = lunar.lunarToSolar(y, m, d, leap, false);
return { solar: s ? `${s.y}-${pad(s.m)}-${pad(s.d)}` : null, hasLeap: !!mo || !leap, monthDays: (mo || months.find((x) => x.month === m && !x.leap) || {}).days || null, leapMonths: months.filter((x) => x.leap).map((x) => x.month) };
}
module.exports = { normalizeEvent, expand, range, nextOccurrence, dayInfo, lunarToSolar, UNITS, FREQS, COLORS, MAX_RANGE_DAYS };
+361
View File
@@ -0,0 +1,361 @@
'use strict';
// Claude Code 헤드리스(-p) 실행 래퍼.
// 사이트 디렉터리를 작업 폴더로 하여 Claude Opus 5.5 로 페이지를 생성/수정하고,
// stream-json 이벤트를 파싱해 진행 로그와 최종 토큰 사용량을 돌려준다.
const { spawn } = require('child_process');
const readline = require('readline');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const settings = require('./settings');
const os = require('os');
const fs = require('fs');
const { execFileSync } = require('child_process');
// claude 실행 파일 자동 탐색: CLAUDE_BIN → PATH → 잘 알려진 설치 위치 순. 실행 실패(ENOENT) 시 다시 탐색한다.
const HOME = os.homedir();
const KNOWN_DIRS = [path.join(HOME, '.local', 'bin'), path.join(HOME, '.npm-global', 'bin'), '/usr/local/bin', '/usr/bin', '/opt/homebrew/bin', path.join(HOME, '.claude', 'local'), path.join(HOME, 'node_modules', '.bin')];
function isExec(p) { try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch (_) { return false; } }
let resolvedBin = null; let resolvedAt = 0;
function resolveClaudeBin(force) {
if (resolvedBin && !force && Date.now() - resolvedAt < 60000 && isExec(resolvedBin)) return resolvedBin;
const candidates = [];
if (process.env.CLAUDE_BIN) candidates.push(process.env.CLAUDE_BIN);
for (const d of (process.env.PATH || '').split(path.delimiter)) if (d) candidates.push(path.join(d, 'claude'));
for (const d of KNOWN_DIRS) candidates.push(path.join(d, 'claude'));
const found = candidates.find(isExec);
resolvedBin = found || null; resolvedAt = Date.now();
return resolvedBin;
}
function claudeInfo() {
const bin = resolveClaudeBin(true); if (!bin) return { bin: null, version: null, searched: [...new Set([process.env.CLAUDE_BIN, ...KNOWN_DIRS].filter(Boolean))] };
let version = null; try { version = execFileSync(bin, ['--version'], { timeout: 15000, env: cleanEnv(os.tmpdir()) }).toString().trim().split('\n')[0]; } catch (e) { version = '버전 확인 실패: ' + (e.message || '').split('\n')[0]; }
return { bin, realpath: (() => { try { return fs.realpathSync(bin); } catch (_) { return bin; } })(), version, searched: [...new Set([process.env.CLAUDE_BIN, ...KNOWN_DIRS].filter(Boolean))] };
}
const MODEL = process.env.CLAUDE_MODEL || 'claude-opus-5-5'; // 기본값. 실제 실행 모델은 settings.getModel()
const MAX_TURNS = parseInt(process.env.CLAUDE_MAX_TURNS || '80', 10);
// v1.6.31 (요구 3): 훅 설정은 서버마다 다른 설치 경로(/home/master/webmaker, /srv/webmaker/current …)에 맞춰 기동 때 만든다.
// 1.5.x 의 hooks/settings.json 은 개발 PC 의 절대 경로가 박혀 있어 다른 서버에서는 훅이 실행되지 못했다(명령 없음 → Claude 는 차단 없이 진행) → 가드가 사실상 꺼져 있었다.
const HOOK_SETTINGS = (() => { const dir = path.join(process.env.DATA_ROOT || path.join(ROOT, 'data'), 'run'); try { fs.mkdirSync(dir, { recursive: true }); } catch (_) {} const f = path.join(dir, 'claude-hooks.json'); try { fs.writeFileSync(f, JSON.stringify({ hooks: { PreToolUse: [{ matcher: 'Read|Write|Edit|MultiEdit|NotebookEdit|Glob|Grep|Bash|WebFetch|WebSearch', hooks: [{ type: 'command', command: path.join(ROOT, 'hooks', 'guard.sh') }] }] } }, null, 1)); } catch (e) { console.error('[claude] 훅 설정 파일 작성 실패: ' + e.message); } return f; })();
/** 작업 하나의 최대 실행 시간(분) — 설정 ai.maxJobMinutes(기본 45). 넘으면 중단해 다른 사용자·서버에 영향을 주지 않는다 (요구 3·12) */
const MAX_JOB_MIN = () => { try { const v = parseInt(require('./config').get('ai.maxJobMinutes') || '45', 10); return Number.isFinite(v) && v > 0 ? v : 45; } catch (_) { return 45; } };
const IDLE_MIN = 15; // 출력이 이만큼 없으면 멈춘 것으로 보고 중단
// 파일 조작 도구만 사용 가능하게 제한(--tools). Bash/Web/Task 등은 모델에 아예 노출되지 않는다.
// 쓰기 범위는 hooks/guard.sh(PreToolUse) 가 사이트 폴더로 제한한다.
const ALLOWED_TOOLS = 'Write,Edit,MultiEdit,Read,Glob,Grep';
// ============================================================================
// v1.4.5 이번 실행 비용 계산 (세션 누적값 문제)
//
// Claude CLI 결과의 total_cost_usd 와 modelUsage 는 "그 세션의 누적값" 이다. --resume 으로 같은 세션을 이어 가면
// 앞선 실행 비용이 계속 더해져 나온다(2026-10-10 실측: 새 세션 0.046121 → 이어서 0.0496223 → 0.0522053 USD).
// 1.4.4 까지는 이 값을 그대로 "이번 작업 비용" 으로 청구해 같은 비용이 작업마다 다시 청구되었다.
// 반면 결과의 usage(입력·출력·캐시 토큰)는 이번 실행분만이다.
//
// 그래서 세션마다 마지막 누적값을 장부(data/claude-sessions.json)에 남기고, 이번 비용 = 지금 누적 − 지난 누적 으로 구한다.
// 장부를 믿을 수 없을 때는 이번 실행 usage 를 가격표로 계산한다(가격표 계산은 CLI 비용과 일치함을 실측으로 확인):
// - 1.4.5 이전에 시작된 세션을 처음 이어 갈 때(장부에 없음)
// - 지난 실행이 중단(취소·캐시 소진)되어 결과 누적값을 받지 못했을 때 — 그 실행의 완성된 메시지는 세션에 남아
// 다음 누적값에 들어가므로, 차이로 계산하면 이미 추정 청구한 중단 실행분이 한 번 더 청구된다
// - 누적값이 줄어든 경우(CLI 가 세션 비용을 새로 시작)
// ============================================================================
const SESSION_LEDGER = path.join(process.env.DATA_ROOT || path.join(ROOT, 'data'), 'claude-sessions.json');
const LEDGER_DAYS = 120; // 이 기간 동안 쓰지 않은 세션 기록은 지운다 (파일이 끝없이 커지지 않게)
function loadLedger() { try { return JSON.parse(fs.readFileSync(SESSION_LEDGER, 'utf8')) || {}; } catch (_) { return {}; } }
function saveLedger(d) {
const cut = Date.now() - LEDGER_DAYS * 86400000; for (const k of Object.keys(d)) if (!(Date.parse(d[k].at) > cut)) delete d[k];
try { fs.mkdirSync(path.dirname(SESSION_LEDGER), { recursive: true }); const tmp = SESSION_LEDGER + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(d), { mode: 0o600 }); fs.renameSync(tmp, SESSION_LEDGER); } catch (_) {}
}
/** 세션 장부에 "지난 실행이 중단되어 누적값을 모름" 표시 — 다음 실행은 가격표 계산 (위 설명) */
function markSessionDirty(sessionId) { if (!sessionId) return; const d = loadLedger(); d[sessionId] = { ...(d[sessionId] || {}), clean: false, at: new Date().toISOString() }; saveLedger(d); }
/** modelUsage 두 개의 차이 (모델별 토큰·비용). 줄어든 값은 0 으로 */
function diffModelUsage(cur, prev) {
const out = {};
for (const [m, v] of Object.entries(cur || {})) {
const p = (prev || {})[m] || {}; const o = {};
for (const k of Object.keys(v)) o[k] = typeof v[k] === 'number' && /Tokens$|^costUSD$|Requests$/.test(k) ? Math.max(0, v[k] - (p[k] || 0)) : v[k];
if ((o.costUSD || 0) > 0 || (o.inputTokens || 0) + (o.outputTokens || 0) > 0) out[m] = o;
}
return out;
}
/** 이번 실행 usage 만으로 만든 modelUsage (가격표 계산 때) */
function runModelUsage(model, u, costUsd) {
return { [model]: { inputTokens: u.input, outputTokens: u.output, cacheReadInputTokens: u.cacheRead, cacheCreationInputTokens: u.cacheCreate, thinkingTokens: u.thinking, costUSD: costUsd, priced: true } };
}
/**
* 이번 실행의 비용·모델별 사용량을 정한다.
* @param {object} r CLI result 이벤트
* @param {object} usage 정규화한 이번 실행 usage
* @param {object} o runClaudeOnce 옵션 (resume, sessionId, noPersist)
* @returns {{costUsd, modelUsage, costSource: 'single'|'delta'|'priced', cumulativeUsd, pricedUsd}}
*/
function runCost(r, usage, model, o) {
const cum = Number(r.total_cost_usd) || 0; const cumMU = r.modelUsage || {};
const pricedUsd = settings.estimateUsd(usage, model); // 이번 실행 토큰 × 가격표 (확인·대체용)
if (o.noPersist) return { costUsd: cum, modelUsage: cumMU, costSource: 'single', cumulativeUsd: cum, pricedUsd }; // 세션을 남기지 않는 한 번짜리 실행: 누적 = 이번
const sid = o.sessionId || r.session_id; const d = loadLedger(); const prev = o.resume ? d[sid] : null;
let out;
if (!o.resume) out = { costUsd: cum, modelUsage: cumMU, costSource: 'single' }; // 새 세션의 첫 실행
else if (prev && prev.clean !== false && Number.isFinite(prev.costUsd) && cum + 1e-9 >= prev.costUsd) out = { costUsd: Math.max(0, cum - prev.costUsd), modelUsage: diffModelUsage(cumMU, prev.modelUsage), costSource: 'delta' };
else out = { costUsd: pricedUsd, modelUsage: runModelUsage(model, usage, pricedUsd), costSource: 'priced' };
if (sid) { d[sid] = { costUsd: cum, modelUsage: cumMU, clean: true, at: new Date().toISOString() }; saveLedger(d); }
return { ...out, cumulativeUsd: cum, pricedUsd };
}
/**
* 자식(claude) 환경 변수 — v1.6.31 (요구 3): 서비스 프로세스의 환경을 통째로 넘기지 않고 허용 목록만 넘긴다.
* 1.5.x 까지는 DATA_ROOT·SITES_ROOT·PORT 등 서비스 환경과 상위 세션 변수가 그대로 전달됐다(검토 S1). AI 가 셸로 env 를 보면 서버 구조가 드러났다.
* HOME 은 claude 의 로그인 상태(~/.claude)를 위해 남긴다(서버 로그인 방식). 인증은 authEnv(API 키·OAuth 토큰)로만 넣는다.
*/
const ENV_ALLOW = ['PATH', 'HOME', 'USER', 'LOGNAME', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TERM', 'TMPDIR', 'TZ', 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY', 'https_proxy', 'http_proxy', 'no_proxy', 'ANTHROPIC_BASE_URL', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME'];
function cleanEnv(siteDir, authEnv) {
const env = {};
for (const k of ENV_ALLOW) if (process.env[k] !== undefined) env[k] = process.env[k];
env.SITE_DIR = siteDir; // hooks/guard.js 가 참조
env.CI = '1';
// v1.6.31 (검토 S8): TLS 검증 완화는 기본 꺼짐. 사설 프록시 환경에서만 설정 ai.allowInsecureTls 로 켠다(자식 프로세스 전체의 TLS 검증이 꺼지므로 경고 대상)
if (process.env.CLAUDE_ALLOW_INSECURE_TLS === '1') env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
env.HOOK_SCRIPT = path.join(ROOT, 'hooks', 'guard.sh');
if (!env.HOME) env.HOME = require('os').homedir();
Object.assign(env, authEnv || settings.authEnv()); // 실행별 지정(사용자 키) 또는 관리자 설정
const bin = resolvedBin; if (bin) { const dir = path.dirname(bin); const parts = (env.PATH || '').split(path.delimiter); if (!parts.includes(dir)) env.PATH = [dir, ...parts].filter(Boolean).join(path.delimiter); }
return env;
}
function friendlyProgress(c) {
const input = c.input || {}; const fp = input.file_path ? path.basename(input.file_path) : '';
switch (c.name) {
case 'Write': return `파일 작성 중: ${fp}`;
case 'Edit': case 'MultiEdit': return `파일 수정 중: ${fp}`;
case 'Read': return `내용 확인 중: ${fp}`;
case 'Glob': case 'Grep': return '파일 구조 확인 중';
case 'WebFetch': return '참고 웹 페이지 확인 중';
default: return '작업 진행 중';
}
}
function summarizeToolUse(c) {
const input = c.input || {};
const fp = input.file_path ? path.basename(input.file_path) : '';
switch (c.name) {
case 'Write': return `파일 작성: ${fp} (${(input.content || '').length.toLocaleString()}자)`;
case 'Edit': case 'MultiEdit': return `파일 수정: ${fp}`;
case 'Read': return `파일 읽기: ${fp}`;
case 'Glob': return `파일 검색: ${input.pattern || ''}`;
case 'Grep': return `내용 검색: ${input.pattern || ''}`;
case 'WebFetch': return `웹 페이지 확인: ${input.url || ''}`;
default: return `도구 호출: ${c.name}`;
}
}
/**
* @param {object} o
* @param {string} o.siteDir 작업 폴더 (사이트 루트)
* @param {string} o.prompt 사용자 프롬프트
* @param {string} o.systemPrompt append-system-prompt
* @param {string} o.sessionId 신규 세션 ID (uuid)
* @param {boolean} o.resume true 이면 --resume sessionId
* @param {(ev:object)=>void} o.onEvent 진행 이벤트 콜백
* @param {string} [o.tools] 사용 가능 도구 목록 (기본: 파일 도구)
* @param {boolean} [o.noPersist] 세션을 남기지 않음(기획 단계 단발 호출)
* @param {(h:{cancel:Function})=>void} [o.onStart] 실행 핸들(취소 함수) 전달
*/
// 저장된 세션(~/.claude/projects/<cwd 인코딩>/<id>.jsonl)이 이 서버에 있는지 확인.
// 다른 서버에서 DB 를 옮겨온 경우 세션 파일이 없어 --resume 이 "No conversation found" 로 즉시 실패하므로,
// 그런 경우 새 세션(--session-id)으로 시작한다.
function sessionExists(sessionId) {
if (!sessionId || !/^[0-9a-f-]{36}$/i.test(sessionId)) return false;
const root = path.join(HOME, '.claude', 'projects');
try {
for (const d of fs.readdirSync(root)) { if (fs.existsSync(path.join(root, d, `${sessionId}.jsonl`))) return true; }
} catch (_) {}
return false;
}
const MISSING_SESSION_RE = /No conversation found with session ID/i;
function runClaude(o) {
if (o.resume && o.sessionId && !sessionExists(o.sessionId)) {
// v1.4.0: 오류가 아니라 정상 동작(세션 파일이 없는 서버·정리된 세션)이라 info 로 남긴다 — debug 는 관리자 오류 목록에 섞여 오류처럼 보였다
if (o.onEvent) o.onEvent({ type: 'log', level: 'info', text: `이전 세션(${o.sessionId})이 이 서버에 없어 새 세션으로 시작합니다` });
o = { ...o, resume: false, sessionId: require('crypto').randomUUID() };
}
return runClaudeOnce(o).catch((err) => {
// 세션 파일 목록에 있었지만 손상 등으로 이어가기에 실패한 경우에도 한 번 새 세션으로 재시도
if (o.resume && !err.cancelled && MISSING_SESSION_RE.test(err.message || '')) {
if (o.onEvent) o.onEvent({ type: 'log', level: 'debug', text: '이전 세션을 이어갈 수 없어 새 세션으로 다시 시작합니다' });
return runClaudeOnce({ ...o, resume: false, sessionId: require('crypto').randomUUID() });
}
throw err;
});
}
function runClaudeOnce(o) {
return new Promise((resolve, reject) => {
const model = o.model || settings.getModel();
const args = [
'-p', o.prompt,
'--model', model,
'--output-format', 'stream-json',
'--verbose',
// v1.1.6: 응답이 쓰이는 도중의 사용량(message_start 의 입력·캐시 토큰, 본문 조각)을 받아 캐시 소진을 빨리 감지한다
'--include-partial-messages',
'--tools', o.tools || ALLOWED_TOOLS,
'--allowedTools', o.tools || ALLOWED_TOOLS,
'--settings', HOOK_SETTINGS,
'--max-turns', String(MAX_TURNS),
'--append-system-prompt', o.systemPrompt,
];
if (o.resume) args.push('--resume', o.sessionId);
else if (o.sessionId) args.push('--session-id', o.sessionId);
if (o.noPersist) args.push('--no-session-persistence');
const bin = resolveClaudeBin(false) || resolveClaudeBin(true);
if (!bin) return reject(new Error(`claude 실행 파일을 찾지 못했습니다. 서버에 Claude Code 를 설치하거나 CLAUDE_BIN 을 지정하세요 (탐색 위치: ${KNOWN_DIRS.join(', ')})`));
const child = spawn(bin, args, {
cwd: o.siteDir,
env: cleanEnv(o.siteDir, o.authEnv),
stdio: ['ignore', 'pipe', 'pipe'],
detached: true, // 자체 프로세스 그룹 → 취소 시 훅/하위 프로세스까지 한꺼번에 종료
});
let result = null;
let stderr = '';
let cancelled = false; let lastLive = null;
const killGroup = (sig) => { try { process.kill(-child.pid, sig); } catch (_) { try { child.kill(sig); } catch (_) {} } };
// v1.6.31 (요구 3·12·25): 실행 시간 상한과 유휴 상한 — 넘으면 중단하고 이유를 결과에 남긴다. 자식 PID 를 기록해 서비스가 죽어도 다음 기동 때 정리한다(lib/jobpids.js)
let watchdogReason = null; let lastOut = Date.now();
const maxTimer = setTimeout(() => { watchdogReason = `최대 실행 시간 ${MAX_JOB_MIN()}분을 넘겨 중단했습니다`; cancel(); }, MAX_JOB_MIN() * 60000);
const idleTimer = setInterval(() => { if (Date.now() - lastOut > IDLE_MIN * 60000) { watchdogReason = `${IDLE_MIN}분 동안 응답이 없어 중단했습니다`; cancel(); } }, 30000);
try { require('./jobpids').add(child.pid, { kind: 'claude', siteDir: o.siteDir }); } catch (_) {}
const clearWatch = () => { clearTimeout(maxTimer); clearInterval(idleTimer); try { require('./jobpids').remove(child.pid); } catch (_) {} };
const cancel = () => {
if (cancelled) return; cancelled = true;
killGroup('SIGTERM');
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) killGroup('SIGKILL'); }, 2000);
// 종료 신호가 무시되어도 결과를 기다리지 않도록 스트림을 닫는다
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) { try { child.stdout.destroy(); child.stderr.destroy(); } catch (_) {} } }, 4000);
};
if (o.onStart) o.onStart({ cancel });
/**
* 실시간 사용량
* seen : 메시지 ID → usage. 완성된 assistant 메시지의 usage 가 오면 그 값으로 확정, 그 전에는 message_start 의 usage(입력·캐시) 로 미리 채운다.
* partial : 쓰는 중인 메시지의 출력 토큰 추정(본문 조각 글자 수 ÷ 3). 메시지가 완성되면 0 으로.
* emitLive 는 조각마다 부르면 이벤트가 너무 많으므로 0.8초 간격으로 묶는다(확정 시에는 즉시).
*/
const seen = new Map(); let thinkingEst = 0; let partial = 0; let curMsg = null; let liveTimer = null; let lastEmit = 0;
const emitLive = (force) => {
const now = Date.now(); if (!force && now - lastEmit < 800) { if (!liveTimer) liveTimer = setTimeout(() => { liveTimer = null; emitLive(true); }, 800); return; }
lastEmit = now; if (liveTimer) { clearTimeout(liveTimer); liveTimer = null; }
const u = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 };
for (const x of seen.values()) { u.input += x.input_tokens || 0; u.output += x.output_tokens || 0; u.cacheCreate += x.cache_creation_input_tokens || 0; u.cacheRead += x.cache_read_input_tokens || 0; }
u.output += thinkingEst + partial; u.total = u.input + u.output + u.cacheCreate + u.cacheRead; u.thinking = thinkingEst; lastLive = u;
emit({ type: 'usage', live: true, usage: u, calls: seen.size });
};
const emit = (ev) => { try { o.onEvent && o.onEvent(ev); } catch (_) { /* ignore */ } };
child.on('error', (err) => { if (err.code === 'ENOENT') resolveClaudeBin(true); reject(new Error(`claude 실행 실패 (${bin}): ${err.message}`)); });
child.stderr.on('data', (d) => {
stderr += d.toString();
if (stderr.length > 20000) stderr = stderr.slice(-20000);
});
child.stdout.on('data', () => { lastOut = Date.now(); }); // v1.6.31 유휴 감시
const rl = readline.createInterface({ input: child.stdout });
rl.on('line', (line) => {
line = line.trim();
if (!line) return;
let ev;
try { ev = JSON.parse(line); } catch (_) { return; }
switch (ev.type) {
case 'stream_event': {
// 부분 메시지 (--include-partial-messages): 시작 시 입력·캐시 토큰, 진행 중 본문 조각, 끝에 출력 토큰
const e = ev.event || {};
if (e.type === 'message_start' && e.message) { curMsg = e.message.id || `m${seen.size}`; seen.set(curMsg, { ...(e.message.usage || {}), output_tokens: 0 }); partial = 0; emitLive(); }
else if (e.type === 'content_block_delta' && e.delta) { const t = e.delta.text || e.delta.partial_json || e.delta.thinking || ''; if (t) { partial += t.length / 3; emitLive(); } }
else if (e.type === 'message_delta' && e.usage && curMsg && seen.has(curMsg)) { seen.get(curMsg).output_tokens = e.usage.output_tokens || 0; partial = 0; emitLive(true); }
break;
}
case 'system':
if (ev.subtype === 'init') { emit({ type: 'log', level: 'info', text: `세션 시작 (모델: ${ev.model || model})` }); emit({ type: 'progress', text: 'AI 연결됨 · 작업을 구상하고 있습니다' }); }
else if (ev.subtype === 'thinking_tokens' && typeof ev.estimated_tokens === 'number') { thinkingEst = Math.max(thinkingEst, ev.estimated_tokens); emitLive(); emit({ type: 'progress', text: '내용을 구상하고 있습니다…' }); }
break;
case 'assistant': {
if (ev.message && ev.message.usage) { seen.set(ev.message.id || String(seen.size), ev.message.usage); thinkingEst = 0; partial = 0; emitLive(true); }
const content = (ev.message && ev.message.content) || [];
for (const c of content) {
if (c.type === 'text' && c.text && c.text.trim()) { const t = c.text.replace(/<brief>[\s\S]*?<\/brief>/g, '[Brief 갱신됨]').trim(); if (t) { emit({ type: 'log', level: 'assistant', text: t }); emit({ type: 'progress', text: '결과를 정리하고 있습니다' }); } }
else if (c.type === 'tool_use') { emit({ type: 'log', level: 'tool', text: summarizeToolUse(c) }); emit({ type: 'progress', text: friendlyProgress(c) }); }
}
break;
}
case 'user': {
const content = (ev.message && ev.message.content);
if (Array.isArray(content)) {
for (const c of content) {
if (c.type === 'tool_result' && c.is_error) {
const t = typeof c.content === 'string' ? c.content : JSON.stringify(c.content);
emit({ type: 'log', level: 'debug', text: `도구 오류: ${String(t).slice(0, 300)}` });
}
}
}
break;
}
case 'result':
result = ev;
break;
default:
break;
}
});
child.on('close', (code) => {
clearWatch(); // v1.6.31 감시 타이머·PID 기록 정리
if (liveTimer) { clearTimeout(liveTimer); liveTimer = null; } // 종료 뒤에 늦은 사용량 이벤트가 나가지 않게
if (result) result.__model = model;
if ((cancelled || !result) && !o.noPersist) markSessionDirty(o.sessionId); // v1.4.5 누적값을 받지 못한 실행 — 다음 실행은 가격표로 계산
if (cancelled) return reject(Object.assign(new Error(watchdogReason || '사용자가 작업을 중단했습니다'), { cancelled: true, watchdog: !!watchdogReason, usage: lastLive || { input: 0, output: 0, cacheCreate: 0, cacheRead: 0, thinking: 0, total: 0 } }));
if (!result) {
return reject(new Error(`claude 종료 코드 ${code}, 결과 없음. ${stderr.trim().slice(-800)}`));
}
const nr = normalizeResult(result, o); if (!nr.ok && !nr.errorText && stderr.trim()) nr.errorText = stderr.trim().slice(-800);
resolve(nr);
});
});
}
function normalizeResult(r, o = {}) {
const u = r.usage || {}; const cc = u.cache_creation || {};
const usage = {
input: u.input_tokens || 0,
output: u.output_tokens || 0,
cacheCreate: u.cache_creation_input_tokens || 0,
cacheRead: u.cache_read_input_tokens || 0,
thinking: (u.output_tokens_details && u.output_tokens_details.thinking_tokens) || 0,
// v1.4.5 캐시 쓰기 종류별 토큰 (1시간 캐시는 입력 단가의 2배, 5분 캐시는 1.25배 — Claude Code 는 1시간 캐시를 쓴다)
cacheCreate1h: cc.ephemeral_1h_input_tokens || 0,
cacheCreate5m: cc.ephemeral_5m_input_tokens || 0,
};
usage.total = usage.input + usage.output + usage.cacheCreate + usage.cacheRead;
const model = r.__model || Object.keys(r.modelUsage || {})[0] || MODEL;
const cost = runCost(r, usage, model, o); // v1.4.5 이번 실행 비용 (세션 누적값이 아님)
return {
model,
ok: !r.is_error && r.subtype === 'success',
subtype: r.subtype,
isError: !!r.is_error,
resultText: typeof r.result === 'string' ? r.result : '',
errorText: (!r.is_error && r.subtype === 'success') ? '' : [typeof r.result === 'string' ? r.result : '', ...(Array.isArray(r.errors) ? r.errors : [])].filter(Boolean).join(' / ').slice(0, 800),
sessionId: r.session_id,
usage,
costUsd: cost.costUsd, // 이번 실행 비용 (청구 기준)
costSource: cost.costSource, // single(새 세션·한 번짜리) | delta(누적 차이) | priced(이번 토큰 × 가격표)
costCumulativeUsd: cost.cumulativeUsd, // CLI 가 알려 준 세션 누적 비용 (참고용)
costPricedUsd: cost.pricedUsd, // 이번 토큰 × 가격표 (확인용)
numTurns: r.num_turns || 0,
durationMs: r.duration_ms || 0,
modelUsage: cost.modelUsage, // 이번 실행분 모델별 사용량 (v1.4.5 전에는 세션 누적이었음)
permissionDenials: r.permission_denials || [],
raw: r,
};
}
module.exports = { runClaude, sessionExists, MODEL, currentModel: () => settings.getModel(), claudeInfo, resolveClaudeBin };
+303
View File
@@ -0,0 +1,303 @@
'use strict';
// ============================================================================
// 설정 파일 (v1.5.7, 요구 1·2·3)
//
// 웹메이커가 동작하는 데 필요한 "환경 정보" 를 코드·systemd 유닛에서 떼어 한 파일(기본 data/config.json, 권한 0600)에 둔다.
//
// 왜 분리하나
// - 1.5.7 이전에는 공개 주소(PUBLIC_BASE_URL), 소스 저장소 주소(SITES_GIT_REMOTE), AI 실행 파일 경로 같은 값이
// systemd 유닛의 Environment= 줄에 박혀 있어, 처음 설치하는 서버에서는 값을 알기 전에 유닛을 고쳐 써야 했고
// (install-gitea.sh 가 유닛을 sed 로 바꾸는 식), 운영자가 바꾸려면 서버에 들어가 유닛을 편집해야 했다.
// - 이제 "초기 배포 때 모르는 값" 과 "배포에 쓰지 않는 값" 은 모두 이 파일에 두고, 웹 초기 설정 페이지(setup-server.js,
// /setup)나 명령줄(scripts/config.js)로 넣는다. 유닛에는 배포 때 정해지는 값(PORT·HOST·경로)만 남는다.
//
// 우선순위 (apply)
// 1) 설정 파일 값 2) 환경 변수(systemd 유닛) 3) 기본값
// 단, "배포 때 정해지는 값"(SCHEMA 의 deployTime: PORT·HOST·SITES_ROOT·DATA_ROOT 등)은 환경 변수가 우선이고 파일에는
// 참고용으로만 비친다(파일을 고쳐도 포트가 바뀌지 않게 — 포트는 웹서버 프록시 설정과 함께 바꿔야 한다).
//
// 기존 운영 유지 (요구 3)
// 설정 파일이 없는 서버에서 처음 기동하면 migrateFromEnv() 가 지금 환경 변수에 있는 값을 그대로 파일에 옮겨 적는다.
// 그러므로 1.5.7 로 올려도 운영 중인 서비스의 값은 바뀌지 않고, 이후부터는 파일이 기준이 된다.
// 코드의 나머지 부분은 여전히 process.env.X 를 읽는다 — apply() 가 파일 값을 process.env 에 넣어 주기 때문에
// 기존 모듈을 고치지 않아도 된다(모듈이 기동 때 읽는 값은 재시작 뒤 적용, SCHEMA 의 restart 표시).
//
// 비밀값
// 토큰·비밀번호(SCHEMA 의 secret)는 이 파일에 쓰지 않고 암호화 보관소(lib/vault.js)에 넣는다. 파일에는 "있음" 표시조차
// 두지 않으며 화면에는 보관소의 힌트(앞뒤 몇 글자)만 보인다.
//
// 파일 형식
// { "version": 1, "updatedAt": ISO, "updatedBy": "setup|cli|migrate:env", "values": { "<group>.<key>": 값, … } }
// ============================================================================
const fs = require('fs');
const path = require('path');
const os = require('os');
const ROOT = path.join(__dirname, '..');
const DATA_ROOT = process.env.DATA_ROOT || path.join(ROOT, 'data');
/** 설정 파일 경로: WEBMAKER_CONFIG 환경 변수 > <DATA_ROOT>/config.json */
const FILE = process.env.WEBMAKER_CONFIG || path.join(DATA_ROOT, 'config.json');
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
/**
* 설정 항목표. 그룹마다 화면 카드가 하나씩 그려진다.
* key : 파일에 저장되는 이름 (그룹.항목)
* env : 같은 뜻의 환경 변수 — apply() 가 이 이름으로 process.env 에 넣고, migrateFromEnv() 가 이 이름에서 읽어 온다
* type : text | url | int | bool | enum | list(쉼표 목록) | path | secret
* default : 비어 있을 때의 동작 값(화면에 "기본값" 으로 보임). 코드 쪽 기본값과 같게 둔다
* deployTime: 배포 때 정해지는 값 — 환경 변수가 우선, 화면에는 읽기 전용으로 보인다
* restart : 바꾼 뒤 서비스를 재시작해야 적용되는 값 (모듈이 기동 때 한 번 읽음)
* secret : 값을 암호화 보관소에 두는 항목 (vaultKind 가 보관소 종류)
*/
const SCHEMA = [
{ group: 'service', label: '서비스', desc: '이 서비스 자체의 주소와 관리 설정입니다. 설치 직후 공개 주소를 먼저 확인하세요.', keys: [
{ key: 'service.publicBaseUrl', env: 'PUBLIC_BASE_URL', type: 'url', label: '서비스 공개 주소', desc: '사용자가 접속하는 주소(https://webmaker.example.com). 생성 사이트의 위젯·통계 스크립트·웹메일 안내 파일이 이 주소를 부른다. 틀리면 사이트의 문의폼·게시판이 동작하지 않는다.', default: 'http://192.168.20.66', restart: true, required: true },
{ key: 'service.formBaseUrl', env: 'FORM_BASE_URL', type: 'url', label: '폼 접수 서버 주소', desc: '생성 사이트의 폼이 호출하는 서버. 비우면 공개 주소와 같다(관리자 콘솔 › 설정 › 메일 발송의 값이 있으면 그것이 우선).', default: '', restart: true },
{ key: 'service.adminUsers', env: 'ADMIN_USERS', type: 'list', label: '관리자로 취급할 아이디', desc: '회원 role 과 무관하게 관리자로 보는 아이디(쉼표 구분). 보통 비워 두고 관리자 콘솔(또는 scripts/admin.js grant)로 권한을 준다.', default: '', restart: true },
{ key: 'service.systemdUnit', env: 'WEBMAKER_SERVICE', type: 'text', label: 'systemd 서비스 이름', desc: '초기 설정 페이지의 "서비스 재시작" 이 재시작할 유닛 이름(webmaker / webhosting-agent). 설치 스크립트가 넣는다.', default: '' },
// v1.5.11 (요구 2): 기본 포트 3081 → 8795, 바인드 lan → 0.0.0.0. FQDN 을 모르는 서버에서도 http://<서버 IP>:8795/ 가 "기본 접속 위치" 가 되어 모든 설정을 웹에서 하도록.
// 포트가 겹치면 setup-server.js 가 다음 포트(8796, 8797 …)를 잡고 이 값에 적어 둔다. 접속은 토큰(data/setup.token) 또는 관리자 로그인이 필요하다.
{ key: 'service.setupPort', env: 'WEBMAKER_SETUP_PORT', type: 'int', min: 1024, max: 65535, label: '초기 설정 페이지 포트', desc: '설정 전용 웹페이지(setup-server.js)가 여는 포트. 기본 8795 — 겹치면 다음 포트(8796 …)를 자동으로 쓰고 여기에 기록한다. 바꾸면 webmaker-setup 서비스를 재시작한다. 같은 화면이 서비스 안 /setup 에도 있다.', default: 8795 },
{ key: 'service.setupBind', env: 'WEBMAKER_SETUP_BIND', type: 'text', label: '초기 설정 페이지 바인드 주소', desc: '0.0.0.0(기본: 모든 인터페이스 — 공인 IP 만 있는 서버에서도 http://<IP>:8795/ 로 접속), lan(이 서버의 첫 사설 IPv4 만), 또는 특정 IP. 접속에는 토큰(data/setup.token) 또는 관리자 로그인이 필요하고 로그인 시도는 15분 10회로 제한된다.', default: '0.0.0.0' },
{ key: 'service.setupRedirect', env: 'WEBMAKER_SETUP_REDIRECT', type: 'enum', options: [['auto', '자동 (설치 점검표가 끝나기 전에는 첫 화면을 설정 페이지로)'], ['off', '끔 (항상 로그인 화면)']], label: '첫 화면을 설정 페이지로 보내기', desc: '관리자 비밀번호가 기본값이거나 AI 엔진 인증이 없어 운영할 수 없는 동안에는 서비스 첫 화면(/, /login)을 /setup 으로 보낸다. 점검표가 모두 끝나면 보내지 않는다. 점검 전에 로그인 화면을 열려면 /login?direct=1', default: 'auto' },
// v1.6.31 (요구 5·7): 신뢰 프록시 · 내부 주소 허용 목록 · 생성 사이트 전용 주소
{ key: 'service.trustedProxies', env: 'WM_TRUSTED_PROXIES', type: 'text', label: '신뢰 프록시 IP(쉼표)', desc: '이 주소에서 온 연결의 X-Forwarded-For 만 클라이언트 IP 로 믿는다(속도 제한·기록용). 기본은 같은 서버의 nginx(127.0.0.1, ::1). 앞단 프록시가 다른 서버면 그 IP 를 적는다', default: '' },
{ key: 'service.internalAllow', env: 'WM_INTERNAL_ALLOW', type: 'text', label: '내부 주소 허용 목록 (host:port, 쉼표)', desc: '사용자가 넣는 주소(참고·복사·가져오기, 제공자 URL)는 사설·내부 주소로 연결하지 않는다. 관리자가 허용한 내부 서비스(host:port)만 예외. 설정 파일의 Gitea·Arimail 주소는 자동 허용', default: '' },
{ key: 'service.siteOrigin', env: 'WM_SITE_ORIGIN', type: 'text', label: '생성 사이트 전용 주소 (선택)', desc: 'v1.6.31: 생성 사이트(/<사이트ID>/)를 관리 화면과 다른 origin(예: https://sites.example.com)에서 열어 관리 세션 쿠키가 생성 사이트 페이지에 전달되지 않게 한다. 비우면 같은 주소에서 열고, 서버가 요청 출처(Referer·CSRF 토큰)로 세션 사용을 막는다. 그 주소의 nginx·인증서는 운영자가 준비한다', default: '' },
{ key: 'service.allowSignup', env: 'WEBMAKER_ALLOW_SIGNUP', type: 'bool', label: '회원가입 허용', desc: '끄면 로그인 화면에 회원가입 탭이 사라지고 POST /api/auth/signup 이 403 "회원가입이 닫혀 있습니다" 로 응답한다. 관리자는 콘솔 › 사용자에서 계정을 만든다. (웹서버 설정으로 /api/auth/signup 을 막으면 안내 없는 403 이 되므로 이 값을 쓴다)', default: true, envTrue: '1', envFalse: '0' },
] },
{ group: 'runtime', label: '실행 환경 (배포 때 정해지는 값 — 읽기 전용)', desc: '설치 스크립트가 systemd 유닛에 정한 값입니다. 포트·경로는 웹서버(nginx/Caddy) 프록시 설정과 함께 바뀌어야 하므로 여기서는 보기만 하고, 바꾸려면 유닛과 웹서버 설정을 함께 고친 뒤 재시작합니다.', keys: [
{ key: 'runtime.port', env: 'PORT', type: 'int', label: '앱 포트', desc: '웹서버가 프록시하는 로컬 포트', default: 3080, deployTime: true },
{ key: 'runtime.host', env: 'HOST', type: 'text', label: '앱 바인드 주소', desc: '보통 127.0.0.1 (웹서버 뒤)', default: '127.0.0.1', deployTime: true },
{ key: 'runtime.sitesRoot', env: 'SITES_ROOT', type: 'path', label: '사이트 파일 경로', desc: '생성 사이트가 저장되는 폴더(웹서버가 /<사이트ID>/ 로 정적 서비스)', default: '/var/www/sites', deployTime: true },
{ key: 'runtime.dataRoot', env: 'DATA_ROOT', type: 'path', label: '데이터 경로', desc: '회원·사이트·설정·보관소 파일이 있는 폴더(기본 앱 폴더의 data/). 이 설정 파일도 여기에 있다', default: path.join(ROOT, 'data'), deployTime: true },
{ key: 'runtime.statsLog', env: 'STATS_LOG', type: 'path', label: '접속 통계 로그', desc: 'nginx 가 쓰는 사이트 접속 로그(wmstats 형식)', default: '/var/log/nginx/webmaker-sites.log', deployTime: true },
] },
{ group: 'ai', label: 'AI 엔진', desc: 'Claude Code·OpenAI Codex CLI 실행 파일과 실행 한도입니다. 인증(API 키·OAuth 토큰·서버 로그인)은 관리자 콘솔 › AI 연결 탭(v1.5.16, 이전 설정 › 모델 연결)에서 합니다.', keys: [
{ key: 'ai.claudeBin', env: 'CLAUDE_BIN', type: 'path', label: 'claude 실행 파일', desc: '비우면 PATH 와 알려진 설치 위치(~/.local/bin, ~/.npm-global/bin)에서 자동 탐색', default: '', restart: true },
{ key: 'ai.codexBin', env: 'CODEX_BIN', type: 'path', label: 'codex 실행 파일', desc: '비우면 자동 탐색', default: '', restart: true },
{ key: 'ai.claudeModel', env: 'CLAUDE_MODEL', type: 'text', label: 'Claude 기본 모델(설치값)', desc: '관리자 콘솔 › 설정 › 내부 호출 모델을 정하기 전까지 쓰는 Claude 모델(연결 테스트에도 사용). 콘솔에서 정하면 그 값이 우선', default: 'claude-opus-5-5', restart: true },
{ key: 'ai.claudeMaxTurns', env: 'CLAUDE_MAX_TURNS', type: 'int', min: 1, max: 500, label: 'Claude 최대 턴', desc: '한 작업에서 Claude 가 도구를 쓰는 횟수 상한', default: 80, restart: true },
{ key: 'ai.openaiMaxTurns', env: 'OPENAI_ENGINE_MAX_TURNS', type: 'int', min: 1, max: 500, label: 'OpenAI 호환 엔진 최대 턴', desc: 'API 키로 쓰는 OpenAI 호환 모델의 도구 호출 상한', default: 60, restart: true },
// v1.6.31 (검토 S8): 기본값 끔 — 켜면 claude 자식 프로세스 전체의 TLS 검증이 꺼진다(중간자 위험). 사설 CA 는 NODE_EXTRA_CA_CERTS 로 등록하는 것이 바른 방법
{ key: 'ai.allowInsecureTls', env: 'CLAUDE_ALLOW_INSECURE_TLS', type: 'bool', label: 'Claude 실행 때 TLS 검증 완화 (권장하지 않음)', desc: '켜면 claude 자식 프로세스의 모든 HTTPS 검증이 꺼진다. 사내 프록시(자체 서명 인증서)에서만, 그것도 NODE_EXTRA_CA_CERTS 로 CA 를 등록할 수 없을 때만 켠다', default: false, envTrue: '1', envFalse: '0', restart: true },
{ key: 'ai.codexUnsafe', env: 'CODEX_UNSAFE', type: 'bool', label: 'codex 샌드박스 없는 실행 허용 (위험)', desc: 'v1.6.31: 샌드박스를 만들 수 없을 때 기본은 작업 거부(fail-closed)다. 켜면 샌드박스 없이 실행한다 — 다른 사이트·서버 설정·인증 정보에 접근할 수 있으므로 진단 목적 외에는 켜지 않는다', default: false, envTrue: '1', envFalse: '0', restart: true },
{ key: 'ai.maxJobMinutes', env: 'WM_MAX_JOB_MINUTES', type: 'int', min: 5, max: 240, label: 'AI 작업 최대 실행 시간(분)', desc: 'v1.6.31: 한 작업(생성·수정·기획 단계)이 이 시간을 넘기면 중단한다. 15분 동안 출력이 없어도 중단 — 멈춘 작업이 서버·다른 사용자에게 영향을 주지 않게', default: 45 },
{ key: 'ai.maxConcurrentJobs', env: 'WM_MAX_CONCURRENT_JOBS', type: 'int', min: 1, max: 32, label: '동시 AI 작업 수(전체)', desc: 'v1.6.31: 서버 전체에서 동시에 실행할 AI 작업 수. 넘으면 "잠시 후 다시" 로 거절한다', default: 4 },
{ key: 'ai.codexSandbox', env: 'CODEX_SANDBOX', type: 'enum', options: [['auto', '자동 (커널이 허용하면 샌드박스, 아니면 없이)'], ['workspace-write', '항상 샌드박스 (작업 폴더만 쓰기)'], ['none', '항상 샌드박스 없이']], label: 'codex 샌드박스', desc: 'codex 의 Linux 샌드박스는 비특권 user namespace 가 필요하다. Ubuntu 24.04 기본(kernel.apparmor_restrict_unprivileged_userns=1)에서는 샌드박스 안의 쓰기가 모두 실패하므로 "자동" 은 그때 샌드박스 없이 실행한다. 샌드박스를 쓰려면 sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 (영구: /etc/sysctl.d/60-codex-sandbox.conf) 뒤 재시작', default: 'auto' },
] },
{ group: 'source', label: '소스 보관 연동 (Gitea)', desc: '사이트의 생성·수정 버전을 보관하는 git 서비스입니다. 연동 모듈 lib/connectors/gitea.js 가 이 값으로 접속한다. deploy/install-gitea.sh 가 서비스 서버 안에 전용 Gitea 를 만들고 이 값을 채운다.', keys: [
{ key: 'source.provider', env: 'SOURCE_PROVIDER', type: 'enum', options: [['gitea', 'Gitea'], ['none', '연동 안 함 (로컬 버전 기록만)']], label: '연동 모듈', desc: '다른 git 서비스를 쓰려면 lib/connectors/ 에 모듈을 추가하고 여기서 고른다', default: 'gitea' },
{ key: 'source.remote', env: 'SITES_GIT_REMOTE', type: 'text', label: '푸시 주소 (git 원격)', desc: '예: git@webmaker-gitea:webmaker/sites.git — 서비스 서버의 SSH 키가 그 계정에 등록되어 있어야 한다. 비우면 원격에 보관하지 않는다', default: '' },
{ key: 'source.web', env: 'SITES_GIT_WEB', type: 'url', label: '웹 주소', desc: '운영자가 브라우저로 브랜치를 볼 주소(예: http://192.168.20.23:3030/webmaker/sites). 관리자 콘솔의 버전 링크에 쓴다', default: '' },
{ key: 'source.prefix', env: 'SITES_GIT_PREFIX', type: 'text', label: '브랜치 접두어', desc: '사이트 브랜치 이름 <접두어>/<사이트ID>/main', default: 'sites' },
{ key: 'source.apiUrl', env: 'GITEA_API_URL', type: 'url', label: 'Gitea API 주소', desc: '예: http://192.168.20.23:3030 — 상태 확인·저장소 만들기·SSH 키 등록을 API 로 하려면 넣는다(선택). 토큰은 아래 비밀값', default: '' },
{ key: 'source.user', env: 'GITEA_USER', type: 'text', label: 'Gitea 계정', desc: '저장소 소유 계정(기본 webmaker)', default: 'webmaker' },
{ key: 'source.repo', env: 'GITEA_REPO', type: 'text', label: '저장소', desc: '소유자/이름 (기본 webmaker/sites)', default: 'webmaker/sites' },
{ key: 'source.token', type: 'secret', vaultKind: 'gitea', label: 'Gitea API 토큰', desc: '위 계정의 접근 토큰(repo·user 권한). install-gitea.sh 가 만들어 넣는다. 없으면 API 기능(저장소 만들기·키 등록)만 꺼지고 푸시는 SSH 키로 그대로 된다' },
] },
{ group: 'mail', label: '메일 연동 (Arimail)', desc: 'Arimail(구 mail-gateway)은 받는 메일·웹메일·IMAP/POP/SMTP 를 맡는 메일 서버입니다. 연동 모듈 lib/connectors/arimail.js 가 관리자 API 로 사이트 관리자 계정의 메일함을 만들고 지운다. 보내기(Resend 키)는 관리자 콘솔 › 설정 › 메일 발송 그대로.', keys: [
{ key: 'mail.provider', env: 'MAIL_PROVIDER', type: 'enum', options: [['none', '연동 안 함 (보내기만)'], ['arimail', 'Arimail']], label: '연동 모듈', desc: '다른 메일 서버를 쓰려면 lib/connectors/ 에 모듈을 추가하고 여기서 고른다', default: 'none' },
{ key: 'mail.url', env: 'ARIMAIL_URL', type: 'url', label: 'Arimail 주소 (공개)', desc: '사용자에게 안내하는 웹메일 주소(예: https://mail.example.com). deploy/install-arimail.sh 가 설치하면 자동으로 채운다', default: '' },
{ key: 'mail.apiUrl', env: 'ARIMAIL_API_URL', type: 'url', label: 'Arimail API 주소 (내부)', desc: '웹메이커 서버가 계정을 만들고 지울 때 부르는 주소. 같은 서버에 설치했으면 http://127.0.0.1:8790 처럼 로컬 주소를 넣어 인증서·DNS 와 무관하게 동작시킨다. 비우면 공개 주소를 쓴다', default: '' },
{ key: 'mail.domain', env: 'ARIMAIL_DOMAIN', type: 'text', label: '메일 도메인', desc: 'Arimail 이 다루는 도메인(계정은 아이디@이 도메인). 사이트의 메일 도메인이 이 값과 같을 때 받는 메일·웹메일이 연결된다', default: '' },
{ key: 'mail.adminLogin', env: 'ARIMAIL_ADMIN_LOGIN', type: 'text', label: '관리자 로그인 ID', desc: 'Arimail 관리자 페이지(/admin) 계정. 계정을 만들고 지우는 데 쓴다', default: 'admin' },
{ key: 'mail.adminPassword', type: 'secret', vaultKind: 'arimail', label: '관리자 비밀번호', desc: '위 계정의 비밀번호(암호화 보관). install-arimail.sh 가 설치할 때 무작위로 정해 넣는다' },
// v1.6.38 (요구 2): 메일 서버를 여러 대 등록할 수 있다(관리자 콘솔 › 메일 시스템 › 메일 서버 연동, lib/mailservers.js). 여기 mail.* 는 그중 "기본 서버" 이며 닉네임·IP 는 고를 때 구분용
{ key: 'mail.nickname', env: 'ARIMAIL_NICKNAME', type: 'text', label: '기본 메일 서버 닉네임', desc: '관리자 콘솔에서 메일 서버를 고를 때 보이는 이름(예: ibit 메일). 비우면 호스트 이름', default: '' },
{ key: 'mail.ip', env: 'ARIMAIL_IP', type: 'text', label: '기본 메일 서버 IP', desc: '표시·구분용 서버 IP(예: 115.68.194.192). 다른 서버에 설치한 Arimail 을 구분하기 위한 값이며 접속에는 주소(mail.url / mail.apiUrl)를 쓴다', default: '' },
] },
{ group: 'ssl', label: 'SSL (와일드카드 인증서)', desc: '플랫폼 도메인 와일드카드 인증서(관리자 콘솔 › 설정 › SSL)를 받을 때 쓰는 값입니다.', keys: [
{ key: 'ssl.acmeDir', env: 'WM_ACME_DIR', type: 'path', label: 'ACME 작업 폴더', desc: 'DNS TXT 값을 주고받는 임시 폴더', default: '/var/tmp/webmaker-acme', restart: true },
{ key: 'ssl.acmeWaitMin', env: 'WM_ACME_WAIT_MIN', type: 'int', min: 5, max: 600, label: '수동 DNS 대기(분)', desc: '수동 DNS 방식에서 운영자가 TXT 를 넣을 때까지 기다리는 최대 시간', default: 40, restart: true },
] },
{ group: 'limits', label: '가져오기·복사 한도', desc: '기존 사이트 가져오기·복사의 상한입니다.', keys: [
{ key: 'limits.copyMaxPages', env: 'COPY_MAX_PAGES', type: 'int', min: 1, max: 100000, label: '최대 페이지 수', desc: 'HTML 페이지 상한(무한 링크 방지)', default: 300, restart: true },
{ key: 'limits.copyMaxAssets', env: 'COPY_MAX_ASSETS', type: 'int', min: 1, max: 1000000, label: '최대 자원 파일 수', desc: '이미지·CSS·JS 등 자원 파일 상한', default: 5000, restart: true },
] },
{ group: 'endpoints', label: '외부 API 주소 (고급)', desc: '보통 바꾸지 않습니다. 시험용 대체 서버를 쓸 때만 넣습니다. 접속 키는 관리자 콘솔 › 설정 › 외부 연동 접속 정보에 있습니다.', advanced: true, keys: [
{ key: 'endpoints.resendApiBase', env: 'RESEND_API_BASE', type: 'url', label: 'Resend API', desc: '메일 발송', default: 'https://api.resend.com' },
{ key: 'endpoints.openproviderApiBase', env: 'OPENPROVIDER_API_BASE', type: 'url', label: 'Openprovider API', desc: '도메인 등록·DNS', default: 'https://api.openprovider.eu/v1beta' },
{ key: 'endpoints.googleApiBase', env: 'GOOGLE_API_BASE', type: 'url', label: 'Google API', desc: '검색 등록 자동화', default: 'https://www.googleapis.com' },
{ key: 'endpoints.googleTokenUrl', env: 'GOOGLE_TOKEN_URL', type: 'url', label: 'Google 토큰 URL', desc: '서비스 계정 인증', default: 'https://oauth2.googleapis.com/token' },
{ key: 'endpoints.indexnowEndpoint', env: 'INDEXNOW_ENDPOINT', type: 'url', label: 'IndexNow', desc: '검색 엔진 색인 요청', default: 'https://api.indexnow.org/indexnow' },
] },
];
/** key → 항목 정의 */
const KEYS = Object.fromEntries(SCHEMA.flatMap((g) => g.keys.map((k) => [k.key, { ...k, group: g.group }])));
// ---------- 파일 ----------
let cache = null; let cacheMtime = -1;
function emptyFile() { return { version: 1, updatedAt: null, updatedBy: null, values: {} }; }
function mtimeOf() { try { return fs.statSync(FILE).mtimeMs; } catch (_) { return 0; } }
/**
* 파일 읽기 (없거나 깨졌으면 빈 설정). exists 로 파일 존재 여부를 함께 돌려준다.
* 파일의 수정 시각이 바뀌면 다시 읽는다 — 설치 스크립트(scripts/config.js)나 다른 프로세스(setup-server.js)가 파일을 고쳐도
* 실행 중인 서비스가 다음 호출부터 새 값을 보게 (재시작이 필요한 항목은 SCHEMA 의 restart 표시대로 재시작 뒤 적용).
*/
function load() {
const mt = mtimeOf(); if (cache && mt === cacheMtime) return cache;
// v1.6.39 (요구 2): 파일이 깨졌으면 lib/jsonstore 가 config.json.bak(직전 정상본)을 읽어 준다. 그래도 못 읽으면 broken 으로 표시
try { const raw = fs.readFileSync(FILE, 'utf8'); let d; try { d = JSON.parse(raw); } catch (e) { d = require('./jsonstore').readJson(FILE, null); if (!d) throw e; } cache = { ...emptyFile(), ...d, values: d.values || {}, exists: true }; }
catch (e) { cache = { ...emptyFile(), exists: e.code !== 'ENOENT' ? true : false, broken: e.code !== 'ENOENT' ? e.message : null }; }
cacheMtime = mt; return cache;
}
function save(d, by) {
const out = { version: 1, updatedAt: new Date().toISOString(), updatedBy: by || 'setup', values: d.values };
// v1.6.39 (요구 2): 직전 정상본을 config.json.bak 으로 남긴다 (잘못 저장한 값을 운영자가 되돌릴 수 있게)
require('./jsonstore').writeJsonAtomic(FILE, out, { mode: 0o600, keepBackup: true, indent: 2 });
try { fs.chmodSync(FILE + '.bak', 0o600); } catch (_) {}
cache = { ...out, exists: true }; cacheMtime = mtimeOf(); return cache;
}
function exists() { return load().exists; }
// ---------- 값 해석 ----------
/** 문자열(환경 변수·입력) → 항목 type 에 맞는 값. 틀리면 예외 */
function parse(def, raw) {
if (raw === undefined || raw === null) return undefined;
const s = typeof raw === 'string' ? raw.trim() : raw;
if (s === '' || s === null) return '';
switch (def.type) {
case 'int': { const n = Number(s); if (!Number.isInteger(n)) throw httpError(`${def.label}은(는) 정수로 입력하세요`); if (def.min !== undefined && n < def.min) throw httpError(`${def.label}은(는) ${def.min} 이상이어야 합니다`); if (def.max !== undefined && n > def.max) throw httpError(`${def.label}은(는) ${def.max} 이하여야 합니다`); return n; }
case 'bool': { if (typeof s === 'boolean') return s; const v = String(s).toLowerCase(); if (['1', 'true', 'yes', 'on'].includes(v)) return true; if (['0', 'false', 'no', 'off'].includes(v)) return false; throw httpError(`${def.label}은(는) 켬/끔 값이어야 합니다`); }
case 'url': { const v = String(s); if (!/^https?:\/\/[^\s/]+/i.test(v)) throw httpError(`${def.label}은(는) http:// 또는 https:// 로 시작하는 주소여야 합니다`); return v.replace(/\/+$/, ''); }
case 'path': { const v = String(s); if (!v.startsWith('/')) throw httpError(`${def.label}은(는) / 로 시작하는 절대 경로여야 합니다`); return v; }
case 'enum': { const v = String(s); if (!def.options.some((o) => o[0] === v)) throw httpError(`${def.label}: 고를 수 없는 값입니다 (${def.options.map((o) => o[0]).join(', ')})`); return v; }
case 'list': { const arr = Array.isArray(s) ? s : String(s).split(','); return arr.map((x) => String(x).trim()).filter(Boolean).join(','); }
default: return String(s);
}
}
/** 값 → 환경 변수 문자열 */
function toEnv(def, v) {
if (v === undefined || v === null || v === '') return '';
if (def.type === 'bool') return v ? (def.envTrue || '1') : (def.envFalse || '0');
return String(v);
}
/** 환경 변수 문자열 → 저장값 (migrate 용, 틀린 값은 건너뜀) */
function fromEnv(def, raw) { try { const v = parse(def, raw); return v === undefined ? undefined : v; } catch (_) { return undefined; } }
/**
* 항목의 "지금 동작하는 값" 과 출처.
* - deployTime : 환경 변수 > 파일 > 기본값
* - 그 밖 : 파일 > 환경 변수 > 기본값
* @returns {{value, source:'file'|'env'|'default'}}
*/
function effective(key) {
const def = KEYS[key]; if (!def) throw httpError(`알 수 없는 설정 항목: ${key}`);
if (def.type === 'secret') { let hint = ''; let has = false; try { const v = require('./vault').get(def.vaultKind); has = !!(v && v.hasSecret); hint = v ? v.secretHint : ''; } catch (_) {} return { value: has ? '••••' : '', source: has ? 'vault' : 'default', hasSecret: has, secretHint: hint }; }
const fileV = load().values[key]; const envRaw = def.env ? process.env[def.env] : undefined;
const envV = envRaw !== undefined && envRaw !== '' ? fromEnv(def, envRaw) : undefined;
if (def.deployTime) { if (envV !== undefined) return { value: envV, source: 'env' }; if (fileV !== undefined && fileV !== '') return { value: fileV, source: 'file' }; return { value: def.default, source: 'default' }; }
if (fileV !== undefined && fileV !== '') return { value: fileV, source: 'file' };
// 파일에 빈 값('')을 명시적으로 저장한 경우 = "비움" — 환경 변수가 있어도 쓰지 않는다 (예: 원격 보관 끄기)
if (fileV === '' && load().values.hasOwnProperty(key)) return { value: '', source: 'file' };
if (envV !== undefined) return { value: envV, source: 'env' };
return { value: def.default, source: 'default' };
}
/** 동작 값만 (secret 은 보관소에서 복호화한 원문) */
function get(key) {
const def = KEYS[key]; if (!def) throw httpError(`알 수 없는 설정 항목: ${key}`);
if (def.type === 'secret') { try { return require('./vault').secretOf(def.vaultKind); } catch (_) { return ''; } }
const v = effective(key).value; return v === undefined || v === null ? '' : v;
}
/**
* 파일 값을 process.env 에 반영한다 (서비스 기동 맨 처음, 다른 모듈을 require 하기 전에 부른다).
* 파일이 없으면 환경 변수의 값으로 파일을 만든다(migrateFromEnv) — 1.5.7 로 올린 기존 서버의 값이 그대로 보존된다.
* @returns {{created:boolean, file:string, applied:string[]}}
*/
function apply() {
let created = false;
if (!exists()) { migrateFromEnv('migrate:env'); created = true; }
const applied = [];
for (const def of Object.values(KEYS)) {
if (!def.env || def.type === 'secret') continue;
const e = effective(def.key);
if (def.deployTime) { if (e.source === 'file') { process.env[def.env] = toEnv(def, e.value); applied.push(def.env); } continue; } // 환경 변수가 없을 때만 파일 값으로
if (e.source === 'file') { const s = toEnv(def, e.value); if (s === '') delete process.env[def.env]; else process.env[def.env] = s; applied.push(def.env); }
}
return { created, file: FILE, applied };
}
/**
* 환경 변수(systemd 유닛)에 있는 값을 파일로 옮겨 적는다. 이미 파일에 있는 항목은 건드리지 않는다.
* @param {string} by 기록 출처
* @param {object} [envObj] 읽을 환경(기본 process.env). 설치 스크립트가 유닛 파일을 읽어 넘길 때 쓴다
* @returns {string[]} 옮긴 항목 key
*/
function migrateFromEnv(by = 'migrate:env', envObj = process.env) {
const d = load(); const moved = []; const values = { ...d.values };
for (const def of Object.values(KEYS)) {
if (!def.env || def.type === 'secret' || values.hasOwnProperty(def.key)) continue;
const raw = envObj[def.env]; if (raw === undefined || raw === '') continue;
const v = fromEnv(def, raw); if (v === undefined) continue;
values[def.key] = v; moved.push(def.key);
}
if (moved.length || !d.exists) save({ values }, by);
return moved;
}
/**
* 값 저장 (웹 설정 페이지·관리자 콘솔·CLI).
* @param {object} patch { '<group>.<key>': 값 } — secret 은 원문을 주면 보관소에 저장, null 이면 보관소에서 지움, '••••' 또는 undefined 는 유지
* @param {string} by
* @returns {{changed:string[], restart:boolean}} 바뀐 항목과 "재시작해야 적용되는 항목이 있었는지"
*/
function set(patch, by = 'setup') {
if (!patch || typeof patch !== 'object') throw httpError('저장할 값이 없습니다');
const d = load(); const values = { ...d.values }; const changed = []; let restart = false; const after = [];
for (const [key, raw] of Object.entries(patch)) {
const def = KEYS[key]; if (!def) throw httpError(`알 수 없는 설정 항목: ${key}`);
if (def.deployTime) throw httpError(`${def.label}은(는) 배포 때 정해지는 값이라 여기서 바꿀 수 없습니다 (systemd 유닛과 웹서버 설정을 함께 고치세요)`);
if (def.type === 'secret') {
if (raw === undefined || raw === '••••') continue;
const vault = require('./vault');
if (raw === null || raw === '') { after.push(() => { try { vault.remove(def.vaultKind); } catch (_) {} }); changed.push(key); continue; }
const sec = String(raw); after.push(() => vault.set({ kind: def.vaultKind, secret: sec }, `config:${by}`)); changed.push(key); continue;
}
const v = parse(def, raw); if (v === undefined) continue;
if (def.required && v === '') throw httpError(`${def.label}은(는) 비워 둘 수 없습니다`);
if (values[key] === v) continue;
values[key] = v; changed.push(key); if (def.restart) restart = true;
}
if (!changed.length) return { changed, restart: false };
for (const fn of after) fn(); // 검증을 모두 통과한 뒤 보관소에 쓴다
save({ values }, by);
// 재시작 없이 바뀌어도 되는 값은 지금 바로 process.env 에 반영 (모듈이 호출 시점에 env 를 읽는 경우 즉시 적용)
for (const key of changed) { const def = KEYS[key]; if (def.env && def.type !== 'secret') { const s = toEnv(def, values[key]); if (s === '') delete process.env[def.env]; else process.env[def.env] = s; } }
return { changed, restart };
}
function unset(key, by = 'cli') { const def = KEYS[key]; if (!def) throw httpError(`알 수 없는 설정 항목: ${key}`); const d = load(); const values = { ...d.values }; delete values[key]; save({ values }, by); if (def.env && def.type !== 'secret') delete process.env[def.env]; return true; }
/** 화면용: 그룹·항목·동작 값·출처 (비밀값 원문 없음) */
function publicView() {
const d = load();
return {
file: FILE, exists: d.exists, broken: d.broken || null, updatedAt: d.updatedAt, updatedBy: d.updatedBy,
groups: SCHEMA.map((g) => ({ group: g.group, label: g.label, desc: g.desc, advanced: !!g.advanced, keys: g.keys.map((k) => { const e = effective(k.key); return { key: k.key, label: k.label, desc: k.desc, type: k.type, options: k.options || null, default: k.default === undefined ? '' : k.default, env: k.env || null, deployTime: !!k.deployTime, restart: !!k.restart, required: !!k.required, min: k.min, max: k.max, value: e.value === undefined ? '' : e.value, source: e.source, hasSecret: !!e.hasSecret, secretHint: e.secretHint || '', fileValue: d.values.hasOwnProperty(k.key) ? d.values[k.key] : undefined }; }) })),
};
}
/** 설정 요약 (로그·CLI list) */
function summary() { const out = {}; for (const def of Object.values(KEYS)) { const e = effective(def.key); out[def.key] = def.type === 'secret' ? (e.hasSecret ? `(보관소 ${e.secretHint})` : '(없음)') : e.value; } return out; }
/**
* 이 서버의 사설 IPv4 목록 (설정 페이지 주소 안내·바인드용). 도커 브리지·가상 인터페이스(docker0, br-*, veth*, virbr*, tun/wg …)는 뺀다 —
* 운영 서버에 컨테이너가 많아 172.x 주소가 열 개씩 나오던 안내를 정리(1.5.7 배포 때 확인). 사설 대역(10/8, 172.16/12, 192.168/16)을 앞에 둔다.
*/
function lanIps() {
const out = []; const skip = /^(docker|br-|veth|virbr|lxc|lxd|tun|tap|wg|zt|tailscale|cni|flannel|cali)/i;
for (const [name, list] of Object.entries(os.networkInterfaces())) { if (skip.test(name)) continue; for (const i of list || []) if (i.family === 'IPv4' && !i.internal) out.push(i.address); }
const priv = (a) => /^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/.test(a);
return [...out.filter(priv), ...out.filter((a) => !priv(a))];
}
/** 설정 페이지가 실제로 붙을 주소: 'lan' → 첫 사설 IP(없으면 127.0.0.1), 그 밖(기본 0.0.0.0)은 입력값 그대로 */
function setupBindAddress() { const v = String(get('service.setupBind') || '0.0.0.0').trim(); if (v !== 'lan') return v; const ips = lanIps(); return ips.find((a) => /^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/.test(a)) || ips[0] || '127.0.0.1'; }
/**
* v1.5.11: 초기 설정 페이지 접속 주소 목록 (설치 스크립트·기동 로그·점검표 안내용).
* 바인드가 특정 IP 면 그 주소 하나, 아니면 이 서버의 IP 들(사설 → 공인 순). FQDN 을 모르는 서버에서는 http://<IP>:8795/ 가 기본 접속 위치다.
*/
function setupUrls(token) {
const port = parseInt(String(get('service.setupPort') || 8795), 10) || 8795; const bind = setupBindAddress();
const ips = bind === '0.0.0.0' || bind === '::' ? lanIps() : [bind];
return (ips.length ? ips : ['127.0.0.1']).map((ip) => `http://${ip}:${port}/${token ? `?token=${token}` : ''}`);
}
module.exports = { SCHEMA, KEYS, FILE, load, exists, apply, migrateFromEnv, set, unset, get, effective, publicView, summary, lanIps, setupBindAddress, setupUrls, parse };
+259
View File
@@ -0,0 +1,259 @@
'use strict';
// ============================================================================
// 메일 서버 연동 모듈 — Arimail (v1.5.7, 요구 5 · v1.6.38 요구 2: 여러 서버)
//
// Arimail 은 OZ-Group 의 메일 게이트웨이(mail-gateway, 0.9.x 에서 AriMail 로 이름이 바뀜)다. Resend 로 받은 메일을 보관하고
// 웹메일(/), 관리자 페이지(/admin), IMAP(993/143)·POP3(995/110)·SMTP 제출(465/587) 서버를 연다. 한 설치는 한 메일 도메인
// (환경 파일 MAILBOX=admin@도메인)의 계정 "로그인ID@도메인" 을 다룬다.
//
// 이 모듈이 하는 일 — 웹메이커 쪽 "사이트 메일 시스템"(lib/mailsys.js)의 받는 메일 백엔드
// - 관리자 API 로 계정을 만들고 지운다: 사이트 관리자 계정의 "메일 사용" 을 켤 때 Arimail 계정(같은 로그인 ID)을 만들고,
// 끌 때 지운다. 비밀번호는 Arimail 이 PBKDF2 로 따로 보관하므로 켤 때 사용자가 메일 비밀번호를 정한다
// (웹메이커는 사이트 관리자 비밀번호의 원문을 갖고 있지 않다 — scrypt 해시만).
// - 상태: /api/health(버전) 와 관리자 목록(계정 수·제한)으로 연결 확인.
// - 주소: 웹메일 주소(사이트의 /webmail 진입 파일과 "웹메일 열기" 버튼이 이곳으로), 메일 프로그램 설정값(IMAP/POP/SMTP 호스트·포트).
//
// Arimail 관리자 API (server/api.mjs)
// POST /api/login {login,password} 헤더 X-Mail-Scope: admin → Set-Cookie: mg_admin=<토큰> (관리자 역할 계정만, 15분 10회 제한)
// GET /api/admin/users → { users:[{id,login,name,mailbox,role,status,…}], domain, accounts:{used,limit}, policy, … }
// POST /api/admin/users {login,name,password,role?,status?} → { id } (오류 코드: login_taken, weak_password, account_limit_reached, invalid_login)
// PATCH /api/admin/users/<id> {name?,password?,role?,status?} DELETE /api/admin/users/<id>
// GET /api/admin/status 서버 상태(포트·TLS·동기화)
// 오류 응답은 { ok:false, error:'<코드>' } 와 HTTP 상태. 세션 쿠키는 Secure 속성이 있지만 서버 간 호출에서는 우리가 직접 붙이므로 http 로도 쓸 수 있다.
//
// v1.6.38 (요구 2): Arimail 이 웹메이커와 다른 서버에, 그것도 여러 대 있을 수 있다(webmaker.ibit.kr 121.152.149.45 ↔ webmail.ibit.kr 115.68.194.192).
// 접속 정보는 등록부(lib/mailservers.js)가 준다 — 설정 파일 mail.* 의 "기본 서버" 와 관리자 콘솔에서 추가한 서버들.
// 모든 동작은 forServer(server) 로 서버를 골라 쓴다. 모듈 최상위 함수들은 "기본 서버" 에 묶인 것이라 1.5.7 호출 코드가 그대로 돈다.
// 관리자 세션 쿠키는 서버(주소+계정)마다 따로 20분 보관한다.
// v1.6.39 (요구 2·3·4):
// - 로그인 실패에 오류 코드를 붙이고, rate_limited(15분 10회 제한) 이면 등록부(lib/mailservers.js)에 잠금 해제 시각을 적는다.
// - 사이트 관리자 비밀번호 ↔ 메일 비밀번호 동기화(setPassword): 웹메이커는 해시만 가지므로 "원문을 아는 순간"(사이트 관리자 로그인·메일 사용 켜기)에
// lib/mailsys.syncPassword 가 Arimail 계정 비밀번호를 같은 값으로 맞춘다 — 분리 설치(다른 서버)에서도 웹메이커 계정으로 웹메일에 들어간다.
// - 관리자 계정 보호(protectAdmin): 등록된 연동 관리자·관리자 역할 계정은 웹메이커에서 지우거나 역할을 내릴 수 없다.
// ============================================================================
const id = 'arimail';
const label = 'Arimail (메일 게이트웨이)';
const kind = 'mail';
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
/** Arimail 오류 코드 → 사용자 문구 */
const ERRORS = {
invalid_credentials: 'Arimail 관리자 로그인에 실패했습니다 (아이디·비밀번호 확인 — 관리자 콘솔 › 메일 시스템 › 메일 서버 연동, 기본 서버는 설정 파일 mail.adminLogin / 보관소 arimail)',
admin_only: 'Arimail 에 등록한 계정이 관리자 역할이 아닙니다',
admin_login_required: 'Arimail 관리자 세션이 없습니다',
rate_limited: 'Arimail 로그인 시도가 너무 많아 잠시 막혔습니다 (15분 뒤 다시)',
login_taken: '같은 로그인 ID 의 Arimail 계정이 이미 있습니다',
weak_password: '메일 비밀번호가 Arimail 의 비밀번호 정책에 맞지 않습니다 (관리자 페이지 › 비밀번호 정책)',
account_limit_reached: 'Arimail 의 계정 수 제한(LIMIT_ACCOUNTS)에 이르렀습니다',
invalid_login: '로그인 ID 를 Arimail 계정으로 쓸 수 없습니다 (영문 소문자·숫자·. _ - 2~64자)',
not_found: 'Arimail 에 그 계정이 없습니다',
validation: 'Arimail 이 입력값을 거부했습니다',
cannot_delete_self: 'Arimail 관리자 자신의 계정은 지울 수 없습니다',
pending_approval: '가입 승인을 기다리는 계정입니다',
// v1.7.3 (요구 2): 사용량 API(1.0.9) 와 키 발급(POST /api/setup/usage-key) 오류
usage_api_disabled: 'Arimail 의 사용량 API 가 꺼져 있습니다(키를 만들지 않았거나 지움) — 관리자 콘솔 › 메일 시스템 › 메일 서버 연동의 "사용량 API 키 발급" 을 누르거나 Arimail 전체 설정 › 사용량 API 카드에서 키를 만들어 넣으세요',
unauthenticated: '사용량 API 키가 Arimail 의 키와 다릅니다 — Arimail 에서 키를 다시 만들었다면 새 키를 넣거나 "사용량 API 키 발급" 으로 다시 받으세요',
forbidden_ip: '이 웹메이커 서버의 IP 가 Arimail 사용량 API 의 접속 허용 IP 목록에 없습니다 — Arimail 전체 설정 › 사용량 API › 접속 허용 IP 에 이 서버의 공인 IP(같은 서버면 127.0.0.1 도)를 더하거나 목록을 비우세요',
primary_only: '사용량 API 키는 Arimail 기본 도메인에서만 만들 수 있습니다 — 이 서버 등록의 API 주소를 기본 도메인 호스트로 바꾸거나, Arimail 전체 설정 화면에서 만든 키를 직접 넣으세요',
bad_request: 'Arimail 이 요청을 이해하지 못했습니다 (Arimail 버전이 낮을 수 있음 — 사용량 API 는 1.0.9 부터)',
};
/** v1.7.3: 사용량 API 가 들어간 Arimail 최소 버전 */
const USAGE_MIN_VERSION = '1.0.9';
/** 'x.y.z(-접미)' 두 버전 비교 — a<b 면 음수. 숫자가 아니면 0(비교 불가 → 막지 않음) */
function cmpVersion(a, b) { const pa = String(a || '').match(/^(\d+)\.(\d+)\.(\d+)/), pb = String(b || '').match(/^(\d+)\.(\d+)\.(\d+)/); if (!pa || !pb) return 0; for (let i = 1; i <= 3; i++) { const d = Number(pa[i]) - Number(pb[i]); if (d) return d; } return 0; }
const explain = (code, status) => ERRORS[code] || `Arimail 오류 ${code || status}`;
/** 서버 객체(등록부) → 접속 정보. 서버가 없으면 모두 빈 값 */
function toSettings(server) {
const s = server || {};
const url = String(s.url || '').replace(/\/+$/, '');
let host = s.host || ''; if (!host) { try { host = new URL(url).hostname; } catch (_) {} }
return { id: s.id || '', nickname: s.nickname || '', ip: s.ip || '', url, apiUrl: String(s.apiUrl || '').replace(/\/+$/, '') || url, host, domain: String(s.domain || '').trim().toLowerCase(), adminLogin: String(s.adminLogin || 'admin').trim(), adminPassword: s.adminPassword || '', usageKey: s.usageKey || '' /* v1.7.3: 사용량 API 키(보관소) */ };
}
/** 등록부의 기본 서버 (없으면 null) — 설정 파일 mail.* 또는 관리자 콘솔에서 기본으로 지정한 서버 */
function defaultServer() { try { return require('../mailservers').defaultServer(); } catch (_) { return null; } }
// ---------- 관리자 세션 (서버마다) ----------
/** 로그인 결과 쿠키를 잠시 재사용한다 (같은 주소·계정, 20분). 401 이 나면 다시 로그인한다. 키 = 주소|계정 */
const sessions = new Map();
// 키에 비밀번호의 짧은 해시를 넣어 관리자 콘솔에서 비밀번호를 바꾸면 이전 세션을 버리고 다시 로그인하게 한다 (비밀번호 원문은 키에 넣지 않음)
const sessionKey = (s) => `${s.apiUrl}|${s.adminLogin}|${require('crypto').createHash('sha256').update(String(s.adminPassword || '')).digest('hex').slice(0, 12)}`;
/** 서버 이름표(오류 문구용) — 닉네임이 있으면 닉네임, 없으면 호스트 */
const nameOf = (s) => (s.nickname ? `${s.nickname}${s.ip ? ' ' + s.ip : ''}` : s.host || s.apiUrl);
/**
* 한 서버에 묶인 동작 묶음. mailsys 는 사이트마다 고른 서버로 이것을 만들어 쓴다.
* @param {object|null} server 등록부 서버 객체(비밀번호 포함). null 이면 기본 서버
*/
function forServer(server) {
const resolve = () => toSettings(server || defaultServer());
const settings = () => resolve();
const configured = () => { const s = resolve(); return !!(s.url && s.domain && s.adminLogin && s.adminPassword); };
/** 웹메일·관리자 페이지 주소 */
const webmailUrl = () => { const s = resolve(); return s.url ? s.url + '/' : null; };
const adminUrl = () => { const s = resolve(); return s.url ? s.url + '/admin' : null; };
/** 메일 프로그램 설정값 (사용자 안내용). Arimail 은 웹·IMAP·POP·SMTP 가 같은 아이디·비밀번호다 */
const clientSettings = () => { const s = resolve(); if (!s.host) return null; return { host: s.host, imap: { host: s.host, ports: [993, 143], note: '993 SSL/TLS 또는 143 STARTTLS' }, pop: { host: s.host, ports: [995, 110], note: '995 SSL/TLS 또는 110 STARTTLS' }, smtp: { host: s.host, ports: [465, 587], note: '465 SSL/TLS 또는 587 STARTTLS, 인증: 메일 비밀번호' }, username: '로그인 ID 또는 로그인ID@' + s.domain }; };
/** 로그인 ID → 메일 주소 */
const mailboxFor = (login) => `${String(login).toLowerCase()}@${resolve().domain}`;
async function login() {
const s = resolve(); if (!s.url) throw httpError('Arimail 주소가 없습니다 (관리자 콘솔 › 메일 시스템 › 메일 서버 연동)', 503); if (!s.adminPassword) throw httpError(`Arimail 관리자 비밀번호가 보관소에 없습니다 (${nameOf(s)} — 메일 서버 연동에서 비밀번호를 다시 넣으세요)`, 503);
let r; try { r = await fetch(`${s.apiUrl}/api/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Mail-Scope': 'admin' }, body: JSON.stringify({ login: s.adminLogin, password: s.adminPassword }), signal: AbortSignal.timeout(15000) }); }
catch (e) { throw httpError(`Arimail 연결 실패 (${nameOf(s)}, ${s.apiUrl}): ${e.cause ? e.cause.code || e.message : e.message}`, 502); }
const data = await r.json().catch(() => ({}));
if (!r.ok) {
// v1.6.39 (요구 2): 오류 코드를 예외에 붙여 호출자가 구분할 수 있게 하고, 로그인 잠금(rate_limited / 429)이면 등록부에 해제 시각을 적어
// 관리자 콘솔의 "연결 확인" 이 잠금이 풀릴 때까지 다시 시도하지 않게 한다 (시도할수록 잠금이 길어진다)
const code = data.error || (r.status === 429 ? 'rate_limited' : null);
const err = Object.assign(httpError(explain(code, r.status), 502), { code });
if (code === 'rate_limited') { try { err.lockedUntil = require('../mailservers').noteLocked(s.id); } catch (_) {} }
throw err;
}
// Set-Cookie: mg_admin=<토큰>; Path=/; HttpOnly; Secure; … — 토큰 부분만 보관한다
const raw = (typeof r.headers.getSetCookie === 'function' ? r.headers.getSetCookie() : [r.headers.get('set-cookie')].filter(Boolean)).find((c) => /^mg_admin=/.test(c)) || '';
const m = raw.match(/^(mg_admin=[^;]*)/); if (!m) throw httpError('Arimail 로그인 응답에 세션 쿠키가 없습니다', 502);
sessions.set(sessionKey(s), { cookie: m[1], at: Date.now() });
return m[1];
}
async function cookie() { const s = resolve(); const c = sessions.get(sessionKey(s)); if (c && Date.now() - c.at < 20 * 60 * 1000) return c.cookie; return login(); }
/**
* 관리자 API 호출. 401/403(세션 없음)이면 한 번 다시 로그인해서 재시도한다.
* @returns 응답 JSON (ok:false 면 예외)
*/
async function call(method, p, body, retried = false) {
const s = resolve(); const ck = await cookie();
let r; try { r = await fetch(`${s.apiUrl}/api${p}`, { method, headers: { 'Content-Type': 'application/json', 'X-Mail-Scope': 'admin', Cookie: ck }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(20000) }); }
catch (e) { throw httpError(`Arimail 연결 실패 (${nameOf(s)}, ${s.apiUrl}): ${e.cause ? e.cause.code || e.message : e.message}`, 502); }
const data = await r.json().catch(() => ({}));
if ((r.status === 401 || (r.status === 403 && data.error === 'admin_login_required')) && !retried) { sessions.delete(sessionKey(s)); return call(method, p, body, true); }
if (!r.ok) throw Object.assign(httpError(explain(data.error, r.status), r.status >= 500 ? 502 : 400), { code: data.error || null });
return data;
}
// ---------- 계정 ----------
async function health() {
const s = resolve(); if (!s.url) return { ok: false, detail: 'Arimail 주소가 없습니다' };
try { const r = await fetch(`${s.apiUrl}/api/health`, { signal: AbortSignal.timeout(8000) }); const d = await r.json().catch(() => ({})); return { ok: r.ok && d.ok !== false, version: d.version || null, status: r.status, detail: r.ok ? `Arimail ${d.version || ''} 응답` : `응답 ${r.status}` }; }
catch (e) { return { ok: false, detail: `연결 실패 (${s.apiUrl}): ${e.cause ? e.cause.code || e.message : e.message}` }; }
}
/** 계정 목록 + 도메인·제한 */
async function listUsers() { const d = await call('GET', '/admin/users'); return { users: d.users || [], domain: d.domain || resolve().domain, accounts: d.accounts || null, policy: d.policy || null, limits: d.limits || null }; }
async function findUser(login) { const l = String(login || '').toLowerCase(); const { users } = await listUsers(); return users.find((u) => String(u.login).toLowerCase() === l) || null; }
/**
* 계정 만들기. 같은 로그인 ID 가 이미 있으면 그 계정을 돌려준다(existing:true) — 비밀번호가 주어졌으면 그 비밀번호로 재설정.
* @param {{login:string, name?:string, password:string, role?:'admin'|'user', reuseId?:string}} o
*/
async function createUser(o) {
const login = String(o.login || '').toLowerCase(); if (!login) throw httpError('로그인 ID 가 없습니다');
if (!o.password) throw httpError('메일 비밀번호를 입력하세요 (Arimail 계정의 웹메일·IMAP·POP·SMTP 비밀번호)');
try { const r = await call('POST', '/admin/users', { login, name: o.name || login, password: o.password, role: o.role === 'admin' ? 'admin' : 'user' }); return { id: r.id, login, mailbox: mailboxFor(login), existing: false }; }
catch (e) {
if (e.code !== 'login_taken') throw e;
const u = await findUser(login); if (!u) throw e;
// v1.6.31 (요구 9, 검토 S3): 이미 있는 계정은 이 사이트가 소유한 것(o.reuseId === u.id)일 때만 비밀번호를 새로 정한다. 아니면 다른 사이트의 계정일 수 있으므로 건드리지 않고 알린다
if (!(o.reuseId && String(o.reuseId) === String(u.id))) throw Object.assign(httpError(`메일 서버에 같은 아이디(${login})의 계정이 이미 있습니다. 다른 사이트의 계정일 수 있어 그대로 두었습니다 — 다른 아이디를 쓰거나 관리자가 소유를 확인한 뒤 연결하세요`, 409), { code: 'login_conflict', providerId: u.id });
if (o.password) await call('PATCH', `/admin/users/${encodeURIComponent(u.id)}`, { password: o.password, status: 'active' });
return { id: u.id, login, mailbox: u.mailbox || mailboxFor(login), existing: true };
}
}
/**
* v1.6.39 (요구 4): 관리자 계정 보호 — 등록된 연동 관리자 계정(adminLogin)과 Arimail 의 관리자 역할 계정은 웹메이커가 지우거나 역할·상태를 내리지 않는다.
* 웹메이커가 다루는 것은 사이트 메일 계정(user 역할)뿐이다. 서버 기본 관리자를 잃으면 Arimail 자체에 들어갈 길이 없어진다.
*/
function protectAdmin(u, what) {
const s = resolve(); if (!u) return;
if (String(u.login || '').toLowerCase() === String(s.adminLogin || '').toLowerCase() || u.role === 'admin') throw Object.assign(httpError(`Arimail 관리자 계정 "${u.login}" 은(는) 웹메이커에서 ${what} 수 없습니다 — 서버 기본 관리자·연동 관리자 계정은 보호됩니다 (필요하면 Arimail 관리자 페이지 ${adminUrl() || ''} 에서 직접)`, 403), { code: 'protected_admin' });
}
async function updateUser(userId, fields) {
// 역할을 user 로 내리거나 상태를 비활성으로 바꾸는 요청은 관리자 계정이면 거절 (비밀번호·이름 변경, 역할을 admin 으로 올리기는 허용)
if ((fields && fields.role && fields.role !== 'admin') || (fields && fields.status && fields.status !== 'active')) protectAdmin(await getUserById(userId), '역할·상태를 바꿀');
await call('PATCH', `/admin/users/${encodeURIComponent(userId)}`, fields); return true;
}
/** v1.6.31 (요구 9): 공급자 계정 id 로 조회·삭제 — 로그인 이름이 같은 다른 사이트의 계정을 건드리지 않는다 */
async function getUserById(uid) { const { users } = await listUsers(); return users.find((u) => String(u.id) === String(uid)) || null; }
async function deleteUserById(uid) { protectAdmin(await getUserById(uid), '지울'); await call('DELETE', `/admin/users/${encodeURIComponent(uid)}`); return true; }
/** 로그인 ID 로 계정을 찾아 지운다 (없으면 false) */
async function deleteUserByLogin(login) { const u = await findUser(login); if (!u) return false; protectAdmin(u, '지울'); await call('DELETE', `/admin/users/${encodeURIComponent(u.id)}`); return true; }
/**
* v1.6.39 (요구 3): 로그인 ID 의 계정 비밀번호를 바꾼다 (사이트 관리자 비밀번호 ↔ 메일 비밀번호 동기화용). 계정이 없으면 null
* @returns {Promise<string|null>} 바꾼 계정의 id
*/
async function setPassword(login, password) { if (!password) throw httpError('비밀번호가 비어 있습니다'); const u = await findUser(login); if (!u) return null; await call('PATCH', `/admin/users/${encodeURIComponent(u.id)}`, { password }); return u.id; }
async function adminStatus() { return call('GET', '/admin/status'); }
// ---------- v1.7.3 (요구 2): 사용량 API (docs/USAGE-API.md, Arimail 1.0.9) ----------
/**
* 사용량 API 를 쓸 수 있는 버전인지 health 로 확인. 낮은 버전이면 안내 예외(1.0.8 은 /api/usage 가 없어 401/404 만 돌려주므로 "키가 틀렸다" 로 오해하기 전에 막는다)
* @returns {Promise<string|null>} 버전 문자열
*/
async function requireUsageVersion() {
const s = resolve(); const h = await health(); if (!h.ok) throw httpError(`Arimail(${nameOf(s)})에 연결할 수 없습니다 — ${h.detail}`, 502);
if (h.version && cmpVersion(h.version, USAGE_MIN_VERSION) < 0) throw Object.assign(httpError(`Arimail ${h.version} 에는 사용량 API 가 없습니다 — ${nameOf(s)} 의 Arimail 을 ${USAGE_MIN_VERSION} 이상으로 업데이트하면 메일 사용량이 보입니다`, 409), { code: 'usage_unsupported', version: h.version });
return h.version || null;
}
/**
* 사용량 조회 — GET /api/usage(모든 도메인 요약) 또는 /api/usage/<도메인>(계정 목록·일별 추이 포함). 키는 등록부 서버 객체의 usageKey(보관소).
* 응답은 Arimail 문서 그대로(totals/domains[] 또는 storage/traffic/accountList…). 실패는 코드(code)를 붙인 예외 — 호출자는 문구를 그대로 보여 주면 된다.
* @param {string} [domain] 비우면 전체 요약
* @param {{days?:number}} [o] 일별 추이·계정별 기간 합계의 기간(1~366, 기본 30)
*/
async function usage(domain = '', o = {}) {
const s = resolve(); const key = String(s.usageKey || '');
if (!s.apiUrl) throw httpError('Arimail 주소가 없습니다 (관리자 콘솔 › 메일 시스템 › 메일 서버 연동)', 503);
if (!key) throw Object.assign(httpError(`메일 서버 "${nameOf(s)}" 의 사용량 API 키가 없습니다 — 관리자 콘솔 › 메일 시스템 › 메일 서버 연동에서 "사용량 API 키 발급" 을 누르거나 Arimail 전체 설정 › 사용량 API 카드에서 만든 키를 넣으세요`, 409), { code: 'no_usage_key' });
const days = Math.min(366, Math.max(1, Number(o.days) || 30));
let r; try { r = await fetch(`${s.apiUrl}/api/usage${domain ? '/' + encodeURIComponent(String(domain).toLowerCase()) : ''}?days=${days}`, { headers: { Authorization: 'Bearer ' + key, Accept: 'application/json' }, signal: AbortSignal.timeout(20000) }); }
catch (e) { throw httpError(`Arimail 연결 실패 (${nameOf(s)}, ${s.apiUrl}): ${e.cause ? e.cause.code || e.message : e.message}`, 502); }
const data = await r.json().catch(() => ({}));
if (!r.ok) {
// 1.0.8 이하는 /api/usage 자체가 없어 401(세션 없음)·404 로 답한다 — 버전을 확인해 "키가 틀렸다" 대신 업데이트 안내를 낸다
if (r.status === 401 || r.status === 404) await requireUsageVersion();
const code = data.error || (r.status === 404 ? 'usage_api_disabled' : r.status === 401 ? 'unauthenticated' : r.status === 403 ? 'forbidden_ip' : r.status === 429 ? 'rate_limited' : `http_${r.status}`);
throw Object.assign(httpError(ERRORS[code] || `Arimail 사용량 API 오류 ${code}`, r.status >= 500 ? 502 : (r.status === 429 ? 429 : 409)), { code, httpStatus: r.status });
}
return data;
}
/**
* 사용량 API 키 발급 — 관리자 세션으로 POST /api/setup/usage-key {action:'generate'} (기본 도메인 관리자만; 기존 키가 있으면 교체되어 이전 키는 즉시 무효).
* 돌려받은 키는 호출자(lib/mailservers.issueUsageKey)가 보관소에 저장한다. 키는 기록·로그에 남기지 않는다.
* @returns {Promise<{key:string, endpoint:string|null}>}
*/
async function issueUsageKey() {
await requireUsageVersion();
const d = await call('POST', '/setup/usage-key', { action: 'generate' });
if (!d || !d.key) throw httpError('Arimail 이 사용량 API 키를 돌려주지 않았습니다 (응답에 key 없음)', 502);
return { key: String(d.key), endpoint: d.endpoint || null };
}
/** Arimail 쪽 사용량 API 키 삭제(API 끄기) — 관리자 세션 필요 */
async function revokeUsageKey() { await call('POST', '/setup/usage-key', { action: 'revoke' }); return true; }
/** 연결 상태 (설정 페이지·관리자 콘솔 "연결 확인") */
async function status() {
const s = resolve(); const base = { enabled: true, provider: id, serverId: s.id || null, nickname: s.nickname || null, ip: s.ip || null, url: s.url || null, domain: s.domain || null, adminLogin: s.adminLogin, webmail: webmailUrl(), admin: adminUrl(), client: clientSettings(), configured: configured() };
if (!s.url) return { ...base, ok: false, detail: 'Arimail 주소가 없습니다. 관리자 콘솔 › 메일 시스템 › 메일 서버 연동에서 서버를 등록하거나(다른 서버의 Arimail 도 가능) 이 서버에서 deploy/install-arimail.sh 를 실행하세요' };
const h = await health(); if (!h.ok) return { ...base, ok: false, health: h, detail: `Arimail(${nameOf(s)})에 연결할 수 없습니다 — ${h.detail}` };
if (!s.domain) return { ...base, ok: false, health: h, detail: `Arimail ${h.version || ''} 응답 — 메일 도메인을 넣으세요` };
if (!s.adminPassword) return { ...base, ok: false, health: h, detail: `Arimail ${h.version || ''} 응답 — 관리자 비밀번호가 없어 계정을 관리할 수 없습니다 (보기 전용)` };
try {
const l = await listUsers();
const mismatch = l.domain && l.domain !== s.domain ? ` · 주의: Arimail 의 도메인은 ${l.domain} 인데 등록한 메일 도메인은 ${s.domain}` : '';
return { ...base, ok: !mismatch, health: h, version: h.version, serverDomain: l.domain, accounts: l.accounts, users: l.users.length, detail: `연결 성공 — Arimail ${h.version || ''}${s.nickname ? ` · ${nameOf(s)}` : ''} · 도메인 ${l.domain} · 계정 ${l.users.length}개${l.accounts && l.accounts.limit ? ` / 최대 ${l.accounts.limit}` : ''}${mismatch}` };
} catch (e) {
// v1.6.39: code(rate_limited 등)와 잠금 해제 시각을 함께 돌려 관리자 콘솔이 버튼을 막고 안내할 수 있게 한다
return { ...base, ok: false, health: h, version: h.version, code: e.code || null, lockedUntil: e.lockedUntil || null, detail: `Arimail ${h.version || ''} 응답 — 관리자 로그인 실패: ${e.message}` };
}
}
return { server: server || null, settings, configured, webmailUrl, adminUrl, clientSettings, mailboxFor, health, login, call, listUsers, findUser, createUser, updateUser, setPassword, getUserById, deleteUserById, deleteUserByLogin, adminStatus, status, usage, issueUsageKey, revokeUsageKey, requireUsageVersion /* v1.7.3 */ };
}
// 모듈 최상위 = 기본 서버에 묶인 동작 (1.5.7 호출 코드 호환: connectors.mail().status() 등). 호출할 때마다 기본 서버를 다시 고른다
const base = forServer(null);
module.exports = {
id, label, kind, ERRORS, forServer, toSettings, cmpVersion, USAGE_MIN_VERSION,
settings: () => base.settings(), configured: () => base.configured(), webmailUrl: () => base.webmailUrl(), adminUrl: () => base.adminUrl(), clientSettings: () => base.clientSettings(), mailboxFor: (l) => base.mailboxFor(l),
health: () => base.health(), login: () => base.login(), call: (...a) => base.call(...a), listUsers: () => base.listUsers(), findUser: (l) => base.findUser(l), createUser: (o) => base.createUser(o), updateUser: (u, f) => base.updateUser(u, f),
setPassword: (l, p) => base.setPassword(l, p),
getUserById: (u) => base.getUserById(u), deleteUserById: (u) => base.deleteUserById(u), deleteUserByLogin: (l) => base.deleteUserByLogin(l), adminStatus: () => base.adminStatus(), status: () => base.status(),
};
+122
View File
@@ -0,0 +1,122 @@
'use strict';
// ============================================================================
// 소스 보관 연동 모듈 — Gitea (v1.5.7, 요구 6)
//
// 사이트 버전(sites/<ID>/main, sites/<ID>/changes/NNNN-종류 브랜치)을 보관하는 git 서비스에 "어떻게 접속하는지" 를
// 이 모듈이 맡는다. git 푸시 자체는 lib/gitstore.js 가 표준 git 명령으로 하고, 이 모듈은
// - 접속 정보: 푸시 주소(remote)·웹 주소(web)·브랜치 접두어(prefix) — 설정 파일 source.* (1.4.14~1.4.18 은 systemd 환경 변수
// SITES_GIT_REMOTE/SITES_GIT_WEB/SITES_GIT_PREFIX 였고, 1.5.7 첫 기동 때 설정 파일로 옮겨진다)
// - 링크: 브랜치·커밋을 브라우저로 보는 주소
// - 오류 해석: git/ssh 오류를 운영자가 조치할 수 있는 문구로(gitHint)
// - 상태 확인: git ls-remote(SSH 푸시 경로) + Gitea API(/api/v1, 토큰이 있을 때)
// - 준비 작업(API 토큰이 있을 때): 저장소 만들기, 서비스 서버 SSH 공개키 등록 — deploy/install-gitea.sh 가 하던 일을
// 설정 페이지에서도 할 수 있게
// 를 담당한다. 다른 git 서비스(GitLab 등)로 바꾸려면 같은 내보내기 모양의 모듈을 만들어 connectors/index.js 에 등록한다.
// ============================================================================
const fs = require('fs');
const os = require('os');
const { execFile, execFileSync } = require('child_process');
const id = 'gitea';
const label = 'Gitea (git 저장소)';
const kind = 'source';
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
/** 설정 파일에서 읽은 접속 정보 (호출할 때마다 읽어 설정 페이지에서 바꾼 값이 재시작 없이 적용된다) */
function settings() {
const config = require('../config');
return {
remote: String(config.get('source.remote') || '').trim(),
web: String(config.get('source.web') || '').replace(/\/+$/, ''),
prefix: String(config.get('source.prefix') || 'sites').replace(/^\/+|\/+$/g, '') || 'sites',
apiUrl: String(config.get('source.apiUrl') || '').replace(/\/+$/, ''),
user: String(config.get('source.user') || 'webmaker').trim(),
repo: String(config.get('source.repo') || 'webmaker/sites').trim(),
token: config.get('source.token') || '',
};
}
/** 푸시 주소가 있어야 동작한다 (API 주소·토큰은 선택) */
function configured() { return !!settings().remote; }
function branchUrl(branch) { const { web } = settings(); return web ? `${web}/src/branch/${encodeURIComponent(branch)}` : null; }
function commitUrl(sha) { const { web } = settings(); return web ? `${web}/commit/${sha}` : null; }
/** git/ssh 오류를 운영자가 조치할 수 있는 문구로 바꾼다 (1.4.14 gitstore.gitHint 를 모듈로 옮김) */
function gitHint(msg) {
if (/Permission denied \(publickey|correct access rights|Could not read from remote repository/.test(msg)) return `소스 저장소(Gitea) 접근 거부 — 이 서버의 키가 저장소 계정(${settings().user})에 등록되지 않았거나 쓰기 권한이 없습니다. 초기 설정 페이지(또는 관리자 콘솔 › 설정 › 소스 저장소)의 공개키를 Gitea 계정 키로 등록하거나, 서버에서 deploy/install-gitea.sh 를 다시 실행하세요. (${msg.slice(0, 160)})`;
if (/Could not resolve hostname|Connection (refused|timed out)|No route to host/.test(msg)) return `소스 저장소 서버에 연결할 수 없습니다 — 로컬 Gitea 컨테이너(webmaker-gitea)가 실행 중인지(docker ps), 주소·포트·방화벽을 확인하세요. (${msg.slice(0, 160)})`;
if (/Host key verification failed|REMOTE HOST IDENTIFICATION/.test(msg)) return `소스 저장소 서버 키가 바뀌었습니다 — 서비스 서버의 ~/.ssh/known_hosts 에서 해당 항목을 지우세요. (${msg.slice(0, 160)})`;
if (/non-fast-forward|fetch first|rejected/.test(msg)) return `원격 브랜치와 이력이 달라 푸시가 거부되었습니다 (${msg.slice(0, 160)})`;
return msg;
}
/** 푸시 주소의 호스트(ssh 별칭)에 쓰이는 이 서버의 공개키 — Gitea 계정에 등록해야 하는 값 */
function publicKey() {
const { remote } = settings(); if (!remote) return null;
const m = remote.match(/^(?:ssh:\/\/)?(?:([^@]+)@)?([^:/]+)/); const host = m ? m[2] : null; if (!host) return null;
try {
const out = execFileSync('ssh', ['-G', host], { timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
const files = out.split('\n').filter((l) => l.startsWith('identityfile ')).map((l) => l.slice(13).trim().replace(/^~/, os.homedir()));
for (const f of files) { try { return { file: f, publicKey: fs.readFileSync(f + '.pub', 'utf8').trim() }; } catch (_) {} }
return { file: files[0] || null, publicKey: null };
} catch (_) { return null; }
}
// ---------- Gitea API (선택: source.apiUrl + 토큰) ----------
/**
* Gitea REST API 호출. 토큰 인증(Authorization: token …). 가입 금지·로그인 필수(REQUIRE_SIGNIN_VIEW)로 설치된
* 로컬 Gitea 는 토큰 없이는 /api/v1/version 도 403 이므로 토큰이 없으면 API 기능을 쓰지 않는다.
*/
async function api(method, p, body) {
const s = settings(); if (!s.apiUrl) throw httpError('Gitea API 주소(source.apiUrl)가 없습니다'); if (!s.token) throw httpError('Gitea API 토큰(source.token)이 없습니다');
let r; try { r = await fetch(`${s.apiUrl}/api/v1${p}`, { method, headers: { Authorization: `token ${s.token}`, 'Content-Type': 'application/json', Accept: 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(15000) }); }
catch (e) { throw httpError(`Gitea API 연결 실패 (${s.apiUrl}): ${e.cause ? e.cause.code || e.message : e.message}`, 502); }
const text = await r.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch (_) { data = { raw: text.slice(0, 200) }; }
if (!r.ok) { const msg = (data && (data.message || data.error)) || text.slice(0, 200); throw httpError(r.status === 401 ? 'Gitea API 토큰이 올바르지 않습니다 (401)' : r.status === 403 ? `Gitea API 권한 없음 (403): ${msg}` : `Gitea API 오류 ${r.status}: ${msg}`, 502); }
return data;
}
/** API 로 보는 상태: 버전, 저장소 존재 */
async function apiStatus() {
const s = settings(); if (!s.apiUrl || !s.token) return { ok: false, skipped: true, detail: s.apiUrl ? 'API 토큰 없음 (SSH 푸시만 사용)' : 'API 주소 없음 (SSH 푸시만 사용)' };
const v = await api('GET', '/version'); let repo = null;
try { const r = await api('GET', `/repos/${s.repo}`); repo = { exists: true, private: !!r.private, size: r.size, url: r.html_url }; } catch (e) { repo = { exists: false, detail: e.message }; }
return { ok: true, version: v && v.version, repo, user: s.user, detail: `Gitea ${v && v.version} · 저장소 ${s.repo} ${repo.exists ? '있음' : '없음'}` };
}
/** 저장소가 없으면 만든다 (비공개). 토큰 계정이 소유자여야 한다 */
async function ensureRepo() {
const s = settings(); const [owner, name] = s.repo.split('/');
try { await api('GET', `/repos/${s.repo}`); return { created: false, repo: s.repo }; } catch (_) {}
if (owner !== s.user) throw httpError(`저장소 소유자(${owner})가 토큰 계정(${s.user})과 달라 만들 수 없습니다. Gitea 에서 직접 만드세요`);
await api('POST', '/user/repos', { name, private: true, auto_init: false, default_branch: 'main', description: 'AI Webmaker 사이트 소스 버전 (sites/<사이트ID>/main, sites/<사이트ID>/changes/NNNN-종류)' });
return { created: true, repo: s.repo };
}
/** 이 서버의 공개키를 토큰 계정의 SSH 키로 등록 (이미 있으면 그대로) */
async function registerKey() {
const k = publicKey(); if (!k || !k.publicKey) throw httpError('이 서버의 SSH 공개키를 찾지 못했습니다 (푸시 주소의 ssh 별칭에 IdentityFile 이 있어야 합니다. deploy/install-gitea.sh 가 만듭니다)');
const body = k.publicKey.split(/\s+/)[1]; const list = await api('GET', '/user/keys');
if ((list || []).some((x) => String(x.key || '').split(/\s+/)[1] === body)) return { registered: false, title: null };
const title = `webmaker-service@${os.hostname()}`;
await api('POST', '/user/keys', { title, key: k.publicKey, read_only: false });
return { registered: true, title };
}
/**
* 연결 상태 (관리자 콘솔 › 설정 › 소스 저장소, 설정 페이지 "연결 확인").
* SSH 푸시 경로는 git ls-remote 로 실제 확인하고, API 는 토큰이 있을 때만 본다.
*/
function status() {
const s = settings(); const key = publicKey();
return new Promise((resolve) => {
const base = { enabled: !!s.remote, provider: id, remote: s.remote || null, web: s.web || null, prefix: s.prefix, apiUrl: s.apiUrl || null, user: s.user, repo: s.repo, publicKey: key ? key.publicKey : null, keyFile: key ? key.file : null };
if (!s.remote) return resolve({ ...base, ok: false, detail: '푸시 주소(source.remote)가 없어 소스를 원격에 보관하지 않습니다 (로컬 버전 기록만 사용). 서버에서 deploy/install-gitea.sh 를 실행하면 전용 Gitea 를 만들고 이 값을 채웁니다' });
const started = Date.now();
execFile('git', ['ls-remote', '--heads', s.remote, `refs/heads/${s.prefix}/*/main`], { env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=12' }, timeout: 30000, maxBuffer: 8 * 1024 * 1024 }, async (err, stdout, stderr) => {
const ms = Date.now() - started; let apiInfo = null; try { apiInfo = await apiStatus(); } catch (e) { apiInfo = { ok: false, detail: e.message }; }
if (err) return resolve({ ...base, ok: false, ms, api: apiInfo, detail: gitHint((stderr || err.message).trim().split('\n').map((l) => l.trim()).filter(Boolean).slice(-3).join(' ')) });
const n = stdout.split('\n').filter(Boolean).length;
resolve({ ...base, ok: true, ms, sites: n, api: apiInfo, detail: `연결 성공 — 저장소에 사이트 브랜치 ${n}개${apiInfo && apiInfo.ok ? ` · ${apiInfo.detail}` : ''}` });
});
});
}
/** 짧은 요약 (관리자 첫 화면) */
function info() { const s = settings(); return { provider: id, enabled: !!s.remote, remote: s.remote ? s.remote.replace(/^.*[:/]([^/]+\/[^/]+?)(\.git)?$/, '$1') : null, web: s.web || null }; }
module.exports = { id, label, kind, settings, configured, branchUrl, commitUrl, gitHint, publicKey, api, apiStatus, ensureRepo, registerKey, status, info };
+48
View File
@@ -0,0 +1,48 @@
'use strict';
// ============================================================================
// 외부 서비스 연동 모듈 등록소 (v1.5.7, 요구 5·6)
//
// 웹메이커가 다른 제품으로 바꿔 쓸 수 있는 외부 서비스는 "종류(kind)" 로 묶고, 종류마다 연동 모듈을 하나씩 고른다.
// source : 사이트 소스 보관(git 원격) — gitea (lib/connectors/gitea.js)
// mail : 받는 메일·웹메일·메일 계정 서버 — arimail (lib/connectors/arimail.js, 구 mail-gateway)
// 어느 모듈을 쓰는지는 설정 파일(lib/config.js)의 source.provider / mail.provider 가 정하고, 모듈이 접속에 쓰는 주소·계정도
// 같은 설정 파일(source.*, mail.*)에서 읽는다. 비밀값(토큰·비밀번호)은 암호화 보관소(lib/vault.js)에 있다.
//
// 모듈이 지켜야 하는 공통 모양
// id, label, kind 이름표
// settings() 설정 파일에서 읽은 접속 정보 (비밀값 포함, 서버 내부 전용)
// configured() 동작에 필요한 값이 모두 있는지
// status() → Promise<{enabled, provider, ok, detail, …}> 연결 상태 (관리자 콘솔·설정 페이지 "연결 확인")
// 종류별 추가 동작은 각 모듈 주석을 본다. 다른 제품(예: GitLab, 다른 메일 서버)으로 바꾸려면 같은 모양의 모듈을 이 폴더에
// 추가하고 아래 MODULES 와 config SCHEMA 의 provider 선택지에 넣으면 된다 — 나머지 코드(gitstore·mailsys)는 모듈만 본다.
// ============================================================================
const MODULES = {
source: { gitea: () => require('./gitea') },
mail: { arimail: () => require('./arimail') },
};
const KIND_LABEL = { source: '소스 보관', mail: '메일 서버' };
/** 설정에서 고른 모듈 id (source 는 기본 gitea, mail 은 기본 none) */
function providerOf(kind) {
const config = require('../config');
const v = kind === 'source' ? config.get('source.provider') : config.get('mail.provider');
return v || (kind === 'source' ? 'gitea' : 'none');
}
/** 지금 켜져 있는 모듈 (연동 안 함이면 null) */
function active(kind) { const f = (MODULES[kind] || {})[providerOf(kind)]; return f ? f() : null; }
function source() { return active('source'); }
function mail() { return active('mail'); }
/** 모듈 목록 (화면용) */
function list() {
return Object.entries(MODULES).flatMap(([kind, mods]) => Object.entries(mods).map(([id, f]) => { const m = f(); return { kind, kindLabel: KIND_LABEL[kind], id, label: m.label, active: providerOf(kind) === id, configured: m.configured() }; }));
}
/** 연결 상태. 모듈이 없으면(연동 안 함) 그 사실을 돌려준다 */
async function status(kind) {
const m = active(kind);
if (!m) return { enabled: false, provider: 'none', kind, ok: false, detail: `${KIND_LABEL[kind] || kind} 연동을 쓰지 않습니다 (설정 파일 ${kind}.provider = none)` };
try { return { kind, ...(await m.status()) }; } catch (e) { return { kind, enabled: true, provider: m.id, ok: false, detail: e.message }; }
}
/** "연결 확인" 버튼용 — {ok, detail} 로 간단히 */
async function test(kind) { const s = await status(kind); return { ok: !!s.ok, detail: s.detail, status: s }; }
module.exports = { MODULES, KIND_LABEL, providerOf, active, source, mail, list, status, test };
+64
View File
@@ -0,0 +1,64 @@
'use strict';
// ============================================================================
// 비용 보정 (v1.1.6)
//
// 작업 중에는 실시간 토큰 수에 가격표(settings.MODELS 또는 제공자 단가)를 곱해 비용을 "추정"하고,
// 작업이 끝나면 엔진(Claude Code 등)이 알려 주는 "실제" 비용으로 청구한다.
// 가격표가 실제 단가와 다르면 추정이 크게 빗나가(예: 2.5배 과소) 캐시 소진 중지가 늦어진다.
// 그래서 완료된 작업마다 (실제 ÷ 추정) 비율을 모델별로 학습해 다음 작업의 실시간 추정에 곱한다.
//
// 저장: data/cost-calibration.json { models: { "<제공자:모델>": { factor, n, updatedAt } } }
// - factor : 실제/추정 비율의 지수이동평균(새 관측 30% 반영). 처음 관측은 그대로 사용.
// - n : 관측 횟수
// 비율은 0.3~10 으로 제한해 이상치(아주 짧은 작업 등)에 휘둘리지 않게 한다.
// ============================================================================
const fs = require('fs');
const path = require('path');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const FILE = path.join(DATA_ROOT, 'cost-calibration.json');
const MIN_EST_USD = 0.002; // 이보다 작은 추정은 비율이 불안정하므로 학습하지 않음
let cache = null;
/*
* v1.4.5 보정값 다시 시작 (VERSION 2): 1.4.4 까지는 "실제 비용" 으로 Claude 세션 누적 비용을 써서(lib/claude.js 설명)
* 이어 가는 작업일수록 실제/추정 비율이 커졌고(최대 10배), 그만큼 실시간 추정이 부풀어 캐시 소진 중지가 너무 일찍 걸렸다.
* 가격표 계산도 1.4.5 에서 CLI 비용과 일치하게 고쳤으므로, 버전이 다른 옛 보정값은 버리고 1(보정 없음)에서 다시 배운다.
*/
const VERSION = 2;
function load() {
if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (_) { cache = null; }
if (!cache || cache.version !== VERSION) cache = { version: VERSION, models: {}, resetAt: new Date().toISOString(), previous: cache && cache.models ? cache.models : undefined };
cache.models = cache.models || {}; return cache;
}
function save() { try { fs.mkdirSync(DATA_ROOT, { recursive: true }); const tmp = FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(cache, null, 1)); fs.renameSync(tmp, FILE); } catch (_) {} }
/** 모델 참조의 보정 배율 (관측이 없으면 1) */
function factor(ref) { const m = load().models[ref]; return m && Number.isFinite(m.factor) ? m.factor : 1; }
/**
* 완료된 작업 한 건 관측.
* @param {string} ref 모델 참조 (예: anthropic:claude-fable-5-1)
* @param {number} estUsd 가격표로 계산한 추정 비용 (보정 전)
* @param {number} actualUsd 엔진이 알려 준 실제 비용
*/
function observe(ref, estUsd, actualUsd, persist = true) {
if (!ref || !(estUsd >= MIN_EST_USD) || !(actualUsd > 0)) return;
const r = Math.min(10, Math.max(0.3, actualUsd / estUsd));
const d = load(); const m = d.models[ref] || { factor: 1, n: 0 };
m.factor = m.n ? m.factor * 0.7 + r * 0.3 : r; m.n += 1; m.updatedAt = new Date().toISOString();
d.models[ref] = m; if (persist) save();
}
/**
* 기동 시 기존 작업 기록으로 보정값을 한 번 채운다 (보정 파일이 없을 때만).
* @param {Array<{ref, estUsd, actualUsd}>} samples
*/
function seed(samples) {
if (fs.existsSync(FILE) && load().version === VERSION && Object.keys(load().models).length) return 0; let n = 0;
for (const s of samples) { observe(s.ref, s.estUsd, s.actualUsd, false); n += 1; }
save(); return n;
}
function all() { return load().models; }
module.exports = { factor, observe, seed, all };
+181
View File
@@ -0,0 +1,181 @@
'use strict';
// ============================================================================
// 사이트 복사하기 (v1.2.0)
//
// 웹 주소만 받아 브라우저처럼 HTTP 로 접속해 같은 사이트(같은 호스트)의 HTML·CSS·JS·이미지·글꼴을
// 최대한 내려받고, 서로를 가리키는 주소를 로컬 상대 경로로 바꿔 빌더의 사이트 폴더에 저장한다.
// - 서버 접속 정보가 필요 없는 대신, 서버에서 실행되는 코드(PHP 등)나 로그인 뒤 화면, 스크립트로만 그려지는 내용은 가져올 수 없다.
// - 다른 도메인(CDN 등)의 자원은 원래 주소 그대로 둔다.
// - 크기 한도는 가져오기와 같은 규칙(서버 디스크 용량의 10%, remote.capacityError).
// - 보안: 사설·내부 주소(127.0.0.1, 10.x, 192.168.x 등)는 관리자만 복사할 수 있다(내부 서비스 노출 방지).
// ============================================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const dns = require('dns').promises;
const net = require('net');
const remote = require('./remote');
const UA = 'Mozilla/5.0 (compatible; AI-Webmaker-Copy/1.2; +site-copy)';
const MAX_PAGES = parseInt(process.env.COPY_MAX_PAGES || '300', 10); // HTML 페이지 상한 (무한 링크 방지)
const MAX_ASSETS = parseInt(process.env.COPY_MAX_ASSETS || '5000', 10); // 자원 파일 상한
const TIMEOUT = 25000;
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
/** 사설·예약 IP 인지 (IPv4/IPv6) */
const netguard = require('./netguard');
const isPrivateIp = netguard.isPrivateIp; // v1.3.17: 공용 판정(IPv4 대응 IPv6·예약 대역 포함)으로 통일
/** 복사 시작 전 주소 점검: http(s) 만, 사설 주소는 관리자만 */
async function checkUrl(url, allowPrivate) {
let u; try { u = new URL(url); } catch (_) { throw httpError('주소 형식이 올바르지 않습니다'); }
if (!/^https?:$/.test(u.protocol)) throw httpError('http:// 또는 https:// 주소만 복사할 수 있습니다');
let addrs = []; try { addrs = (await dns.lookup(u.hostname, { all: true })).map((a) => a.address); } catch (_) { throw httpError(`주소를 찾을 수 없습니다: ${u.hostname}`); }
if (!allowPrivate && addrs.some(isPrivateIp)) throw httpError('내부(사설) 네트워크 주소는 복사할 수 없습니다. 관리자에게 문의하세요.', 403);
return u;
}
/**
* URL → 사이트 폴더 안의 상대 파일 경로.
* - / 또는 /dir/ → index.html, /dir/index.html
* - 확장자 없는 페이지(/about) 가 HTML 이면 about/index.html (폴더형 주소를 그대로 유지)
* - 쿼리가 있으면 파일명에 짧은 해시를 붙여 서로 다른 응답을 구분
*/
function localPath(u, isHtml) {
let p = decodeURIComponent(u.pathname || '/');
p = p.replace(/\/{2,}/g, '/').replace(/(^|\/)\.\.?(?=\/|$)/g, '');
if (p.endsWith('/')) p += 'index.html';
else if (isHtml && !/\.[a-z0-9]{1,6}$/i.test(path.posix.basename(p))) p += '/index.html';
if (u.search) { const h = crypto.createHash('sha1').update(u.search).digest('hex').slice(0, 8); const ext = path.posix.extname(p); p = ext ? p.slice(0, -ext.length) + '.' + h + ext : p + '.' + h; }
p = p.replace(/^\/+/, '').split('/').map((seg) => seg.replace(/[<>:"\\|?*\x00-\x1f]/g, '_').slice(0, 120)).join('/');
if (!p || p.startsWith('.')) p = 'index.html';
// HTML 로 받은 페이지가 .php/.asp/.jsp 처럼 서버 실행 확장자이거나 쿼리 해시가 붙어 .html 로 끝나지 않으면 ".html" 을 덧붙인다.
// (예: contact.php → contact.php.html) 받은 내용은 실행 결과 HTML 이므로, 정적 서버가 PHP 로 실행하거나 내려받기로 주지 않게 한다.
if (isHtml && !/\.html?$/i.test(p)) p += '.html';
return p;
}
/**
* CSS url() 안에 넣을 경로. 원래 따옴표(q)를 그대로 써야 HTML style="..." 속성 안에서 따옴표가 충돌하지 않는다.
* 따옴표가 없던 경우에는 공백·괄호·따옴표를 퍼센트 인코딩해 url( ) 문법이 깨지지 않게 한다.
*/
function cssUrl(q, l) { return q ? `url(${q}${l}${q})` : `url(${l.replace(/[\s()'"]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'))})`; }
/** a 파일에서 b 파일로 가는 상대 경로 (같은 사이트 안) */
function relLink(fromFile, toFile, hash) { let r = path.posix.relative(path.posix.dirname(fromFile), toFile) || path.posix.basename(toFile); if (!r.startsWith('.')) r = './' + r; return r.replace(/^\.\//, '') + (hash || ''); }
/** HTML 에서 참조 URL 수집 (href/src/srcset/poster/data-src, style 의 url()) */
function htmlRefs(html) {
const out = []; const attr = /\s(href|src|poster|data-src|data-original|content)\s*=\s*("([^"]*)"|'([^']*)')/gi; let m;
while ((m = attr.exec(html))) { const v = m[3] !== undefined ? m[3] : m[4]; if (m[1].toLowerCase() === 'content' && !/^(https?:)?\/\/|^\//.test(v)) continue; out.push(v); }
const ss = /\ssrcset\s*=\s*("([^"]*)"|'([^']*)')/gi; while ((m = ss.exec(html))) for (const part of (m[2] || m[3] || '').split(',')) { const u = part.trim().split(/\s+/)[0]; if (u) out.push(u); }
const css = /url\(\s*(['"]?)([^'")]+)\1\s*\)/gi; while ((m = css.exec(html))) out.push(m[2]);
return out;
}
function cssRefs(css) { const out = []; let m; const re = /url\(\s*(['"]?)([^'")]+)\1\s*\)|@import\s+(['"])([^'"]+)\3/gi; while ((m = re.exec(css))) out.push(m[2] || m[4]); return out; }
const skipRef = (v) => !v || /^(#|data:|mailto:|tel:|javascript:|about:|blob:)/i.test(v.trim());
/**
* 사이트 복사 실행.
* @param {string} startUrl
* @param {string} siteDir 저장할 사이트 폴더
* @param {{allowPrivate?: boolean, onProgress?: (text)=>void, isCancelled?: ()=>boolean}} opts
* @returns {{pages, assets, bytes, skipped, title, origin, errors: string[]}}
*/
async function copySite(startUrl, siteDir, opts = {}) {
const onProgress = opts.onProgress || (() => {});
const start = await checkUrl(startUrl, opts.allowPrivate);
const hostOk = (h) => h.replace(/^www\./, '') === start.hostname.replace(/^www\./, '');
/*
* v1.4.1 복사 범위(scope): 시작 주소가 폴더 아래에 있으면(예: https://webmaker.ibit.kr/<사이트ID>/) 그 폴더 안의 페이지만 복사한다.
* - 1.4.0 까지는 같은 호스트면 모두 따라가, 사이트 안의 "https://webmaker.ibit.kr/" 링크 → 서비스 첫 화면 → /login 리다이렉트로
* 받은 "AI Webmaker 로그인 화면" 이 복사본의 첫 페이지(index.html)를 덮어썼다(관리자·사용자 모두 "로그인 화면만 복사됨").
* - 범위는 첫 페이지를 받은 "최종 주소" 로 정한다: /<id> → /<id>/ 로 리다이렉트되면 /<id>/, /about.html 처럼 파일이면 그 폴더(/).
* 맨 위(/)에서 시작하면 지금까지처럼 사이트 전체.
* - 범위 밖 페이지는 받지 않고 원래 주소(절대 주소)로 남긴다. 범위 밖의 자원(CSS·JS·이미지)은 받아서 _root/ 아래에 둔다.
* - 범위 안 파일은 범위 폴더를 기준으로 저장한다 → 시작 페이지가 복사본의 index.html 이 된다.
*/
let scope = null; // 첫 페이지를 받기 전에는 null (첫 페이지만 범위 확인 없이 받는다)
/** 이 서비스(PUBLIC_BASE_URL)의 런타임 파일(/wm-modules.js, /wm-stats.js, /api/…)은 복사하지 않고 절대 주소로 둔다 — 모듈·통계가 계속 서비스 최신 코드로 동작하게 */
const svcHost = (() => { try { return new URL(process.env.PUBLIC_BASE_URL || '').hostname; } catch (_) { return ''; } })();
const svcRuntime = (u) => !!svcHost && u.hostname === svcHost && /^\/(wm-[\w-]+\.js$|api\/|theme\.css$|i18n\.js$)/.test(u.pathname);
const inScope = (u) => !scope || scope === '/' || u.pathname === scope.slice(0, -1) || u.pathname.startsWith(scope);
/** 범위 기준 저장 경로: 범위 안 = 범위 폴더 기준, 범위 밖 자원 = _root/<원래 경로> */
const scopedPath = (u, isHtml) => {
if (!scope || scope === '/') return localPath(u, isHtml);
if (inScope(u)) { const x = new URL(u.href); x.pathname = '/' + u.pathname.slice(Math.min(u.pathname.length, scope.length)); return localPath(x, isHtml); }
return '_root/' + localPath(u, isHtml);
};
const queue = [{ url: start.href, html: true }]; const seen = new Map(); // url(해시 제외) → { file, html }
const pages = []; let assets = 0; let bytes = 0; let title = ''; const errors = []; let skipped = 0;
const norm = (u) => { const x = new URL(u); x.hash = ''; return x.href; };
const enqueue = (raw, base, html) => { if (skipRef(raw)) return null; let u; try { u = new URL(raw.trim(), base); } catch (_) { return null; } if (!/^https?:$/.test(u.protocol) || !hostOk(u.hostname)) return null; if ((html && !inScope(u)) || svcRuntime(u)) return null; /* v1.4.1 범위 밖 페이지·서비스 런타임 파일은 복사하지 않음 */ const k = norm(u.href); if (!seen.has(k) && !queue.some((q) => q.url === k)) queue.push({ url: k, html }); return k; };
/**
* 일반 사용자 복사에서 호스트가 여전히 공개 주소인지 확인 (DNS 가 도중에 내부 주소로 바뀌는 경우 대비, 30초 캐시).
* 관리자(allowPrivate)는 확인하지 않는다.
*/
// v1.3.17 (S09): 30초 동안 한 번만 확인하던 방식(www 별칭 등 다른 호스트는 확인 없이 접속)을 없애고,
// netguard.safeFetch 가 요청·리다이렉트 단계마다 그 호스트의 주소를 확인하고 확인한 주소로만 접속한다.
/**
* 한 주소 내려받기. 리다이렉트는 자동으로 따라가지 않고(redirect: 'manual') 최대 5번까지 직접 따라가며,
* 같은 사이트(hostOk) 안으로 가는 경우만 허용한다 — 공개 사이트가 내부 주소로 돌려보내 서버가 내부망에
* 요청을 보내게 되는 것(SSRF)을 막기 위해서다.
*/
const fetchOne = async (url, isPage) => { // isPage: 페이지 요청이면 리다이렉트도 범위 안으로만 따라간다 (v1.4.1)
// v1.3.17 (B16): 본문을 흘려받으며 남은 저장 한도(서버 용량 기준)와 한 파일 상한(200MB) 중 작은 값을 넘으면 바로 끊는다
const cap = remote.capacity().limitBytes; const left = Math.max(0, (Number.isFinite(cap) ? cap : Infinity) - bytes);
const maxBytes = Math.min(left + 1, 200 * 1024 * 1024);
try {
const r = await netguard.safeFetch(url, { allowPrivate: !!opts.allowPrivate, sameSite: (u) => hostOk(u.hostname) && (!isPage || inScope(u)), maxBytes, timeout: TIMEOUT, userAgent: UA, tooBigMessage: '파일이 너무 큽니다' });
if (r.error && r.status === 310) return { ok: false, status: 310, type: '', buf: Buffer.alloc(0), finalUrl: r.finalUrl };
return r;
} catch (e) { if (e.tooBig) { const ce = remote.capacityError(bytes + maxBytes); if (ce) throw ce; } throw e; }
};
// 1) 내려받기: 페이지(HTML)를 먼저 넓게, 자원은 발견되는 대로
while (queue.length) {
if (opts.isCancelled && opts.isCancelled()) throw httpError('사용자가 작업을 중단했습니다', 499);
const item = queue.shift(); if (seen.has(item.url)) continue;
const htmlCount = pages.length; if (item.html && htmlCount >= MAX_PAGES) { skipped += 1; seen.set(item.url, null); continue; } if (!item.html && assets >= MAX_ASSETS) { skipped += 1; seen.set(item.url, null); continue; }
let r; try { r = await fetchOne(item.url, item.html); } catch (e) { errors.push(`${item.url}: ${e.name === 'AbortError' ? '시간 초과' : e.message}`); seen.set(item.url, null); continue; }
if (r.offsite) { seen.set(item.url, null); continue; } // 다른 도메인으로 넘어가는 주소는 복사하지 않음
if (!r.ok) { errors.push(`${item.url}: ${r.status === 310 ? '리다이렉트가 너무 많음' : 'HTTP ' + r.status}`); seen.set(item.url, null); continue; }
const isHtml = /text\/html|application\/xhtml/i.test(r.type) || (item.html && !r.type);
let fin; try { fin = new URL(r.finalUrl || item.url); } catch (_) { fin = new URL(item.url); }
if (!hostOk(fin.hostname)) { seen.set(item.url, null); continue; } // 다른 도메인으로 넘어간 페이지는 복사하지 않음
if (scope === null) { const fp = fin.pathname; scope = fp.endsWith('/') ? fp : fp.slice(0, fp.lastIndexOf('/') + 1) || '/'; } // v1.4.1 첫 페이지의 최종 주소로 범위 결정
else if (isHtml && !inScope(fin)) { seen.set(item.url, null); continue; } // 범위 밖으로 넘어간 페이지
const file = scopedPath(new URL(item.url), isHtml);
bytes += r.buf.length; { const ce = remote.capacityError(bytes); if (ce) throw ce; }
seen.set(item.url, { file, html: isHtml, isCss: /text\/css/i.test(r.type) || /\.css$/i.test(file), buf: r.buf, base: fin.href });
if (isHtml) {
pages.push(item.url); const text = r.buf.toString('utf8');
if (!title) title = ((text.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1] || '').replace(/\s+/g, ' ').trim();
for (const ref of htmlRefs(text)) { const isPageLink = /^\s*(?!.*\.(css|js|mjs|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf|eot|mp4|webm|mp3|pdf|zip|json|xml|txt)(\?|#|$))/i.test(ref); enqueue(ref, fin.href, isPageLink); }
} else if (/text\/css/i.test(r.type) || /\.css$/i.test(file)) { for (const ref of cssRefs(r.buf.toString('utf8'))) enqueue(ref, fin.href, false); assets += 1; }
else assets += 1;
if ((pages.length + assets) % 10 === 0) onProgress(`내려받는 중 — 페이지 ${pages.length}개, 파일 ${assets}개 (${(bytes / 1048576).toFixed(1)}MB)`);
}
if (!pages.length) throw httpError(`첫 페이지를 가져오지 못했습니다${errors.length ? ': ' + errors[0] : ''}`, 502);
// 2) 저장: 같은 사이트 주소를 로컬 상대 경로로 바꿔 쓴다
onProgress('주소를 로컬 경로로 바꿔 저장하고 있습니다');
const toLocal = (raw, base, fromFile) => { if (skipRef(raw)) return null; let u; try { u = new URL(raw.trim(), base); } catch (_) { return null; } if (!/^https?:$/.test(u.protocol) || !hostOk(u.hostname)) return null; const k = norm(u.href); const rec = seen.get(k); if (!rec) return (scope && scope !== '/' && !inScope(u)) || svcRuntime(u) ? u.href : null; return relLink(fromFile, rec.file, u.hash); }; /* v1.4.1 복사하지 않은 범위 밖 주소는 절대 주소로 (복사본이 다른 주소에서 열려도 원래 곳을 가리키게) */
for (const [, rec] of seen) {
if (!rec) continue; const abs = path.join(siteDir, rec.file); fs.mkdirSync(path.dirname(abs), { recursive: true, mode: 0o755 });
let out = rec.buf;
if (rec.html) {
let html = rec.buf.toString('utf8');
html = html.replace(/(\s(?:href|src|poster|data-src|data-original)\s*=\s*)("([^"]*)"|'([^']*)')/gi, (all, pre, q, a, b) => { const v = a !== undefined ? a : b; const l = toLocal(v, rec.base, rec.file); return l ? `${pre}"${l}"` : all; });
html = html.replace(/(\ssrcset\s*=\s*)("([^"]*)"|'([^']*)')/gi, (all, pre, q, a, b) => `${pre}"${(a !== undefined ? a : b).split(',').map((part) => { const [u, ...rest] = part.trim().split(/\s+/); const l = toLocal(u, rec.base, rec.file); return [l || u, ...rest].join(' '); }).join(', ')}"`);
html = html.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (all, q, v) => { const l = toLocal(v, rec.base, rec.file); return l ? cssUrl(q, l) : all; });
html = html.replace(/<base\s[^>]*>/gi, ''); // 원래 사이트의 <base> 는 로컬 경로를 깨뜨리므로 제거
out = Buffer.from(html, 'utf8');
} else if (rec.isCss) {
let css = rec.buf.toString('utf8');
css = css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (all, q, v) => { const l = toLocal(v, rec.base, rec.file); return l ? cssUrl(q, l) : all; }).replace(/@import\s+(['"])([^'"]+)\1/gi, (all, q, v) => { const l = toLocal(v, rec.base, rec.file); return l ? `@import "${l}"` : all; });
out = Buffer.from(css, 'utf8');
}
fs.writeFileSync(abs, out, { mode: 0o644 });
}
if (!fs.existsSync(path.join(siteDir, 'index.html'))) { const first = seen.get(norm(start.href)); if (first && first.file !== 'index.html') fs.writeFileSync(path.join(siteDir, 'index.html'), `<!doctype html><meta charset="utf-8"><meta http-equiv="refresh" content="0; url=${first.file}"><a href="${first.file}">이동</a>`); }
const dynamic = pages.some((p) => /\.(php|asp|aspx|jsp)(\?|$)/i.test(p));
return { pages: pages.length, assets, bytes, skipped, title, origin: start.origin, errors: errors.slice(0, 50), dynamic };
}
module.exports = { cssUrl, copySite, checkUrl, isPrivateIp, localPath, relLink };
+85
View File
@@ -0,0 +1,85 @@
'use strict';
// 사용자별 캐시(크레딧, 원 단위) 보관소. data/credits.json
// - users[userId] = { balance, updatedAt }
// - ledger[] = { id, at, userId, type: set|add|adjust|usage|refund|migrate, amount, balanceAfter, memo, by, ref }
// 관리자 콘솔(수동 설정/추가)과 외부 서비스(서비스 키 API)가 같은 함수로 값을 조정한다.
// 잔액이 바뀌면 onChange 리스너에 알려 캐시 대기 중인 작업을 이어갈 수 있게 한다.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const FILE = path.join(DATA_ROOT, 'credits.json');
const TYPES = ['set', 'add', 'adjust', 'usage', 'refund', 'migrate'];
const listeners = new Set();
// v1.6.31 (요구 10·11): 파일이 깨졌으면 빈 원장으로 "정상처럼" 계속하지 않는다 — strict 로 예외(관리자 콘솔 운영 복구 화면에 손상 파일로 보인다)
const js = require('./jsonstore');
function load() { const d = js.readJson(FILE, null, { strict: true }); return d && typeof d === 'object' ? { users: d.users || {}, ledger: d.ledger || [] } : { users: {}, ledger: [] }; }
function save(d) { js.writeJsonAtomic(FILE, d, { mode: 0o600, keepBackup: true }); }
const MAX_ABS = 1e9; // 한 번에 조정할 수 있는 최대 금액(원) — 잘못된 입력으로 잔액이 비정상이 되지 않게
function httpError(msg, status = 400) { return Object.assign(new Error(msg), { status }); }
function balance(userId) { const d = load(); const u = d.users[userId]; return u ? Math.round(u.balance) : 0; }
function info(userId) {
const d = load(); const u = d.users[userId] || { balance: 0, updatedAt: null };
const mine = d.ledger.filter((e) => e.userId === userId);
const used = mine.filter((e) => e.type === 'usage').reduce((a, e) => a - e.amount, 0);
const granted = mine.filter((e) => ['set', 'add', 'adjust', 'refund', 'migrate'].includes(e.type)).reduce((a, e) => a + e.amount, 0);
return { userId, balance: Math.round(u.balance), updatedAt: u.updatedAt, used: Math.round(used), granted: Math.round(granted), entries: mine.length };
}
function ledger(userId, limit = 100) { const d = load(); return d.ledger.filter((e) => !userId || e.userId === userId).slice(-limit).reverse(); }
/**
* v1.3.17 (B20): 기간별(일·주·월) 사용 합계용 — 화면에 보이는 최근 원장 30건이 아니라 since 이후 사용 기록 전부를 {at, amount} 로 준다.
* (예전에는 최근 30건만 더해 기간 합계가 실제보다 작게 보일 수 있었다)
*/
function usageSince(userId, since) { const d = load(); const t = since ? Date.parse(since) : 0; return d.ledger.filter((e) => e.userId === userId && e.type === 'usage' && Date.parse(e.at) >= t).map((e) => ({ at: e.at, amount: e.amount })); }
function all() { const d = load(); return Object.entries(d.users).map(([userId, u]) => ({ userId, balance: Math.round(u.balance), updatedAt: u.updatedAt })); }
/** 원장에 기록하고 잔액을 갱신한다. type=set 은 amount 를 "새 잔액"으로 해석(원장에는 차액 기록). */
function apply({ userId, type, amount, memo, by, ref }) {
if (!userId || typeof userId !== 'string') throw httpError('사용자를 지정하세요');
if (!TYPES.includes(type)) throw httpError('알 수 없는 조정 종류: ' + type);
const n = Math.round(Number(amount));
if (!Number.isFinite(n)) throw httpError('금액이 올바르지 않습니다');
if (Math.abs(n) > MAX_ABS) throw httpError(`금액이 너무 큽니다 (최대 ${MAX_ABS.toLocaleString()}원)`);
if ((type === 'add' || type === 'refund') && n < 0) throw httpError('충전·환불 금액은 0보다 커야 합니다 (줄이려면 조정(adjust)을 쓰세요)');
if (type === 'set' && n < 0) throw httpError('잔액은 음수로 설정할 수 없습니다');
const d = load(); const u = d.users[userId] || { balance: 0, updatedAt: null };
// v1.6.31 (요구 11·12): 같은 작업(ref)의 사용 차감이 이미 원장에 있으면 다시 차감하지 않는다 — 재시도·서버 재시작 뒤 중복 차감 방지
if (type === 'usage' && ref && typeof ref === 'object') { const key = JSON.stringify(ref); const dup = d.ledger.find((e) => e.userId === userId && e.type === 'usage' && e.ref && JSON.stringify(e.ref) === key); if (dup) return { entry: dup, balance: Math.round(u.balance), duplicate: true }; }
const before = Math.round(u.balance);
let delta = n;
if (type === 'set') delta = n - before;
else if (type === 'usage') delta = -Math.abs(n);
if (delta === 0 && type !== 'set') throw httpError('0원은 기록할 수 없습니다');
const after = before + delta;
u.balance = after; u.updatedAt = new Date().toISOString(); d.users[userId] = u;
const e = { id: crypto.randomBytes(6).toString('hex'), at: u.updatedAt, userId, type, amount: delta, balanceAfter: after, memo: String(memo || '').slice(0, 200), by: by || null, ref: ref || null };
d.ledger.push(e); if (d.ledger.length > 50000) d.ledger.splice(0, d.ledger.length - 50000);
save(d);
if (delta > 0 || type === 'set') for (const fn of listeners) { try { fn({ userId, before, after, entry: e }); } catch (_) {} }
return { entry: e, balance: after };
}
const set = (userId, amount, by, memo) => apply({ userId, type: 'set', amount, by, memo: memo || '관리자 설정' });
const add = (userId, amount, by, memo) => apply({ userId, type: amount < 0 ? 'adjust' : 'add', amount, by, memo: memo || '관리자 추가' });
/** 작업 비용 차감 (잔액이 음수가 되어도 기록) */
function charge(userId, amountKrw, ref, memo) { const n = Math.round(Number(amountKrw) || 0); if (n <= 0) return { balance: balance(userId), entry: null }; return apply({ userId, type: 'usage', amount: n, ref, memo }); }
function onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }
/** 이전 선결제 충전(billing.json) 을 초기 잔액으로 1회 이관 */
function migrateFromBilling(charges) {
if (fs.existsSync(FILE) || !charges || !charges.length) return 0;
const byUser = {}; for (const c of charges) byUser[c.userId] = (byUser[c.userId] || 0) + (c.amountKrw || 0);
let n = 0; for (const [userId, amt] of Object.entries(byUser)) if (amt) { apply({ userId, type: 'migrate', amount: amt, by: 'system', memo: '선결제 충전 내역 이관' }); n += 1; }
return n;
}
/**
* v1.4.1 캐시 금액 표시 (관리자 콘솔 › 설정 › 캐시 표시 단위). 내부 값은 원 기준 숫자 그대로이고 글자만 바뀐다.
* 한글 단위(원·환·동…)는 숫자에 붙이고, 그 밖(D, ING, Gold…)은 한 칸 띄운다: 1,234원 / 1,234 D
* @param {number} n 금액(원 기준)
*/
function unit() { try { return require('./settings').load().cashUnit || '원'; } catch (_) { return '원'; } }
function fmt(n) { const u = unit(); return `${Math.round(Number(n) || 0).toLocaleString('ko-KR')}${/^[가-힣]+$/.test(u) ? '' : ' '}${u}`; }
module.exports = { unit, fmt, usageSince, balance, info, ledger, all, apply, set, add, charge, onChange, migrateFromBilling, TYPES };
+560
View File
@@ -0,0 +1,560 @@
'use strict';
// 배포 서버(호스팅 서버) 관리와 사이트 배포.
// - 종류: local(이 서버의 디렉터리) | ssh(원격 서버, tar-over-ssh 로 전송 후 원자적 교체)
// - 설정: data/deploy-targets.json, 개인키: data/deploy-keys/<id> (0600)
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { spawn, execFile, execFileSync } = require('child_process');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const FILE = path.join(DATA_ROOT, 'deploy-targets.json');
const KEYS = path.join(DATA_ROOT, 'deploy-keys');
fs.mkdirSync(KEYS, { recursive: true, mode: 0o700 });
function httpError(msg, status = 400) { return Object.assign(new Error(msg), { status }); }
function load() { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (_) { return { defaultId: null, targets: [] }; } }
function save(d) { const tmp = FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(d, null, 1), { mode: 0o600 }); fs.renameSync(tmp, FILE); try { fs.chmodSync(FILE, 0o600); } catch (_) {} }
function keyPath(id) { return path.join(KEYS, id); }
function pub(t, d) { return { ...t, hasKey: fs.existsSync(keyPath(t.id)), publicKey: fs.existsSync(keyPath(t.id) + '.pub') ? fs.readFileSync(keyPath(t.id) + '.pub', 'utf8').trim() : null, isDefault: d.defaultId === t.id }; }
function list() { const d = load(); return { defaultId: d.defaultId, targets: d.targets.map((t) => pub(t, d)) }; }
function get(id) { const d = load(); const t = d.targets.find((x) => x.id === id); return t ? pub(t, d) : null; }
function siteUrlOn(t, siteId) { return t.publicBaseUrl ? `${String(t.publicBaseUrl).replace(/\/$/, '')}/${siteId}/` : null; }
function upsert(input) {
const d = load(); const id = input.id || crypto.randomBytes(6).toString('hex');
const t = d.targets.find((x) => x.id === id) || { id, createdAt: new Date().toISOString() };
const type = input.type === 'local' ? 'local' : 'ssh';
const name = String(input.name || '').trim(); if (!name) throw httpError('이름을 입력하세요');
const baseDir = String(input.baseDir || '').trim(); if (!baseDir.startsWith('/')) throw httpError('사이트 저장 경로는 / 로 시작하는 절대 경로여야 합니다');
const publicBaseUrl = String(input.publicBaseUrl || '').trim(); if (publicBaseUrl && !/^https?:\/\//.test(publicBaseUrl)) throw httpError('공개 URL 을 입력할 경우 http:// 또는 https:// 로 시작해야 합니다');
Object.assign(t, { name, type, baseDir, publicBaseUrl, note: String(input.note || ''), manageVhost: input.manageVhost === undefined ? (t.manageVhost !== undefined ? t.manageVhost : true) : !!(input.manageVhost === true || input.manageVhost === 'true' || input.manageVhost === 'on'), enableSsl: input.enableSsl === undefined ? (t.enableSsl !== undefined ? t.enableSsl : true) : !!(input.enableSsl === true || input.enableSsl === 'true' || input.enableSsl === 'on'), allowSudo: input.allowSudo === undefined ? (t.allowSudo !== undefined ? t.allowSudo : null) : !!(input.allowSudo === true || input.allowSudo === 'true' || input.allowSudo === 'on'), updatedAt: new Date().toISOString() });
if (type === 'ssh') {
t.host = String(input.host || '').trim(); if (!t.host) throw httpError('서버 주소(host)를 입력하세요');
t.port = parseInt(input.port || 22, 10) || 22; t.user = String(input.user || 'root').trim() || 'root';
if (typeof input.privateKey === 'string' && input.privateKey.trim()) {
const k = input.privateKey.replace(/\r/g, '').trim() + '\n'; if (!/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(k)) throw httpError('개인키 형식이 아닙니다 (-----BEGIN ... PRIVATE KEY-----)');
fs.writeFileSync(keyPath(id), k, { mode: 0o600 }); try { fs.chmodSync(keyPath(id), 0o600); } catch (_) {}
try { fs.unlinkSync(keyPath(id) + '.pub'); } catch (_) {}
try { const out = execFileSync('ssh-keygen', ['-y', '-f', keyPath(id)], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); fs.writeFileSync(keyPath(id) + '.pub', out + '\n'); } catch (_) {}
}
}
if (!d.targets.some((x) => x.id === id)) d.targets.push(t);
if (!d.defaultId) d.defaultId = id;
save(d); return pub(t, d);
}
function remove(id) { const d = load(); const i = d.targets.findIndex((x) => x.id === id); if (i < 0) throw httpError('배포 서버가 없습니다', 404); d.targets.splice(i, 1); if (d.defaultId === id) d.defaultId = d.targets[0] ? d.targets[0].id : null; save(d); for (const f of [keyPath(id), keyPath(id) + '.pub']) { try { fs.unlinkSync(f); } catch (_) {} } return list(); }
function setDefault(id) { const d = load(); if (!d.targets.some((x) => x.id === id)) throw httpError('배포 서버가 없습니다', 404); d.defaultId = id; save(d); return list(); }
function generateKey(id) {
return new Promise((resolve, reject) => {
const d = load(); const t = d.targets.find((x) => x.id === id); if (!t) return reject(httpError('배포 서버가 없습니다', 404));
for (const f of [keyPath(id), keyPath(id) + '.pub']) { try { fs.unlinkSync(f); } catch (_) {} }
execFile('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', `webhosting-deploy-${t.name}`, '-f', keyPath(id)], (err) => { if (err) return reject(new Error('키 생성 실패: ' + err.message)); try { fs.chmodSync(keyPath(id), 0o600); } catch (_) {} resolve(pub(t, d)); });
});
}
function sshArgs(t) { return ['-i', keyPath(t.id), '-p', String(t.port || 22), '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=12', '-o', 'IdentitiesOnly=yes', `${t.user}@${t.host}`]; }
/**
* 외부 명령 실행 (ssh·tar·bash). opts.stdin 으로 넘긴 스트림을 자식의 표준 입력에 흘려 넣는다.
* v1.5.13: 자식이 입력을 다 읽기 전에 끝나면(ssh 접속 실패, 스크립트가 바로 exit 등) 자식 stdin 소켓에 EPIPE 'error' 가 난다.
* 이 소켓에 처리기가 없어 프로세스 전체가 죽었다(2026-10-14 11:27 UTC webmaker.ibit.kr, 발행 중 `write EPIPE` 로 서비스 재시작).
* 이제 stdin 양쪽의 오류를 받아 두었다가 자식의 종료 코드·stderr 와 함께 결과로 돌려준다 — 실패 원인은 호출한 쪽의 안내 문구로 나간다.
* @param {string} cmd @param {string[]} args
* @param {{stdin?:import('stream').Readable, timeout?:number, raw?:boolean}} opts
* @returns {Promise<{code:number, out:string, err:string}>}
*/
function run(cmd, args, opts = {}) {
return new Promise((resolve) => {
const child = spawn(cmd, args, { stdio: [opts.stdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
let out = '', err = '', stdinErr = ''; child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { err += c; });
const timer = setTimeout(() => child.kill('SIGKILL'), opts.timeout || 120000);
// opts.raw: 출력을 다듬지 않는다 — 접속 로그 읽기는 마지막 줄바꿈이 "완전한 줄" 의 표시라 지우면 마지막 기록을 버리게 된다 (v1.3.17 B10)
child.on('close', (code) => { clearTimeout(timer); const e = err.trim() || (stdinErr ? `입력 전송 중단 (${stdinErr}) — 상대가 입력을 받기 전에 끝났습니다` : ''); resolve({ code, out: opts.raw ? out : out.trim(), err: e }); });
child.on('error', (e) => { clearTimeout(timer); resolve({ code: -1, out: '', err: e.message }); });
if (opts.stdin) {
child.stdin.on('error', (e) => { stdinErr = e.code || e.message; }); // EPIPE 등 — 프로세스를 죽이지 않고 결과에 남긴다
opts.stdin.on('error', (e) => { stdinErr = stdinErr || (e.code || e.message); try { child.stdin.end(); } catch (_) {} });
opts.stdin.pipe(child.stdin);
}
});
}
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
/** 연결/쓰기 권한 테스트 */
async function test(id) {
const t = get(id); if (!t) throw httpError('배포 서버가 없습니다', 404);
const started = Date.now();
if (t.type === 'local') {
try { fs.mkdirSync(t.baseDir, { recursive: true }); fs.accessSync(t.baseDir, fs.constants.W_OK); const cap = await vhostCapability(t); return record(id, { ok: true, detail: `로컬 경로 ${t.baseDir} 쓰기 가능 · ${capText(cap)}`, ms: Date.now() - started }); }
catch (e) { return record(id, { ok: false, detail: `로컬 경로 접근 실패: ${e.message}`, ms: Date.now() - started }); }
}
if (!t.hasKey) return record(id, { ok: false, detail: 'SSH 키가 없습니다. 키를 생성하거나 붙여넣으세요.', ms: 0 });
// 단계별 진단: 접속 → 사용자 → 경로 존재/생성 → 쓰기 권한 → tar
const script = `D=${shq(t.baseDir)}; echo "USER=$(id -un)"; echo "HOST=$(uname -n)";
if [ -d "$D" ]; then echo "DIR=exists"; else if mkdir -p "$D" 2>/tmp/.wh_err; then echo "DIR=created"; else echo "DIR=mkdir_failed $(cat /tmp/.wh_err 2>/dev/null)"; fi; fi;
if [ -d "$D" ]; then echo "OWNER=$(stat -c %U:%G "$D" 2>/dev/null || ls -ld "$D" | awk '{print $3":"$4}')"; echo "PERM=$(stat -c %a "$D" 2>/dev/null)"; if [ -w "$D" ]; then echo "WRITE=ok"; else echo "WRITE=denied"; fi; fi;
command -v tar >/dev/null 2>&1 && echo "TAR=ok" || echo "TAR=missing"; echo "END"`;
const r = await run('ssh', [...sshArgs(t), script], { timeout: 30000 });
const kv = {}; for (const line of r.out.split('\n')) { const m = line.match(/^([A-Z]+)=(.*)$/); if (m) kv[m[1]] = m[2]; }
if (!/END/.test(r.out)) {
const err = (r.err || r.out || '').split('\n').filter(Boolean).slice(-2).join(' ');
const hint = /Permission denied \(publickey/.test(err) ? ' → 배포 서버의 ~/.ssh/authorized_keys 에 공개키가 등록되어 있는지, 사용자 이름이 맞는지 확인하세요.' : /Connection (refused|timed out)|No route|Could not resolve/.test(err) ? ' → 주소·포트와 방화벽을 확인하세요.' : /Host key verification failed|REMOTE HOST IDENTIFICATION/.test(err) ? ' → 서버 키가 바뀌었습니다. 서비스 서버의 ~/.ssh/known_hosts 에서 해당 호스트 항목을 지우세요.' : '';
return record(id, { ok: false, detail: `접속 실패: ${err || `exit ${r.code}`}${hint}`, ms: Date.now() - started });
}
const problems = [];
if (kv.DIR && kv.DIR.startsWith('mkdir_failed')) problems.push(`저장 경로 ${t.baseDir} 를 만들 수 없습니다 (${kv.DIR.replace('mkdir_failed', '').trim() || '권한 없음'}). 서버에서 미리 만들고 접속 사용자에게 쓰기 권한을 주세요: sudo mkdir -p ${t.baseDir} && sudo chown ${kv.USER || t.user} ${t.baseDir}`);
else if (kv.WRITE === 'denied') problems.push(`접속은 되지만 저장 경로 ${t.baseDir} 에 쓰기 권한이 없습니다 (소유자 ${kv.OWNER || '?'}, 권한 ${kv.PERM || '?'}, 접속 사용자 ${kv.USER || t.user}). 서버에서: sudo chown -R ${kv.USER || t.user} ${t.baseDir} (또는 chmod 로 쓰기 허용)`);
if (kv.TAR === 'missing') problems.push('배포 서버에 tar 가 없습니다. 설치하세요 (apt install tar).');
const ok = !problems.length;
let capLine = '';
if (ok && t.manageVhost !== false) { try { const cap = await vhostCapability(t); capLine = ` · ${capText(cap)}`; } catch (_) {} }
return record(id, { ok, detail: ok ? `연결 성공 — ${kv.USER}@${kv.HOST}, 저장 경로 ${t.baseDir} (${kv.DIR === 'created' ? '새로 생성' : '존재'}, 소유자 ${kv.OWNER}, 쓰기 가능), tar 있음${capLine}` : problems.join(' / '), ms: Date.now() - started });
}
function record(id, result) { const d = load(); const t = d.targets.find((x) => x.id === id); if (t) { t.lastTest = { ...result, at: new Date().toISOString() }; save(d); } return { ...result, at: new Date().toISOString() }; }
/** 사이트 디렉터리를 대상 서버의 baseDir/<siteId>/ 로 배포 (원자적 교체) */
async function deploySite(siteId, siteDir, id) {
const t = get(id); if (!t) throw httpError('배포 서버가 없습니다', 404);
const started = Date.now(); const dest = path.posix.join(t.baseDir, siteId);
if (t.type === 'local') {
if (path.resolve(dest) === path.resolve(siteDir)) return { ok: true, url: siteUrlOn(t, siteId), detail: '서비스 서버 자체(같은 경로) — 이미 배포된 상태', ms: 0, targetId: id, samePath: true };
// 배포 대상이 빌더의 작업 폴더 자체인데 다른 내용(예: 안내 페이지)을 올리려는 경우: 작업 파일이 지워지므로 거부한다 (v1.1.4)
const own = path.resolve(path.join(process.env.SITES_ROOT || '/var/www/sites', siteId));
if (path.resolve(dest) === own) return { ok: false, url: null, detail: '서비스 서버 자체 경로에는 작업 폴더 외의 내용을 올릴 수 없습니다 (작업 파일 보호)', ms: 0, targetId: id, samePath: true };
// v1.6.31 (요구 15, 검토 B): 임시 폴더에 복사 → 기존 폴더를 .prev 로 옮기고 새 폴더를 제자리에 → 실패하면 .prev 를 되돌린다. .prev 는 "이전 배포본으로 되돌리기" 에 쓴다
const tmp = dest + '.tmp-' + Date.now(); const prev = dest + '.prev';
try { fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.cpSync(siteDir, tmp, { recursive: true, filter: (src) => !/[\\/]\.(git|claude|refs)(\b|$)/.test(src) }); fs.rmSync(prev, { recursive: true, force: true }); if (fs.existsSync(dest)) fs.renameSync(dest, prev); try { fs.renameSync(tmp, dest); } catch (e) { if (fs.existsSync(prev) && !fs.existsSync(dest)) fs.renameSync(prev, dest); throw e; } return { ok: true, url: siteUrlOn(t, siteId), detail: `로컬 복사 완료 → ${dest}${fs.existsSync(prev) ? ' (이전 배포본 보관)' : ''}`, ms: Date.now() - started, targetId: id, hasPrev: fs.existsSync(prev) }; }
catch (e) { fs.rmSync(tmp, { recursive: true, force: true }); return { ok: false, url: null, detail: `로컬 복사 실패: ${e.message}`, ms: Date.now() - started, targetId: id }; }
}
if (!t.hasKey) return { ok: false, url: null, detail: 'SSH 키가 없습니다', ms: 0, targetId: id };
const tar = spawn('tar', ['-C', siteDir, '--exclude=./.git', '--exclude=./.claude', '--exclude=./.refs', '-cf', '-', '.']);
// v1.6.31 (요구 15, 검토 B): 릴리스 디렉터리(<dest>.releases/<시각>)에 풀고 심볼릭 링크 <dest> 를 바꾼다(원자적 rename). 실패해도 현재 링크는 그대로라 사이트가 끊기지 않는다.
// 예전 방식(rm -rf 뒤 mv)으로 만들어진 실제 디렉터리는 첫 배포 때 릴리스로 옮겨 링크로 바꾼다. 이전 릴리스 2개를 남겨 "되돌리기" 에 쓴다.
const rel = new Date().toISOString().replace(/[:.]/g, '-');
const remote = `set -e; D=${shq(dest)}; R="$D.releases"; N="$R/${rel}"; mkdir -p "$N"; tar -C "$N" -xf -; chmod -R a+rX "$N"; if [ -d "$D" ] && [ ! -L "$D" ]; then mv "$D" "$R/legacy-${rel}"; fi; ln -sfn "$N" "$D.new" && mv -Tf "$D.new" "$D"; ls -1dt "$R"/*/ 2>/dev/null | tail -n +4 | xargs -r rm -rf; echo DEPLOYED`;
const r = await run('ssh', [...sshArgs(t), remote], { stdin: tar.stdout, timeout: 300000 });
const ok = r.code === 0 && /DEPLOYED/.test(r.out);
const errText = (r.err || r.out || `exit ${r.code}`).split('\n').filter(Boolean).slice(-2).join(' ');
return { ok, url: ok ? siteUrlOn(t, siteId) : null, detail: ok ? `전송 완료 → ${t.user}@${t.host}:${dest}` : `전송 실패: ${errText}${/Permission denied/.test(errText) ? ' → 저장 경로의 쓰기 권한을 확인하세요 (연결 테스트 참고)' : ''}`, ms: Date.now() - started, targetId: id };
}
/**
* v1.6.31 (요구 15): 이전 배포본으로 되돌리기. 원격은 릴리스 디렉터리 중 현재가 아닌 가장 최근 것으로 링크를 바꾸고, 로컬(다른 경로)은 .prev 와 자리를 맞바꾼다. 같은 경로(서비스 서버 자체)는 되돌릴 배포본이 따로 없다(버전 되돌리기 뒤 재배포).
* @returns {Promise<{ok:boolean, detail:string, release?:string}>}
*/
async function rollback(siteId, id) {
const t = get(id); if (!t) throw httpError('배포 서버가 없습니다', 404); const dest = path.posix.join(t.baseDir, siteId);
if (t.type === 'local') {
if (isSamePathTarget(t, siteId)) return { ok: false, detail: '서비스 서버 자체(같은 경로) 배포는 보관된 이전 배포본이 없습니다 — 수정 탭의 버전 되돌리기로 내용을 되돌린 뒤 다시 발행하세요' };
const prev = dest + '.prev'; if (!fs.existsSync(prev)) return { ok: false, detail: '보관된 이전 배포본(.prev)이 없습니다' };
const tmp = dest + '.swap-' + Date.now(); try { fs.renameSync(dest, tmp); fs.renameSync(prev, dest); fs.renameSync(tmp, prev); } catch (e) { return { ok: false, detail: '되돌리기 실패: ' + e.message }; }
return { ok: true, detail: '이전 배포본으로 되돌렸습니다 (지금 것은 .prev 로 보관)' };
}
const script = `set -e; D=${shq(dest)}; R="$D.releases"; [ -L "$D" ] || { echo NO_LINK; exit 0; }; CUR=$(readlink -f "$D"); P=$(ls -1dt "$R"/*/ 2>/dev/null | sed 's#/$##' | grep -vx "$CUR" | head -1); [ -n "$P" ] || { echo NO_PREV; exit 0; }; ln -sfn "$P" "$D.new" && mv -Tf "$D.new" "$D"; echo "ROLLED=$P"`;
const r = await execOn(t, script, { timeout: 60000 }); const o = r.out || '';
if (/NO_LINK/.test(o)) return { ok: false, detail: '이 서버의 배포본은 아직 릴리스 방식(1.6.31)으로 전환되지 않았습니다 — 한 번 재배포한 뒤부터 되돌릴 수 있습니다' };
if (/NO_PREV/.test(o)) return { ok: false, detail: '보관된 이전 릴리스가 없습니다' };
const m = o.match(/ROLLED=(\S+)/); return m ? { ok: true, detail: `이전 릴리스로 되돌렸습니다: ${path.posix.basename(m[1])}`, release: m[1] } : { ok: false, detail: '되돌리기 실패: ' + (r.err || o).split('\n').slice(-2).join(' ') };
}
/** 같은 경로(서비스 서버 자체) 대상인지: 발행 취소를 파일 교체 대신 .unpublished 표식으로 처리해야 하는 경우 */
function isSamePathTarget(t, siteId) { return !!(t && t.type === 'local' && path.resolve(path.posix.join(t.baseDir, siteId)) === path.resolve(path.join(process.env.SITES_ROOT || '/var/www/sites', siteId))); }
/** 대상 서버에서 사이트 제거 */
async function removeSite(siteId, id) {
const t = get(id); if (!t) return { ok: false, detail: '배포 서버가 없습니다' };
const dest = path.posix.join(t.baseDir, siteId);
if (t.type === 'local') { if (path.resolve(dest) !== path.resolve(path.join(process.env.SITES_ROOT || '/var/www/sites', siteId))) fs.rmSync(dest, { recursive: true, force: true }); return { ok: true }; }
const r = await run('ssh', [...sshArgs(t), `rm -rf ${shq(dest)} && echo REMOVED`], { timeout: 60000 });
return { ok: r.code === 0, detail: r.err };
}
// ---------- 도메인 / vhost ----------
const DOMAIN_RE = /^(?=.{1,253}$)(?!-)[a-z0-9-]{1,63}(?<!-)(\.[a-z0-9-]{1,63})+$/i;
function normalizeDomains(list) {
const out = []; for (const raw of Array.isArray(list) ? list : String(list || '').split(/[\s,]+/)) { const d = String(raw || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, ''); if (!d) continue; if (!DOMAIN_RE.test(d)) throw httpError(`도메인 형식이 올바르지 않습니다: ${raw}`); if (!out.includes(d)) out.push(d); }
return out.slice(0, 20);
}
// 접속 통계용 로그 형식(http 컨텍스트). vhost 를 쓸 때 conf.d 에 함께 설치한다 (lib/stats.js 와 동일 형식)
const WMSTATS_FORMAT = "log_format wmstats '$msec|$remote_addr|$host|$request_method|$request_uri|$status|$request_length|$bytes_sent|$http_referer|$http_user_agent';";
/** 사이트별 접속 로그 경로 (원격 배포 서버). 수집기가 sudo -n 으로 읽는다 */
function statsLogPath(siteId) { return `/var/log/nginx/wh-${siteId}.access.log`; }
/**
* nginx 설정 본문.
* v1.4.14 (요구 9): 도메인 묶음(groups) 마다 server 블록을 만든다. 묶음은 configureVhost 가 나눈다 —
* platform: 플랫폼 도메인의 서브 도메인(mycompany.ibit.kr) → 와일드카드 인증서(wh-wild-<플랫폼 도메인>) 하나로 HTTPS
* own : 고객이 가진 도메인(example.com, www.example.com) → 그 사이트만의 인증서(wh-<사이트ID>, webroot 발급)
* 두 인증서를 분리하는 이유: 와일드카드는 운영자가 한 번 발급하면 모든 서브 도메인 사이트가 바로 HTTPS 가 되고(발급 한도 걱정 없음),
* 고객 도메인은 사이트 폐기 때 그 인증서만 지울 수 있다.
* @param {Array<{domains:string[], ssl:{certName,hasOptions,hasDhparam}|null}>|string[]} groups 묶음 목록 (옛 호출 호환: 도메인 배열 + ssl)
*/
function vhostConf(groups, root, ssl, siteId) {
if (Array.isArray(groups) && (!groups.length || typeof groups[0] === 'string')) groups = [{ domains: groups, ssl }]; // 1.4.5 이전 호출 형태
return `# generated by webhosting-agent (v1.4.14: ${groups.map((g) => `${g.domains.join(' ')} → ${g.ssl ? g.ssl.certName : 'http'}`).join(' | ')})\n` + groups.filter((g) => g.domains && g.domains.length).map((g) => vhostBlock(g.domains, root, g.ssl, siteId)).join('\n');
}
function vhostBlock(domains, root, ssl, siteId) {
const acme = ` location ^~ /.well-known/acme-challenge/ { root ${root}; default_type "text/plain"; }\n`;
const statsLog = siteId ? ` access_log ${statsLogPath(siteId)} wmstats;\n` : '';
const errPages = ` # 오류 페이지: 서버 종류·버전이나 다른 서비스의 내용이 드러나지 않도록 일반 안내로 대체\n server_tokens off;\n error_page 404 /__wh_404;\n error_page 403 =404 /__wh_404;\n error_page 500 502 503 504 /__wh_50x;\n location = /__wh_404 { internal; default_type text/html; return 404 '<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>페이지를 찾을 수 없습니다</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;font-family:-apple-system,"Segoe UI","Noto Sans KR",sans-serif;background:#f4f5f7;color:#1f2933}main{text-align:center;padding:32px}h1{font-size:22px;margin:0 0 8px}p{color:#6b7684;margin:0}</style></head><body><main><h1>페이지를 찾을 수 없습니다</h1><p>요청하신 페이지가 존재하지 않거나 이동되었습니다.</p></main></body></html>'; }\n location = /__wh_50x { internal; default_type text/html; return 503 '<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>일시적인 오류가 발생했습니다</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;font-family:-apple-system,"Segoe UI","Noto Sans KR",sans-serif;background:#f4f5f7;color:#1f2933}main{text-align:center;padding:32px}h1{font-size:22px;margin:0 0 8px}p{color:#6b7684;margin:0}</style></head><body><main><h1>일시적인 오류가 발생했습니다</h1><p>잠시 후 다시 시도해 주세요.</p></main></body></html>'; }\n`;
// v1.5.27 (요구 4): 발행 취소 표식(.unpublished)이 작업 폴더에 있으면 어떤 경로든(/, /ko/ …) 안내 페이지(503). 1.5.19 까지는 서비스 공개 주소(/<ID>/)의 nginx 규칙에만
// 있어, 같은 경로 배포 서버의 도메인 주소(sample.ariweb.net/ko/)는 발행 취소 뒤에도 그대로 열렸다. 원격 서버는 파일 자체가 안내 페이지로 바뀌므로 표식이 없어 영향 없음.
const maint = ` location = /__wh_maint { internal; default_type text/html; add_header Cache-Control "no-store"; return 503 '${require('./maintpage').nginxString()}'; }\n`;
const common = ` root ${root};\n index index.html;\n charset utf-8;\n${statsLog}${errPages}${maint}${acme} location / { if (-f $document_root/.unpublished) { rewrite ^ /__wh_maint last; } try_files $uri $uri/ =404; }\n location ~ /\\. { return 404; }\n`;
if (!ssl) return `server {\n listen 80;\n listen [::]:80;\n server_name ${domains.join(' ')};\n${common}}\n`;
// v1.5.27 (요구 3): 인증서가 letsencrypt 가 아닌 곳(예: /etc/ssl/cloudflare/x.pem, Cloudflare 원본 인증서)에 있으면 ssl.certFile·keyFile 로 그 경로를 쓴다
const live = `/etc/letsencrypt/live/${ssl.certName}`; const certFile = ssl.certFile || `${live}/fullchain.pem`; const keyFile = ssl.keyFile || `${live}/privkey.pem`;
return `# ssl: ${ssl.certName}\nserver {\n listen 80;\n listen [::]:80;\n server_name ${domains.join(' ')};\n server_tokens off;\n${acme} location / { return 301 https://$host$request_uri; }\n}\nserver {\n listen 443 ssl http2;\n listen [::]:443 ssl http2;\n server_name ${domains.join(' ')};\n ssl_certificate ${certFile};\n ssl_certificate_key ${keyFile};\n${ssl.hasOptions ? ' include /etc/letsencrypt/options-ssl-nginx.conf;\n' : ' ssl_protocols TLSv1.2 TLSv1.3;\n ssl_prefer_server_ciphers off;\n'}${ssl.hasDhparam ? ' ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;\n' : ''}${common}}\n`;
}
/**
* v1.5.19 (요구 2): sudo 점검 셸 조각 — 안 되면 NO_SUDO 와 함께 sudo 의 오류 문장(SUDO_ERR=…)을 남기고, 되더라도 /etc/nginx 가 읽기 전용이면 RO_ETC 를 남긴다.
* 배경: webmaker.ariweb.net 의 서비스 유닛(다른 절차로 설치)에 NoNewPrivileges=yes 와 ProtectSystem=strict 가 있어 서비스 안에서는 sudo 가 "no new privileges" 로
* 막히고, 설령 되더라도 /etc/nginx 가 읽기 전용이다. 1.5.17 까지는 둘 다 "비밀번호 없는 sudo 권한이 없습니다 — sudoers 를 고치세요" 로만 안내해 원인을 찾을 수 없었다
* (2026-10-15 07:27 UTC sample.ariweb.net 첫 배포 실패). exitWord: 실패 때 실행할 명령(예: 'rm -f "$T"; exit 0').
*/
function sudoProbe(exitWord) { return `if ! sudo -n true 2>/tmp/wh-sudo.$$; then echo "NO_SUDO"; echo "SUDO_ERR=$(tr '\\n' ' ' </tmp/wh-sudo.$$ | cut -c1-300)"; rm -f /tmp/wh-sudo.$$; ${exitWord}; fi; rm -f /tmp/wh-sudo.$$; if [ -d /etc/nginx ] && ! sudo -n test -w /etc/nginx; then echo "RO_ETC"; fi`; }
/**
* sudo 실패·읽기 전용의 원인별 안내 문구 (vhost·인증서·기본 서버·연결 테스트·설치 점검표가 같이 쓴다).
* @param {string} out 스크립트 출력 (NO_SUDO / SUDO_ERR=… / RO_ETC)
* @returns {string} 원인과 고치는 방법. sudo 가 되고 쓰기도 되면 ''
*/
function sudoHint(out) {
const o = String(out || ''); const err = (o.match(/SUDO_ERR=(.*)/) || [, ''])[1].trim();
if (/NO_SUDO/.test(o)) {
if (/no new privileges/i.test(err)) return '서비스가 systemd 유닛의 NoNewPrivileges=yes 아래에서 실행되어 sudo 를 쓸 수 없습니다 (sudo: "no new privileges" flag is set). 서버에서 sudo node scripts/unit-check.js --fix 를 실행해 드롭인(NoNewPrivileges=no, ReadWritePaths 추가)을 만든 뒤 sudo systemctl daemon-reload && systemctl restart <유닛> — 또는 유닛 파일에서 NoNewPrivileges 를 지우고 ProtectSystem=strict 면 ReadWritePaths 에 /etc/nginx /etc/letsencrypt /var/lib/letsencrypt /var/log/letsencrypt /var/tmp 를 더한다 (v1.5.31)';
return `배포 서버에서 접속 사용자에게 비밀번호 없는 sudo 권한이 없어 nginx 설정을 쓸 수 없습니다${err ? ` (sudo: ${err})` : ''}. /etc/sudoers 에 "<user> ALL=(ALL) NOPASSWD: ALL" 또는 nginx 관련 명령을 허용하세요.`;
}
if (/RO_ETC/.test(o)) return '/etc/nginx 가 읽기 전용입니다 — 서비스 유닛의 ProtectSystem=strict(또는 full) 때문입니다. 서버에서 sudo node scripts/unit-check.js --fix 로 드롭인을 만들거나, 유닛의 ReadWritePaths 에 /etc/nginx /etc/letsencrypt /var/lib/letsencrypt /var/log/letsencrypt /var/tmp 를 더한(또는 ProtectSystem=yes) 뒤 daemon-reload·재시작하세요 (v1.5.31)';
return '';
}
/**
* v1.7.3 (요구 1): nginx 설정 검사가 "duplicate log_format wmstats" 로 실패한 서버의 자가 복구 조각 — 우리가 만든 conf.d/wmstats.conf 를 지우고 다시 검사한다.
* (2026-10-24 webmaker.ibit.kr: 운영자가 서버 이전 때 넣은 webmaker-log-format.conf 와 겹쳐 nginx -t 가 계속 실패 → 기본 오류 서버·vhost 가 모두 적용되지 않았다.)
* $ERRF 에 nginx -t 의 stderr 가 있어야 한다. 성공하면 WMSTATS_DUP_REMOVED 를 찍고 0 으로 끝난다.
*/
const WMSTATS_DEDUP = `if grep -q 'duplicate "log_format" name "wmstats"' "$ERRF" && [ -f /etc/nginx/conf.d/wmstats.conf ]; then sudo -n rm -f /etc/nginx/conf.d/wmstats.conf; echo "WMSTATS_DUP_REMOVED"; sudo -n nginx -t 2>"$ERRF"; else false; fi`;
/**
* v1.7.3: 설정을 바꾸지 않을 때("이미 같음")도 nginx 가 지금 깨진 상태면 고쳐 둔다 — vhost 파일은 있지만 reload 가 안 된 서버(중복 wmstats)를 재배포로 살리기 위함.
* sudo 가 안 되면 아무것도 하지 않는다(SKIP). 출력: NGINX_OK / NGINX_FIXED / NGINX_BROKEN <사유>
*/
function nginxRepairScript() {
return `if ! sudo -n true 2>/dev/null; then echo SKIP; exit 0; fi; ERRF=/tmp/wh-nginx-err.$$; if sudo -n nginx -t 2>"$ERRF"; then echo NGINX_OK; elif ${WMSTATS_DEDUP}; then (sudo -n systemctl reload nginx 2>/dev/null || sudo -n nginx -s reload); echo NGINX_FIXED; else echo "NGINX_BROKEN $(tail -2 "$ERRF" | tr '\\n' ' ')"; fi; rm -f "$ERRF"`;
}
/** vhost 설정 스크립트: stdin 으로 설정 내용을 받는다. sudo -n 필요 */
function vhostScript(siteId) {
const name = `wh-${siteId}.conf`;
return `set -e; T=/tmp/${name}.$; cat > "$T";
${sudoProbe('rm -f "$T"; exit 0')}
if ! command -v nginx >/dev/null 2>&1 && [ ! -x /usr/sbin/nginx ]; then echo "NO_NGINX"; rm -f "$T"; exit 0; fi
# v1.7.3 (요구 1): wmstats 로그 형식이 다른 파일(운영자가 서버 이전 때 만든 conf.d/webmaker-log-format.conf 등)에 이미 있으면 우리 파일을 만들지 않는다 — 같은 이름을 두 번 정의하면 nginx -t 가 실패해 vhost 가 적용되지 않는다 (2026-10-24 webmaker.ibit.kr guide.ariweb.xyz)
if [ -d /etc/nginx/conf.d ] && [ ! -f /etc/nginx/conf.d/wmstats.conf ]; then if sudo -n nginx -T 2>/dev/null | grep -Eq '^\\s*log_format\\s+wmstats\\b'; then echo "WMSTATS_EXISTS"; else printf '%s\\n' ${shq(WMSTATS_FORMAT)} | sudo -n tee /etc/nginx/conf.d/wmstats.conf >/dev/null; echo "WMSTATS_WRITTEN"; fi; fi
if [ -d /etc/nginx/sites-available ]; then C=/etc/nginx/sites-available/${name}; sudo -n install -m 644 "$T" "$C"; sudo -n ln -sf "$C" /etc/nginx/sites-enabled/${name};
elif [ -d /etc/nginx/conf.d ]; then C=/etc/nginx/conf.d/${name}; sudo -n install -m 644 "$T" "$C";
else echo "NO_CONF_DIR"; rm -f "$T"; exit 0; fi
rm -f "$T"; echo "CONF=$C";
ERRF=/tmp/wh-nginx-err.$$
# v1.7.3: 검사 실패가 wmstats 중복(이전 버전이 만든 우리 파일 + 다른 파일의 같은 정의)이면 우리 파일을 지우고 다시 검사한다 — 다른 쪽 정의도 같은 형식(lib/stats.js 와 맞춤)이라 통계 수집은 그대로다
if sudo -n nginx -t 2>"$ERRF" || ${WMSTATS_DEDUP}; then (sudo -n systemctl reload nginx 2>/dev/null || sudo -n nginx -s reload) && echo "VHOST_OK"; else echo "NGINX_TEST_FAIL $(tail -2 "$ERRF" | tr '\n' ' ')"; fi; rm -f "$ERRF"`;
}
function removeVhostScript(siteId) { const name = `wh-${siteId}.conf`; return `if sudo -n true 2>/dev/null; then sudo -n rm -f /etc/nginx/sites-enabled/${name} /etc/nginx/sites-available/${name} /etc/nginx/conf.d/${name}; (sudo -n nginx -t >/dev/null 2>&1 && (sudo -n systemctl reload nginx 2>/dev/null || sudo -n nginx -s reload)) || true; command -v certbot >/dev/null 2>&1 && sudo -n certbot delete --cert-name wh-${siteId} --non-interactive >/dev/null 2>&1; echo VHOST_REMOVED; else echo NO_SUDO; fi`; }
function vhostResult(out, err, domains) {
if (/VHOST_OK/.test(out)) return { ok: true, detail: `도메인 ${domains.join(', ')} 을(를) nginx 에 연결했습니다 (${(out.match(/CONF=(\S+)/) || [])[1] || ''})${/WMSTATS_DUP_REMOVED/.test(out) ? ' · 중복 정의된 접속 통계 로그 형식 파일(conf.d/wmstats.conf)을 정리했습니다' : ''}` };
if (/NO_SUDO/.test(out)) return { ok: false, detail: sudoHint(out) };
if (/NO_NGINX/.test(out)) return { ok: false, detail: '배포 서버에 nginx 가 없습니다.' };
if (/NO_CONF_DIR/.test(out)) return { ok: false, detail: 'nginx 설정 디렉터리(/etc/nginx/sites-available 또는 conf.d)를 찾지 못했습니다.' };
if (/NGINX_TEST_FAIL/.test(out)) {
const msg = (out.match(/NGINX_TEST_FAIL (.*)/) || [, ''])[1];
// v1.7.3 (요구 1): 원인별 안내 — 같은 log_format 이름이 두 파일에 있으면 어느 파일인지 찾는 방법까지 적는다 (2026-10-24 webmaker.ibit.kr: 운영자가 만든 webmaker-log-format.conf 와 우리 wmstats.conf)
const hint = /duplicate "log_format" name "wmstats"/.test(msg) ? ' — 접속 통계 로그 형식(wmstats)이 nginx 설정의 두 파일에 정의되어 있습니다. 배포 서버에서 sudo grep -rn "log_format wmstats" /etc/nginx 로 중복 파일을 찾아 하나만 남기세요(webmaker 가 만든 파일은 /etc/nginx/conf.d/wmstats.conf). 다음 배포부터는 자동으로 정리합니다' : (/unknown directive/.test(msg) ? ' — 배포 서버의 nginx 가 이 지시어를 모릅니다(버전이 낮음). nginx -v 로 버전을 확인하세요' : '');
return { ok: false, detail: 'nginx 설정 검사 실패: ' + msg + hint };
}
if (/RO_ETC/.test(out) || /Read-only file system/i.test(err || '')) return { ok: false, detail: 'vhost 설정 실패: ' + (sudoHint('RO_ETC')) + (err ? ` (${err.split('\n').slice(-1)[0]})` : '') };
return { ok: false, detail: 'vhost 설정 실패: ' + (err || out || '알 수 없는 오류').split('\n').slice(-2).join(' ') };
}
const { Readable } = require('stream');
/** 와일드카드 인증서 이름 (플랫폼 도메인별) */
function wildcardCertName(platformDomain) { return `wh-wild-${String(platformDomain || '').toLowerCase()}`; }
/** 이름이 플랫폼 도메인의 "한 단계 아래" 서브 도메인인지 (mycompany.ibit.kr ○, a.b.ibit.kr ×, ibit.kr ×) — 와일드카드 *.ibit.kr 가 덮는 범위 */
function isPlatformSub(name, platformDomain) { if (!platformDomain) return false; const n = String(name || '').toLowerCase(); const p = String(platformDomain).toLowerCase(); return n.endsWith('.' + p) && !n.slice(0, -(p.length + 1)).includes('.'); }
/** 플랫폼 도메인 (관리자 설정, 없으면 서비스 주소에서 유도) — lib/domains 는 설정만 읽으므로 순환 참조가 없다 */
function platformDomain() { try { return require('./domains').config().platformDomain || ''; } catch (_) { return ''; } }
/** 대상 서버의 사이트 인증서 상태: {hasCert, sans[], expires, hasOptions, hasDhparam} */
async function certStatus(t, siteId) { return certStatusFor(t, `wh-${siteId}`); }
/** 대상 서버의 인증서 상태 (이름으로). v1.4.14: 와일드카드 인증서도 같은 방식으로 본다 */
async function certStatusFor(t, certName) {
const live = `/etc/letsencrypt/live/${certName}`;
const script = `if sudo -n test -f ${shq(live + '/fullchain.pem')} 2>/dev/null; then echo HAS_CERT; echo "SANS=$(sudo -n openssl x509 -in ${shq(live + '/fullchain.pem')} -noout -ext subjectAltName 2>/dev/null | grep -o 'DNS:[^,]*' | sed 's/DNS://' | tr '\\n' ' ')"; echo "EXP=$(sudo -n openssl x509 -in ${shq(live + '/fullchain.pem')} -noout -enddate 2>/dev/null | cut -d= -f2)"; fi; [ -f /etc/letsencrypt/options-ssl-nginx.conf ] && echo HAS_OPTIONS; [ -f /etc/letsencrypt/ssl-dhparams.pem ] && echo HAS_DHPARAM; echo END`;
const r = t.type === 'local' ? await run('bash', ['-c', script], { timeout: 30000 }) : (t.hasKey ? await run('ssh', [...sshArgs(t), script], { timeout: 40000 }) : { out: '' });
const o = r.out || '';
const expires = (o.match(/EXP=(.*)/) || [, ''])[1].trim(); const expMs = Date.parse(expires);
return { certName, hasCert: /HAS_CERT/.test(o), sans: ((o.match(/SANS=(.*)/) || [, ''])[1]).trim().split(/\s+/).filter(Boolean), expires, daysLeft: Number.isFinite(expMs) ? Math.floor((expMs - Date.now()) / 86400000) : null, hasOptions: /HAS_OPTIONS/.test(o), hasDhparam: /HAS_DHPARAM/.test(o) };
}
/**
* v1.5.17 (요구 1): 대상 서버에 있는 플랫폼 도메인 와일드카드 인증서 찾기.
* 1.5.16 까지는 서비스가 발급한 이름(wh-wild-<도메인>)만 봤다. 서버를 설치할 때 운영자가 이미 certbot 으로 *.<도메인> 인증서를 받아 둔 경우
* (이름이 다름 — 예: certbot certonly -d '*.example.com' → live/example.com)에는 그것을 못 보고 서브 도메인 사이트가 HTTP 로 열렸다.
* 이제 /etc/letsencrypt/live/<모든 이름>/fullchain.pem 의 SAN 을 보고 *.<플랫폼 도메인> 을 포함한 인증서를 고른다.
* 우선순위: 서비스가 발급한 wh-wild-<도메인> → 그 밖의 인증서 중 만료가 가장 먼 것.
* live/ 는 root 만 읽을 수 있어 sudo -n 으로 훑는다(sudo 가 안 되면 빈 결과 → HTTP).
* @returns {Promise<{certName:string, hasCert:boolean, external:boolean, sans:string[], expires:string, daysLeft:number|null, hasOptions:boolean, hasDhparam:boolean, candidates:Array<{name:string, sans:string[], expires:string}>}>}
* external: 서비스가 아니라 다른 절차로 발급된 인증서(이름이 wh-wild-… 가 아님)를 쓰고 있음
*/
async function findWildcardCert(t, pd) {
const want = `*.${String(pd || '').toLowerCase()}`; const own = wildcardCertName(pd);
// v1.5.27 (요구 3): letsencrypt 외의 인증서도 후보 — nginx 설정(sites-enabled·conf.d)이 참조하는 ssl_certificate 파일과 /etc/ssl 아래 .pem/.crt (예: Cloudflare 원본 인증서 *.example.com).
// 키 파일은 같은 폴더의 같은 이름 .key / privkey.pem / 이름.key 중 있는 것. 줄 형식 CERT|이름|SAN…|만료|인증서경로|키경로 (letsencrypt 는 경로를 비워 live/<이름> 규칙을 쓴다)
const inner = `for d in /etc/letsencrypt/live/*/; do f="$d/fullchain.pem"; [ -f "$f" ] || continue; n=$(basename "$d"); s=$(openssl x509 -in "$f" -noout -ext subjectAltName 2>/dev/null | grep -o 'DNS:[^,]*' | sed 's/DNS://' | tr '\\n' ' '); e=$(openssl x509 -in "$f" -noout -enddate 2>/dev/null | cut -d= -f2); echo "CERT|$n|$s|$e||"; done
for f in $( { grep -hoE '^\\s*ssl_certificate\\s+[^;]+' /etc/nginx/sites-enabled/* /etc/nginx/conf.d/* 2>/dev/null | awk '{print $2}'; ls /etc/ssl/*/*.pem /etc/ssl/*/*.crt /etc/nginx/ssl/*.pem /etc/nginx/ssl/*.crt 2>/dev/null; } | grep -v '^/etc/letsencrypt/' | sort -u ); do [ -f "$f" ] || continue; s=$(openssl x509 -in "$f" -noout -ext subjectAltName 2>/dev/null | grep -o 'DNS:[^,]*' | sed 's/DNS://' | tr '\\n' ' '); [ -n "$s" ] || continue; e=$(openssl x509 -in "$f" -noout -enddate 2>/dev/null | cut -d= -f2); b="\${f%.*}"; k=""; for c in "$b.key" "$(dirname "$f")/privkey.pem" "$b-key.pem" "\${b}.pem.key"; do [ -f "$c" ] && { k="$c"; break; }; done; echo "CERT|$(basename "$f")|$s|$e|$f|$k"; done`;
const script = `sudo -n bash -c ${shq(inner)} 2>/dev/null; [ -f /etc/letsencrypt/options-ssl-nginx.conf ] && echo HAS_OPTIONS; [ -f /etc/letsencrypt/ssl-dhparams.pem ] && echo HAS_DHPARAM; echo END`;
const r = t.type === 'local' ? await run('bash', ['-c', script], { timeout: 40000 }) : (t.hasKey ? await run('ssh', [...sshArgs(t), script], { timeout: 50000 }) : { out: '' });
const o = r.out || '';
const certs = o.split('\n').filter((l) => l.startsWith('CERT|')).map((l) => { const [, name, sans, expires, certFile, keyFile] = l.split('|'); return { name, sans: (sans || '').trim().split(/\s+/).filter(Boolean).map((x) => x.toLowerCase()), expires: (expires || '').trim(), certFile: (certFile || '').trim(), keyFile: (keyFile || '').trim() }; });
// 키 파일을 못 찾은 외부 인증서는 nginx 가 쓸 수 없으므로 뺀다. 같은 파일이 여러 번 나오면 하나만
const seen = new Set(); const candidates = certs.filter((c) => c.sans.includes(want) && (!c.certFile || c.keyFile)).filter((c) => { const k = c.certFile || c.name; if (seen.has(k)) return false; seen.add(k); return true; });
const pick = candidates.find((c) => c.name === own) || candidates.slice().sort((a, b) => (Date.parse(b.expires) || 0) - (Date.parse(a.expires) || 0))[0] || null;
const expMs = pick ? Date.parse(pick.expires) : NaN;
return { certName: pick ? pick.name : own, hasCert: !!pick, external: !!pick && pick.name !== own, certFile: pick ? pick.certFile || '' : '', keyFile: pick ? pick.keyFile || '' : '', sans: pick ? pick.sans : [], expires: pick ? pick.expires : '', daysLeft: Number.isFinite(expMs) ? Math.floor((expMs - Date.now()) / 86400000) : null, hasOptions: /HAS_OPTIONS/.test(o), hasDhparam: /HAS_DHPARAM/.test(o), candidates: candidates.map((c) => ({ name: c.name, sans: c.sans, expires: c.expires, certFile: c.certFile || '' })), scanned: certs.length };
}
/**
* 사이트 도메인을 인증서 묶음으로 나눈다 (v1.4.14, 요구 9).
* @returns {{ platform: string[], own: string[], platformDomain: string }} platform = 플랫폼 서브 도메인(와일드카드 대상), own = 고객 도메인(사이트 인증서 대상)
*/
function splitDomains(domains) {
const pd = platformDomain(); const platform = []; const own = [];
for (const d of domains) (isPlatformSub(d, pd) ? platform : own).push(d);
return { platform, own, platformDomain: pd };
}
/**
* 대상 서버에 도메인 vhost 를 구성한다. 기존 인증서가 있으면 HTTPS 설정을 유지한다(재배포 시 HTTPS 유실 방지).
* v1.4.14 (요구 9): 플랫폼 서브 도메인은 와일드카드 인증서(있을 때), 고객 도메인은 사이트 인증서(있을 때)로 각각 HTTPS.
* @returns {{ok, detail, groups, needCert: string[], sslKept, platformNoWild: boolean, cert, wildcard}}
* needCert: 아직 인증서가 없는 고객 도메인(webroot 발급 대상) · platformNoWild: 서브 도메인이 있는데 와일드카드 인증서가 없음
*/
async function configureVhost(siteId, id, domains) {
const t = get(id); if (!t) throw httpError('배포 서버가 없습니다', 404);
if (t.allowSudo !== true) return { ok: false, needsSudo: true, detail: '이 배포 서버에서 sudo 사용이 허용되지 않아 nginx 설정을 쓰지 못했습니다. 관리자 콘솔 › 배포 서버에서 sudo 사용 허용을 켜 주세요.' };
domains = normalizeDomains(domains); if (!domains.length) return { ok: true, detail: '도메인 없음(생략)', skipped: true, needCert: [], groups: [] };
const root = path.posix.join(t.baseDir, siteId);
const { platform, own, platformDomain: pd } = splitDomains(domains);
// v1.5.17: 이름과 무관하게 *.<플랫폼 도메인> SAN 을 가진 인증서를 찾는다(서버 설치 때 미리 받아 둔 인증서도 자동 적용)
const wild = platform.length ? await findWildcardCert(t, pd) : { hasCert: false, sans: [] };
const wildOk = wild.hasCert && wild.sans.includes(`*.${pd}`.toLowerCase());
const cs = own.length ? await certStatus(t, siteId) : { hasCert: false, sans: [] };
const ownOk = cs.hasCert && own.every((d) => cs.sans.includes(d));
const groups = [];
if (platform.length) groups.push({ kind: 'platform', domains: platform, ssl: wildOk ? { certName: wild.certName, certFile: wild.certFile || '', keyFile: wild.keyFile || '', hasOptions: wild.hasOptions, hasDhparam: wild.hasDhparam } : null });
if (own.length) groups.push({ kind: 'own', domains: own, ssl: ownOk ? { certName: 'wh-' + siteId, hasOptions: cs.hasOptions, hasDhparam: cs.hasDhparam } : null });
const conf = vhostConf(groups, root, null, siteId);
// v1.5.27 (요구 1·7): 지금 설정 파일이 이미 같은 내용이고 nginx 에 켜져 있으면 다시 쓰지 않는다(sudo 불필요). webmaker.ariweb.net 처럼 서비스 안에서 sudo 가 막힌 서버에서
// 한 번 구성된 도메인이 재배포 때마다 "도메인 연결 설정에 실패" 로 나오던 것을 막는다. 설정 파일(644)은 sudo 없이 읽을 수 있다.
if (t.type === 'local') { try { const name = `wh-${siteId}.conf`; const cur = fs.readFileSync(fs.existsSync(`/etc/nginx/sites-available/${name}`) ? `/etc/nginx/sites-available/${name}` : `/etc/nginx/conf.d/${name}`, 'utf8'); const enabled = fs.existsSync(`/etc/nginx/sites-enabled/${name}`) || fs.existsSync(`/etc/nginx/conf.d/${name}`); if (enabled && cur === conf) {
// v1.7.3 (요구 1): 설정은 같아도 nginx 가 지금 깨져 있으면(중복 wmstats 로 reload 가 안 된 채 남은 서버) 고쳐 둔다 — 2026-10-24 webmaker.ibit.kr 에서 재배포가 "이미 같음" 으로 끝나 복구되지 않던 것
let fixed = ''; try { const rr = await run('bash', ['-c', nginxRepairScript()], { timeout: 30000 }); const o = rr.out || ''; if (/NGINX_FIXED/.test(o)) fixed = ' · 중복 정의된 접속 통계 로그 형식 파일(conf.d/wmstats.conf)을 정리하고 nginx 를 다시 읽었습니다'; else if (/NGINX_BROKEN/.test(o)) { const res = { ok: false, detail: `도메인 ${domains.join(', ')} 의 nginx 설정은 이미 있지만 nginx 설정 검사가 실패해 적용되지 않습니다: ${(o.match(/NGINX_BROKEN (.*)/) || [, ''])[1]}` }; return finishVhost(res, groups, cs, ownOk, wild, wildOk, platform, own, pd, domains); } } catch (_) {}
const res = { ok: true, unchanged: true, detail: `도메인 ${domains.join(', ')} 의 nginx 설정이 이미 같아 그대로 둠${fixed}` }; return finishVhost(res, groups, cs, ownOk, wild, wildOk, platform, own, pd, domains); } } catch (_) {} }
const r = t.type === 'local' ? await run('bash', ['-c', vhostScript(siteId)], { stdin: Readable.from([conf]), timeout: 60000 }) : (t.hasKey ? await run('ssh', [...sshArgs(t), vhostScript(siteId)], { stdin: Readable.from([conf]), timeout: 90000 }) : { out: '', err: 'SSH 키가 없습니다' });
const res = vhostResult(r.out, r.err, domains);
return finishVhost(res, groups, cs, ownOk, wild, wildOk, platform, own, pd, domains);
}
/** configureVhost 결과 마무리: 묶음·인증서·안내 문구를 붙인다 (새로 쓴 경우와 "이미 같음" 경우가 함께 쓴다, v1.5.27) */
function finishVhost(res, groups, cs, ownOk, wild, wildOk, platform, own, pd, domains) {
res.groups = groups.map((g) => ({ kind: g.kind, domains: g.domains, ssl: !!g.ssl, certName: g.ssl ? g.ssl.certName : null }));
res.cert = cs; res.wildcard = platform.length ? { ...wild, ok: wildOk, platformDomain: pd } : null;
res.needCert = ownOk ? [] : own.slice(); res.platformNoWild = platform.length > 0 && !wildOk;
res.sslKept = groups.length > 0 && groups.every((g) => !!g.ssl);
if (res.ok) {
const notes = [];
if (platform.length) notes.push(wildOk ? `${platform.join(', ')}: 와일드카드 인증서(*.${pd}${wild.external ? `, 서버에 있던 인증서 ${wild.certName}` : ''})로 HTTPS` : `${platform.join(', ')}: 와일드카드 인증서(*.${pd})가 없어 HTTP — 관리자 콘솔 › 설정 › SSL 에서 발급`);
if (own.length) notes.push(ownOk ? `${own.join(', ')}: 기존 사이트 인증서로 HTTPS 유지` : (cs.hasCert ? `${own.join(', ')}: 기존 인증서가 일부 도메인을 포함하지 않아(${own.filter((d) => !cs.sans.includes(d)).join(', ')}) 재발급 필요` : `${own.join(', ')}: 사이트 인증서 발급 대상`));
if (notes.length) res.detail += ' · ' + notes.join(' · ');
}
return res;
}
async function removeVhost(siteId, id) {
const t = get(id); if (!t) return { ok: false };
const r = t.type === 'local' ? await run('bash', ['-c', removeVhostScript(siteId)], { timeout: 60000 }) : (t.hasKey ? await run('ssh', [...sshArgs(t), removeVhostScript(siteId)], { timeout: 60000 }) : { out: '', err: 'no key' });
return { ok: /VHOST_REMOVED/.test(r.out), detail: r.out || r.err };
}
/** 연결 테스트 보조: sudo/nginx 가능 여부 */
/**
* 대상 서버의 vhost 구성 능력: sudo -n · nginx · /etc/nginx 쓰기. v1.5.19: 안 되는 이유(hint)도 돌려준다 — NoNewPrivileges·ProtectSystem 같은 유닛 설정을 구분.
* @returns {Promise<{sudo:boolean, nginx:boolean, writable:boolean, ok:boolean, hint:string}>}
*/
async function vhostCapability(t) {
const script = `${sudoProbe('true')}; if sudo -n true 2>/dev/null; then echo SUDO=yes; else echo SUDO=no; fi; if command -v nginx >/dev/null 2>&1 || [ -x /usr/sbin/nginx ]; then echo NGINX=yes; else echo NGINX=no; fi`;
const r = t.type === 'local' ? await run('bash', ['-c', script], { timeout: 20000 }) : await run('ssh', [...sshArgs(t), script], { timeout: 30000 });
const o = r.out || ''; const sudo = /SUDO=yes/.test(o); const nginx = /NGINX=yes/.test(o); const writable = sudo && !/RO_ETC/.test(o);
return { sudo, nginx, writable, ok: sudo && nginx && writable, hint: !nginx ? 'nginx 없음' : sudoHint(o) };
}
/** 연결 테스트·점검표용 한 줄: "도메인 vhost 설정 가능" 또는 "불가(이유)" */
function capText(cap) { return `도메인 vhost 설정 ${cap.ok ? '가능' : '불가(' + (cap.hint || 'sudo 권한 없음') + ')'}`; }
// ---------- 배포 후 접속 확인 ----------
const dns = require('dns').promises;
/**
* v1.5.19: 종류 local 배포 서버(서비스 서버 자신)의 "도메인이 가리켜야 할 IP" 후보. 앞에 올수록 안내에 쓰인다.
* ① 설정 › 도메인 서비스 › 사이트 IP(domains.targetIp) ② 배포 서버 공개 URL 호스트의 A 레코드 ③ 서비스 공개 주소 호스트의 A 레코드
* ④ 이 서버 인터페이스의 공인 IPv4 ⑤ 사설 IPv4(사내망 운영) — 127.0.0.1 은 넣지 않는다.
*/
async function localServerIps(t) {
const out = []; const add = (ip) => { if (ip && /^\d+\.\d+\.\d+\.\d+$/.test(ip) && ip !== '127.0.0.1' && !out.includes(ip)) out.push(ip); };
try { add(require('./domains').config().targetIp); } catch (_) {}
const hostOf = (u) => { try { return new URL(u).hostname; } catch (_) { return ''; } };
for (const u of [t.publicBaseUrl, process.env.PUBLIC_BASE_URL || (() => { try { return require('./config').get('service.publicBaseUrl'); } catch (_) { return ''; } })()]) { const h = hostOf(u); if (h) for (const ip of await resolveA(h)) add(ip); }
const priv = (ip) => /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.)/.test(ip); const ifs = [];
for (const list of Object.values(require('os').networkInterfaces())) for (const i of list || []) if (i.family === 'IPv4' && !i.internal) ifs.push(i.address);
for (const ip of ifs.filter((x) => !priv(x))) add(ip); for (const ip of ifs.filter(priv)) add(ip);
return out;
}
const http = require('http'); const https = require('https');
function httpGet(url, hostHeader) {
return new Promise((resolve) => {
let u; try { u = new URL(url); } catch (_) { return resolve({ status: 0, error: '잘못된 URL' }); }
const mod = u.protocol === 'https:' ? https : http;
// v1.5.31: IP 로 접속하며 Host 만 바꿀 때 TLS SNI 도 그 호스트로 — 서버가 SNI 로 인증서·server 블록을 고르는 구성을 위해
const req = mod.get(u, { timeout: 8000, rejectUnauthorized: false, servername: u.protocol === 'https:' && hostHeader && !/^\d+\.\d+\.\d+\.\d+$/.test(hostHeader) ? hostHeader : undefined, headers: { Host: hostHeader || u.host, 'User-Agent': 'WebHostingAgent-check/1.0' } }, (res) => {
let body = ''; res.on('data', (c) => { if (body.length < 20000) body += c; }); res.on('end', () => resolve({ status: res.statusCode, location: res.headers.location || null, title: (body.match(/<title[^>]*>([^<]*)/i) || [])[1] || '' }));
});
req.on('timeout', () => req.destroy(new Error('시간 초과'))); req.on('error', (e) => resolve({ status: 0, error: e.message }));
});
}
async function resolveA(host) { if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) return [host]; try { return await dns.resolve4(host); } catch (_) { return []; } }
/** 배포 결과 접속 확인: 도메인별 DNS·vhost 응답, 공개 URL 응답 */
const DEFAULT_PAGE_RE = /^(Welcome to nginx!?|Apache2 .* Default Page|Index of \/|It works!?)$/i; // 웹서버 기본 페이지 제목 — vhost 가 아직 활성화되지 않았다는 신호
async function checkAccess(id, siteId, domains, opts = {}) {
const t = get(id); if (!t) return { items: [] };
// v1.6.33 (요구 0): nginx reload 는 비동기라 vhost 적용 직후의 확인이 기본 페이지("Welcome to nginx!")·HTTPS 미적용으로 나와 cert-wait 로 오판했다
// (guide.ariweb.net 2026-10-18). 도메인 항목이 기본 페이지이거나 HTTPS 가 안 되면 3초 뒤 한 번 더 확인한다(최대 2회).
const first = await checkAccessOnce(t, siteId, domains);
const suspicious = (it) => it.kind === 'domain' && it.pointsToTarget && (DEFAULT_PAGE_RE.test(it.title || '') || (!it.https && it.ok));
if (opts.retry !== false && first.items.some(suspicious)) { await new Promise((r) => setTimeout(r, 3000)); const second = await checkAccessOnce(t, siteId, domains); if (second.items.filter(suspicious).length < first.items.filter(suspicious).length) { second.retried = true; return second; } first.retried = true; }
return first;
}
async function checkAccessOnce(t, siteId, domains) {
// v1.5.19 (요구 2): 종류 local 은 1.5.17 까지 127.0.0.1 을 "서버 IP" 로 써서, DNS 가 서버의 공인 IP 를 바르게 가리켜도 "DNS 가 다른 서버를 가리킴 — A 레코드를
// 127.0.0.1 로 바꾸세요" 라는 틀린 안내가 나왔다(webmaker.ariweb.net, sample.ariweb.net). 이제 이 서버가 가질 수 있는 공개 IP 들(localServerIps)과 비교하고,
// 응답 확인(vhost)만 127.0.0.1 로 한다. 안내에 적는 IP 는 공인 IP(또는 설정 › 도메인 서비스 › 사이트 IP)다.
const local = t.type === 'local'; const targetIps = local ? await localServerIps(t) : await resolveA(t.host);
const targetIp = targetIps[0] || null; const probeIp = local ? '127.0.0.1' : targetIp; const items = [];
for (const d of normalizeDomains(domains || [])) {
const ips = await resolveA(d); const viaIp = probeIp ? await httpGet(`http://${probeIp}/`, d) : { status: 0, error: '대상 서버 IP 확인 실패' };
const vhostOk = viaIp.status === 200; const dnsOk = ips.length > 0; const points = dnsOk && targetIps.length ? ips.some((ip) => targetIps.includes(ip)) : false;
let note = '';
if (!dnsOk) note = `DNS 레코드 없음 — 도메인 관리(DNS)에서 A 레코드를 ${targetIp || '배포 서버 IP'} 로 추가하세요`;
else if (!points) note = `DNS 가 다른 서버(${ips.join(', ')})를 가리킴 — A 레코드를 ${targetIp} 로 바꾸세요`;
const httpsR = probeIp ? await httpGet(`https://${probeIp}/`, d) : { status: 0 };
// v1.5.31 (요구 2): HTTP 가 301 로 HTTPS 로 보내는 정상 구성에서 nginx 의 301 본문 제목("301 Moved Permanently")과 HTTPS 응답 제목("Webmaker")을 비교해
// "접속 불가: 배포 서버 응답 301 → https://…" 로 오판했다(webmaker.ariweb.net, 와일드카드 인증서 적용 직후). 제목 비교는 HTTP 가 200 일 때(같은 사이트인지 확인용)만 한다
const httpsOk = httpsR.status === 200 && (viaIp.status !== 200 || !httpsR.title || !viaIp.title || httpsR.title === viaIp.title);
const httpRedirectsToHttps = viaIp.status === 301 && /^https:\/\//.test(viaIp.location || '') && (viaIp.location || '').includes(d);
const serveOk = vhostOk || (httpRedirectsToHttps && httpsOk);
if (!serveOk) note += (note ? ' · ' : '') + `배포 서버 응답 ${viaIp.status || viaIp.error}${viaIp.location ? ' → ' + viaIp.location : ''} (vhost 설정을 확인하세요)`;
items.push({ kind: 'domain', url: httpsOk ? `https://${d}/` : `http://${d}/`, ok: dnsOk && points && serveOk, https: httpsOk, dns: ips, pointsToTarget: points, serverStatus: viaIp.status, title: (httpsOk && httpsR.title ? httpsR.title : viaIp.title) || '', /* v1.5.31: HTTPS 가 적용됐으면 그 제목 */ vhostPending: DEFAULT_PAGE_RE.test((viaIp.title || '')) /* v1.6.33 */, note: note || `정상 (DNS → ${targetIp}, ${httpsOk ? 'HTTPS 적용됨' : 'HTTP 응답 200 · HTTPS 미적용'}${(httpsOk && httpsR.title ? httpsR.title : viaIp.title) ? ', ' + (httpsOk && httpsR.title ? httpsR.title : viaIp.title) : ''})` });
}
const pubUrl = siteUrlOn(t, siteId);
if (pubUrl) { const r = await httpGet(pubUrl); items.push({ kind: 'public', url: pubUrl, ok: r.status === 200, serverStatus: r.status, title: r.title || '', note: r.status === 200 ? `정상 (응답 200${r.title ? ', ' + r.title : ''})` : `응답 ${r.status || r.error}${r.location ? ' → ' + r.location : ''} — 이 주소는 배포 서버의 웹서버가 저장 경로를 직접 서비스할 때만 열립니다. 아니면 배포 서버 설정의 공개 URL 을 비우세요` }); }
return { items, targetIp, checkedAt: new Date().toISOString() };
}
// ---------- Let's Encrypt SSL (webroot 방식, nginx 플러그인 불필요) ----------
function certbotScript(siteId, root, domains, email) {
const dArgs = domains.map((d) => `-d ${shq(d)}`).join(' ');
return `${sudoProbe('exit 0')}
if ! command -v certbot >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then echo INSTALLING; sudo -n env DEBIAN_FRONTEND=noninteractive apt-get install -y -q certbot >/tmp/wh-certbot-install.log 2>&1 || { echo "INSTALL_FAIL $(tail -2 /tmp/wh-certbot-install.log | tr '\\n' ' ')"; exit 0; }; else echo NO_CERTBOT; exit 0; fi
fi
sudo -n certbot certonly --webroot -w ${shq(root)} --non-interactive --agree-tos ${email ? `-m ${shq(email)}` : '--register-unsafely-without-email'} --cert-name ${shq('wh-' + siteId)} --expand --keep-until-expiring --deploy-hook 'systemctl reload nginx || nginx -s reload' ${dArgs} >/tmp/wh-certbot.log 2>&1 && echo CERT_OK || { echo "CERT_FAIL $(grep -iE 'error|failed|problem|detail|too many|rate|unauthorized|timeout' /tmp/wh-certbot.log | grep -v '^Saving' | head -4 | tr '\\n' ' ')"; }
[ -f /etc/letsencrypt/options-ssl-nginx.conf ] && echo HAS_OPTIONS; [ -f /etc/letsencrypt/ssl-dhparams.pem ] && echo HAS_DHPARAM; echo END`;
}
/**
* 대상 서버에서 고객 도메인들에 대해 Let's Encrypt 인증서를 발급(webroot, 이름 wh-<사이트ID>)하고 HTTPS 설정을 적용한다.
* v1.4.14: 플랫폼 서브 도메인은 여기서 발급하지 않는다(와일드카드 인증서 담당, lib/ssl.js). 발급 뒤 설정은 configureVhost 가
* 두 인증서를 함께 반영한다.
* @param {string[]} domains 발급할 도메인 (플랫폼 서브 도메인이 섞여 있으면 뺀다)
*/
async function issueSsl(siteId, id, domains, email) {
const t = get(id); if (!t) throw httpError('배포 서버가 없습니다', 404);
const all = normalizeDomains(domains); domains = splitDomains(all).own; if (!domains.length) return { ok: false, skipped: true, detail: all.length ? '플랫폼 서브 도메인은 와일드카드 인증서로 처리합니다 (사이트별 발급 없음)' : '도메인 없음' };
if (t.allowSudo !== true) return { ok: false, needsSudo: true, detail: 'sudo 사용이 허용되지 않아 인증서를 발급하지 못했습니다' };
const root = path.posix.join(t.baseDir, siteId);
const exec = (script, stdin, timeout) => (t.type === 'local' ? run('bash', ['-c', script], { stdin, timeout }) : (t.hasKey ? run('ssh', [...sshArgs(t), script], { stdin, timeout }) : Promise.resolve({ out: '', err: 'no key', code: 1 })));
const r = await exec(certbotScript(siteId, root, domains, email), null, 300000);
const o = r.out || '';
if (/NO_SUDO/.test(o)) return { ok: false, detail: sudoHint(o) };
if (/NO_CERTBOT|INSTALL_FAIL/.test(o)) return { ok: false, detail: 'certbot 이 없고 자동 설치에 실패했습니다: ' + o.replace(/[\s\S]*INSTALL_FAIL/, '').trim() };
if (!/CERT_OK/.test(o)) return { ok: false, detail: '인증서 발급 실패: ' + ((o.match(/CERT_FAIL (.*)/) || [, ''])[1].trim() || (r.err || o).split('\n').slice(-2).join(' ')).slice(0, 500) };
// 발급된 인증서를 포함해 vhost 를 다시 구성 (플랫폼 서브 도메인의 와일드카드 묶음도 함께)
const vr = await configureVhost(siteId, id, all);
if (!vr.ok) return { ok: false, detail: '인증서는 발급되었지만 HTTPS 설정 적용 실패: ' + vr.detail };
return { ok: true, domains, detail: `Let's Encrypt 인증서 발급·HTTPS 설정 완료 (${domains.join(', ')}, HTTP→HTTPS 리디렉션)${/INSTALLING/.test(o) ? ' · certbot 설치함' : ''}`, vhost: vr };
}
// ---------- 기본 오류 서버(default_server): 미등록 호스트·경로가 다른 서비스로 흘러가지 않도록 ----------
function defaultServerScript() {
const page404 = "<!doctype html><html lang=\"ko\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>페이지를 찾을 수 없습니다</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;font-family:-apple-system,\"Segoe UI\",\"Noto Sans KR\",sans-serif;background:#f4f5f7;color:#1f2933}main{text-align:center;padding:32px}h1{font-size:22px;margin:0 0 8px}p{color:#6b7684;margin:0}</style></head><body><main><h1>페이지를 찾을 수 없습니다</h1><p>요청하신 페이지가 존재하지 않거나 이동되었습니다.</p></main></body></html>";
const conf80 = `server {\n listen 80 default_server;\n listen [::]:80 default_server;\n server_name _;\n server_tokens off;\n location ^~ /.well-known/acme-challenge/ { root /var/www/html; default_type "text/plain"; }\n location / { default_type text/html; return 404 '${page404}'; }\n}\n`;
const conf443 = `server {\n listen 443 ssl default_server;\n listen [::]:443 ssl default_server;\n server_name _;\n server_tokens off;\n ssl_reject_handshake on;\n}\n`;
// v1.7.3 (요구 1): ssl_reject_handshake 는 nginx 1.19.4 부터다 — Ubuntu 20.04 의 1.18(2026-10-24 webmaker.ibit.kr) 에서는 "unknown directive" 로 nginx -t 가 실패해 기본 오류 서버가 되돌려졌다.
// 낮은 버전에서는 ssl-cert 패키지의 자체 서명 인증서(snakeoil)로 443 기본 서버를 열고 404 안내만 준다(브라우저에는 인증서 경고가 뜨지만 다른 사이트 내용은 새지 않는다). 그 인증서도 없으면 443 기본 서버는 만들지 않는다.
const conf443Legacy = `server {\n listen 443 ssl default_server;\n listen [::]:443 ssl default_server;\n server_name _;\n server_tokens off;\n ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;\n ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;\n location / { default_type text/html; return 404 '${page404}'; }\n}\n`;
return `${sudoProbe('exit 0')}
NGV=$( (command -v nginx >/dev/null 2>&1 && nginx -v || /usr/sbin/nginx -v) 2>&1 | sed -n 's#.*nginx/\\([0-9.][0-9.]*\\).*#\\1#p')
REJECT_OK=1; if [ -n "$NGV" ] && [ "$(printf '%s\\n%s\\n' 1.19.4 "$NGV" | sort -V | head -1)" != "1.19.4" ]; then REJECT_OK=0; if sudo -n test -f /etc/ssl/private/ssl-cert-snakeoil.key && [ -f /etc/ssl/certs/ssl-cert-snakeoil.pem ]; then echo "OLD_NGINX=$NGV SNAKEOIL"; else echo "OLD_NGINX=$NGV NO443"; fi; fi
if [ -d /etc/nginx/sites-available ]; then A=/etc/nginx/sites-available/wh-default.conf; E=/etc/nginx/sites-enabled/wh-default.conf; elif [ -d /etc/nginx/conf.d ]; then A=/etc/nginx/conf.d/00-wh-default.conf; E=$A; else echo NO_CONF_DIR; exit 0; fi
sudo -n rm -f "$E" 2>/dev/null; [ "$A" != "$E" ] || sudo -n rm -f "$A"
T=$(sudo -n nginx -T 2>/dev/null)
H80=$(printf '%s' "$T" | grep -E '^\\s*listen\\b[^;]*default_server' | grep -E '(:80\\b|\\b80\\b)' | grep -vE '443' | head -1)
H443=$(printf '%s' "$T" | grep -E '^\\s*listen\\b[^;]*default_server' | grep -E '443' | head -1)
L443=$(printf '%s' "$T" | grep -E '^\\s*listen\\b[^;]*443' | head -1)
OUT=""; [ -z "$H80" ] && OUT="$OUT${'$'}(printf '%s' ${shq(conf80)})"
if [ -z "$H443" ] && [ -n "$L443" ]; then if [ "$REJECT_OK" = 1 ]; then OUT="$OUT
${'$'}(printf '%s' ${shq(conf443)})"; elif [ -f /etc/ssl/certs/ssl-cert-snakeoil.pem ] && sudo -n test -f /etc/ssl/private/ssl-cert-snakeoil.key; then OUT="$OUT
${'$'}(printf '%s' ${shq(conf443Legacy)})"; fi; fi
[ -n "$H80" ] && echo "EXISTING80=$(echo $H80 | tr -s ' ')"; [ -n "$H443" ] && echo "EXISTING443=$(echo $H443 | tr -s ' ')"
if [ -n "$OUT" ]; then printf '# generated by webhosting-agent: default error server\\n%s' "$OUT" > /tmp/wh-default.$$; sudo -n install -m 644 /tmp/wh-default.$$ "$A"; [ "$A" != "$E" ] && sudo -n ln -sf "$A" "$E"; rm -f /tmp/wh-default.$$; echo "WROTE=$A"; fi
ERRF=/tmp/wh-nginx-err.$$
if sudo -n nginx -t 2>"$ERRF" || ${WMSTATS_DEDUP}; then (sudo -n systemctl reload nginx 2>/dev/null || sudo -n nginx -s reload); echo RELOADED; else echo "NGINX_TEST_FAIL $(tail -2 "$ERRF" | tr '\\n' ' ')"; sudo -n rm -f "$E"; [ "$A" != "$E" ] || true; sudo -n nginx -t >/dev/null 2>&1 && (sudo -n systemctl reload nginx 2>/dev/null || sudo -n nginx -s reload); fi; rm -f "$ERRF"
sleep 1
echo "CHECK80=$(curl -s -o /tmp/wh-c80.$$ -w '%{http_code}' -H 'Host: unknown-host.invalid' http://127.0.0.1/ 2>/dev/null)|$(grep -o '<title>[^<]*' /tmp/wh-c80.$$ 2>/dev/null | head -1)"; rm -f /tmp/wh-c80.$$
[ -n "$L443" ] && echo "CHECK443=$(curl -sk -o /dev/null -w '%{http_code}' --resolve unknown-host.invalid:443:127.0.0.1 https://unknown-host.invalid/ 2>/dev/null)"
echo END`;
}
/** 배포 서버의 기본 오류 서버를 점검·적용하고 미등록 호스트 요청이 안내 페이지/거부로 처리되는지 검증 */
async function ensureDefaultServer(id) {
const t = get(id); if (!t) throw httpError('배포 서버가 없습니다', 404);
if (t.manageVhost === false) return { ok: true, skipped: true, detail: 'vhost 자동 설정 꺼짐 — 기본 오류 서버 점검 생략' };
if (t.allowSudo !== true) return { ok: false, needsSudo: true, detail: 'sudo 가 허용되지 않아 기본 오류 서버를 설정하지 못했습니다' };
// v1.5.27: 같은 서버에 이미 우리 기본 오류 서버 설정이 켜져 있으면 sudo 없이 "유지" (sudo 가 막힌 유닛에서 매 배포마다 경고가 나지 않게)
if (t.type === 'local' && (fs.existsSync('/etc/nginx/sites-enabled/wh-default.conf') || fs.existsSync('/etc/nginx/conf.d/00-wh-default.conf'))) { try { const r0 = await run('bash', ['-c', 'sudo -n true 2>/dev/null && echo SUDO_OK || echo SUDO_NO'], { timeout: 10000 }); if (/SUDO_NO/.test(r0.out)) return { ok: true, kept: true, detail: '기존 기본 오류 서버 설정 유지 (sudo 를 쓸 수 없어 점검 생략)' }; } catch (_) {} }
const script = defaultServerScript();
const r = t.type === 'local' ? await run('bash', ['-c', script], { timeout: 90000 }) : (t.hasKey ? await run('ssh', [...sshArgs(t), script], { timeout: 120000 }) : { out: '', err: 'no key' });
const o = r.out || '';
if (/NO_SUDO/.test(o)) return { ok: false, detail: sudoHint(o) };
if (/NO_CONF_DIR/.test(o)) return { ok: false, detail: 'nginx 설정 디렉터리를 찾지 못했습니다' };
const c80 = (o.match(/CHECK80=(\d+)\|?(.*)/) || []); const c443 = (o.match(/CHECK443=(\d+)/) || [])[1];
const ok80 = c80[1] === '404' && /페이지를 찾을 수 없습니다/.test(c80[2] || '');
const ok443 = c443 === undefined || c443 === '000' || c443 === '404';
const parts = [];
parts.push(/WROTE=/.test(o) ? `기본 오류 서버 설정 적용(${(o.match(/WROTE=(\S+)/) || [])[1]})` : '기존 기본 서버 유지');
if (/EXISTING80=/.test(o)) parts.push(`80 포트에 기존 default_server 있음`);
if (/EXISTING443=/.test(o)) parts.push(`443 포트에 기존 default_server 있음`);
if (/NGINX_TEST_FAIL/.test(o)) parts.push('nginx 설정 검사 실패로 되돌림: ' + (o.match(/NGINX_TEST_FAIL (.*)/) || [, ''])[1]);
if (/WMSTATS_DUP_REMOVED/.test(o)) parts.push('중복 정의된 접속 통계 로그 형식 파일(conf.d/wmstats.conf) 정리'); // v1.7.3
// v1.7.3: 낮은 nginx 버전 안내 — 자체 서명 인증서로 대신했는지, 443 기본 서버를 만들지 못했는지
if (/OLD_NGINX=/.test(o)) { const v = (o.match(/OLD_NGINX=(\S+)/) || [, '?'])[1]; parts.push(/SNAKEOIL/.test(o) ? `nginx ${v} 은 ssl_reject_handshake 를 지원하지 않아(1.19.4 부터) 자체 서명 인증서로 443 기본 서버를 열고 404 로 응답` : `nginx ${v} 은 ssl_reject_handshake 를 지원하지 않고(1.19.4 부터) 자체 서명 인증서(ssl-cert 패키지)도 없어 443 기본 서버를 만들지 못함 — sudo apt install ssl-cert 뒤 다시 배포하거나 nginx 를 올리세요`); }
parts.push(`미등록 호스트 HTTP 요청 → ${c80[1] || '?'}${ok80 ? ' (안내 페이지)' : ' (다른 내용이 노출될 수 있음!)'}`);
if (c443 !== undefined) parts.push(`미등록 호스트 HTTPS 요청 → ${c443 === '000' ? '핸드셰이크 거부' : c443}${ok443 ? '' : ' (다른 사이트가 노출될 수 있음!)'}`);
return { ok: ok80 && ok443 && !/NGINX_TEST_FAIL/.test(o), detail: parts.join(' · '), raw: o.slice(0, 600) };
}
/**
* 원격 배포 서버의 사이트 접속 로그에서 offset 이후 부분을 읽는다 (접속 통계 수집용).
* 파일이 교체되어 inode 가 바뀌거나 크기가 줄었으면 처음부터 읽는다. 읽기 권한이 없으면 sudo -n 으로 시도.
* @returns {{ok, inode, size, text, detail}}
*/
async function readSiteLog(id, siteId, offset, knownInode) {
const t = get(id); if (!t || t.type !== 'ssh' || !t.hasKey) return { ok: false, detail: '원격 서버 아님' };
const f = statsLogPath(siteId);
const script = `F=${shq(f)}; R=cat; [ -r "$F" ] || R="sudo -n cat"; T=tail; [ -r "$F" ] || T="sudo -n tail"; S=$($R "$F" >/dev/null 2>&1 && stat -c '%i %s' "$F" 2>/dev/null || sudo -n stat -c '%i %s' "$F" 2>/dev/null); [ -n "$S" ] || { echo "NOFILE"; exit 0; }; set -- $S; echo "INODE=$1 SIZE=$2"; START=${Number(offset) || 0}; if [ "$1" != "${knownInode || ''}" ] || [ "$2" -lt "$START" ]; then START=0; fi; echo "START=$START"; echo "---"; $T -c +$((START + 1)) "$F" 2>/dev/null | head -c 33554432`;
const r = await run('ssh', [...sshArgs(t), script], { timeout: 120000, maxOut: 40 * 1024 * 1024, raw: true });
if (r.code !== 0 && !/INODE=/.test(r.out)) return { ok: false, detail: (r.err || '').split('\n').slice(-1)[0] || `exit ${r.code}` };
if (/^NOFILE/m.test(r.out)) return { ok: false, detail: '로그 파일 없음', nofile: true };
const head = r.out.slice(0, r.out.indexOf('---')); const text = r.out.slice(r.out.indexOf('---') + 4);
const inode = (head.match(/INODE=(\d+)/) || [])[1]; const size = parseInt((head.match(/SIZE=(\d+)/) || [])[1] || '0', 10); const start = parseInt((head.match(/START=(\d+)/) || [])[1] || '0', 10);
return { ok: true, inode, size, start, text };
}
module.exports = { rollback, _run: run, /* v1.5.13 시험용 */ isSamePathTarget, readSiteLog, statsLogPath, list, get, upsert, remove, setDefault, generateKey, test, deploySite, removeSite, siteUrlOn, normalizeDomains, configureVhost, removeVhost, vhostCapability, sudoHint, localServerIps, checkAccess, issueSsl, ensureDefaultServer, certStatus, certStatusFor, findWildcardCert, wildcardCertName, isPlatformSub, splitDomains, platformDomain, vhostConf, run, sshArgs, shq };
+59
View File
@@ -0,0 +1,59 @@
'use strict';
// ============================================================================
// Google Cloud DNS 공급자 (v1.5.36, 요구 5)
//
// 접속 정보: 보관소 종류 'gcloud-dns' — 키(secret) = 서비스 계정 키 JSON 전체(project_id 포함), 설정 PROJECT(선택, 키의 project_id 대신).
// IAM 역할: roles/dns.admin (관리 영역·레코드 만들기·지우기).
// API: https://dns.googleapis.com/dns/v1/projects/{project}/managedZones … (JSON). 토큰은 lib/google.js(서비스 계정 JWT → OAuth2) 에 cloud-platform 범위로 받는다.
// 시험용 대체 주소: 환경 변수 GCLOUD_DNS_API_BASE. 레코드 모델은 Route 53 과 같은 RRSet(이름·종류 묶음에 rrdatas 여러 개)이며 변경은 changes(additions/deletions)로 한다.
// 관리 영역 이름(managedZone.name)은 영문·숫자·하이픈만 허용 → "wm-" + 도메인의 점을 하이픈으로 바꾼 이름을 쓴다.
// ============================================================================
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const SCOPE = 'https://www.googleapis.com/auth/ndev.clouddns.readwrite';
const base = () => (process.env.GCLOUD_DNS_API_BASE || 'https://dns.googleapis.com').replace(/\/+$/, '');
function creds() {
let x = null; try { x = require('../vault').resolve('gcloud-dns'); } catch (_) {}
if (!x || !x.secret) return null; let j = null; try { j = JSON.parse(x.secret); } catch (_) { return null; }
const project = (x.settings && (x.settings.PROJECT || x.settings.PROJECT_ID)) || j.project_id; if (!j.client_email || !j.private_key || !project) return null;
return { secret: x.secret, project };
}
const ready = () => !!creds();
async function call(method, path, body, query) {
const c = creds(); if (!c) throw httpError('Google Cloud DNS 접속 정보가 없습니다 (관리자 콘솔 › 설정 › 외부 연동 접속 정보 › Google Cloud DNS: 서비스 계정 키 JSON)', 503);
const token = await require('../google').accessToken(c.secret, [SCOPE]);
const qs = query ? '?' + new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined && v !== null && v !== '')).toString() : '';
let r; try { r = await fetch(`${base()}/dns/v1/projects/${encodeURIComponent(c.project)}${path}${qs}`, { method, headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(25000) }); } catch (e) { throw httpError(`Google Cloud DNS 연결 실패: ${e.message}`, 502); }
const j = await r.json().catch(() => ({}));
if (!r.ok) throw httpError(`Google Cloud DNS 오류 (HTTP ${r.status}): ${(j.error && j.error.message) || JSON.stringify(j).slice(0, 200)}`, r.status === 403 ? 403 : r.status === 404 ? 404 : r.status >= 500 ? 502 : 400);
return j;
}
const fq = (d) => String(d).toLowerCase().replace(/\.$/, '') + '.';
const zoneName = (domain) => 'wm-' + String(domain).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60);
async function listZones() { const out = []; let pageToken = null; do { const j = await call('GET', '/managedZones', null, pageToken ? { pageToken } : null); for (const z of j.managedZones || []) out.push({ id: z.name, name: String(z.dnsName || '').replace(/\.$/, ''), nameServers: z.nameServers || [], private: z.visibility === 'private' }); pageToken = j.nextPageToken || null; } while (pageToken); return out; }
async function getZone(domain) { return (await listZones()).find((z) => z.name === String(domain).toLowerCase() && !z.private) || null; }
async function createZone(domain) { const cur = await getZone(domain); if (cur) return cur; const j = await call('POST', '/managedZones', { name: zoneName(domain), dnsName: fq(domain), description: 'webmaker', visibility: 'public' }); return { id: j.name, name: String(domain).toLowerCase(), nameServers: j.nameServers || [] }; }
async function rrsets(zoneId) { const out = []; let pageToken = null; do { const j = await call('GET', `/managedZones/${encodeURIComponent(zoneId)}/rrsets`, null, pageToken ? { pageToken } : null); for (const s of j.rrsets || []) out.push({ name: s.name, type: s.type, ttl: s.ttl || 300, values: s.rrdatas || [] }); pageToken = j.nextPageToken || null; } while (pageToken); return out; }
async function deleteZone(domain) {
const z = await getZone(domain); if (!z) return { ok: true, existed: false };
const sets = (await rrsets(z.id)).filter((s) => !((s.type === 'NS' || s.type === 'SOA') && s.name === fq(domain)));
if (sets.length) await call('POST', `/managedZones/${encodeURIComponent(z.id)}/changes`, { deletions: sets.map((s) => ({ name: s.name, type: s.type, ttl: s.ttl, rrdatas: s.values })) });
await call('DELETE', `/managedZones/${encodeURIComponent(z.id)}`); return { ok: true, existed: true };
}
const toVal = (r) => (r.type === 'TXT' ? `"${String(r.value).replace(/"/g, '\\"')}"` : r.type === 'MX' || r.type === 'SRV' ? `${r.prio == null ? 10 : r.prio} ${String(r.value).replace(/\.?$/, '.')}` : r.type === 'CNAME' || r.type === 'NS' ? String(r.value).replace(/\.?$/, '.') : String(r.value));
const fromVal = (type, v) => { if (type === 'TXT') return { value: String(v).replace(/^"|"$/g, '').replace(/\\"/g, '"') }; if (type === 'MX' || type === 'SRV') { const m = String(v).match(/^(\d+)\s+(.*)$/); return m ? { prio: Number(m[1]), value: m[2].replace(/\.$/, '') } : { value: v }; } return { value: String(v).replace(/\.$/, '') }; };
const relName = (zone, name) => { const n = String(name).replace(/\.$/, '').toLowerCase(); const z = String(zone).toLowerCase(); return n === z ? '' : n.endsWith('.' + z) ? n.slice(0, -(z.length + 1)) : n; };
const absName = (zone, rel) => fq(rel ? `${rel}.${zone}` : zone);
async function listRecords(domain) { const z = await getZone(domain); if (!z) throw httpError(`Google Cloud DNS 에 ${domain} 영역이 없습니다`, 404); const out = []; for (const s of await rrsets(z.id)) for (const v of s.values) out.push({ name: relName(domain, s.name), type: s.type, ttl: s.ttl, ...fromVal(s.type, v) }); return out; }
async function updateRecords(domain, { add = [], remove = [] }) {
const z = await getZone(domain); if (!z) throw httpError(`Google Cloud DNS 에 ${domain} 영역이 없습니다`, 404);
const sets = await rrsets(z.id); const key = (n, t) => `${n}|${t}`; const map = new Map(sets.map((s) => [key(s.name, s.type), { ...s, values: s.values.slice() }])); const touched = new Set();
for (const r of add) { const n = absName(domain, r.name); const k = key(n, r.type); const s = map.get(k) || { name: n, type: r.type, ttl: r.ttl || 300, values: [] }; const v = toVal(r); if (!s.values.includes(v)) s.values.push(v); if (r.ttl) s.ttl = r.ttl; map.set(k, s); touched.add(k); }
for (const r of remove) { const n = absName(domain, r.name); const k = key(n, r.type); const s = map.get(k); if (!s) continue; const v = toVal(r); s.values = s.values.filter((x) => x !== v && fromVal(r.type, x).value !== r.value); touched.add(k); }
const deletions = []; const additions = [];
for (const k of touched) { const s = map.get(k); const orig = sets.find((x) => key(x.name, x.type) === k); if (orig) deletions.push({ name: orig.name, type: orig.type, ttl: orig.ttl, rrdatas: orig.values }); if (s.values.length) additions.push({ name: s.name, type: s.type, ttl: s.ttl, rrdatas: s.values }); }
if (deletions.length || additions.length) await call('POST', `/managedZones/${encodeURIComponent(z.id)}/changes`, { deletions, additions });
return { ok: true, changes: deletions.length + additions.length };
}
async function nameServers(domain) { const z = await getZone(domain); return z ? z.nameServers : []; }
module.exports = { id: 'gcloud', label: 'Google Cloud DNS', ready, creds, listZones, getZone, createZone, deleteZone, listRecords, updateRecords, nameServers, call, SCOPE };
+8
View File
@@ -0,0 +1,8 @@
'use strict';
// Openprovider DNS 공급자 (v1.5.36) — 1.4.0 부터 있던 lib/openprovider.js 의 영역·레코드 함수를 공통 공급자 모양(lib/dnszone.js)으로 감싼다
const op = require('../openprovider');
async function listZones() { const d = await op.call('GET', '/dns/zones', null, { limit: 500 }); return ((d && d.results) || []).map((z) => ({ id: z.id, name: String(z.name || '').toLowerCase(), nameServers: [] })); }
async function getZone(domain) { const z = await op.getZone(domain); return z ? { id: z.id, name: String(domain).toLowerCase(), nameServers: (z.name_servers || []).map((x) => x.name || x) } : null; }
async function createZone(domain, records) { const z = await getZone(domain); if (z) { if (records && records.length) await op.updateRecords(domain, { add: records }); return z; } await op.createZone(domain, records || []); return (await getZone(domain)) || { id: null, name: domain, nameServers: [] }; }
async function deleteZone(domain) { const z = await op.getZone(domain); if (!z) return { ok: true, existed: false }; await op.call('DELETE', `/dns/zones/${encodeURIComponent(domain)}`); return { ok: true, existed: true }; }
module.exports = { id: 'openprovider', label: 'Openprovider DNS', ready: op.ready, listZones, getZone, createZone, deleteZone, listRecords: op.listRecords, updateRecords: op.updateRecords, nameServers: async (d) => { const z = await getZone(d); return z ? z.nameServers : []; } };
+36
View File
@@ -0,0 +1,36 @@
'use strict';
// ============================================================================
// Porkbun DNS 공급자 (v1.5.36, 요구 5) — Porkbun 에 등록(또는 Porkbun 네임서버를 쓰는) 도메인의 레코드 관리
//
// 접속 정보: 보관소 종류 'porkbun' — 키(secret) = Secret API Key, 설정 API_KEY=… (공개 API 키). 도메인마다 Porkbun 화면에서 "API Access" 를 켜야 한다.
// API: https://api.porkbun.com/api/json/v3/ (JSON, 모든 호출이 POST 이고 본문에 apikey·secretapikey 를 넣는다). 시험용 대체 주소: PORKBUN_API_BASE.
// 영역은 "계정에 있는 도메인" 그 자체라 따로 만들지 않는다(createZone = 계정 보유 확인). 레코드마다 id 가 있어 지울 때 이름·종류·값으로 id 를 찾는다.
// ============================================================================
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const base = () => (process.env.PORKBUN_API_BASE || 'https://api.porkbun.com/api/json/v3').replace(/\/+$/, '');
function creds() { let x = null; try { x = require('../vault').resolve('porkbun'); } catch (_) {} const key = x && x.settings && (x.settings.API_KEY || x.settings.PORKBUN_API_KEY); if (!x || !x.secret || !key) return null; return { apikey: key, secretapikey: x.secret }; }
const ready = () => !!creds();
/** 호출: 응답 { status: 'SUCCESS'|'ERROR', message, ... } */
async function call(path, body) {
const c = creds(); if (!c) throw httpError('Porkbun 접속 정보가 없습니다 (관리자 콘솔 › 설정 › 외부 연동 접속 정보 › Porkbun: API_KEY 설정과 Secret API Key)', 503);
let r; try { r = await fetch(`${base()}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...c, ...(body || {}) }), signal: AbortSignal.timeout(25000) }); } catch (e) { throw httpError(`Porkbun 연결 실패: ${e.message}`, 502); }
const j = await r.json().catch(() => ({}));
if (!r.ok || j.status !== 'SUCCESS') throw httpError(`Porkbun 오류: ${j.message || `HTTP ${r.status}`}`, r.status === 403 || /invalid api|not authorized|api access/i.test(j.message || '') ? 403 : r.status >= 500 ? 502 : 400);
return j;
}
const low = (d) => String(d).toLowerCase().replace(/\.$/, '');
async function listZones() { const j = await call('/domain/listAll', { start: '0', includeLabels: 'no' }); return (j.domains || []).map((d) => ({ id: d.domain, name: low(d.domain), status: d.status, expires: d.expireDate || null, nameServers: [] })); }
async function getZone(domain) { const z = (await listZones()).find((x) => x.name === low(domain)); if (!z) return null; try { const ns = await call(`/domain/getNs/${low(domain)}`); z.nameServers = ns.ns || []; } catch (_) {} return z; }
async function createZone(domain) { const z = await getZone(domain); if (!z) throw httpError(`Porkbun 계정에 ${domain} 이(가) 없습니다 — Porkbun 에서 등록한(또는 이전한) 도메인만 관리할 수 있습니다`, 404); return z; }
async function deleteZone(domain) { const recs = await rawRecords(domain); for (const r of recs) if (!(r.type === 'NS' && r.rel === '')) await call(`/dns/delete/${low(domain)}/${r.id}`); return { ok: true, existed: true, cleared: recs.length }; }
async function rawRecords(domain) { const j = await call(`/dns/retrieve/${low(domain)}`); const d = low(domain); return (j.records || []).map((r) => { const n = low(r.name); return { id: r.id, rel: n === d ? '' : n.endsWith('.' + d) ? n.slice(0, -(d.length + 1)) : n, type: r.type, value: r.content, ttl: Number(r.ttl) || 600, prio: r.prio != null && r.prio !== '' ? Number(r.prio) : undefined }; }); }
async function listRecords(domain) { return (await rawRecords(domain)).map(({ rel, type, value, ttl, prio }) => ({ name: rel, type, value, ttl, prio })); }
async function updateRecords(domain, { add = [], remove = [] }) {
let n = 0;
if (remove.length) { const cur = await rawRecords(domain); for (const r of remove) { const hit = cur.find((x) => x.rel === String(r.name || '') && x.type === r.type && String(x.value) === String(r.value)); if (hit) { await call(`/dns/delete/${low(domain)}/${hit.id}`); n += 1; } } }
for (const r of add) { await call(`/dns/create/${low(domain)}`, { name: r.name || '', type: r.type, content: r.value, ttl: String(Math.max(600, r.ttl || 600)), ...(r.prio != null ? { prio: String(r.prio) } : {}) }); n += 1; }
return { ok: true, changes: n };
}
async function nameServers(domain) { const j = await call(`/domain/getNs/${low(domain)}`); return j.ns || []; }
async function setNameServers(domain, ns) { await call(`/domain/updateNs/${low(domain)}`, { ns }); return { ok: true }; }
module.exports = { id: 'porkbun', label: 'Porkbun', ready, creds, call, listZones, getZone, createZone, deleteZone, listRecords, updateRecords, nameServers, setNameServers };
+104
View File
@@ -0,0 +1,104 @@
'use strict';
// ============================================================================
// Amazon Route 53 DNS 공급자 (v1.5.36, 요구 5)
//
// 접속 정보: 보관소(lib/vault.js) 종류 'route53' — 키(secret) = AWS Secret Access Key, 설정 ACCESS_KEY_ID=…, (선택) SESSION_TOKEN=…, REGION(기본 us-east-1).
// IAM 권한: route53:ListHostedZones*, GetHostedZone, CreateHostedZone, DeleteHostedZone, ListResourceRecordSets, ChangeResourceRecordSets, GetChange.
// API 는 XML 이다(https://route53.amazonaws.com/2013-04-01/…). 서명은 lib/awsv4.js. 시험용 대체 주소: 환경 변수 ROUTE53_API_BASE.
// 레코드 모델: Route 53 는 (이름, 종류) 한 묶음(RRSet)에 값 여러 개 → 우리 레코드(값 하나씩)를 더하거나 뺄 때는 묶음을 읽어 값을 합치고 UPSERT/DELETE 한다.
// TXT 값은 따옴표로 감싸 보내고 읽을 때 벗긴다. MX 값은 "우선순위 호스트" 한 줄이다. 이름은 끝에 점이 붙은 FQDN.
// ============================================================================
const awsv4 = require('../awsv4');
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const HOST = 'route53.amazonaws.com'; const API = '/2013-04-01';
const base = () => (process.env.ROUTE53_API_BASE || `https://${HOST}`).replace(/\/+$/, '');
function creds() {
let x = null; try { x = require('../vault').resolve('route53'); } catch (_) {}
// v1.6.38: 운영 서버에서 설정 칸에 ACCESS_KEY= 로 넣은 사례가 있어 흔한 별칭도 받는다 (ACCESS_KEY, AWS_ACCESS_KEY, KEY_ID)
const id = x && x.settings && (x.settings.ACCESS_KEY_ID || x.settings.AWS_ACCESS_KEY_ID || x.settings.ACCESS_KEY || x.settings.AWS_ACCESS_KEY || x.settings.KEY_ID);
if (!x || !x.secret || !id) return null;
return { accessKeyId: id, secretAccessKey: x.secret, sessionToken: x.settings.SESSION_TOKEN || x.settings.AWS_SESSION_TOKEN || '', region: x.settings.REGION || 'us-east-1' };
}
const ready = () => !!creds();
/**
* v1.6.39 (요구 1): 접속 정보가 없을 때 "무엇이 빠졌는지" 를 말해 준다 — webmaker.ibit.kr 에서 설정 칸 이름을 ACCESS_KEY 로 넣어 503 이 난 사례(1.6.38 에서 별칭 허용).
* 비밀 키 자체(secret)가 없는지, 설정 칸에 액세스 키 ID 줄이 없는지(지금 들어 있는 설정 이름을 함께)를 구분해 안내한다. 값은 절대 넣지 않는다.
*/
function missingMessage() {
let x = null; try { x = require('../vault').resolve('route53'); } catch (_) {}
const where = '관리자 콘솔 › 설정 › 외부 연동 접속 정보 › Route 53';
if (!x) return `Route 53 접속 정보가 없습니다 — ${where} 에 항목을 추가하세요 (키 칸: Secret Access Key, 설정 칸: ACCESS_KEY_ID=액세스 키 ID)`;
const names = Object.keys(x.settings || {});
if (!x.secret) return `Route 53 비밀 키(Secret Access Key)가 없습니다 — ${where} 의 키 칸에 넣으세요${names.length ? ` (설정 칸에는 ${names.join(', ')} 만 있음)` : ''}`;
return `Route 53 액세스 키 ID 가 없습니다 — ${where} 의 설정 칸에 "ACCESS_KEY_ID=AKIA…" 한 줄을 넣으세요${names.length ? ` (지금 설정 칸: ${names.join(', ')} — ACCESS_KEY·AWS_ACCESS_KEY_ID·KEY_ID 도 인정)` : ' (설정 칸이 비어 있음)'}`;
}
const esc = (s) => String(s).replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]));
const unesc = (s) => String(s).replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&amp;/g, '&');
/** 아주 작은 XML 읽기: 태그 이름으로 값·블록 뽑기 (응답 구조가 단순해 전체 파서를 두지 않는다) */
const tag = (xml, name) => { const m = String(xml).match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`)); return m ? unesc(m[1]) : null; };
const blocks = (xml, name) => { const out = []; const re = new RegExp(`<${name}>([\\s\\S]*?)</${name}>`, 'g'); let m; while ((m = re.exec(String(xml)))) out.push(m[1]); return out; };
/** 서명된 호출. 오류는 Route 53 의 <Message> 를 담아 던진다 */
async function call(method, path, query, bodyXml) {
const c = creds(); if (!c) throw httpError(missingMessage(), 503);
const u = new URL(base()); const headers = awsv4.sign({ method, host: u.host, path: API + path, query, headers: bodyXml ? { 'content-type': 'application/xml' } : {}, body: bodyXml || '', service: 'route53', region: c.region, accessKeyId: c.accessKeyId, secretAccessKey: c.secretAccessKey, sessionToken: c.sessionToken });
const qs = query ? '?' + new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined && v !== null && v !== '')).toString() : '';
let r; try { r = await fetch(`${base()}${API}${path}${qs}`, { method, headers, body: bodyXml || undefined, signal: AbortSignal.timeout(25000) }); } catch (e) { throw httpError(`Route 53 연결 실패: ${e.message}`, 502); }
const text = await r.text();
if (!r.ok) throw httpError(`Route 53 오류 (HTTP ${r.status}): ${tag(text, 'Message') || tag(text, 'Code') || text.slice(0, 200)}`, r.status === 403 ? 403 : r.status >= 500 ? 502 : 400);
return text;
}
const fq = (d) => String(d).toLowerCase().replace(/\.$/, '') + '.';
const idOf = (s) => String(s || '').replace(/^\/hostedzone\//, '');
/** 호스팅 영역 목록 */
async function listZones() {
const out = []; let marker = null;
do { const xml = await call('GET', '/hostedzone', marker ? { marker } : null); for (const b of blocks(xml, 'HostedZone')) out.push({ id: idOf(tag(b, 'Id')), name: String(tag(b, 'Name') || '').replace(/\.$/, ''), records: Number(tag(b, 'ResourceRecordSetCount') || 0), private: /<PrivateZone>true/.test(b) }); marker = tag(xml, 'IsTruncated') === 'true' ? tag(xml, 'NextMarker') : null; } while (marker);
return out;
}
async function getZone(domain) { const z = (await listZones()).find((x) => x.name === String(domain).toLowerCase() && !x.private); if (!z) return null; const xml = await call('GET', `/hostedzone/${z.id}`); return { ...z, nameServers: blocks(xml, 'NameServer').map(unesc) }; }
/** 영역 만들기 → { id, nameServers }. 이미 있으면 그것을 돌려준다 */
async function createZone(domain) {
const cur = await getZone(domain); if (cur) return cur;
const xml = await call('POST', '/hostedzone', null, `<?xml version="1.0" encoding="UTF-8"?><CreateHostedZoneRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/"><Name>${esc(fq(domain))}</Name><CallerReference>webmaker-${Date.now()}</CallerReference><HostedZoneConfig><Comment>webmaker</Comment></HostedZoneConfig></CreateHostedZoneRequest>`);
return { id: idOf(tag(xml, 'Id')), name: String(domain).toLowerCase(), nameServers: blocks(xml, 'NameServer').map(unesc) };
}
/** 영역 지우기 — Route 53 는 NS·SOA 외 레코드가 남아 있으면 거부하므로 먼저 모두 지운다 */
async function deleteZone(domain) {
const z = await getZone(domain); if (!z) return { ok: true, existed: false };
const sets = await rrsets(z.id); const del = sets.filter((s) => !(s.type === 'NS' || s.type === 'SOA') || s.name !== fq(domain));
if (del.length) await change(z.id, del.map((s) => ({ action: 'DELETE', set: s })));
await call('DELETE', `/hostedzone/${z.id}`); return { ok: true, existed: true };
}
/** 영역의 RRSet 전체 (원본 모양: { name(끝점), type, ttl, values[] }) */
async function rrsets(zoneId) {
const out = []; let q = null;
do { const xml = await call('GET', `/hostedzone/${zoneId}/rrset`, q); for (const b of blocks(xml, 'ResourceRecordSet')) { if (/<AliasTarget>/.test(b)) continue; out.push({ name: tag(b, 'Name'), type: tag(b, 'Type'), ttl: Number(tag(b, 'TTL') || 300), values: blocks(b, 'ResourceRecord').map((r) => tag(r, 'Value')) }); } q = tag(xml, 'IsTruncated') === 'true' ? { name: tag(xml, 'NextRecordName'), type: tag(xml, 'NextRecordType') } : null; } while (q);
return out;
}
function setXml(action, s) { return `<Change><Action>${action}</Action><ResourceRecordSet><Name>${esc(s.name)}</Name><Type>${s.type}</Type><TTL>${s.ttl || 300}</TTL><ResourceRecords>${s.values.map((v) => `<ResourceRecord><Value>${esc(v)}</Value></ResourceRecord>`).join('')}</ResourceRecords></ResourceRecordSet></Change>`; }
async function change(zoneId, changes) { if (!changes.length) return; await call('POST', `/hostedzone/${zoneId}/rrset`, null, `<?xml version="1.0" encoding="UTF-8"?><ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/"><ChangeBatch><Changes>${changes.map((c) => setXml(c.action, c.set)).join('')}</Changes></ChangeBatch></ChangeResourceRecordSetsRequest>`); }
// ---- 우리 레코드 모양 ↔ Route 53 값 ----
const toVal = (r) => (r.type === 'TXT' ? `"${String(r.value).replace(/"/g, '\\"')}"` : r.type === 'MX' ? `${r.prio == null ? 10 : r.prio} ${r.value}` : r.type === 'SRV' ? `${r.prio == null ? 10 : r.prio} ${r.value}` : r.type === 'CNAME' || r.type === 'NS' ? String(r.value).replace(/\.?$/, '.') : String(r.value));
const fromVal = (type, v) => { if (type === 'TXT') return { value: String(v).replace(/^"|"$/g, '').replace(/\\"/g, '"') }; if (type === 'MX' || type === 'SRV') { const m = String(v).match(/^(\d+)\s+(.*)$/); return m ? { prio: Number(m[1]), value: m[2].replace(/\.$/, '') } : { value: v }; } return { value: String(v).replace(/\.$/, '') }; };
const relName = (zone, name) => { const n = String(name).replace(/\.$/, '').toLowerCase(); const z = String(zone).toLowerCase(); return n === z ? '' : n.endsWith('.' + z) ? n.slice(0, -(z.length + 1)) : n; };
const absName = (zone, rel) => fq(rel ? `${rel}.${zone}` : zone);
/** 레코드 목록 (우리 모양: name 은 영역 기준 상대 이름) */
async function listRecords(domain) {
const z = await getZone(domain); if (!z) throw httpError(`Route 53 에 ${domain} 영역이 없습니다`, 404);
const out = []; for (const s of await rrsets(z.id)) for (const v of s.values) out.push({ name: relName(domain, s.name), type: s.type, ttl: s.ttl, ...fromVal(s.type, v) });
return out;
}
/** 레코드 더하기/빼기 (같은 이름·종류 묶음에 값을 합치거나 뺀다) */
async function updateRecords(domain, { add = [], remove = [] }) {
const z = await getZone(domain); if (!z) throw httpError(`Route 53 에 ${domain} 영역이 없습니다`, 404);
const sets = await rrsets(z.id); const key = (n, t) => `${n}|${t}`; const map = new Map(sets.map((s) => [key(s.name, s.type), { ...s, values: s.values.slice() }])); const touched = new Set();
for (const r of add) { const n = absName(domain, r.name); const k = key(n, r.type); const s = map.get(k) || { name: n, type: r.type, ttl: r.ttl || 300, values: [] }; const v = toVal(r); if (!s.values.includes(v)) s.values.push(v); if (r.ttl) s.ttl = r.ttl; map.set(k, s); touched.add(k); }
for (const r of remove) { const n = absName(domain, r.name); const k = key(n, r.type); const s = map.get(k); if (!s) continue; const v = toVal(r); s.values = s.values.filter((x) => x !== v && fromVal(r.type, x).value !== r.value); touched.add(k); }
const changes = [];
for (const k of touched) { const s = map.get(k); const orig = sets.find((x) => key(x.name, x.type) === k); if (!s.values.length) { if (orig) changes.push({ action: 'DELETE', set: orig }); } else changes.push({ action: 'UPSERT', set: s }); }
await change(z.id, changes); return { ok: true, changes: changes.length };
}
async function nameServers(domain) { const z = await getZone(domain); return z ? z.nameServers : []; }
module.exports = { id: 'route53', label: 'Amazon Route 53', ready, creds, missingMessage, listZones, getZone, createZone, deleteZone, listRecords, updateRecords, nameServers, call };
+101
View File
@@ -0,0 +1,101 @@
'use strict';
// ============================================================================
// DNS 관리 자동화 — 공급자 공통 창구 (v1.5.36, 요구 5)
//
// 공급자: openprovider(1.4.0 부터) · porkbun · route53(Amazon) · gcloud(Google Cloud DNS). 모두 같은 모양의 함수를 가진다(lib/dns/<id>.js):
// ready() · listZones() · getZone(domain) · createZone(domain, records?) · deleteZone(domain) · listRecords(domain) · updateRecords(domain, {add, remove}) · nameServers(domain)
// 레코드 모양(공통): { name: 영역 기준 상대 이름('' = 도메인 자신, 'www', '_acme-challenge' …), type, value, ttl, prio }.
//
// 어느 공급자를 쓰나
// - 도메인 기록(data/domains.json)에 dnsProvider 가 있으면 그것 (구매 때 정해지고, 관리자가 바꿀 수 있다)
// - 없으면 설정 › 도메인 서비스 › DNS 공급자(settings.domains.dnsProvider; 1.5.31 까지의 platformDns 'openprovider' 는 그대로 openprovider 로 읽는다)
// - 'manual' 이면 자동 DNS 없음(운영자가 직접) — 각 기능은 안내 모드로 돌아간다
//
// 보안(요구 5 "여러 도메인의 여러 사용자")
// - 공급자 접속 정보(API 키·서비스 계정)는 관리자만 넣고(보관소), 사용자 API 응답에는 절대 나가지 않는다.
// - 사용자는 lib/domains.js 가 "그 사용자 소유" 로 확인한 도메인의 영역만, 그 영역 기준 상대 이름으로만 만진다(다른 영역·상위 이름 불가 — normRecord).
// - 관리자 전용 영역 관리(목록·만들기·지우기·이전된 영역 정리)는 /api/admin/dns/* 이며 모든 변경은 감사 기록(data/dns-audit.json: 누가·언제·어느 공급자·영역·무엇)에 남는다.
// - 사용자 소유 도메인의 영역을 관리자가 지우려면 명시적 확인(force)이 필요하다.
// ============================================================================
const fs = require('fs');
const path = require('path');
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const AUDIT = path.join(DATA_ROOT, 'dns-audit.json');
const PROVIDERS = { openprovider: require('./dns/openprovider'), porkbun: require('./dns/porkbun'), route53: require('./dns/route53'), gcloud: require('./dns/gcloud') };
const IDS = Object.keys(PROVIDERS);
const LABELS = { manual: '수동 (운영자가 직접 관리)', openprovider: 'Openprovider DNS', porkbun: 'Porkbun DNS', route53: 'Amazon Route 53', gcloud: 'Google Cloud DNS' };
/** 공급자 모듈. 모르는 id 면 오류 */
function get(id) { const p = PROVIDERS[id]; if (!p) throw httpError(`알 수 없는 DNS 공급자: ${id}`); return p; }
/** 설정의 기본 DNS 공급자 id ('manual' 포함). 1.5.31 까지의 platformDns 와 호환 */
function defaultId() { let c = {}; try { c = require('./domains').config(); } catch (_) {} const v = c.dnsProvider || (c.platformDns === 'openprovider' ? 'openprovider' : 'manual'); return PROVIDERS[v] ? v : 'manual'; }
/** 공급자 id 가 쓸 수 있는 상태인지 (접속 정보 있음) */
function ready(id) { const p = PROVIDERS[id]; try { return !!(p && p.ready()); } catch (_) { return false; } }
/** 화면·API 용 공급자 목록: id·이름·준비 여부·기본 여부 */
function catalog() { const d = defaultId(); return ['manual', ...IDS].map((id) => ({ id, label: LABELS[id], ready: id === 'manual' ? true : ready(id), isDefault: id === d })); }
/**
* 어떤 영역(도메인)에 쓸 공급자 고르기: 도메인 기록의 dnsProvider → 기본 공급자. 준비되지 않았으면 null (안내 모드).
* @param {object|string|null} domainRec data/domains.json 의 기록 또는 영역 이름
* @returns {{id:string, p:object}|null}
*/
function forZone(domainRec) {
let rec = domainRec; if (typeof domainRec === 'string') { try { rec = require('./domains').byName(domainRec); } catch (_) { rec = null; } }
const id = (rec && rec.dnsProvider) || defaultId(); if (id === 'manual' || !ready(id)) return null; return { id, p: PROVIDERS[id] };
}
/** 감사 기록 (최근 2000건 보관) */
function audit(entry) { try { let d = []; try { d = JSON.parse(fs.readFileSync(AUDIT, 'utf8')); } catch (_) {} d.push({ at: new Date().toISOString(), ...entry }); if (d.length > 2000) d = d.slice(-2000); fs.mkdirSync(DATA_ROOT, { recursive: true }); fs.writeFileSync(AUDIT, JSON.stringify(d)); } catch (_) {} }
function auditList(n = 200) { try { return JSON.parse(fs.readFileSync(AUDIT, 'utf8')).slice(-n).reverse(); } catch (_) { return []; } }
// ---- 영역 작업 (공급자 지정 또는 자동) ----
const pick = (providerId, zone) => { if (providerId) { if (!ready(providerId)) throw httpError(`${LABELS[providerId] || providerId} 접속 정보가 없거나 올바르지 않습니다 (관리자 콘솔 › 설정 › 외부 연동 접속 정보)`, 503); return { id: providerId, p: get(providerId) }; } const f = forZone(zone); if (!f) throw httpError('이 도메인에 자동 DNS 공급자가 없습니다 (관리자 콘솔 › 설정 › 도메인 서비스 › DNS 공급자)', 503); return f; };
async function listZones(providerId) { const { p } = pick(providerId, null); return p.listZones(); }
async function getZone(zone, providerId) { const { p } = pick(providerId, zone); return p.getZone(zone); }
async function ensureZone(zone, records, by, providerId) { const { id, p } = pick(providerId, zone); const z = await p.createZone(zone, records || []); if (records && records.length && id !== 'openprovider') await p.updateRecords(zone, { add: records }); audit({ by, action: 'zone.ensure', provider: id, zone, records: (records || []).length }); return { provider: id, ...z }; }
async function deleteZone(zone, by, providerId) { const { id, p } = pick(providerId, zone); const r = await p.deleteZone(zone); audit({ by, action: 'zone.delete', provider: id, zone, existed: !!r.existed }); return { provider: id, ...r }; }
async function listRecords(zone, providerId) { const { id, p } = pick(providerId, zone); return { provider: id, records: await p.listRecords(zone) }; }
async function addRecords(zone, records, by, providerId) { const { id, p } = pick(providerId, zone); const r = await p.updateRecords(zone, { add: records }); audit({ by, action: 'records.add', provider: id, zone, records: records.map((x) => `${x.type} ${x.name || '@'} ${String(x.value).slice(0, 80)}`) }); return { provider: id, ...r }; }
async function removeRecords(zone, records, by, providerId) { const { id, p } = pick(providerId, zone); const r = await p.updateRecords(zone, { remove: records }); audit({ by, action: 'records.remove', provider: id, zone, records: records.map((x) => `${x.type} ${x.name || '@'} ${String(x.value).slice(0, 80)}`) }); return { provider: id, ...r }; }
/** 같은 이름·종류의 레코드를 주어진 값으로 바꾼다(기존 값 제거 후 추가) — MX 교체 등 */
async function setRecords(zone, records, by, providerId) { const { id, p } = pick(providerId, zone); const cur = await p.listRecords(zone); const keys = new Set(records.map((r) => `${r.name || ''}|${r.type}`)); const remove = cur.filter((r) => keys.has(`${r.name || ''}|${r.type}`)); await p.updateRecords(zone, { add: records, remove: remove.filter((r) => !records.some((x) => x.type === r.type && (x.name || '') === (r.name || '') && String(x.value) === String(r.value))) }); audit({ by, action: 'records.set', provider: id, zone, records: records.map((x) => `${x.type} ${x.name || '@'}`) }); return { provider: id, ok: true }; }
async function nameServers(zone, providerId) { const { id, p } = pick(providerId, zone); return { provider: id, nameServers: await p.nameServers(zone) }; }
/**
* 이전된(다른 곳에서 넘어온) 영역 받기: 지금 공용 DNS 에 보이는 레코드(A·AAAA·MX·TXT·CNAME)를 읽어 새 영역에 넣는다 — 네임서버를 바꾸기 전에 내용을 옮겨 두는 용도.
* @returns {{provider, zone, imported:number, nameServers:string[]}}
*/
/**
* v1.6.33 (요구 1): 공용 DNS 조회 오류 코드 → 사람이 읽는 원인. 이전된 영역 받기가 "실패: not found" 처럼 뭉뚱그리지 않고 어디서 막혔는지 말하게 한다.
* ENOTFOUND(NXDOMAIN) = 등록되지 않았거나 상위(등록 업체)에 위임(NS)이 없음 · ESERVFAIL = 위임된 네임서버가 응답을 못 줌(영역 설정 없음·DNSSEC 오류) · ETIMEOUT/ECONNREFUSED = 네임서버에 닿지 않음 · ENODATA = 이름은 있으나 그 종류 레코드 없음
*/
function describeDnsError(code, zone) {
const c = String(code || '');
if (c === 'ENOTFOUND') return `${zone} 은(는) 공용 DNS 에 존재하지 않습니다(NXDOMAIN) — 도메인이 등록되어 있지 않거나, 등록 업체에 네임서버(NS)가 지정되지 않았습니다. 등록 상태와 네임서버를 먼저 확인하세요`;
if (c === 'ESERVFAIL') return `${zone} 의 네임서버가 응답을 주지 못했습니다(SERVFAIL) — 지정된 네임서버에 이 영역이 없거나 DNSSEC 설정이 깨져 있습니다`;
if (c === 'ETIMEOUT' || c === 'ECONNREFUSED' || c === 'EREFUSED') return `${zone} 의 네임서버에 연결할 수 없습니다(${c}) — 네임서버가 꺼져 있거나 쿼리를 거부합니다`;
if (c === 'ENODATA') return ''; // 이름은 있으나 그 종류의 레코드가 없음 — 오류가 아님
return c ? `DNS 조회 오류(${c})` : '';
}
/**
* 이전된(다른 곳에서 넘어온) 영역 받기: 지금 공용 DNS 에 보이는 레코드(A·AAAA·MX·TXT·CNAME)를 읽어 새 영역에 넣는다 — 네임서버를 바꾸기 전에 내용을 옮겨 두는 용도.
* v1.6.33: 먼저 NS 조회로 "영역이 공용 DNS 에 살아 있는지" 를 확인한다. 등록되지 않았거나 위임이 없거나 네임서버가 죽어 있으면 **실패**(원인 설명). 살아 있는데 레코드가 하나도 없으면
* 실패가 아니라 "zone 파일 구성을 찾을 수 없습니다" 안내와 함께 빈 영역을 만든다(운영자가 레코드를 직접 넣을 수 있게). 실패도 감사 기록에 남긴다.
* @param {{resolver?:object}} [opts] 시험용 — dns.Resolver 와 같은 모양(resolveNs/resolve4/…)의 객체를 넣으면 공용 DNS 대신 그것을 쓴다
* @returns {{provider, zone, imported:number, nameServers:string[], currentNs:string[], message:string, empty:boolean}}
*/
async function importZone(zone, by, providerId, opts = {}) {
const dns = require('dns').promises; let r = opts.resolver; if (!r) { r = new dns.Resolver({ timeout: 4000, tries: 1 }); try { r.setServers(['1.1.1.1', '8.8.8.8']); } catch (_) {} }
const { id: provId } = pick(providerId, zone); // 공급자 준비 여부를 먼저 — 접속 정보가 없으면 DNS 를 읽기 전에 503 안내
// 1) 영역이 공용 DNS 에 살아 있는지: NS 조회. 실패 원인별로 다른 메시지
let currentNs = []; try { currentNs = (await r.resolveNs(zone)).map((x) => String(x).toLowerCase().replace(/\.$/, '')); }
catch (e) { const why = describeDnsError(e.code, zone); if (why) { audit({ by, action: 'zone.import', provider: provId, zone, ok: false, reason: why }); throw httpError(`이전된 영역을 받을 수 없습니다 — ${why}`, 409); } }
if (!currentNs.length) { try { await r.resolveSoa(zone); } catch (e) { const why = describeDnsError(e.code, zone) || `${zone} 에 NS·SOA 레코드가 없습니다 — 이 이름은 독립된 영역(zone)이 아닙니다(상위 도메인의 레코드일 수 있음)`; audit({ by, action: 'zone.import', provider: provId, zone, ok: false, reason: why }); throw httpError(`이전된 영역을 받을 수 없습니다 — ${why}`, 409); } }
const recs = []; const names = ['', 'www', 'mail', 'webmail', 'ftp', '_dmarc'];
for (const n of names) { const fq = n ? `${n}.${zone}` : zone; for (const [type, fn] of [['A', 'resolve4'], ['AAAA', 'resolve6'], ['CNAME', 'resolveCname'], ['TXT', 'resolveTxt'], ['MX', 'resolveMx']]) { if (n && type === 'MX') continue; let v = []; try { v = await r[fn](fq); } catch (_) { v = []; } for (const x of v) { if (type === 'TXT') recs.push({ name: n, type, value: x.join(''), ttl: 900 }); else if (type === 'MX') recs.push({ name: n, type, value: x.exchange, prio: x.priority, ttl: 900 }); else recs.push({ name: n, type, value: x, ttl: 900 }); } if (type === 'CNAME' && v.length) break; } }
// 2) 레코드가 하나도 없으면 실패가 아니라 "zone 파일 구성을 찾을 수 없습니다" — 빈 영역을 만들어 두고 운영자가 채우게 한다
const z = await ensureZone(zone, recs, by, providerId); audit({ by, action: 'zone.import', provider: z.provider, zone, imported: recs.length, currentNs });
const empty = recs.length === 0;
const message = empty ? `zone 파일 구성을 찾을 수 없습니다 — ${zone} 은(는) 공용 DNS 에 위임되어 있지만(네임서버 ${currentNs.join(', ') || '확인 안 됨'}) A·AAAA·CNAME·MX·TXT 레코드가 하나도 보이지 않습니다. 빈 영역을 만들었으니 레코드를 직접 추가하세요` : `이전된 영역 받음 — 레코드 ${recs.length}개 (지금 네임서버: ${currentNs.join(', ') || '확인 안 됨'})`;
return { provider: z.provider, zone, imported: recs.length, nameServers: z.nameServers || [], currentNs, message, empty };
}
module.exports = { describeDnsError, PROVIDERS, IDS, LABELS, get, defaultId, ready, catalog, forZone, audit, auditList, listZones, getZone, ensureZone, deleteZone, listRecords, addRecords, removeRecords, setRecords, nameServers, importZone };
+710
View File
@@ -0,0 +1,710 @@
'use strict';
// ============================================================================
// 도메인 관리 (v1.4.0) — 사용자 "도메인" 탭의 업무 규칙
//
// 계정 종류 (관리자 콘솔 › 회원에서 사용자마다 지정, users[].accountType)
// - subdomain : 서비스가 제공하는 플랫폼 도메인(예: ibit.kr)의 서브 도메인만 쓴다.
// mycompany 를 등록하면 홈페이지 https://mycompany.ibit.kr, 기본 메일 admin@mycompany.ibit.kr,
// 메일 접속 https://mycompany.ibit.kr/mail. 도메인 구매·DNS 직접 관리는 할 수 없다(검색·등록만).
// - domain : 자기 도메인을 사고(Openprovider) DNS 레코드·MX 를 직접 관리한다. 서브 도메인도 쓸 수 있다.
//
// 도메인 기록의 종류 (type) — v1.6.35
// - subdomain : 플랫폼 서브 도메인(무료) - registered : 이 서비스에서 구매(등록 업체 API)
// - external : 사용자가 다른 곳에서 구매한 도메인. mode 'ns'(네임서버를 우리 DNS 공급자로 바꿔 영역을 우리가 관리) 또는 'manual'(사용자가 자기 DNS 에서 직접 관리)
// 프로세스(요구 1-2: 구매 → 이전/수동 입력 → 자동 설정)는 아래 "도메인 준비 프로세스" 절. 관리자 콘솔은 접속 정보·플랫폼 도메인·서브 도메인 허용 여부만 정하고,
// 검색·구매·장바구니·영역 만들기·레코드 관리는 사용자 "도메인" 탭에서 사용자가 스스로 한다.
//
// 비용: Openprovider 가 주는 가격(리셀러 가격) × 관리자가 정한 요율(기본 250% = 2.5배)을 원화로 바꿔 캐시에서 차감한다.
// 사용 전 화면에 먼저 보이고(quote), 실제 등록이 성공한 뒤에만 차감한다(실패하면 차감 없음).
// DNS 전파: 레코드를 만들거나 새 도메인을 등록하면 pending 을 남기고, 관련 서비스(사이트 연결·메일)를 쓰려 할 때
// 공용 DNS(1.1.1.1·8.8.8.8)에서 확인해 아직이면 "DNS 연결이 진행 중입니다. 약 N분 후" 안내를 보인다.
// 저장: data/domains.json { domains: [...], contacts: { userId: {...연락처, handle} } }
// ============================================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const dnsLib = require('dns');
// v1.5.36 (요구 4·5): 등록 업체·DNS 공급자는 공통 창구를 거친다 — Openprovider 외에 Porkbun(등록·DNS), Amazon Route 53·Google Cloud DNS(DNS)
const registrar = require('./registrar');
const dnszone = require('./dnszone');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const FILE = path.join(DATA_ROOT, 'domains.json');
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const nowIso = () => new Date().toISOString();
/** DNS 전파 예상 시간(분): 레코드 변경은 TTL(900초)+여유, 새 도메인 위임은 보통 수 시간(최대 48시간) */
const ETA = { records: 20, delegation: 240, registration: 30, transfer: 240 }; // v1.6.32: registration = 등록 업체(Route 53)가 등록을 비동기로 처리하는 동안 · v1.6.35: transfer = 네임서버 변경(이전) 전파
/** 도메인 이름(영역) 형식 — 두 라벨 이상, 마지막은 영문 TLD */
const DOMAIN_RE = /^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,24}$/;
/** 기본 예약 이름 (서브 도메인으로 쓸 수 없음) — 관리자 설정 reserved 와 합친다 */
const RESERVED = ['www', 'mail', 'webmail', 'smtp', 'imap', 'pop', 'pop3', 'mx', 'ns', 'ns1', 'ns2', 'dns', 'ftp', 'admin', 'api', 'app', 'webmaker', 'static', 'cdn', 'assets', 'help', 'support', 'blog', 'shop', 'test', 'dev', 'staging', 'root', 'localhost', 'autodiscover', 'autoconfig', '_dmarc'];
// ---------- 설정 ----------
/**
* 도메인 설정 (settings.domains) + 기본값.
* marginPct : API 가격 대비 청구 요율(%) — 250 = 2.5배
* platformDomain : 서브 도메인을 나눠 주는 도메인 (비우면 서비스 주소 host 에서 첫 라벨을 뺀 값, webmaker.ibit.kr → ibit.kr)
* platformDns : (1.5.31 까지) 'manual' | 'openprovider' — v1.5.36 부터는 dnsProvider 로 대체되며 호환을 위해 함께 유지한다
* dnsProvider : (v1.5.36) 'manual' | 'openprovider' | 'porkbun' | 'route53' | 'gcloud' — 플랫폼 도메인과 새 도메인의 영역을 관리할 공급자
* registrar : (v1.5.36) 'openprovider' | 'porkbun' — 도메인 검색·구매·갱신 업체
* targetIp : 사이트 주소가 가리킬 IP (비우면 서비스 주소의 IP)
* tlds : 검색할 확장자 reserved: 추가 예약 이름 defaultAccountType: 새 가입자의 계정 종류 userMail: 도메인 연결 시 메일 시스템 자동 켜기
*/
function config() {
let s = {}; try { s = require('./settings').load().domains || {}; } catch (_) {}
// v1.5.17: 서비스 안에서는 config.apply() 가 설정 파일 값을 환경 변수로 올려 두지만, 명령줄 도구(scripts/ssl-wildcard.js 등)에서는 환경 변수가 없다
// → 설정 파일(service.publicBaseUrl)을 두 번째로 본다. (1.5.17 배포 때 확인: 설치 스크립트의 감지가 "플랫폼 도메인 없음" 으로 나왔던 원인)
const pubUrl = process.env.PUBLIC_BASE_URL || (() => { try { return require('./config').get('service.publicBaseUrl'); } catch (_) { return ''; } })();
const pubHost = (() => { try { return new URL(pubUrl || 'http://localhost').hostname; } catch (_) { return ''; } })();
/* 서비스 주소가 IP(192.168.20.66 등)·localhost 면 플랫폼 도메인을 추측하지 않는다(관리자가 직접 입력) */
const derived = require('net').isIP(pubHost) || !pubHost.includes('.') ? '' : pubHost.split('.').length > 2 ? pubHost.split('.').slice(1).join('.') : pubHost;
// v1.6.35 (요구 1-1): allowSubdomain — 서브 도메인 등록 허용 여부(관리자 콘솔). false 면 서브 도메인 계정도 새 서브 도메인을 등록할 수 없다(이미 등록된 것은 유지)
const out = { marginPct: 250, platformDomain: derived, platformDns: 'manual', dnsProvider: null, registrar: 'openprovider', targetIp: '', tlds: ['com', 'net', 'org', 'co', 'io', 'shop'], reserved: [], defaultAccountType: 'subdomain', userMail: true, allowSubdomain: true, ...s };
if (!out.dnsProvider) out.dnsProvider = out.platformDns === 'openprovider' ? 'openprovider' : 'manual'; // 1.5.31 설정 호환
return out;
}
/** 관리자 입력 검증 (settings.save 가 부름) */
function normalizeConfig(input, prev) {
const c = { ...(prev || {}) }; const i = input || {};
if (i.marginPct !== undefined && String(i.marginPct).trim() !== '') { const n = Number(i.marginPct); if (!Number.isFinite(n) || n < 100 || n > 1000) throw httpError('도메인 요율은 100~1000 % 사이로 정하세요 (100 = 원가, 250 = 2.5배)'); c.marginPct = n; }
if (i.platformDomain !== undefined) { const d = String(i.platformDomain || '').trim().toLowerCase(); if (d && !/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,24}$/.test(d)) throw httpError('플랫폼 도메인은 ibit.kr 같은 도메인 이름이어야 합니다'); c.platformDomain = d; }
// v1.5.36: dnsProvider 가 오면 그것을, 옛 화면이 platformDns 만 보내면 그것을 읽는다. platformDns 는 호환용으로 함께 맞춘다
if (i.dnsProvider !== undefined) { const v = String(i.dnsProvider || 'manual'); if (v !== 'manual' && !dnszone.IDS.includes(v)) throw httpError(`DNS 공급자는 manual·${dnszone.IDS.join('·')} 중 하나입니다`); c.dnsProvider = v; c.platformDns = v === 'openprovider' ? 'openprovider' : 'manual'; }
else if (i.platformDns !== undefined) { c.platformDns = i.platformDns === 'openprovider' ? 'openprovider' : 'manual'; c.dnsProvider = c.platformDns; }
if (i.registrar !== undefined) { const v = String(i.registrar || 'openprovider'); if (!registrar.IDS.includes(v)) throw httpError(`등록 업체는 ${registrar.IDS.join('·')} 중 하나입니다`); c.registrar = v; }
if (i.targetIp !== undefined) { const ip = String(i.targetIp || '').trim(); if (ip && !require('net').isIP(ip)) throw httpError('사이트 IP 는 IPv4·IPv6 주소여야 합니다'); c.targetIp = ip; }
if (i.tlds !== undefined) c.tlds = (Array.isArray(i.tlds) ? i.tlds : String(i.tlds).split(/[,\s]+/)).map((x) => x.trim().toLowerCase().replace(/^\./, '')).filter((x) => /^[a-z]{2,24}(\.[a-z]{2,24})?$/.test(x)).slice(0, 20);
if (i.reserved !== undefined) c.reserved = (Array.isArray(i.reserved) ? i.reserved : String(i.reserved).split(/[,\s]+/)).map((x) => x.trim().toLowerCase()).filter(Boolean).slice(0, 200);
if (i.defaultAccountType !== undefined) c.defaultAccountType = i.defaultAccountType === 'domain' ? 'domain' : 'subdomain';
if (i.userMail !== undefined) c.userMail = i.userMail === true || i.userMail === 'true';
if (i.allowSubdomain !== undefined) c.allowSubdomain = i.allowSubdomain === true || i.allowSubdomain === 'true'; // v1.6.35
return c;
}
// ---------- 저장소 ----------
// v1.6.31 (요구 10): 원자적 쓰기·백업·손상 strict (도메인 기록이 "빈 목록" 으로 보이면 중복 구매로 이어진다)
const js = require('./jsonstore');
function load() { const d = js.readJson(FILE, null, { strict: true }); const o = d && typeof d === 'object' ? d : {}; o.domains = o.domains || []; o.contacts = o.contacts || {}; o.purchases = o.purchases || []; o.carts = o.carts || {}; /* v1.6.35: 사용자별 장바구니 { userId: [{domain, addedAt, quote, available}] } */ return o; }
function save(d) { js.writeJsonAtomic(FILE, d, { mode: 0o600, keepBackup: true }); }
/**
* v1.6.31 (요구 18) 구매·갱신 원장: 업체 호출 전에 작업 기록(pending)을 남기고, 업체 성공 → 차감 → 저장을 단계별로 표시한다.
* state: pending_provider(업체 호출 중) → provider_succeeded → charged → persisted(완료) / unknown(응답 없음·시간 초과 — 확인 필요) / failed
* 같은 사용자·같은 도메인·같은 작업이 pending/unknown 이면 새 구매를 시작하지 않는다(중복 구매·이중 차감 방지). reconcile() 이 업체에 조회해 상태를 맞춘다.
*/
function purchaseKey(userId, name, op) { return `${userId}|${name}|${op}`; }
function openPurchase(userId, name, op, extra) { const dd = load(); const open = dd.purchases.find((p) => p.key === purchaseKey(userId, name, op) && ['pending_provider', 'provider_succeeded', 'charged', 'unknown'].includes(p.state)); if (open) return { dup: open }; const p = { id: crypto.randomBytes(6).toString('hex'), key: purchaseKey(userId, name, op), userId, name, op, state: 'pending_provider', at: nowIso(), ...extra }; dd.purchases.push(p); if (dd.purchases.length > 2000) dd.purchases.splice(0, dd.purchases.length - 2000); save(dd); return { p }; }
function setPurchase(id, patch) { const dd = load(); const p = dd.purchases.find((x) => x.id === id); if (p) { Object.assign(p, patch, { updatedAt: nowIso() }); save(dd); } return p; }
function purchases(filter = {}) { const dd = load(); return dd.purchases.filter((p) => (!filter.userId || p.userId === filter.userId) && (!filter.open || ['pending_provider', 'provider_succeeded', 'charged', 'unknown'].includes(p.state))).slice().reverse(); }
/**
* 화면용 도메인 기록. v1.6.35: type 'external'(사용자가 다른 곳에서 구매한 도메인) 과 그 관리 방식 mode('ns' = 네임서버를 우리 DNS 공급자로 바꿔 영역을 우리가 관리 /
* 'manual' = 사용자가 자기 DNS 에서 직접 관리), 이전 진행 상태(transfer), 수동 확인 상태(manual), 레코드 수정 잠금(dnsLocked — 네임서버 이전 전파 중 이중 수정 방지)을 함께 보낸다.
*/
const pub = (x) => ({ id: x.id, name: x.name, type: x.type, mode: x.mode || (x.type === 'registered' ? 'ns' : x.type === 'subdomain' ? 'platform' : null), status: x.status, siteId: x.siteId || null, createdAt: x.createdAt, expiresAt: x.expiresAt || null, period: x.period || null, pending: x.pending || null, mail: x.mail || null, lastCharge: x.charges && x.charges.length ? x.charges[x.charges.length - 1] : null, registrar: x.registrar || (x.type === 'registered' ? 'openprovider' : null), dnsProvider: x.dnsProvider || null, nameServers: x.nameServers || null, currentNs: x.currentNs || null, transfer: x.transfer || null, manual: x.manual || null, imported: x.imported || null, subdomains: x.subdomains || null, zoneError: x.zoneError || null, dnsLocked: !!(x.pending && x.pending.kind === 'transfer'), host: x.host || null /* v1.7.2: 사이트를 연결한 하위 이름(없으면 @·www) */, siteNames: x.siteNames || null });
/** v1.5.36 관리자용 전체 목록 (소유자 포함) */
function adminList() { return load().domains.map((x) => ({ ...pub(x), userId: x.userId })); }
/** v1.5.36 관리자: 도메인의 DNS 공급자 지정 ('' 이면 기본값으로) */
function setDnsProvider(id, provider) { const d = load(); const x = d.domains.find((y) => y.id === id); if (!x) throw httpError('도메인이 없습니다', 404); const v = String(provider || ''); if (v && v !== 'manual' && !dnszone.IDS.includes(v)) throw httpError('알 수 없는 DNS 공급자'); x.dnsProvider = v || null; save(d); return pub(x); }
/** 화면용: 등록 업체·DNS 공급자 목록과 준비 여부 */
function publicCatalog() { return { registrar: registrar.catalog(), dns: dnszone.catalog() }; }
/** 플랫폼 도메인의 자동 DNS 공급자 ({id, p}) — 없으면 null */
function platformZone() { const c = config(); if (!c.platformDomain) return null; return dnszone.forZone({ dnsProvider: c.dnsProvider }); }
/** 구입 도메인의 자동 DNS 공급자 — 없으면 503 안내 */
function zoneOf(x) { const z = dnszone.forZone(x); if (!z) throw httpError('이 도메인에 자동 DNS 공급자가 없습니다. 관리자에게 문의하세요 (관리자: 설정 › 도메인 서비스 › DNS 공급자)', 503); return z; }
function listFor(userId) { return load().domains.filter((x) => x.userId === userId).map(pub); }
function getOwned(userId, id) { const d = load(); const x = d.domains.find((y) => y.id === id && y.userId === userId); if (!x) throw httpError('도메인이 없습니다', 404); return { d, x }; }
/** 사이트 도메인 이름 → 기록 (게이트·연결용) */
function byName(name) { return load().domains.find((x) => x.name === String(name || '').toLowerCase()) || null; }
// ---------- 계정 종류 ----------
/** 사용자 계정 종류. 기록이 없으면(1.4.0 이전 가입자) 'domain' — 예전처럼 도메인을 직접 쓰던 사용자의 동작을 바꾸지 않는다 */
function accountTypeOf(userOrId) { const u = typeof userOrId === 'string' ? require('./auth').getUser(userOrId) : userOrId; if (!u) return 'subdomain'; if (u.role === 'admin') return 'domain'; return u.accountType === 'subdomain' || u.accountType === 'domain' ? u.accountType : 'domain'; }
const requireDomainUser = (user) => { if (accountTypeOf(user) !== 'domain') throw httpError('서브 도메인 계정은 도메인 구매·DNS 관리를 할 수 없습니다. 서브 도메인 검색·등록만 할 수 있습니다 (관리자에게 계정 종류 변경을 요청하세요)', 403); };
// ---------- 서브 도메인 ----------
const LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
/**
* 서브 도메인 이름 확인: 형식(영문 소문자·숫자·하이픈 1~32자) → 예약 이름 → 이 서비스의 기존 등록 → (자동 DNS 면) 영역에 이미 있는 이름.
* @returns {{ available: boolean, fqdn: string, reason: string }}
*/
async function checkSubdomain(label) {
const c = config(); const l = String(label || '').trim().toLowerCase();
if (!c.platformDomain) throw httpError('플랫폼 도메인이 설정되지 않았습니다. 관리자에게 문의하세요', 503);
if (c.allowSubdomain === false) throw httpError('이 서비스는 서브 도메인 등록을 받지 않습니다 (관리자 설정). 자기 도메인을 구매하거나 이미 가진 도메인을 등록해 쓰세요', 403); // v1.6.35 (요구 1-1)
const fqdn = `${l}.${c.platformDomain}`;
if (!LABEL_RE.test(l)) return { available: false, fqdn, reason: '영문 소문자·숫자·하이픈(-) 1~32자로, 하이픈으로 시작·끝나지 않게 입력하세요' };
if ([...RESERVED, ...(c.reserved || [])].includes(l)) return { available: false, fqdn, reason: '서비스가 쓰는 이름이라 등록할 수 없습니다' };
if (byName(fqdn)) return { available: false, fqdn, reason: '이미 등록된 이름입니다' };
const pz = platformZone(); if (pz) { try { const recs = await pz.p.listRecords(c.platformDomain); if (recs.some((r) => r.name === l || r.name === fqdn || r.name.startsWith(`${l}.`))) return { available: false, fqdn, reason: '이미 쓰고 있는 이름입니다' }; } catch (_) {} }
return { available: true, fqdn, reason: '' };
}
/** 서브 도메인 등록 (무료). 사용자당 10개까지 */
async function registerSubdomain(user, label) {
const r = await checkSubdomain(label); if (!r.available) throw httpError(r.reason, 409);
const d = load(); if (d.domains.filter((x) => x.userId === user.id && x.type === 'subdomain').length >= 10) throw httpError('서브 도메인은 계정당 10개까지 등록할 수 있습니다', 409);
const x = { id: crypto.randomBytes(8).toString('hex'), userId: user.id, name: r.fqdn, label: String(label).toLowerCase(), type: 'subdomain', status: 'active', createdAt: nowIso(), charges: [] };
d.domains.push(x); save(d); return pub(x);
}
// ---------- 가격 ----------
/** 리셀러 가격 → 사용자 청구 원화 (요율 적용, 100원 단위 올림) */
async function toUserKrw(price) {
const c = config(); const fx = require('./fx'); const k = await fx.toKrw(price.amount, price.currency);
const krw = Math.ceil((k.krw * (c.marginPct / 100)) / 100) * 100;
return { costKrw: krw, base: { currency: price.currency, amount: price.amount }, usd: Math.round(k.usd * 100) / 100, rate: k.rate, marginPct: c.marginPct };
}
/** 도메인 검색: 이름 하나 × 확장자 여러 개 → 등록 가능 여부와 사용자 가격 */
async function search(user, name, tlds) {
requireDomainUser(user); const c = config();
const n = String(name || '').trim().toLowerCase().replace(/\s+/g, ''); const base = n.includes('.') ? n : null;
const list = base ? [base] : (tlds && tlds.length ? tlds : c.tlds).filter((t) => c.tlds.includes(t)).map((t) => `${n}.${t}`);
if (!list.length) throw httpError('검색할 확장자를 고르세요');
const reg = registrar.current(); for (const f of list) reg.split(f); // 형식 확인 (잘못되면 안내 오류)
const res = await reg.check(list); const out = [];
for (const r of res) out.push({ domain: r.domain, available: r.status === 'free', status: r.status, premium: r.premium, reason: r.reason, registrar: reg.id, quote: (r.status === 'free' || r.status === 'unknown') && r.price ? await toUserKrw(r.price) : null });
return out;
}
/** 작업 견적 (사용 전에 보이는 금액) */
async function quote(user, fqdn, operation = 'create', period = 1, regId) { requireDomainUser(user); const reg = registrar.current(regId); const p = await reg.price(fqdn, operation, period); return { domain: fqdn, operation, period, registrar: reg.id, ...(await toUserKrw(p)) }; }
// ---------- 등록자 연락처 ----------
const contactFields = ['firstName', 'lastName', 'company', 'email', 'phoneCountry', 'phoneArea', 'phoneNumber', 'street', 'number', 'zipcode', 'city', 'state', 'country'];
function getContact(userId) { const c = load().contacts[userId]; if (!c) return null; const { handle, ...rest } = c; return { ...rest, hasHandle: !!handle }; }
/** 등록자 정보 저장 → Openprovider 고객(handle) 생성. 바꾸면 새 handle 을 만든다(다음 등록부터 적용) */
async function saveContact(user, input) {
requireDomainUser(user); const c = {}; for (const k of contactFields) c[k] = String((input || {})[k] || '').trim().slice(0, 120);
c.country = (c.country || 'KR').toUpperCase(); c.phoneCountry = c.phoneCountry || '+82';
const miss = ['firstName', 'lastName', 'email', 'phoneNumber', 'street', 'zipcode', 'city', 'country'].filter((k) => !c[k]);
if (miss.length) throw httpError(`등록자 정보를 모두 입력하세요 (빠진 항목: ${miss.join(', ')})`);
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email)) throw httpError('등록자 이메일 형식을 확인하세요');
c.handle = await registrar.current().createCustomer(c); c.registrar = registrar.current().id; c.updatedAt = nowIso();
const d = load(); d.contacts[user.id] = c; save(d); return getContact(user.id);
}
// ---------- 구매·갱신 ----------
/**
* 도메인 구매. 순서: 견적 다시 받기 → 화면에서 본 금액(expectedKrw)보다 비싸지면 다시 확인 요청 → 잔액 확인 → 등록 → 성공하면 차감 → DNS 영역 만들기.
* @returns {object} 등록된 도메인 (pending: 위임 전파 중)
*/
async function purchase(user, fqdn, { period = 1, expectedKrw } = {}) {
requireDomainUser(user); const credits = require('./credits');
const reg = registrar.current(); const name = String(fqdn || '').toLowerCase(); reg.split(name);
if (byName(name)) throw httpError('이미 이 서비스에 등록된 도메인입니다', 409); // 이름은 서비스 전체에서 하나 — 다른 사용자의 도메인을 가로채지 못한다
const d0 = load(); const contact = d0.contacts[user.id]; if (reg.needsContact !== false && (!contact || !contact.handle)) throw httpError('먼저 등록자 정보(이름·주소·전화·이메일)를 저장하세요', 409);
const chk = (await reg.check([name]))[0]; if (!chk || (chk.status !== 'free' && chk.status !== 'unknown')) throw httpError(`등록할 수 없는 도메인입니다 (${(chk && (chk.reason || chk.status)) || '확인 실패'})`, 409);
const q = await quote(user, name, 'create', period);
if (Number.isFinite(Number(expectedKrw)) && q.costKrw > Number(expectedKrw)) throw Object.assign(httpError(`가격이 바뀌었습니다 (${credits.fmt(expectedKrw)} → ${credits.fmt(q.costKrw)}). 금액을 확인하고 다시 구매하세요`, 409), { quote: q });
if (user.role !== 'admin' && credits.balance(user.id) < q.costKrw) throw httpError(`캐시가 부족합니다 (필요 ${credits.fmt(q.costKrw)}, 잔액 ${credits.fmt(credits.balance(user.id))})`, 402);
// v1.6.31 (요구 18): 원장에 pending 을 남기고 업체 호출 — 응답이 없으면 unknown 으로 남겨 "확인 중" 으로 보이게(성공·실패로 임의 표시하지 않음)
const op0 = openPurchase(user.id, name, 'create', { period, quoteKrw: q.costKrw, registrar: reg.id });
if (op0.dup) throw Object.assign(httpError(`이 도메인의 구매가 이미 진행 중이거나 확인 중입니다 (${op0.dup.state}, ${op0.dup.at}). 관리자 콘솔 › 운영 복구에서 상태를 확인한 뒤 다시 시도하세요`, 409), { purchase: op0.dup });
let r;
try { r = await Promise.race([reg.createDomain(name, { handle: contact && contact.handle, period, contact }), new Promise((_, rej) => setTimeout(() => rej(Object.assign(new Error('timeout'), { timeout: true })), 90000))]); }
catch (e) { if (e.timeout || /timeout|ECONNRESET|fetch failed|socket hang up/i.test(e.message)) { setPurchase(op0.p.id, { state: 'unknown', error: e.message }); throw httpError('등록 업체의 응답을 받지 못해 결과를 알 수 없습니다. 비용은 차감하지 않았으며, 관리자가 확인(운영 복구 › 도메인 구매 확인)한 뒤 반영됩니다. 다시 구매하지 마세요', 504); } setPurchase(op0.p.id, { state: 'failed', error: e.message }); throw e; }
setPurchase(op0.p.id, { state: 'provider_succeeded', opId: r.id, expiresAt: r.expiresAt });
const charge = user.role === 'admin' ? null : credits.charge(user.id, q.costKrw, { domain: name, op: 'create', purchaseId: op0.p.id }, `도메인 등록 ${name} (${period}년)`);
setPurchase(op0.p.id, { state: 'charged', charged: !!charge });
const x = { id: crypto.randomBytes(8).toString('hex'), userId: user.id, name, type: 'registered', opId: r.id, status: r.status, period, expiresAt: r.expiresAt, createdAt: nowIso(), charges: [{ at: nowIso(), op: 'create', krw: q.costKrw, base: q.base, charged: !!charge }], pending: { kind: 'delegation', since: nowIso(), etaMinutes: ETA.delegation, checks: [{ type: 'NS', name }] } };
x.registrar = reg.id;
// DNS 영역: 사이트 주소(@, www)가 서비스 서버를 가리키게 만든다 — v1.5.36: 설정의 DNS 공급자(Route 53·Google Cloud DNS·Porkbun·Openprovider)에 영역을 만들고,
// 공급자가 등록 업체와 다르면(예: Porkbun 등록 + Route 53 DNS) 등록 업체에 그 영역의 네임서버를 지정한다
const zp = dnszone.forZone(null); x.dnsProvider = zp ? zp.id : 'manual';
// v1.6.32: Route 53 Domains 는 등록을 비동기로 처리하고 완료 때 호스팅 영역을 스스로 만든다 — 아직 처리 중(PENDING)이면 영역을 지금 만들지 않고
// '등록 처리 중' 으로 남겨 두었다가 finalizeRegistration() 이 완료를 확인한 뒤 영역·레코드·네임서버를 맞춘다(같은 이름의 영역이 둘 생기는 일 방지).
if (r.status === 'PENDING') { x.pending = { kind: 'registration', since: nowIso(), etaMinutes: ETA.registration, checks: [], operationId: r.operationId || null }; x.zoneError = '등록 업체가 아직 등록을 처리하고 있습니다 — 완료되면 DNS 영역을 자동으로 만듭니다'; }
else await setupZone(x, user.id, reg, r.id);
const d = load(); d.domains.push(x); const pp = d.purchases.find((p) => p.id === op0.p.id); if (pp) Object.assign(pp, { state: 'persisted', domainId: x.id, updatedAt: nowIso() });
if (d.carts[user.id]) d.carts[user.id] = d.carts[user.id].filter((i) => i.domain !== name); // v1.6.35: 구매한 도메인은 장바구니에서 빠진다
save(d); return pub(x);
}
/**
* 구매한 도메인의 DNS 영역 만들기(@·www A) + 등록 업체와 DNS 공급자가 다르면 네임서버 지정. purchase()·finalizeRegistration() 이 부른다. 실패는 x.zoneError/x.nsError 에 남긴다.
* @param {object} x 도메인 기록(수정됨) @param {string} userId 감사 기록용 @param {object} reg 등록 업체 모듈 @param {string} ref 업체 쪽 도메인 참조(opId)
*/
async function setupZone(x, userId, reg, ref) {
const name = x.name; const zp = dnszone.forZone(null); x.dnsProvider = zp ? zp.id : 'manual';
if (!zp) { x.zoneError = '자동 DNS 공급자가 없어 영역을 만들지 않았습니다 (관리자 콘솔 › 설정 › 도메인 서비스 › DNS 공급자)'; return; }
try { const ip = await targetIp(); const z = await dnszone.ensureZone(name, [{ name: '', type: 'A', value: ip, ttl: 900 }, { name: 'www', type: 'A', value: ip, ttl: 900 }], userId, zp.id); x.zone = { createdAt: nowIso(), ip, provider: zp.id, id: z.id || null }; x.nameServers = z.nameServers || null; x.zoneError = null; x.pending = x.pending && x.pending.kind === 'delegation' ? x.pending : { kind: 'delegation', since: nowIso(), etaMinutes: ETA.delegation, checks: [{ type: 'NS', name }] }; x.pending.checks.push({ type: 'A', name, value: ip }); if (zp.id !== reg.id && (z.nameServers || []).length && reg.setNameServers) { try { await reg.setNameServers(name, ref, z.nameServers); x.nsDelegated = true; } catch (e) { x.nsError = e.message; } } }
catch (e) { x.zoneError = e.message; }
}
/**
* v1.6.32 (요구 1): 비동기 등록(Route 53 Domains)의 완료 확인. 업체에 도메인이 생겼으면 상태·만료일을 채우고 DNS 영역을 만든다(멱등 — 이미 ACT 면 아무것도 안 함).
* 사용자 쪽 propagation()(도메인 탭을 열 때)과 관리자 운영 복구 "등록 상태 확인" 이 부른다.
* @returns {{done:boolean, detail:string}}
*/
async function finalizeRegistration(id, by) {
const d = load(); const x = d.domains.find((z) => z.id === id); if (!x) throw httpError('도메인 기록이 없습니다', 404);
if (x.status !== 'PENDING') return { done: true, detail: '이미 등록 완료' };
const reg = registrar.get(x.registrar); let own = null;
if (x.pending && x.pending.operationId && reg.call) { try { const st = await reg.call('GetOperationDetail', { OperationId: x.pending.operationId }); if (st.Status === 'ERROR' || st.Status === 'FAILED') { x.status = 'FAILED'; x.pending = null; x.zoneError = `등록 실패: ${st.Message || st.Status}`; save(d); return { done: false, failed: true, detail: x.zoneError }; } } catch (_) {} }
try { own = reg.ownsDomain ? await reg.ownsDomain(x.name) : await reg.getDomain(x.opId); } catch (e) { return { done: false, detail: `업체 조회 실패: ${e.message}` }; }
if (!own || !own.expiresAt) return { done: false, detail: '등록 업체가 아직 처리 중입니다' };
x.status = 'ACT'; x.expiresAt = own.expiresAt; x.pending = null;
await setupZone(x, by || x.userId, reg, x.opId); save(d);
return { done: true, detail: `등록 완료 (만료 ${x.expiresAt})${x.zone ? ' · DNS 영역 준비됨' : ''}${x.zoneError ? ' · ' + x.zoneError : ''}` };
}
/**
* v1.6.31 (요구 18·20) 미완 구매 재조정: 업체에 그 도메인이 있는지 조회해 — 있으면(우리 계정 소유) 기록·차감을 완성하고, 없으면 failed 로 닫는다. 관리자가 운영 복구 화면에서 부른다.
*/
async function reconcilePurchase(id, by) {
const dd = load(); const p = dd.purchases.find((x) => x.id === id); if (!p) throw httpError('구매 기록이 없습니다', 404);
if (p.state === 'persisted' || p.state === 'failed') return p;
const reg = registrar.get(p.registrar); const credits = require('./credits');
let found = null; try { const z = await reg.check([p.name]); found = z[0] && z[0].status !== 'free' ? z[0] : null; } catch (e) { setPurchase(id, { lastCheck: nowIso(), checkError: e.message }); throw httpError(`업체 조회 실패: ${e.message}`, 502); }
// 업체 계정에 그 도메인이 실제로 있는지(우리 소유인지)까지 확인 — porkbun 은 목록, route53 은 GetDomainDetail(v1.6.32)
let own = null; const canOwn = reg.id === 'porkbun' || !!reg.ownsDomain; try { if (reg.ownsDomain) own = await reg.ownsDomain(p.name); else if (reg.id === 'porkbun') own = await reg.getDomain(p.name); } catch (_) {}
if (!found || (canOwn && !(own && (own.status || own.expiresAt)))) { setPurchase(id, { state: 'failed', lastCheck: nowIso(), note: '업체에 등록되지 않음 — 다시 구매할 수 있습니다' }); return setPurchase(id, {}); }
if (byName(p.name)) { setPurchase(id, { state: 'persisted', lastCheck: nowIso(), note: '이미 기록됨' }); return setPurchase(id, {}); }
const u = { id: p.userId, role: 'user' }; const charge = credits.charge(p.userId, p.quoteKrw, { domain: p.name, op: p.op, purchaseId: p.id }, `도메인 등록 ${p.name} (${p.period}년, 재조정)`);
const x = { id: crypto.randomBytes(8).toString('hex'), userId: p.userId, name: p.name, type: 'registered', opId: p.opId || p.name, status: 'ACT', period: p.period, expiresAt: p.expiresAt || (own && own.expiresAt) || null, createdAt: nowIso(), registrar: reg.id, dnsProvider: 'manual', charges: [{ at: nowIso(), op: p.op, krw: p.quoteKrw, charged: !!(charge && charge.entry), reconciled: true }], pending: { kind: 'delegation', since: nowIso(), etaMinutes: ETA.delegation, checks: [] } };
const d2 = load(); d2.domains.push(x); const pp = d2.purchases.find((q) => q.id === id); if (pp) Object.assign(pp, { state: 'persisted', domainId: x.id, reconciledBy: by, updatedAt: nowIso() }); save(d2); return pp;
}
async function renew(user, id, { period = 1, expectedKrw } = {}) {
requireDomainUser(user); const credits = require('./credits'); const { x } = getOwned(user.id, id); if (x.type !== 'registered') throw httpError('서브 도메인은 갱신이 필요 없습니다');
const q = await quote(user, x.name, 'renew', period);
if (Number.isFinite(Number(expectedKrw)) && q.costKrw > Number(expectedKrw)) throw Object.assign(httpError('가격이 바뀌었습니다. 금액을 확인하고 다시 갱신하세요', 409), { quote: q });
if (user.role !== 'admin' && credits.balance(user.id) < q.costKrw) throw httpError(`캐시가 부족합니다 (필요 ${credits.fmt(q.costKrw)})`, 402);
const reg = registrar.current(x.registrar); await reg.renewDomain(x.opId, x.name, period);
const charge = user.role === 'admin' ? null : credits.charge(user.id, q.costKrw, { domain: x.name, op: 'renew' }, `도메인 갱신 ${x.name} (${period}년)`);
const d = load(); const y = d.domains.find((z) => z.id === id); y.charges = y.charges || []; y.charges.push({ at: nowIso(), op: 'renew', krw: q.costKrw, base: q.base, charged: !!charge });
try { const info = await reg.getDomain(y.opId); if (info && info.expiresAt) y.expiresAt = info.expiresAt; } catch (_) {}
save(d); return pub(y);
}
// ---------- DNS 레코드 ----------
/** 사이트가 가리킬 IP: 설정 → 서비스 주소의 IP */
async function targetIp() {
const c = config(); if (c.targetIp) return c.targetIp;
const host = (() => { try { return new URL(process.env.PUBLIC_BASE_URL || '').hostname; } catch (_) { return ''; } })();
if (!host) throw httpError('사이트 IP 를 정할 수 없습니다. 관리자 콘솔 › 설정 › 도메인 에 사이트 IP 를 넣으세요', 503);
if (require('net').isIP(host)) return host;
const a = await dnsLib.promises.lookup(host, { family: 4 }).catch(() => null); if (!a) throw httpError('서비스 주소의 IP 를 찾지 못했습니다. 관리자 콘솔 › 설정 › 도메인 에 사이트 IP 를 넣으세요', 503);
return a.address;
}
/** 레코드 목록 (구입 도메인: 영역 전체 / 서브 도메인: 플랫폼 영역에서 그 이름에 해당하는 것) */
async function records(user, id) {
const { x } = getOwned(user.id, id); const c = config();
if (x.type === 'registered' || (x.type === 'external' && x.mode === 'ns')) { requireDomainUser(user); return zoneOf(x).p.listRecords(x.name); } // 자기 영역만 (x 는 getOwned 로 소유 확인됨) · v1.6.35: 네임서버 이전 도메인도 우리 영역
if (x.type === 'external') throw httpError('이 도메인의 DNS 는 사용자가 자기 DNS 서비스에서 관리합니다 — 여기서는 "필요한 DNS 설정" 과 확인 결과만 보입니다', 409);
const pz = platformZone(); if (!pz) return [];
return (await pz.p.listRecords(c.platformDomain)).filter((r) => r.name === x.label || r.name.endsWith(`.${x.label}`) || r.name === x.name || r.name.endsWith(`.${x.name}`));
}
const REC_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'CAA', 'SRV'];
/** 레코드 입력 검증 (이름은 도메인 기준 상대 이름: '' = 도메인 자신, 'www', 'mail' …) */
function normRecord(r) {
const type = String(r.type || '').toUpperCase(); if (!REC_TYPES.includes(type)) throw httpError(`레코드 종류는 ${REC_TYPES.join('·')} 중 하나입니다`);
const name = String(r.name || '').trim().toLowerCase().replace(/^@$/, ''); if (name && !/^(\*\.)?([a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9])?\.)*[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9])?$|^\*$/.test(name)) throw httpError('레코드 이름은 www·mail 처럼 입력하세요 (도메인 자신은 비우거나 @)');
const value = String(r.value || '').trim(); if (!value || value.length > 2000) throw httpError('레코드 값을 입력하세요');
if (type === 'A' && require('net').isIP(value) !== 4) throw httpError('A 레코드 값은 IPv4 주소입니다'); if (type === 'AAAA' && require('net').isIP(value) !== 6) throw httpError('AAAA 레코드 값은 IPv6 주소입니다');
const ttl = Math.min(86400, Math.max(300, Number(r.ttl) || 900)); const prio = type === 'MX' || type === 'SRV' ? Math.min(65535, Math.max(0, Number(r.prio) || 10)) : undefined;
return { name, type, value, ttl, prio };
}
/** 레코드 더하기 (구입 도메인만 — 서브 도메인 사용자는 DNS 를 직접 관리하지 않음) */
/**
* v1.6.35 (요구 1-2): 사용자가 레코드를 직접 고칠 수 있는 도메인인지 — 구입 도메인과 네임서버 이전(mode 'ns') 외부 도메인만. 수동 관리(mode 'manual') 도메인은 사용자가 자기 DNS 에서 고친다.
* 네임서버 이전이 전파 중(pending.kind 'transfer')이면 409 — 옛 DNS 와 새 영역을 동시에 고쳐 생기는 혼란(이중 수정)을 막는다.
*/
function assertEditable(x) {
if (x.type === 'subdomain') throw httpError('서브 도메인은 DNS 를 직접 관리할 수 없습니다', 403);
if (x.type === 'external' && x.mode === 'manual') throw httpError('이 도메인의 DNS 는 사용자가 자기 DNS 서비스에서 직접 관리합니다 — "필요한 DNS 설정" 을 보고 그곳에서 고치세요', 409);
if (x.pending && x.pending.kind === 'transfer') throw httpError(`DNS 변경 진행 중 — ${x.name} 의 네임서버 이전이 확인될 때까지 레코드를 수정할 수 없습니다 (이중 수정 방지). "네임서버 변경 확인" 으로 상태를 확인하세요`, 409);
}
async function addRecord(user, id, r) {
requireDomainUser(user); const { x } = getOwned(user.id, id); assertEditable(x);
const rr = normRecord(r); await dnszone.addRecords(x.name, [rr], user.id, zoneOf(x).id); markPending(id, 'records', [{ type: rr.type, name: rr.name ? `${rr.name}.${x.name}` : x.name, value: rr.value }]); return records(user, id);
}
async function removeRecord(user, id, r) {
requireDomainUser(user); const { x } = getOwned(user.id, id); assertEditable(x);
const rr = normRecord(r); await dnszone.removeRecords(x.name, [rr], user.id, zoneOf(x).id); return records(user, id);
}
function markPending(id, kind, checks) { const d = load(); const y = d.domains.find((z) => z.id === id); if (!y) return; const prev = y.pending && y.pending.kind === 'delegation' ? y.pending : null; y.pending = { kind: prev ? 'delegation' : kind, since: prev ? prev.since : nowIso(), etaMinutes: prev ? prev.etaMinutes : ETA[kind], checks: [...((prev && prev.checks) || []), ...checks].slice(-20) }; save(d); }
// ---------- 사이트 연결 ----------
/**
* 도메인(또는 서브 도메인)을 사이트에 연결: site.domains 를 바꾸고, 자동 DNS 가 가능하면 A 레코드를 만든다.
* 실제 웹서버 설정(vhost·SSL)은 다음 배포(발행) 때 lib/deploy.js 가 한다.
* @returns {{ domains: string[], dns: 'auto'|'manual', guide: string|null }}
*/
/**
* v1.7.2 (요구 1): 사이트를 도메인에 "어떤 이름으로" 올릴지 — host 가 없으면 도메인 자신(@)과 www, host 가 있으면 그 하위 이름 하나(예: shop → shop.example.com).
* 서브 도메인(플랫폼 아래 이름)은 이미 하나의 이름이라 host 를 받지 않는다. 하위 이름 규칙: 영문 소문자·숫자·하이픈 1~32자.
* @param {object} user @param {string} id 도메인 ID @param {object} site 사이트(저장소 기록, site.domains 를 바꾼다) @param {{host?: string}} [opts]
*/
async function connect(user, id, site, opts = {}) {
const { x } = getOwned(user.id, id); if (site.ownerId !== user.id && user.role !== 'admin') throw httpError('내 사이트에만 연결할 수 있습니다', 403);
const host = x.type === 'subdomain' ? '' : String(opts.host || '').trim().toLowerCase().replace(/\.$/, '');
if (host && (host === 'www' || host === '@')) throw httpError('www 와 @ 는 "기본(@ · www)" 으로 연결하세요');
if (host && !/^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/.test(host)) throw httpError('하위 이름은 영문 소문자·숫자·하이픈 1~32자입니다 (예: shop)');
const names = x.type === 'subdomain' ? [x.name] : (host ? [`${host}.${x.name}`] : [x.name, `www.${x.name}`]);
const d = load(); for (const y of d.domains) if (y.siteId === site.id && y.id !== id) { y.siteId = null; y.host = null; y.siteNames = null; } const me = d.domains.find((z) => z.id === id); me.siteId = site.id; me.host = host || null; me.siteNames = names; save(d);
site.domains = names; let dnsMode = 'manual'; let guide = null; const c = config();
try {
const ip = await targetIp();
const pz = platformZone();
if (x.type === 'subdomain' && pz) { await pz.p.updateRecords(c.platformDomain, { add: [{ name: x.label, type: 'A', value: ip, ttl: 900 }] }).catch((e) => { if (!/exist|duplicate/i.test(e.message)) throw e; }); dnsMode = 'auto'; markPending(id, 'records', [{ type: 'A', name: x.name, value: ip }]); }
else if (x.type === 'registered') { dnsMode = 'auto'; if (host) { const zp = dnszone.forZone(x); if (zp) await ensureSiteRecords(x, ip, user.id, [host]); else guide = 'DNS 공급자 접속 정보가 없어 하위 이름 레코드를 만들 수 없습니다 (관리자)'; } } // 구매 때 @·www A 레코드를 이미 만들었다 — v1.7.2: 하위 이름이면 그 A 레코드를 더한다
// v1.6.35 (요구 1-2): 네임서버 이전 도메인 — 우리 영역에 사이트 레코드(@·www A)를 넣는다. 네임서버가 아직 바뀌지 않았으면 전파가 끝난 뒤에 살아난다(이전 진행 중 안내는 status 가 한다)
else if (x.type === 'external' && x.mode === 'ns') { const zp = dnszone.forZone(x); if (zp) { await ensureSiteRecords(x, ip, user.id, host ? [host] : ['', 'www']); dnsMode = 'auto'; if (x.pending && x.pending.kind === 'transfer') guide = '네임서버 이전이 아직 확인되지 않았습니다 — 네임서버 변경이 전파된 뒤 이 주소로 열립니다'; } else guide = 'DNS 공급자 접속 정보가 없어 레코드를 만들 수 없습니다 (관리자)'; }
// v1.6.35: 수동 관리 도메인 — 사용자가 자기 DNS 에 넣어야 할 레코드를 안내하고, 확인(verify)되기 전에는 배포가 되지 않는다고 알린다
else if (x.type === 'external') { const ok = x.manual && x.manual.verifiedAt && x.manual.siteOk; guide = ok ? `DNS 확인됨 (${fmtT(x.manual.verifiedAt)}) — 다음 발행 때 이 주소로 배포됩니다` : `${names.join(' 과 ')} 의 A 레코드가 ${ip} 를 가리켜야 합니다 — "필요한 DNS 설정" 을 자기 DNS 에 넣고 "설정 완료 — 확인" 을 누르세요. 확인되기 전에는 이 주소로 배포되지 않습니다`; }
else guide = `${x.name} 이 ${ip} 를 가리키도록 플랫폼 DNS 에 와일드카드(*.${c.platformDomain}) 또는 ${x.label} A 레코드가 있어야 합니다 (운영자 설정)`;
} catch (e) { guide = e.message; }
return { domains: names, dns: dnsMode, guide, host: host || null };
}
/**
* v1.7.2 (요구 1): 사이트 연결 끊기 — 도메인 기록의 siteId 를 비우고 사이트의 domains 에서 그 도메인 이름들을 뺀다. DNS 레코드는 지우지 않는다(다시 연결하면 그대로 쓴다).
* @returns {{domains: string[]}} 사이트에 남은 도메인 이름
*/
function disconnect(user, id, site) {
const { d, x } = getOwned(user.id, id); if (site.ownerId !== user.id && user.role !== 'admin') throw httpError('내 사이트만 다룰 수 있습니다', 403);
if (x.siteId !== site.id) throw httpError('이 도메인은 그 사이트에 연결되어 있지 않습니다', 409);
const names = new Set(x.siteNames || (x.type === 'subdomain' ? [x.name] : [x.name, `www.${x.name}`, ...(x.host ? [`${x.host}.${x.name}`] : [])]));
x.siteId = null; x.host = null; x.siteNames = null; save(d);
site.domains = (site.domains || []).filter((n) => !names.has(n)); return { domains: site.domains };
}
// ---------- 메일(MX) 자동 설정 ----------
/**
* 메일용 DNS 레코드 자동 생성: 받기 MX(메일 서버) + 보내기 SPF·DMARC + Resend(보내기 서명 DKIM·반송 MX) 레코드.
* 구입 도메인은 그 영역에, 서브 도메인은 플랫폼 영역이 Openprovider 일 때 그 이름 아래에 만든다. 그 밖은 수동 안내.
* @returns {{ records: Array, mode: 'auto'|'manual' }}
*/
async function setupMail(user, id, site) {
const { x } = getOwned(user.id, id); const c = config(); const mailsys = require('./mailsys');
if (x.type === 'registered') requireDomainUser(user);
const resendInfo = await mailsys.ensureResendDomain(x.name, site && site.webmail && site.webmail.domain === x.name ? site.webmail.resend : null).catch((e) => { throw httpError(`메일 발송(Resend) 도메인을 준비하지 못했습니다: ${e.message}`, 502); });
// v1.6.38 (요구 2·3): 사이트가 없어도 도메인에 맞는 메일 서버(등록부)의 호스트가 MX 기본값이 되게 siteConfig 에 가짜 사이트(메일 도메인만)를 넘긴다
const want = mailsys.dnsRecords(x.name, resendInfo, mailsys.siteConfig(site || { webmail: { domain: x.name } }));
// 레코드 이름을 영역 기준 상대 이름으로 (구입 도메인: x.name 기준, 서브 도메인: 플랫폼 도메인 기준)
const zone = x.type === 'subdomain' ? c.platformDomain : x.name; // v1.6.35: 외부 도메인(ns·manual)도 자기 이름이 영역
const rel = (fqdn) => { const f = String(fqdn).toLowerCase().replace(/\.$/, ''); return f === zone ? '' : f.endsWith(`.${zone}`) ? f.slice(0, -(zone.length + 1)) : f; };
const recs = want.map((r) => ({ name: rel(r.name), type: r.type, value: r.value, ttl: 900, prio: r.type === 'MX' ? (r.priority || 10) : undefined }));
// 어느 영역에 넣나: 서브 도메인 → 플랫폼 영역, 구입·네임서버 이전 도메인 → 그 도메인의 영역, 수동 관리 도메인 → 자동 없음(안내 모드: 사용자가 자기 DNS 에 직접 넣는다)
const zp = x.type === 'subdomain' ? platformZone() : (x.type === 'external' && x.mode === 'manual') ? null : dnszone.forZone(x);
if (!zp) { const dd0 = load(); const y0 = dd0.domains.find((z) => z.id === id); if (y0 && y0.type === 'external') { y0.mail = { ...(y0.mail || {}), requestedAt: nowIso(), manual: true, resendId: resendInfo && resendInfo.id || null }; save(dd0); } return { records: want, mode: 'manual', resend: resendInfo }; }
await dnszone.addRecords(zone, recs, user.id, zp.id);
const dd = load(); const y = dd.domains.find((z) => z.id === id); y.mail = { setupAt: nowIso(), resendId: resendInfo && resendInfo.id || null }; save(dd);
markPending(id, 'records', want.filter((r) => r.required && (r.type === 'MX' || r.type === 'TXT')).map((r) => ({ type: r.type, name: r.name, value: r.value })));
return { records: want, mode: 'auto', resend: resendInfo };
}
// ============================================================================
// v1.6.35 (요구 1-2) 도메인 준비 프로세스 — 사용자가 스스로 "자기 홈페이지 주소" 를 마련하고 관리한다
// ① 구매 : 검색(가격 포함) → 장바구니(보관) → 최종 선택 → 구매(purchase). 장바구니 가격은 열 때마다 다시 받아 바뀐 값을 표시한다.
// ② 이전 : 등록 업체 이전(registrar transfer)은 지원하지 않는다. 다른 곳에서 산 도메인의 "네임서버" 를 우리 DNS 공급자로 바꾸는 방식(mode 'ns').
// 사용자가 쓰는 하위 이름(subdomains)을 받아 지금 공용 DNS 의 레코드를 읽어 새 영역에 옮기고 → 바꿀 네임서버 목록을 보여 주고 → 사용자가 "바꿨다" 고 확인하면
// 공용 DNS 의 NS 가 실제로 바뀌었는지 확인(transferCheck)한다. 확인될 때까지 pending.kind='transfer' 로 두어 레코드 수정을 잠근다(이중 수정 방지).
// 영역을 먼저 만드는 이유: 공급자(Route 53 등)는 영역을 만들어야 그 영역의 네임서버 주소를 알려 주기 때문.
// ③ 수동 입력 : 사용자가 자기 DNS 서비스에서 레코드를 직접 관리(mode 'manual'). 배포(A)·메일(MX·SPF·DMARC·DKIM)에 필요한 레코드를 보여 주고(requiredRecords),
// 사용자가 "넣었다" 고 확인하면 공용 DNS 에서 확인(verifyManual)해 사용 가능 여부를 보인다. 확인되지 않으면 "DNS 변경이 완료되지 않았거나 반영되지 않아 배포가 되지 않는다" 고 알린다.
// ④ 자동 설정 : 위가 끝나면 사이트 연결(connect) → 발행 때 vhost·SSL 이 적용되고 메일(setupMail)을 쓸 수 있다. 어디서 막혔는지는 status() 가 단계별로 설명한다.
// ============================================================================
// ---------- ① 장바구니 ----------
/** 사용자의 장바구니 (저장된 그대로 — 가격은 담을 때 값. 최신 가격은 cartRefresh) */
function cart(userId) { return (load().carts[userId] || []).slice(); }
/** 검색 결과의 도메인을 장바구니에 담는다. 등록 가능 여부와 가격을 그 시점 값으로 함께 저장. 같은 도메인은 한 번만, 20개까지 */
async function cartAdd(user, domain) {
requireDomainUser(user); const reg = registrar.current(); const name = String(domain || '').trim().toLowerCase(); reg.split(name);
const d = load(); const c = d.carts[user.id] || []; if (c.find((i) => i.domain === name)) return c;
if (c.length >= 20) throw httpError('장바구니에는 20개까지 담을 수 있습니다 — 쓰지 않을 도메인을 먼저 빼세요');
if (byName(name)) throw httpError('이미 이 서비스에 등록된 도메인입니다', 409);
const chk = (await reg.check([name]))[0]; const available = !!chk && (chk.status === 'free' || chk.status === 'unknown');
const quote = available && chk.price ? await toUserKrw(chk.price) : null;
c.push({ domain: name, addedAt: nowIso(), available, status: chk ? chk.status : 'unknown', premium: !!(chk && chk.premium), quote, checkedAt: nowIso(), registrar: reg.id });
d.carts[user.id] = c; save(d); return c;
}
/** 장바구니에서 빼기 */
function cartRemove(user, domain) { const d = load(); const name = String(domain || '').trim().toLowerCase(); d.carts[user.id] = (d.carts[user.id] || []).filter((i) => i.domain !== name); save(d); return d.carts[user.id]; }
/** 장바구니의 등록 가능 여부·가격을 다시 받는다(화면을 열 때). 가격이 바뀌면 priceChanged·prevKrw 로 표시해 사용자가 알고 고르게 한다 */
async function cartRefresh(user) {
requireDomainUser(user); const items = cart(user.id); if (!items.length) return items;
const reg = registrar.current(); let res = [];
try { res = await reg.check(items.map((i) => i.domain)); } catch (e) { return items.map((i) => ({ ...i, refreshError: `가격을 다시 받지 못했습니다: ${e.message}` })); }
const out = [];
for (const i of items) {
const r = res.find((z) => z.domain === i.domain); if (!r) { out.push(i); continue; }
const available = r.status === 'free' || r.status === 'unknown'; const quote = available && r.price ? await toUserKrw(r.price) : null;
out.push({ ...i, available, status: r.status, premium: !!r.premium, prevKrw: i.quote ? i.quote.costKrw : null, quote, priceChanged: !!(i.quote && quote && i.quote.costKrw !== quote.costKrw), checkedAt: nowIso(), refreshError: undefined });
}
const d = load(); d.carts[user.id] = out; save(d); return out;
}
// ---------- ②·③ 이미 가진 도메인 등록 ----------
const SUB_RE = /^(\*|[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9])?)(\.[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9])?)*$/;
/** 사용자가 적은 하위 이름 목록(쉼표·공백 구분, 'www', 'blog', 'shop.kr' …) 정리 — 잘못된 이름은 안내 오류, 중복 제거, 30개까지 */
function normSubdomains(list) {
const arr = Array.isArray(list) ? list : String(list || '').split(/[,\s]+/); const out = [];
for (let s of arr) { s = String(s).trim().toLowerCase().replace(/^@$/, '').replace(/\.$/, ''); if (!s) continue; if (!SUB_RE.test(s)) throw httpError(`하위 이름이 올바르지 않습니다: "${s}" — www, blog 처럼 도메인 앞부분만 적으세요`); if (!out.includes(s)) out.push(s); }
return out.slice(0, 30);
}
/**
* 지금 공용 DNS 에 보이는 레코드를 읽어 영역 기준 상대 이름으로 모은다(A·AAAA·CNAME·TXT, 도메인 자신은 MX 도). CNAME 이 있으면 그 이름의 다른 종류는 보지 않는다(RFC 상 공존 불가).
* @param {string} zone 도메인 @param {string[]} names 상대 이름들('' 포함) @param {object} r 리졸버
*/
async function collectLive(zone, names, r) {
const recs = [];
for (const n of names) {
const fq = n ? `${n}.${zone}` : zone;
for (const [type, fn] of [['CNAME', 'resolveCname'], ['A', 'resolve4'], ['AAAA', 'resolve6'], ['TXT', 'resolveTxt'], ['MX', 'resolveMx']]) {
if (type === 'MX' && n) continue; if (type === 'CNAME' && !n) continue; // 도메인 자신은 CNAME 불가
let v = []; try { v = await r[fn](fq); } catch (_) { v = []; }
for (const x of v) { if (type === 'TXT') recs.push({ name: n, type, value: x.join(''), ttl: 900 }); else if (type === 'MX') recs.push({ name: n, type, value: normNs(x.exchange), prio: x.priority, ttl: 900 }); else recs.push({ name: n, type, value: type === 'CNAME' ? normNs(x) : x, ttl: 900 }); }
if (type === 'CNAME' && v.length) break;
}
}
return recs;
}
/**
* 이미 가진(다른 곳에서 구매한) 도메인 등록.
* @param {object} user @param {string} fqdn 도메인
* @param {{mode:'ns'|'manual', subdomains?:string[]|string, owned:boolean, resolver?:object}} opts
* owned : 사용자가 "이 도메인을 이미 구매(소유)했다" 고 확인했는지 — 확인 없이는 진행하지 않는다(요구: 구매 여부를 확인해서 진행)
* mode 'ns' : 네임서버 이전 — 공용 DNS 레코드를 읽어 우리 공급자에 영역을 만들고, 바꿀 네임서버를 돌려준다(전파 확인 전까지 레코드 수정 잠금)
* mode 'manual' : 사용자가 자기 DNS 에서 직접 관리 — 필요한 레코드 안내(requiredRecords)와 확인(verifyManual)만 한다
* @returns {{domain, imported:Array, nameServers:string[], currentNs:string[], message:string, next:string}}
*/
async function addExternal(user, fqdn, opts = {}) {
requireDomainUser(user); const name = String(fqdn || '').trim().toLowerCase().replace(/\.$/, '').replace(/^www\./, '');
if (!DOMAIN_RE.test(name)) throw httpError('도메인 이름이 올바르지 않습니다 (예: mycompany.com)');
if (byName(name)) throw httpError('이미 이 서비스에 등록된 도메인입니다 — 내 도메인 목록을 확인하세요', 409);
if (opts.owned !== true) throw httpError('이 도메인을 이미 구매(소유)했는지 확인해 주세요 — 아직 없으면 "도메인 검색·구매" 로 구매하세요', 409);
const mode = opts.mode === 'manual' ? 'manual' : opts.mode === 'ns' ? 'ns' : null; if (!mode) throw httpError('관리 방식을 고르세요: 네임서버 이전(ns) 또는 직접 DNS 관리(manual)');
const subs = normSubdomains(opts.subdomains); const r = opts.resolver || resolver;
// 1) 공용 DNS 에 살아 있는 도메인인지(등록·위임) — 미등록이면 실패하고 이유를 설명한다(1.6.33 describeDnsError)
let currentNs = []; try { currentNs = (await r.resolveNs(name)).map(normNs); }
catch (e) { const why = dnszone.describeDnsError(e.code, name); if (why) throw httpError(`${name} 을(를) 등록할 수 없습니다 — ${why}. 도메인을 아직 구매하지 않았다면 "도메인 검색·구매" 를 이용하세요`, 409); }
if (!currentNs.length) { try { await r.resolveSoa(name); } catch (e) { throw httpError(`${name} 을(를) 등록할 수 없습니다 — ${dnszone.describeDnsError(e.code, name) || 'NS·SOA 레코드가 없어 독립된 도메인(영역)이 아닙니다. 상위 도메인을 등록하세요'}`, 409); } }
const x = { id: crypto.randomBytes(8).toString('hex'), userId: user.id, name, type: 'external', mode, status: 'active', createdAt: nowIso(), charges: [], currentNs, subdomains: subs };
let message; let next; let imported = [];
if (mode === 'ns') {
const zp = dnszone.forZone(null); if (!zp) throw httpError('이 서비스의 DNS 공급자가 준비되지 않아 네임서버 이전을 받을 수 없습니다 — "직접 DNS 관리" 로 등록하거나 관리자에게 문의하세요 (관리자 콘솔 › 설정 › 도메인 서비스 › DNS 공급자)', 503);
// 2) 지금 레코드를 읽어(도메인 자신·www·사용자가 적은 하위 이름) 새 영역에 넣는다 — 네임서버를 바꾼 뒤에도 기존 서비스(메일 등)가 끊기지 않게
imported = await collectLive(name, ['', 'www', ...subs.filter((s) => s !== 'www')], r);
const z = await dnszone.ensureZone(name, imported, user.id, zp.id);
x.dnsProvider = zp.id; x.zone = { createdAt: nowIso(), provider: zp.id, id: z.id || null }; x.nameServers = (z.nameServers || []).map(normNs); x.imported = imported.length; x.importedRecords = imported.slice(0, 100);
// 3) 이미 우리 네임서버를 가리키면(앉은 자리 재등록) 이전 절차가 필요 없다
const already = sameNs(currentNs, x.nameServers);
x.transfer = { step: already ? 'done' : 'ns-change', since: nowIso(), userConfirmedAt: null, completedAt: already ? nowIso() : null, checks: [] };
if (!already) x.pending = { kind: 'transfer', since: nowIso(), etaMinutes: ETA.transfer, checks: [{ type: 'NS', name, expect: x.nameServers }] };
dnszone.audit({ by: user.id, action: 'zone.transfer-start', provider: zp.id, zone: name, imported: imported.length, currentNs, nameServers: x.nameServers });
message = already ? `${name} 의 네임서버가 이미 이 서비스의 DNS(${x.nameServers.join(', ')})를 가리킵니다 — 바로 사용할 수 있습니다. 기존 레코드 ${imported.length}개를 확인했습니다`
: `${name} 의 현재 DNS 레코드 ${imported.length}개를 새 영역에 옮겨 두었습니다. 도메인을 구매한 등록 업체에서 네임서버를 아래 목록으로 바꾸세요. 바꾼 뒤 "네임서버 변경했습니다" 를 누르면 실제로 바뀌었는지 확인합니다. 확인될 때까지(보통 수 분~수 시간, 최대 48시간) 이 도메인의 DNS 레코드 수정은 잠깁니다`;
next = already ? 'connect' : 'ns-change';
} else {
x.dnsProvider = 'manual'; x.manual = { confirmedAt: null, verifiedAt: null, siteOk: false, mailOk: null, lastCheck: null };
message = `${name} 을(를) 등록했습니다(DNS 는 직접 관리). "필요한 DNS 설정" 의 레코드를 도메인의 DNS 서비스에 넣은 뒤 "설정 완료 — 확인" 을 누르세요. 확인되기 전에는 이 주소로 배포되지 않습니다`;
next = 'manual-records';
}
const d = load(); d.domains.push(x); save(d);
return { domain: pub(x), imported, nameServers: x.nameServers || [], currentNs, message, next };
}
/** 네임서버 이전 도메인의 사이트 레코드(@·www A)를 우리 영역에 맞춘다(같은 이름·종류는 바꿈). connect() 가 부른다 */
async function ensureSiteRecords(x, ip, by, labels = ['', 'www']) {
const zp = dnszone.forZone(x); if (!zp) throw httpError('DNS 공급자 접속 정보가 없습니다', 503);
await dnszone.setRecords(x.name, labels.map((l) => ({ name: l, type: 'A', value: ip, ttl: 900 })), by, zp.id); // v1.7.2: 하위 이름(labels)을 받는다 — 기본 @·www
const d = load(); const y = d.domains.find((z) => z.id === x.id); if (y) { y.siteRecordsAt = nowIso(); y.siteIp = ip; save(d); }
}
/**
* 네임서버 이전 판정(내부): 공용 DNS 의 NS 가 우리 영역의 네임서버 집합과 같으면 완료(pending 해제·레코드 수정 열림). 아니면 단계별 안내.
* ns-change : 사용자가 아직 "바꿨다" 고 확인하지 않음 → 바꿀 네임서버를 다시 안내
* propagating : 확인했지만 공용 DNS 에는 옛/일부 네임서버가 보임 → "DNS 변경 진행 중" (수정 잠금 유지)
* error : NS 조회 자체가 실패(NXDOMAIN·SERVFAIL 등) → 원인 설명
* @param {object} y 저장소의 도메인 기록(수정됨) @param {object} d 저장소 문서(save 에 쓴다)
*/
async function transferCheckRecord(y, d) {
const expect = (y.nameServers || []).map(normNs); let got = []; let err = null;
try { got = (await resolver.resolveNs(y.name)).map(normNs); } catch (e) { err = dnszone.describeDnsError(e.code, y.name) || `DNS 조회 오류(${e.code || e.message})`; }
y.transfer = y.transfer || { step: 'ns-change', since: y.pending ? y.pending.since : nowIso(), userConfirmedAt: null, checks: [] };
if (got.length) y.currentNs = got;
y.transfer.lastCheck = nowIso(); y.transfer.checks = [...(y.transfer.checks || []), { at: nowIso(), ns: got, error: err }].slice(-10);
if (expect.length && sameNs(got, expect)) {
y.transfer.step = 'done'; y.transfer.completedAt = nowIso(); y.pending = null; y.propagatedAt = nowIso(); save(d);
dnszone.audit({ by: y.userId, action: 'zone.transfer-done', provider: y.dnsProvider, zone: y.name, nameServers: got });
return { done: true, step: 'done', currentNs: got, expect, etaMinutes: 0, message: `네임서버 변경이 확인되었습니다 — ${y.name} 의 DNS 를 이 서비스에서 관리합니다. 레코드 수정이 열렸고, 사이트에 연결하면 이 주소로 배포됩니다` };
}
const since = (y.pending && y.pending.since) || y.transfer.since || nowIso(); const elapsed = (Date.now() - Date.parse(since)) / 60000; const left = Math.max(5, Math.ceil(ETA.transfer - elapsed));
const partial = got.some((n) => expect.includes(n)); let step; let message;
if (err) { step = 'error'; message = `네임서버를 확인할 수 없습니다 — ${err}`; }
else if (!y.transfer.userConfirmedAt) { step = 'ns-change'; message = `네임서버가 아직 바뀌지 않았습니다 (지금: ${got.join(', ') || '없음'}). 등록 업체에서 네임서버를 ${expect.join(', ')} 로 바꾼 뒤 "네임서버 변경했습니다" 를 누르세요`; }
else if (partial) { step = 'propagating'; message = `DNS 변경 진행 중 — 네임서버가 일부만 바뀌어 보입니다 (지금: ${got.join(', ')}). 전파가 끝날 때까지(약 ${left}분, 최대 48시간) 레코드 수정은 잠깁니다`; }
else { step = 'propagating'; message = `DNS 변경 진행 중 — 공용 DNS 에는 아직 옛 네임서버(${got.join(', ') || '없음'})가 보입니다. 등록 업체에서 변경을 저장했는지 확인하고 전파(약 ${left}분, 최대 48시간)를 기다려 주세요. 그동안 레코드 수정은 잠깁니다`; }
y.transfer.step = step; save(d);
return { done: false, step, currentNs: got, expect, etaMinutes: left, message };
}
/**
* 사용자 쪽 "네임서버 변경했습니다"(confirmed) / "다시 확인". confirmed 면 사용자 확인 시각을 기록하고 바로 공용 DNS 를 확인한다.
* @returns {{done, step, currentNs, expect, etaMinutes, message}}
*/
async function transferCheck(user, id, { confirmed } = {}) {
requireDomainUser(user); const d = load(); const y = d.domains.find((z) => z.id === id && z.userId === user.id); if (!y) throw httpError('도메인이 없습니다', 404);
if (y.type !== 'external' || y.mode !== 'ns') throw httpError('네임서버 이전으로 등록한 도메인이 아닙니다');
if (confirmed) { y.transfer = y.transfer || { step: 'ns-change', since: nowIso(), checks: [] }; y.transfer.userConfirmedAt = nowIso(); if (!y.pending && y.transfer.step !== 'done') y.pending = { kind: 'transfer', since: nowIso(), etaMinutes: ETA.transfer, checks: [{ type: 'NS', name: y.name, expect: y.nameServers || [] }] }; }
if (y.transfer && y.transfer.step === 'done' && !y.pending && !confirmed) return { done: true, step: 'done', currentNs: y.currentNs || [], expect: y.nameServers || [], etaMinutes: 0, message: '네임서버 변경이 이미 확인되었습니다' };
return transferCheckRecord(y, d);
}
/** 레코드 하나를 공용 DNS 에서 찾아 기대 값과 비교 — {values:[], ok} (SPF 는 "v=spf1 … mx" 포함이면 통과: 기존 SPF 에 mx 를 더한 경우) */
async function lookupFound(rec) {
try {
if (rec.type === 'A') { const v = await resolver.resolve4(rec.name); return { values: v, ok: v.includes(rec.value) }; }
if (rec.type === 'AAAA') { const v = await resolver.resolve6(rec.name); return { values: v, ok: v.includes(rec.value) }; }
if (rec.type === 'MX') { const v = (await resolver.resolveMx(rec.name)).map((m) => normNs(m.exchange)); return { values: v, ok: v.includes(normNs(rec.value)) }; }
if (rec.type === 'CNAME') { const v = (await resolver.resolveCname(rec.name)).map(normNs); return { values: v, ok: v.includes(normNs(rec.value)) }; }
if (rec.type === 'TXT') { const v = (await resolver.resolveTxt(rec.name)).map((t) => t.join('')); const want = String(rec.value); const ok = /^v=spf1/i.test(want) ? v.some((t) => /^v=spf1/i.test(t) && (t === want || /\smx(\s|$)/i.test(t))) : /^v=DMARC1/i.test(want) ? v.some((t) => /^v=DMARC1/i.test(t)) : v.includes(want); return { values: v, ok }; }
} catch (e) { return { values: [], ok: false, error: e.code || e.message }; }
return { values: [], ok: false };
}
/**
* 이 도메인으로 배포·메일을 쓰려면 DNS 에 있어야 하는 레코드 목록(수동 관리 사용자에게 보여 주는 "필요한 DNS 설정"; 다른 방식의 도메인도 상태 확인에 같이 쓴다).
* site 그룹 : A @ · A www → 사이트 IP (필수)
* mail 그룹 : MX · SPF · DMARC (+ Resend DKIM 등, 메일 도메인이 준비된 뒤) — opts.mail 이거나, 사이트 메일이 이 도메인으로 켜져 있거나, 전에 메일 설정을 요청한 경우
* @param {{site?:object, mail?:boolean}} opts
*/
async function requiredRecords(user, id, opts = {}) {
const { x } = getOwned(user.id, id); if (x.type === 'subdomain') throw httpError('서브 도메인은 플랫폼이 DNS 를 관리합니다 — 따로 넣을 레코드가 없습니다', 409);
const ip = await targetIp(); const site = opts.site || null;
const items = [
{ key: 'a-root', group: 'site', type: 'A', name: x.name, host: '@', value: ip, required: true, why: '홈페이지 주소가 이 서비스의 서버를 가리킵니다 (없으면 배포해도 열리지 않음)' },
{ key: 'a-www', group: 'site', type: 'A', name: `www.${x.name}`, host: 'www', value: ip, required: true, why: 'www 를 붙인 주소도 같은 서버로 (인증서도 www 를 포함해 발급)' },
];
let mailNote = null; const wantMail = !!opts.mail || !!(site && site.webmail && site.webmail.enabled && site.webmail.domain === x.name) || !!(x.mail && x.mail.requestedAt);
if (wantMail) {
try {
const mailsys = require('./mailsys'); const resendInfo = site && site.webmail && site.webmail.domain === x.name ? site.webmail.resend : null;
for (const r of mailsys.dnsRecords(x.name, resendInfo, mailsys.siteConfig(site || { webmail: { domain: x.name } }) /* v1.6.38: 등록된 메일 서버 호스트가 MX 기본값 */)) items.push({ key: r.key, group: 'mail', type: r.type, name: String(r.name).toLowerCase(), host: r.host, value: r.value, priority: r.priority || null, required: !!r.required, why: r.why, purpose: r.purpose || 'receive' });
if (!resendInfo) mailNote = '보내는 메일 인증(DKIM 등) 레코드는 사이트를 연결하고 "메일(MX) 자동 설정" 을 눌러 메일 도메인을 준비한 뒤에 추가로 안내됩니다';
} catch (e) { mailNote = `메일용 레코드를 만들 수 없습니다: ${e.message}`; }
} else mailNote = '메일 서비스도 쓰려면 "메일용 레코드 포함" 을 켜세요 (MX·SPF·DMARC)';
return { domain: x.name, mode: x.mode || (x.type === 'registered' ? 'ns' : null), ip, records: items, mailNote, wantMail, verified: x.manual ? { at: x.manual.verifiedAt || null, confirmedAt: x.manual.confirmedAt || null, lastCheck: x.manual.lastCheck || null, siteOk: !!x.manual.siteOk, mailOk: x.manual.mailOk === undefined ? null : x.manual.mailOk } : null };
}
/**
* 수동 관리 도메인의 "설정 완료 — 확인": 필요한 레코드가 공용 DNS 에 실제로 보이는지 확인한다(사용자 확인 → 실제 확인 → 사용 가능 여부).
* siteOk(A @·www 모두) 면 배포 가능 — manual.verifiedAt 기록. 아니면 "DNS 변경이 완료되지 않았거나 반영되지 않았음 — 배포가 정상적으로 되지 않음" 을 분명히 알린다.
* mailOk 는 받기용 필수 레코드(MX·SPF) 기준(보내기 Resend 레코드는 공급자 확인 결과를 따름). 메일 레코드를 보지 않았으면 null.
* @param {{site?:object, confirmed?:boolean, mail?:boolean}} opts confirmed = 사용자가 "레코드를 넣었다" 고 확인(시각 기록)
*/
async function verifyManual(user, id, opts = {}) {
const { x } = getOwned(user.id, id); const req = await requiredRecords(user, id, opts);
const results = []; for (const r of req.records) { const f = r.purpose === 'send' && r.key.startsWith('resend-') ? { values: [], ok: false, provider: true } : await lookupFound(r); results.push({ ...r, found: f.values, ok: f.ok, lookupError: f.error || null, providerChecked: !!f.provider }); }
const siteOk = results.filter((r) => r.group === 'site').every((r) => r.ok);
const mailReq = results.filter((r) => r.group === 'mail' && r.required && r.purpose !== 'send'); const mailOk = mailReq.length ? mailReq.every((r) => r.ok) : null;
const d = load(); const y = d.domains.find((z) => z.id === id);
if (y) { const prev = y.manual || {}; y.manual = { ...prev, confirmedAt: opts.confirmed ? nowIso() : prev.confirmedAt || null, lastCheck: nowIso(), siteOk, mailOk, verifiedAt: siteOk ? nowIso() : null }; save(d); }
const miss = results.filter((r) => !r.ok && r.required);
const missSite = miss.filter((r) => r.group === 'site').map((r) => `${r.type} ${r.host} (지금: ${r.found.join(', ') || '없음'}, 필요: ${r.value})`);
const missMail = miss.filter((r) => r.group === 'mail' && r.purpose !== 'send').map((r) => `${r.type} ${r.host}`);
let message;
if (!siteOk) message = `DNS 변경이 완료되지 않았거나 변경된 내용이 아직 반영되지 않았습니다 — ${missSite.join(' · ')}. 이 상태로는 ${x.name} 으로 배포가 정상적으로 되지 않습니다. 도메인의 DNS 서비스에서 레코드를 확인하고, 반영(보통 몇 분~최대 48시간)된 뒤 다시 확인하세요`;
else if (mailOk === false) message = `사이트용 DNS 는 확인되었습니다 — ${x.name} 으로 배포할 수 있습니다. 메일용 레코드(${missMail.join(', ')})는 아직 확인되지 않아 메일 서비스는 쓸 수 없습니다`;
else if (mailOk === true) message = `DNS 설정이 확인되었습니다 — ${x.name} 으로 배포하고 메일 서비스를 쓸 수 있습니다`;
else message = `사이트용 DNS 가 확인되었습니다 — ${x.name} 으로 배포할 수 있습니다 (메일 서비스를 쓰려면 메일용 레코드도 넣고 확인하세요)`;
return { ok: siteOk, siteOk, mailOk, records: results, message, checkedAt: nowIso() };
}
/** ISO 시각을 화면용 'YYYY-MM-DD HH:MM UTC' 로 */
const fmtT = (s) => (s ? String(s).replace('T', ' ').slice(0, 16) + ' UTC' : '-');
const PHASE_LABEL = { ready: '운영 주소 접속·HTTPS 정상', transferred: '전송 완료', 'vhost-wait': '웹서버 설정 적용 대기', 'dns-wait': 'DNS 설정 대기(도메인이 아직 이 서버를 가리키지 않음)', 'cert-wait': '인증서 준비 중(HTTP 로는 열림)', 'check-failed': '접속 확인 실패', 'vhost-failed': '도메인 연결(웹서버 설정) 실패', failed: '배포 실패' };
/**
* 도메인 상태 — 어디까지 됐고 어디서 막혔는지 단계별로(요구 ④: 배포가 안 되거나 메일을 못 쓰는 이유를 알린다).
* steps[]: { key, label, ok: true|false|null(해당 없음), state: ok|fail|wait|warn|na, detail }
* own(도메인 등록·소유) → zone/dns(DNS 영역·네임서버 또는 수동 설정 확인) → site-dns(A 레코드가 실제로 이 서버를 가리키나) → deploy(연결된 사이트의 마지막 배포 단계) → mail(메일 시스템 상태)
* @param {object|null} site 연결된 사이트(없으면 배포·메일은 "연결 후")
*/
async function status(user, id, site) {
const { x } = getOwned(user.id, id); const steps = [];
const add = (key, label, ok, detail, state) => steps.push({ key, label, ok, state: state || (ok === null ? 'na' : ok ? 'ok' : 'fail'), detail });
// ① 소유·등록
if (x.type === 'subdomain') add('own', '도메인', true, `${x.name} — 플랫폼 서브 도메인(무료)`);
else if (x.type === 'registered') add('own', '도메인 등록', x.status !== 'PENDING' && x.status !== 'FAILED', x.status === 'PENDING' ? '등록 업체가 아직 등록을 처리하고 있습니다' : x.status === 'FAILED' ? (x.zoneError || '등록 실패') : `이 서비스에서 구매 · 만료 ${x.expiresAt ? String(x.expiresAt).slice(0, 10) : '-'}`, x.status === 'PENDING' ? 'wait' : undefined);
else add('own', '도메인 소유', true, `${x.name} — 다른 등록 업체에서 구매한 도메인 (${x.mode === 'ns' ? '네임서버 이전으로 DNS 를 이 서비스가 관리' : 'DNS 를 사용자가 직접 관리'})`);
// ② DNS 영역·네임서버 / 수동 확인
let p = null; if (x.pending) p = await propagation(x.id).catch(() => null);
if (x.type === 'subdomain') { const pz = platformZone(); add('zone', 'DNS 영역', pz ? true : null, pz ? `플랫폼 영역에서 자동 관리 (${pz.id})` : '플랫폼 DNS 는 운영자가 직접 관리합니다(와일드카드 또는 개별 A 레코드)'); }
else if (x.type === 'external' && x.mode === 'manual') { const m = x.manual || {}; add('zone', 'DNS 설정(직접 관리)', !!m.siteOk, m.siteOk ? `사이트용 레코드 확인됨 (${fmtT(m.verifiedAt)})` : m.lastCheck ? `마지막 확인 ${fmtT(m.lastCheck)}: 사이트용 A 레코드가 아직 확인되지 않았습니다 — DNS 변경이 완료되지 않았거나 반영되지 않았습니다 (이 상태로는 배포가 정상적으로 되지 않습니다)` : '아직 확인하지 않았습니다 — "필요한 DNS 설정" 을 자기 DNS 에 넣고 "설정 완료 — 확인" 을 누르세요'); }
else {
const zoneOk = !x.zoneError && !!(x.zone || (x.dnsProvider && x.dnsProvider !== 'manual'));
add('zone', 'DNS 영역', zoneOk, x.zoneError || (zoneOk ? `${x.dnsProvider} 영역 · 네임서버 ${(x.nameServers || []).join(', ') || '-'}` : '자동 DNS 영역이 없습니다 (관리자: 설정 › 도메인 서비스 › DNS 공급자)'));
if (x.type === 'external') add('ns', '네임서버 변경', !(p && p.pending), p && p.pending ? p.message : `네임서버가 이 서비스의 DNS 를 가리킵니다 (${(x.currentNs || x.nameServers || []).join(', ')})`, p && p.pending ? 'wait' : undefined);
else if (p && p.pending) add('ns', 'DNS 전파', false, p.message, 'wait');
}
// ③ 사이트 레코드가 실제로 이 서버를 가리키나
const ip = await targetIp().catch(() => null);
if (ip) {
// v1.7.2: 하위 이름으로 연결한 도메인은 그 이름만 본다 (예: shop.example.com)
const first = x.host ? `${x.host}.${x.name}` : x.name;
const a = await lookupFound({ type: 'A', name: first, value: ip }); const w = x.type === 'subdomain' || x.host ? { ok: true, values: [] } : await lookupFound({ type: 'A', name: `www.${x.name}`, value: ip });
const ok = a.ok && w.ok;
add('site-dns', '사이트 레코드(A)', ok, ok ? `${first}${x.type === 'subdomain' || x.host ? '' : ' · www'} → ${ip}` : `${x.name}: ${a.values.join(', ') || '없음'}${x.type === 'subdomain' ? '' : ` · www: ${w.values.join(', ') || '없음'}`} (필요: ${ip}) — ${x.type === 'subdomain' ? '사이트에 연결하면 자동으로 만들어집니다' : x.mode === 'manual' ? '자기 DNS 에 A 레코드를 넣고 반영을 기다리세요' : p && p.pending ? '네임서버 변경이 전파되면 반영됩니다' : '공용 DNS 에 아직 반영되지 않았거나 레코드가 없습니다 — 사이트에 연결하면 레코드를 다시 맞춥니다'}`, ok ? 'ok' : (p && p.pending ? 'wait' : 'fail'));
} else add('site-dns', '사이트 레코드(A)', false, '사이트 IP 를 정할 수 없습니다 (관리자 콘솔 › 설정 › 도메인 서비스 › 사이트 IP)');
// ④ 배포
if (!site) add('deploy', '배포', null, '연결된 사이트가 없습니다 — "사이트에 연결" 뒤 빌더에서 발행하세요');
else {
const last = [...(site.deployments || [])].reverse().find((dd) => dd.kind !== 'unpublish');
if (!last) add('deploy', '배포', false, `${site.title}: 아직 발행하지 않았습니다 — 빌더에서 발행하면 이 주소에 웹서버 설정과 인증서가 적용됩니다`);
else if (!last.ok) add('deploy', '배포', false, `${site.title}: 마지막 배포 실패 — ${last.detail || ''}`);
else { const ph = last.phase || 'ready'; const wait = ['dns-wait', 'cert-wait', 'vhost-wait'].includes(ph); add('deploy', '배포', ph === 'ready', `${site.title}: ${PHASE_LABEL[ph] || ph}${last.phaseDetail ? ' — ' + last.phaseDetail : ''}${ph !== 'ready' ? ' · 빌더 발행 카드의 "다시 확인" 으로 현재 상태를 다시 판정할 수 있습니다' : ''}`, ph === 'ready' ? 'ok' : wait ? 'wait' : 'fail'); }
}
// ⑤ 메일
if (!site) add('mail', '메일 서비스', null, '사이트에 연결한 뒤 "메일(MX) 자동 설정" 으로 켤 수 있습니다');
else { const ms = require('./mailsys').status(site); if (!ms.enabled) add('mail', '메일 서비스', null, '이 사이트는 메일 시스템을 쓰지 않습니다 — "메일(MX) 자동 설정" 으로 켤 수 있습니다'); else if (ms.domain !== x.name) add('mail', '메일 서비스', null, `이 사이트의 메일 도메인은 ${ms.domain} 입니다`); else add('mail', '메일 서비스', ms.level === 'ready', ms.message, ms.level === 'ready' ? 'ok' : ms.level === 'setting-up' ? 'wait' : ms.level === 'send-only' ? 'warn' : 'fail'); }
const blockers = steps.filter((s) => s.ok === false); const waiting = steps.filter((s) => s.state === 'wait');
const summary = !blockers.length ? `${x.name} 은(는) 사용할 준비가 되었습니다` : waiting.length === blockers.length ? `진행 중: ${waiting.map((s) => s.label).join(', ')} — 전파·처리가 끝나면 자동으로 반영됩니다` : `아직 쓸 수 없는 항목: ${blockers.map((s) => s.label).join(', ')} — 각 항목의 설명대로 조치하세요`;
return { domain: pub(x), steps, ok: !blockers.length, summary };
}
// ---------- DNS 전파 ----------
let resolver = new dnsLib.promises.Resolver({ timeout: 4000, tries: 1 }); try { resolver.setServers(['1.1.1.1', '8.8.8.8']); } catch (_) {}
/** 시험용: 공용 DNS 대신 쓸 리졸버(resolveNs/resolve4/… 같은 모양)를 바꿔 넣는다. null 이면 공용 DNS 로 되돌린다 */
function _setResolver(r) { if (r) resolver = r; else { resolver = new dnsLib.promises.Resolver({ timeout: 4000, tries: 1 }); try { resolver.setServers(['1.1.1.1', '8.8.8.8']); } catch (_) {} } }
/** 네임서버 이름 정규화(소문자·끝 점 제거) 와 집합 비교 — 이전 확인에 쓴다 */
const normNs = (s) => String(s || '').toLowerCase().replace(/\.$/, '');
const sameNs = (a, b) => { const A = [...new Set(a.map(normNs))].sort(); const B = [...new Set(b.map(normNs))].sort(); return A.length > 0 && A.length === B.length && A.every((v, i) => v === B[i]); };
/** 레코드 하나가 공용 DNS 에 보이는지 */
async function visible(ch) {
try {
if (ch.type === 'A') return (await resolver.resolve4(ch.name)).includes(ch.value);
if (ch.type === 'MX') return (await resolver.resolveMx(ch.name)).some((m) => m.exchange.replace(/\.$/, '').toLowerCase() === String(ch.value).toLowerCase());
if (ch.type === 'TXT') return (await resolver.resolveTxt(ch.name)).some((t) => t.join('') === ch.value);
// v1.6.35: expect 가 있으면(네임서버 이전) 공용 DNS 의 NS 가 기대한 네임서버 집합과 같아야 "보인다" 로 친다 — 하나라도 옛 네임서버가 남아 있으면 아직 전파 중
if (ch.type === 'NS') { const got = (await resolver.resolveNs(ch.name)).map(normNs); return Array.isArray(ch.expect) && ch.expect.length ? sameNs(got, ch.expect) : got.length > 0; }
if (ch.type === 'CNAME') return (await resolver.resolveCname(ch.name)).length > 0;
} catch (_) { return false; }
return true;
}
/**
* 전파 확인: 모두 보이면 pending 을 지운다. 아니면 남은 예상 시간(분)을 돌려준다.
* @returns {{ pending: boolean, etaMinutes: number, message: string }}
*/
async function propagation(id) {
const d0 = load(); const y0 = d0.domains.find((z) => z.id === id);
// v1.6.32: 등록 처리 중(Route 53)이면 완료 여부를 먼저 확인한다 — 끝났으면 영역을 만들고 위임 전파 확인으로 넘어간다
if (y0 && y0.pending && y0.pending.kind === 'registration') { const f = await finalizeRegistration(id, y0.userId).catch((e) => ({ done: false, detail: e.message })); if (!f.done) { const left = Math.max(5, Math.ceil(ETA.registration - (Date.now() - Date.parse(y0.pending.since)) / 60000)); return { pending: true, etaMinutes: left, message: `도메인 등록을 처리하고 있습니다 (${y0.name}). ${f.detail}. 약 ${left}분 후에 다시 확인해 주세요${f.failed ? '' : ' — 등록 업체(AWS)가 등록자 이메일로 보낸 확인 메일이 있으면 승인해 주세요'}` }; } }
const d = load(); const y = d.domains.find((z) => z.id === id); if (!y || !y.pending) return { pending: false, etaMinutes: 0, message: '' };
// v1.6.35: 네임서버 이전 전파는 transferCheck 가 판정한다(현재 NS 를 기록하고, 사용자가 변경을 확인하기 전에는 "변경 전" 으로 안내)
if (y.pending.kind === 'transfer') { const t = await transferCheckRecord(y, d); return { pending: !t.done, etaMinutes: t.etaMinutes || 0, message: t.done ? '' : t.message, transfer: t }; }
const res = await Promise.all((y.pending.checks || []).map(visible));
if (res.every(Boolean)) { y.pending = null; y.propagatedAt = nowIso(); save(d); return { pending: false, etaMinutes: 0, message: '' }; }
const elapsed = (Date.now() - Date.parse(y.pending.since)) / 60000; const left = Math.max(5, Math.ceil(y.pending.etaMinutes - elapsed));
const longer = elapsed > y.pending.etaMinutes;
return { pending: true, etaMinutes: left, message: `DNS 연결이 진행 중입니다 (${y.name}). 약 ${left}분 후에 다시 이용해 주세요${y.pending.kind === 'delegation' ? ' — 새 도메인은 전 세계에 알려지는 데 최대 48시간이 걸릴 수 있습니다' : ''}${longer ? ' (예상보다 오래 걸리고 있습니다)' : ''}` };
}
/** 사이트의 도메인 중 전파 중인 것이 있으면 안내 문구 (없으면 null) — 사이트 연결·발행·메일 사용 전에 부른다 */
async function gate(site) {
// v1.6.35: 네임서버 이전(transfer) 전파 중에는 발행을 막지 않는다 — 발행은 되고, 배포 판정(dns-wait)과 도메인 상태(status)가 "아직 이 서버를 가리키지 않음" 을 알린다. 레코드 수정만 잠근다(assertEditable)
for (const n of site.domains || []) { const y = byName(n) || byName(String(n).replace(/^www\./, '')); if (y && y.pending && y.pending.kind !== 'transfer') { const p = await propagation(y.id); if (p.pending) return p.message; } }
return null;
}
/** 도메인 이름 하나(메일 도메인 등)가 전파 중이면 안내 문구 (없으면 null) — 웹메일 화면·메일 보내기 전에 부른다 */
async function gateName(name) {
const y = byName(name) || byName(String(name || '').replace(/^www\./, '')); if (!y || !y.pending) return null;
const p = await propagation(y.id); return p.pending ? p.message : null;
}
/** 서브 도메인 해제 (구입 도메인은 해제하지 않음 — 만료까지 유지) */
/** 서브 도메인·외부 도메인 해제 (구입 도메인은 해제하지 않음 — 만료까지 유지). v1.6.35: 외부 도메인(ns)은 기록만 지우고 공급자의 영역은 남긴다(사용자가 네임서버를 아직 그 영역으로 두고 있을 수 있음 — 관리자가 DNS 영역 현황에서 정리) */
function release(user, id) {
const { d, x } = getOwned(user.id, id); if (x.type === 'registered') throw httpError('구입한 도메인은 지울 수 없습니다 (만료되면 자동으로 끝납니다)');
d.domains = d.domains.filter((y) => y.id !== id); save(d);
return { ok: true, note: x.type === 'external' && x.mode === 'ns' ? `${x.name} 의 DNS 영역은 공급자에 그대로 남아 있습니다 — 네임서버를 다른 곳으로 옮긴 뒤 관리자에게 영역 정리를 요청하세요` : null };
}
module.exports = { disconnect /* v1.7.2 */, finalizeRegistration, _openPurchase: openPurchase /* 테스트용 */, _setResolver /* 테스트용 */, purchases, reconcilePurchase, setPurchase, adminList, setDnsProvider, publicCatalog, platformZone, config, normalizeConfig, accountTypeOf, listFor, checkSubdomain, registerSubdomain, search, quote, getContact, saveContact, purchase, renew, records, addRecord, removeRecord, connect, setupMail, propagation, gate, gateName, release, byName, targetIp, ETA, DOMAIN_RE,
// v1.6.35 (요구 1-2): 장바구니 · 외부 도메인(네임서버 이전 / 수동 입력) · 이전 확인 · 필요한 DNS 설정 · 수동 확인 · 도메인 상태
cart, cartAdd, cartRemove, cartRefresh, addExternal, transferCheck, requiredRecords, verifyManual, status };
+184
View File
@@ -0,0 +1,184 @@
'use strict';
// OpenAI Codex CLI(서버 로그인: ChatGPT 계정) 헤드리스 실행 엔진. `codex exec --json` 이벤트를 파싱해 runClaude 와 같은 결과를 돌려준다.
// 서버에서 관리자가 `codex login`(또는 `codex login --device-auth`) 으로 로그인해 두면 API 키 없이 사용할 수 있다.
const { spawn, execFileSync } = require('child_process');
const readline = require('readline');
const fs = require('fs');
const os = require('os');
const path = require('path');
const HOME = os.homedir();
const KNOWN = [process.env.CODEX_BIN, path.join(HOME, '.npm-global', 'bin', 'codex'), path.join(HOME, '.local', 'bin', 'codex'), '/usr/local/bin/codex', '/usr/bin/codex'].filter(Boolean);
function isExec(p) { try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch (_) { return false; } }
function resolveBin() { for (const d of (process.env.PATH || '').split(path.delimiter)) { const p = path.join(d, 'codex'); if (d && isExec(p)) return p; } return KNOWN.find(isExec) || null; }
/**
* 실행 환경 변수: v1.3.17 (S07) 서비스 프로세스의 환경을 통째로 넘기지 않고 codex 실행에 필요한 것만 넘긴다
* (DATA_ROOT·키 파일 경로·서비스 설정이 사용자 지시로 움직이는 명령에 보이지 않게).
*/
function cleanEnv() { const env = {}; for (const k of ['PATH', 'HOME', 'LANG', 'LC_ALL', 'TERM', 'TMPDIR', 'CODEX_HOME', 'SSL_CERT_FILE', 'NODE_EXTRA_CA_CERTS', 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY']) if (process.env[k]) env[k] = process.env[k]; if (!env.HOME) env.HOME = HOME; return env; }
/**
* v1.5.7 (오류 수정): 쓰기 작업의 샌드박스 옵션.
* 1.4.x 까지는 `--full-auto`(작업 폴더 쓰기 + 승인 없음)를 썼는데, codex CLI 0.159 부터 이 옵션이 없어져
* `error: unexpected argument '--full-auto' found` 로 모든 OpenAI 서버 로그인 작업이 13ms 만에 실패했다(2026-10-14 운영 로그 2건).
* 지금 버전은 `-s workspace-write`(작업 폴더에만 쓰기, 네트워크 없음)가 같은 뜻이고 exec 는 원래 승인을 묻지 않는다.
* 설치된 codex 의 `exec --help` 에 --full-auto 가 있으면 옛 옵션을, 없으면 새 옵션을 쓴다(실행 파일별로 한 번만 확인).
*/
const helpCache = new Map();
function execHelp(bin) {
let hit = helpCache.get(bin); let mtime = 0; try { mtime = fs.statSync(bin).mtimeMs; } catch (_) {}
if (hit && hit.mtime === mtime) return hit.help;
let help = ''; try { help = execFileSync(bin, ['exec', '--help'], { timeout: 15000, env: cleanEnv(), stdio: ['ignore', 'pipe', 'pipe'] }).toString(); } catch (e) { help = String((e.stdout || '') + (e.stderr || '')); }
helpCache.set(bin, { mtime, help }); return help;
}
/**
* 샌드박스 사용 여부 (v1.5.7).
* codex 의 Linux 샌드박스는 비특권 user namespace 로 만든다. Ubuntu 24.04 는 기본으로 이를 막아
* (kernel.apparmor_restrict_unprivileged_userns=1) 샌드박스 안의 모든 파일 쓰기·셸 명령이 "Operation not permitted" 로 실패한다
* (2026-10-14 두 운영 서버 모두 확인 — 값을 0 으로 바꾸면 정상). 그래서
* - 설정 파일 ai.codexSandbox = auto(기본) : 커널 제한이 있으면 샌드박스 없이(--dangerously-bypass-approvals-and-sandbox) 실행하고 기동 로그에 한 번 안내,
* 제한이 없으면 workspace-write 샌드박스(작업 폴더에만 쓰기, 네트워크 없음)
* - workspace-write : 항상 샌드박스 (제한된 서버에서는 작업이 실패한다)
* - none : 항상 샌드박스 없이 (1.3.16 이전 방식 — 작업 폴더 밖 쓰기는 프롬프트 규칙에만 의존)
* 샌드박스를 쓰려면 운영자가 `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0` (영구: /etc/sysctl.d/60-codex-sandbox.conf) 뒤 재시작.
* @returns {{mode:'workspace-write'|'none', reason:string, restricted:boolean, setting:string}}
*/
let warned = false;
function sandboxMode() {
const setting = String(process.env.CODEX_SANDBOX || 'auto').trim().toLowerCase();
let restricted = false; try { restricted = fs.readFileSync('/proc/sys/kernel/apparmor_restrict_unprivileged_userns', 'utf8').trim() === '1' && (process.getuid ? process.getuid() !== 0 : true); } catch (_) { restricted = false; }
const fix = 'sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 (영구: /etc/sysctl.d/60-codex-sandbox.conf 에 같은 줄) 뒤 서비스 재시작';
let mode, reason;
// v1.6.31 (요구 3, 검토 S2): 샌드박스를 쓸 수 없으면 "제한 없이 실행" 이 아니라 **실행하지 않는다**(fail-closed). 샌드박스 없는 실행은 운영자가 ai.codexUnsafe=true 를
// 명시했을 때만 허용하고(진단용), 그래도 기동 로그에 경고한다. mode 'blocked' 는 runCodex 가 작업을 시작하지 않고 이유를 돌려준다.
const unsafeOk = (() => { try { return require('./config').get('ai.codexUnsafe') === true; } catch (_) { return false; } })();
if (setting === 'none') { if (unsafeOk) { mode = 'none'; reason = '설정 ai.codexSandbox=none + ai.codexUnsafe=true — 샌드박스 없이 실행 (운영자 명시, 권장하지 않음)'; } else { mode = 'blocked'; reason = '설정 ai.codexSandbox=none 이지만 ai.codexUnsafe 가 켜져 있지 않아 OpenAI 서버 로그인 작업을 실행하지 않습니다 (샌드박스 없는 실행은 다른 사이트·서버 설정에 접근할 수 있음). 커널 제한을 풀어 샌드박스를 쓰거나(아래 명령) ai.codexSandbox=auto 로 두세요'; } }
else if (setting === 'workspace-write') { mode = 'workspace-write'; reason = restricted ? `설정 파일 ai.codexSandbox=workspace-write — 그러나 커널이 비특권 user namespace 를 막고 있어(kernel.apparmor_restrict_unprivileged_userns=1) 작업이 실패할 수 있습니다. ${fix}` : '워크스페이스 샌드박스 (작업 폴더에만 쓰기, 네트워크 없음)'; }
else if (restricted) { if (unsafeOk) { mode = 'none'; reason = `커널이 비특권 user namespace 를 막고 있어 codex 샌드박스를 쓸 수 없고, 운영자가 ai.codexUnsafe=true 로 샌드박스 없는 실행을 허용했습니다 (권장하지 않음). 샌드박스를 쓰려면 ${fix}`; } else { mode = 'blocked'; reason = `커널이 비특권 user namespace 를 막고 있어(kernel.apparmor_restrict_unprivileged_userns=1, Ubuntu 24.04 기본) codex 샌드박스를 쓸 수 없습니다. 안전한 실행 환경이 없으므로 OpenAI 서버 로그인 작업을 실행하지 않습니다 — 운영자가 ${fix} 하면 샌드박스로 실행됩니다`; } }
else { mode = 'workspace-write'; reason = '워크스페이스 샌드박스 (작업 폴더에만 쓰기, 네트워크 없음)'; }
if ((mode === 'none' || mode === 'blocked') && !warned) { warned = true; console.log(`[codex] ${reason}`); }
return { mode, reason, restricted, setting };
}
/**
* 실행 인수 중 샌드박스·승인 부분.
* 1.4.x 까지는 `--full-auto` 였는데 codex CLI 0.159 부터 이 옵션이 없어져 `error: unexpected argument '--full-auto' found` 로 모든
* OpenAI 서버 로그인 작업이 13ms 만에 실패했다(2026-10-14 운영 로그 2건). 설치된 codex 의 `exec --help` 를 보고 옵션을 고른다.
* @param {string} bin codex 실행 파일
* @param {boolean} readOnly 읽기 전용 작업(기획·분석)인지
*/
function sandboxArgs(bin, readOnly) {
const sb = sandboxMode(); const help = execHelp(bin);
if (sb.mode === 'none') return /--dangerously-bypass-approvals-and-sandbox/.test(help) ? ['--dangerously-bypass-approvals-and-sandbox'] : ['-s', 'danger-full-access', '-c', 'approval_policy="never"'];
if (readOnly) return ['-s', 'read-only'];
if (/--full-auto/.test(help)) return ['--full-auto'];
return ['-s', 'workspace-write', '-c', 'approval_policy="never"'];
}
/** codex 가 ChatGPT 백엔드에서 받아 둔 모델 목록 파일 (codex 실행 때마다 갱신됨) */
const MODELS_CACHE = path.join(HOME, '.codex', 'models_cache.json');
/** 설치·로그인 상태 */
function codexInfo() {
const bin = resolveBin(); let version = null; if (bin) { try { version = execFileSync(bin, ['--version'], { timeout: 15000, env: cleanEnv() }).toString().trim(); } catch (e) { version = null; } }
let login = { loggedIn: false, mode: null };
try { const a = JSON.parse(fs.readFileSync(path.join(HOME, '.codex', 'auth.json'), 'utf8')); login = { loggedIn: !!(a.tokens || a.OPENAI_API_KEY), mode: a.OPENAI_API_KEY ? 'api-key' : (a.tokens ? 'chatgpt' : null), lastRefresh: a.last_refresh || null }; } catch (_) {}
let defaultModel = null; try { const c = fs.readFileSync(path.join(HOME, '.codex', 'config.toml'), 'utf8'); defaultModel = (c.match(/^\s*model\s*=\s*"([^"]+)"/m) || [])[1] || null; } catch (_) {}
let modelsCachedAt = null; try { modelsCachedAt = JSON.parse(fs.readFileSync(MODELS_CACHE, 'utf8')).fetched_at || null; } catch (_) {}
return { bin, version, ...login, defaultModel, modelsCachedAt, searched: KNOWN, sandbox: sandboxMode() }; // v1.5.7 sandbox: 샌드박스 사용 여부와 이유(설정 페이지에 표시)
}
/**
* v1.4.14 (요구 5) 서버 로그인(ChatGPT 계정)으로 쓸 수 있는 모델 전체.
*
* 1.4.5 까지는 미리 적어 둔 후보(gpt-5.x)를 하나씩 짧게 실행해 통과한 것만 돌려줬다. 후보 이름이 백엔드의 실제 모델과
* 달라져(gpt-6.x 세대) config.toml 의 기본 모델 하나만 조회되는 문제가 있었다("모델 1개 조회").
* codex CLI 는 로그인 상태로 실행될 때 백엔드의 모델 목록을 ~/.codex/models_cache.json 에 저장한다
* ({ fetched_at, models: [{ slug, display_name, description, visibility: 'list'|'hide', upgrade: {retirement_at…} … }] }).
* 이 파일을 읽으면 그 계정에 보이는 모델을 모두 한 번에 알 수 있다 — 아래 cachedModels().
* 파일이 없을 때(설치 직후·한 번도 실행 안 함)만 기본 모델로 한 번 실행해 파일을 만들게 하고, 그래도 없으면 후보 목록으로 돌아간다.
*/
function cachedModels() {
let d; try { d = JSON.parse(fs.readFileSync(MODELS_CACHE, 'utf8')); } catch (_) { return null; }
const list = Array.isArray(d && d.models) ? d.models : []; if (!list.length) return null;
return list.filter((m) => m && m.slug).map((m) => {
const retire = m.upgrade && m.upgrade.retirement_at ? m.upgrade.retirement_at : null;
return { id: m.slug, name: m.display_name || m.slug, description: m.description || '', hidden: m.visibility === 'hide', priority: Number.isFinite(m.priority) ? m.priority : 999, retireAt: retire, upgradeTo: m.upgrade && m.upgrade.model ? m.upgrade.model : null, reasoning: Array.isArray(m.supported_reasoning_levels) ? m.supported_reasoning_levels.map((x) => x.effort) : [] };
}).sort((a, b) => a.priority - b.priority);
}
/** 모델 목록 파일이 없을 때 후보로 쓰는 이름 (2026-10 ChatGPT/Codex 세대) — 실제 목록은 cachedModels() 가 우선 */
const CANDIDATES = ['gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5'];
/** 참고 단가(USD/1M, 입력·출력). ChatGPT 구독 로그인은 토큰 과금이 아니라 관리자가 화면에서 고쳐 쓰는 기준값이다. 모르는 모델은 0 */
const PRICE = { 'gpt-6-astra': [2, 16], 'gpt-6.1-sol': [1.25, 10], 'gpt-6-sol': [1.25, 10], 'gpt-6-luna': [0.25, 2], 'gpt-5.6-sol': [1.25, 10], 'gpt-5.6-terra': [0.5, 4], 'gpt-5.6-luna': [0.25, 2], 'gpt-5.5': [1.25, 10], 'gpt-5.2-codex': [1.75, 14], 'gpt-5.2': [1.75, 14], 'gpt-5.1-codex-max': [1.25, 10], 'gpt-5.1': [1.25, 10], 'gpt-5-codex': [1.25, 10], 'gpt-5': [1.25, 10], 'gpt-5-mini': [0.25, 2] };
function probeModel(bin, model) {
return new Promise((resolve) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-probe-'));
const child = spawn(bin, ['exec', '--json', '-s', 'read-only', '--skip-git-repo-check', '--ephemeral', '-C', dir, '-m', model, 'Reply with exactly: OK'], { env: cleanEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
let out = ''; child.stdout.on('data', (d) => { out += d; }); const t = setTimeout(() => child.kill('SIGKILL'), 90000);
child.on('close', () => { clearTimeout(t); try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {} let ok = false; for (const line of out.split('\n')) { try { const d = JSON.parse(line); if (d.type === 'item.completed' && d.item && d.item.type === 'agent_message' && /OK/.test(d.item.text || '')) ok = true; } catch (_) {} } resolve(ok); });
child.on('error', () => { clearTimeout(t); resolve(false); });
});
}
/**
* 서버 로그인으로 쓸 수 있는 모델 목록.
* @param {boolean} probe 목록 파일이 없을 때 codex 를 한 번 실행해 파일을 만들지(느림, 최대 90초) 여부
* @returns {Array<{id,label,description,input,output,hidden,retireAt,available}>} 보이는 모델 → 숨김 모델 순
*/
async function serverLoginModels(probe = true) {
const info = codexInfo();
const fmt = (m) => ({ id: m.id, label: `${m.name}${m.id === info.defaultModel ? ' (codex 기본)' : ''}${m.hidden ? ' (목록에 숨김)' : ''}${m.retireAt ? ` (${m.retireAt.slice(0, 10)} 종료 예정${m.upgradeTo ? ' → ' + m.upgradeTo : ''})` : ''}`, description: m.description, input: (PRICE[m.id] || [0, 0])[0], output: (PRICE[m.id] || [0, 0])[1], hidden: !!m.hidden, retireAt: m.retireAt || null, reasoning: m.reasoning, available: true });
let list = cachedModels();
if (!list && probe && info.bin && info.loggedIn) { await probeModel(info.bin, info.defaultModel || CANDIDATES[0]); list = cachedModels(); } // 파일을 만들기 위한 한 번짜리 실행
if (list) return list.map(fmt);
// 목록 파일을 끝내 얻지 못함(로그인 안 됨·옛 codex): 후보 이름으로 — probe 면 실제로 짧게 실행해 되는 것만
const ids = [...new Set([info.defaultModel, ...CANDIDATES].filter(Boolean))];
const mk = (id, ok) => ({ id, label: id + (id === info.defaultModel ? ' (codex 기본)' : '') + (ok === false ? ' (이 계정에서 사용 불가)' : ''), description: '', input: (PRICE[id] || [0, 0])[0], output: (PRICE[id] || [0, 0])[1], hidden: false, retireAt: null, available: ok });
if (!probe || !info.bin) return ids.map((id) => mk(id, undefined));
const results = []; const queue = ids.slice();
await Promise.all([1, 2, 3].map(async () => { while (queue.length) { const id = queue.shift(); results.push(mk(id, await probeModel(info.bin, id))); } }));
return ids.map((id) => results.find((r) => r.id === id)).filter((r) => r && r.available !== false);
}
const RULES = `\n\n[작업 규칙]\n- 파일은 현재 작업 디렉터리(사이트 루트) 안에만 만들고 수정합니다. 그 밖의 경로, 숨김 파일(.git 등), 시스템 파일은 절대 읽거나 쓰지 않습니다.\n- 셸 명령은 파일을 만들거나 확인하는 용도로만 씁니다(패키지 설치·네트워크 접근·프로세스 실행 금지).\n- 마지막 답변은 한국어 요약만 짧게 씁니다.`;
/**
* @param {object} o siteDir, prompt, systemPrompt, toolset('files'|'read'|'none'), model, price{input,output}, onEvent, onStart
*/
function runCodex(o) {
return new Promise((resolve, reject) => {
const bin = resolveBin(); if (!bin) return reject(new Error('codex 실행 파일을 찾지 못했습니다. 서버에 OpenAI Codex CLI 를 설치하세요 (npm i -g @openai/codex)'));
const started = Date.now(); const emit = (ev) => { try { o.onEvent && o.onEvent(ev); } catch (_) {} };
const lastFile = path.join(os.tmpdir(), `codex-last-${process.pid}-${Date.now()}.txt`);
const readOnly = o.toolset === 'read' || o.toolset === 'none';
// v1.6.31 (요구 3): 샌드박스를 만들 수 없으면 작업을 시작하지 않고 이유를 알린다 (제한 없는 실행으로 넘어가지 않는다)
{ const sb = sandboxMode(); if (sb.mode === 'blocked') return reject(Object.assign(new Error('AI 작업을 실행할 안전한 환경이 없습니다: ' + sb.reason), { status: 503, sandboxBlocked: true })); }
const args = ['exec', '--json', '--skip-git-repo-check', '-C', o.siteDir, '-o', lastFile, ...sandboxArgs(bin, readOnly), /* v1.3.17 (S07): 샌드박스 없이 실행하던 것을 codex 샌드박스(작업 폴더에만 쓰기, 네트워크 없음)로 */ ...(o.model ? ['-m', o.model] : []), `${o.systemPrompt || ''}${RULES}\n\n${o.prompt}`];
const child = spawn(bin, args, { cwd: o.siteDir, env: cleanEnv(), stdio: ['ignore', 'pipe', 'pipe'], detached: true });
let cancelled = false; const killGroup = (sig) => { try { process.kill(-child.pid, sig); } catch (_) { try { child.kill(sig); } catch (_) {} } };
if (o.onStart) o.onStart({ cancel: () => { if (cancelled) return; cancelled = true; killGroup('SIGTERM'); setTimeout(() => { if (child.exitCode === null && child.signalCode === null) killGroup('SIGKILL'); }, 2000); } });
const usage = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0, thinking: 0, total: 0 }; let turns = 0; let lastText = ''; let errText = ''; let stderr = '';
const live = () => { usage.total = usage.input + usage.output + usage.cacheCreate + usage.cacheRead; emit({ type: 'usage', live: true, usage: { ...usage }, calls: turns }); };
emit({ type: 'log', level: 'info', text: `세션 시작 (모델: ${o.model || 'codex 기본'}, 제공자: openai/codex 서버 로그인)` }); emit({ type: 'progress', text: 'AI 연결됨 · 작업을 구상하고 있습니다' });
child.stderr.on('data', (d) => { stderr += d; if (stderr.length > 20000) stderr = stderr.slice(-20000); });
const rl = readline.createInterface({ input: child.stdout });
rl.on('line', (line) => {
let ev; try { ev = JSON.parse(line); } catch (_) { return; }
const it = ev.item || {};
if (ev.type === 'item.started' && it.type === 'file_change') { for (const c of it.changes || []) emit({ type: 'progress', text: `파일 ${c.kind === 'add' ? '작성' : '수정'} 중: ${path.basename(c.path || '')}` }); }
else if (ev.type === 'item.completed') {
if (it.type === 'agent_message' && it.text) { lastText = it.text.trim(); emit({ type: 'log', level: 'assistant', text: lastText.replace(/<brief>[\s\S]*?<\/brief>/g, '[Brief 갱신됨]') }); emit({ type: 'progress', text: '결과를 정리하고 있습니다' }); }
else if (it.type === 'file_change') { for (const c of it.changes || []) emit({ type: 'log', level: 'tool', text: `파일 ${c.kind === 'add' ? '작성' : '수정'}: ${path.basename(c.path || '')}${it.status === 'failed' ? ' (실패)' : ''}` }); if (it.status === 'failed') emit({ type: 'log', level: 'debug', text: '도구 오류: 파일 변경 실패' }); }
else if (it.type === 'command_execution') { emit({ type: 'log', level: 'tool', text: `명령 실행: ${String(it.command || '').slice(0, 120)}` }); emit({ type: 'progress', text: '파일 구조 확인 중' }); }
else if (it.type === 'reasoning') emit({ type: 'progress', text: '내용을 구상하고 있습니다…' });
}
else if (ev.type === 'turn.completed') { const u = ev.usage || {}; turns += 1; const cached = u.cached_input_tokens || 0; usage.input += Math.max(0, (u.input_tokens || 0) - cached); usage.cacheRead += cached; usage.cacheCreate += u.cache_write_input_tokens || 0; usage.output += u.output_tokens || 0; usage.thinking += u.reasoning_output_tokens || 0; live(); }
else if (ev.type === 'turn.failed' || ev.type === 'error') { errText = (ev.error && ev.error.message) || ev.message || JSON.stringify(ev).slice(0, 300); emit({ type: 'log', level: 'debug', text: `codex 오류: ${errText}` }); }
});
child.on('error', (e) => reject(new Error(`codex 실행 실패 (${bin}): ${e.message}`)));
child.on('close', (code) => {
usage.total = usage.input + usage.output + usage.cacheCreate + usage.cacheRead;
if (cancelled) return reject(Object.assign(new Error('사용자가 작업을 중단했습니다'), { cancelled: true, usage: { ...usage } }));
let text = lastText; try { const t = fs.readFileSync(lastFile, 'utf8').trim(); if (t) text = t; fs.unlinkSync(lastFile); } catch (_) {}
const price = o.price || { input: 0, output: 0 }; const costUsd = (usage.input * (price.input || 0) + usage.cacheRead * (price.input || 0) * 0.1 + usage.output * (price.output || 0)) / 1e6;
const ok = code === 0 && !errText;
// v1.5.7: engine/errorText — 실패 기록이 "Claude 종료" 가 아니라 "Codex 종료: <원인>" 으로 남게 (server.js runCommand)
resolve({ engine: 'codex', errorText: ok ? null : (errText || stderr.trim().split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 2).join(' ').slice(0, 200) || null), ok, subtype: ok ? 'success' : 'error', isError: !ok, resultText: text || errText || (ok ? '' : `codex 종료 코드 ${code}: ${stderr.trim().slice(-400)}`), error: ok ? null : (errText || `codex 종료 코드 ${code}`), usage, costUsd, numTurns: turns, durationMs: Date.now() - started, model: o.model, modelUsage: { [`openai:${o.model || 'codex'}`]: { inputTokens: usage.input, outputTokens: usage.output, cacheReadInputTokens: usage.cacheRead, cacheCreationInputTokens: usage.cacheCreate, costUSD: costUsd } }, permissionDenials: [] });
});
});
}
module.exports = { runCodex, codexInfo, serverLoginModels, cachedModels, sandboxMode };
+96
View File
@@ -0,0 +1,96 @@
'use strict';
// OpenAI 호환 Chat Completions(도구 호출) 로 동작하는 파일 작업 에이전트.
// OpenAI · Groq · OpenRouter · Gemini(OpenAI 호환 엔드포인트) · 기타 호환 서버에서 공통으로 사용한다.
// runClaude 와 같은 결과 형태를 돌려준다: {ok, resultText, usage{input,output,cacheCreate,cacheRead,thinking,total}, costUsd, model, numTurns, durationMs}
const fs = require('fs');
const path = require('path');
const MAX_TURNS = parseInt(process.env.OPENAI_ENGINE_MAX_TURNS || '60', 10);
const MAX_FILE = 2 * 1024 * 1024;
/**
* 사이트 폴더 안의 경로만 허용. v1.6.31 (검토 S15): 심볼릭 링크가 밖을 가리키는 경우도 막는다 — 존재하는 가장 깊은 조상의 실경로가 사이트 실경로 아래여야 한다.
*/
function safePath(siteDir, rel) {
const root = (() => { try { return fs.realpathSync(siteDir); } catch (_) { return path.resolve(siteDir); } })();
const p = path.resolve(root, String(rel || '').replace(/^\/+/, ''));
if (!p.startsWith(root + path.sep) && p !== root) throw new Error('사이트 폴더 밖 경로는 사용할 수 없습니다: ' + rel);
if (path.basename(p).startsWith('.') || p.split(path.sep).some((s) => s.startsWith('.') && s !== '.refs')) throw new Error('숨김 파일은 사용할 수 없습니다: ' + rel);
let probe = p; while (probe !== root && !fs.existsSync(probe)) probe = path.dirname(probe);
try { const real = fs.realpathSync(probe); if (!real.startsWith(root + path.sep) && real !== root) throw new Error('심볼릭 링크가 사이트 폴더 밖을 가리킵니다: ' + rel); } catch (e) { if (/심볼릭/.test(e.message)) throw e; }
return p;
}
// v1.6.31 (검토 S14): 요청 시간·응답 크기·누적 문맥·전체 실행 시간 상한
const REQ_TIMEOUT_MS = 180000; const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; const MAX_CONTEXT_BYTES = 6 * 1024 * 1024;
const MAX_JOB_MIN = () => { try { const v = parseInt(require('./config').get('ai.maxJobMinutes') || '45', 10); return Number.isFinite(v) && v > 0 ? v : 45; } catch (_) { return 45; } };
function listFiles(dir, base = '') { let out = []; let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return out; } for (const e of ents) { if (e.name.startsWith('.') && e.name !== '.refs') continue; const rel = base ? `${base}/${e.name}` : e.name; if (e.isDirectory()) out = out.concat(listFiles(path.join(dir, e.name), rel)); else out.push(rel); } return out.sort(); }
const TOOLS = {
list_files: { def: { type: 'function', function: { name: 'list_files', description: '사이트 폴더의 파일 목록을 돌려준다(상대 경로).', parameters: { type: 'object', properties: {}, additionalProperties: false } } },
run: (siteDir) => listFiles(siteDir).join('\n') || '(파일 없음)', progress: () => '파일 구조 확인 중', log: () => '파일 검색: **/*' },
read_file: { def: { type: 'function', function: { name: 'read_file', description: '파일 내용을 읽는다. 텍스트 파일만 지원한다.', parameters: { type: 'object', properties: { path: { type: 'string', description: '사이트 루트 기준 상대 경로' } }, required: ['path'], additionalProperties: false } } },
run: (siteDir, a) => { const p = safePath(siteDir, a.path); const st = fs.statSync(p); if (st.size > MAX_FILE) throw new Error('파일이 너무 큽니다'); return fs.readFileSync(p, 'utf8'); }, progress: (a) => `내용 확인 중: ${path.basename(a.path || '')}`, log: (a) => `파일 읽기: ${path.basename(a.path || '')}` },
write_file: { def: { type: 'function', function: { name: 'write_file', description: '파일을 통째로 쓴다(없으면 생성, 있으면 덮어씀). 하위 폴더는 자동 생성.', parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'], additionalProperties: false } } },
run: (siteDir, a) => { const p = safePath(siteDir, a.path); fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o755 }); fs.writeFileSync(p, String(a.content), { mode: 0o644 }); return `written ${a.path} (${Buffer.byteLength(String(a.content))} bytes)`; }, progress: (a) => `파일 작성 중: ${path.basename(a.path || '')}`, log: (a) => `파일 작성: ${path.basename(a.path || '')} (${String(a.content || '').length.toLocaleString()}자)` },
edit_file: { def: { type: 'function', function: { name: 'edit_file', description: '파일에서 old_text 를 찾아 new_text 로 한 번 바꾼다(정확히 일치해야 함).', parameters: { type: 'object', properties: { path: { type: 'string' }, old_text: { type: 'string' }, new_text: { type: 'string' } }, required: ['path', 'old_text', 'new_text'], additionalProperties: false } } },
run: (siteDir, a) => { const p = safePath(siteDir, a.path); const s = fs.readFileSync(p, 'utf8'); const i = s.indexOf(a.old_text); if (i < 0) throw new Error('old_text 를 찾지 못했습니다'); fs.writeFileSync(p, s.slice(0, i) + a.new_text + s.slice(i + a.old_text.length)); return `edited ${a.path}`; }, progress: (a) => `파일 수정 중: ${path.basename(a.path || '')}`, log: (a) => `파일 수정: ${path.basename(a.path || '')}` },
delete_file: { def: { type: 'function', function: { name: 'delete_file', description: '파일을 삭제한다.', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], additionalProperties: false } } },
run: (siteDir, a) => { fs.unlinkSync(safePath(siteDir, a.path)); return `deleted ${a.path}`; }, progress: (a) => `파일 삭제 중: ${path.basename(a.path || '')}`, log: (a) => `파일 삭제: ${path.basename(a.path || '')}` },
};
const TOOLSETS = { files: ['list_files', 'read_file', 'write_file', 'edit_file', 'delete_file'], read: ['list_files', 'read_file'], none: [] };
/**
* @param {object} o siteDir, prompt, systemPrompt, toolset('files'|'read'|'none'), model, apiKey, baseUrl, provider, price{input,output}, onEvent, onStart
*/
async function runAgent(o) {
const started = Date.now(); const emit = (ev) => { try { o.onEvent && o.onEvent(ev); } catch (_) {} };
const ac = new AbortController(); let cancelled = false;
if (o.onStart) o.onStart({ cancel: () => { cancelled = true; ac.abort(); } });
const base = String(o.baseUrl || '').replace(/\/$/, ''); if (!base) throw new Error('제공자 기본 URL 이 없습니다');
// v1.6.31 (요구 5, 검토 패치 2): 제공자 주소가 서버 내부(사설·루프백)를 가리키면 거절 — 관리자가 설정 service.internalAllow 에 적은 호스트:포트만 예외
await require('./netguard').assertExternalUrl(base + '/', { allowHttp: true });
const deadline = started + MAX_JOB_MIN() * 60000;
const headers = { 'Content-Type': 'application/json' }; if (o.apiKey) headers.Authorization = `Bearer ${o.apiKey}`;
if (o.provider === 'openrouter') { headers['HTTP-Referer'] = 'https://webmaker.ibit.kr'; headers['X-Title'] = 'WebMaker'; }
const toolNames = TOOLSETS[o.toolset || 'files'] || TOOLSETS.files; const tools = toolNames.map((n) => TOOLS[n].def);
const messages = [{ role: 'system', content: o.systemPrompt || '' }, { role: 'user', content: o.prompt }];
const usage = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0, thinking: 0, total: 0 }; let calls = 0; let finalText = ''; let ok = false; let error = null;
const live = () => { usage.total = usage.input + usage.output + usage.cacheCreate + usage.cacheRead; emit({ type: 'usage', live: true, usage: { ...usage }, calls }); };
emit({ type: 'log', level: 'info', text: `세션 시작 (모델: ${o.model}, 제공자: ${o.provider})` }); emit({ type: 'progress', text: 'AI 연결됨 · 작업을 구상하고 있습니다' });
try {
for (let turn = 0; turn < MAX_TURNS; turn++) {
if (cancelled) throw Object.assign(new Error('사용자가 작업을 중단했습니다'), { cancelled: true });
const body = { model: o.model, messages, temperature: 0.4 }; if (tools.length) { body.tools = tools; body.tool_choice = 'auto'; }
if (Date.now() > deadline) throw new Error(`최대 실행 시간 ${MAX_JOB_MIN()}분을 넘겨 중단했습니다`);
if (JSON.stringify(messages).length > MAX_CONTEXT_BYTES) throw new Error('대화 문맥이 너무 커져 중단했습니다 (작업을 나눠 다시 지시하세요)');
const reqAc = new AbortController(); const onAbort = () => reqAc.abort(); ac.signal.addEventListener('abort', onAbort, { once: true }); const reqTimer = setTimeout(() => reqAc.abort(), REQ_TIMEOUT_MS);
let res; try { res = await fetch(`${base}/chat/completions`, { method: 'POST', headers, body: JSON.stringify(body), signal: reqAc.signal, redirect: 'error' }); } finally { clearTimeout(reqTimer); ac.signal.removeEventListener('abort', onAbort); }
calls += 1;
if (!res.ok) { const t = await res.text().catch(() => ''); throw new Error(`API 오류 HTTP ${res.status}: ${t.slice(0, 300)}`); }
const rawText = await res.text(); if (rawText.length > MAX_RESPONSE_BYTES) throw new Error('응답이 너무 큽니다'); let j; try { j = JSON.parse(rawText); } catch (_) { throw new Error('응답이 JSON 이 아닙니다'); }
const u = j.usage || {}; usage.input += (u.prompt_tokens || 0) - ((u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0); usage.cacheRead += (u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0; usage.output += u.completion_tokens || 0; usage.thinking += (u.completion_tokens_details && u.completion_tokens_details.reasoning_tokens) || 0; live();
const choice = (j.choices || [])[0]; if (!choice) throw new Error('응답에 choices 가 없습니다');
const msg = choice.message || {}; messages.push({ role: 'assistant', content: msg.content || null, tool_calls: msg.tool_calls || undefined });
if (msg.content && String(msg.content).trim()) { finalText = String(msg.content).trim(); emit({ type: 'log', level: 'assistant', text: finalText.replace(/<brief>[\s\S]*?<\/brief>/g, '[Brief 갱신됨]') }); }
const tcs = msg.tool_calls || [];
if (!tcs.length) { ok = true; break; }
emit({ type: 'progress', text: '결과를 정리하고 있습니다' });
for (const tc of tcs) {
const name = tc.function && tc.function.name; let args = {}; try { args = JSON.parse(tc.function.arguments || '{}'); } catch (_) { args = {}; }
const T = TOOLS[name]; let result;
if (!T || !toolNames.includes(name)) result = `오류: 사용할 수 없는 도구 ${name}`;
else { emit({ type: 'progress', text: T.progress(args) }); emit({ type: 'log', level: 'tool', text: T.log(args) }); try { result = T.run(o.siteDir, args); } catch (e) { result = `오류: ${e.message}`; emit({ type: 'log', level: 'debug', text: `도구 오류: ${e.message}` }); } }
messages.push({ role: 'tool', tool_call_id: tc.id, content: String(result).slice(0, 200000) });
}
if (turn === MAX_TURNS - 1) { error = `최대 반복 횟수(${MAX_TURNS})에 도달했습니다`; }
}
} catch (e) {
if (e.cancelled || e.name === 'AbortError' || cancelled) { usage.total = usage.input + usage.output + usage.cacheCreate + usage.cacheRead; throw Object.assign(new Error('사용자가 작업을 중단했습니다'), { cancelled: true, usage: { ...usage } }); }
error = e.message;
}
usage.total = usage.input + usage.output + usage.cacheCreate + usage.cacheRead;
const price = o.price || { input: 0, output: 0 };
const costUsd = (usage.input * (price.input || 0) + usage.cacheRead * (price.input || 0) * 0.5 + usage.output * (price.output || 0)) / 1e6;
return { engine: 'openai', errorText: error || null, /* v1.5.7: 실패 기록에 엔진 이름·원인을 남긴다 (server.js runCommand) */ ok: ok && !error, subtype: ok && !error ? 'success' : 'error', isError: !!error || !ok, resultText: error && !finalText ? error : finalText, error, usage, costUsd, numTurns: calls, durationMs: Date.now() - started, model: o.model, modelUsage: { [`${o.provider}:${o.model}`]: { inputTokens: usage.input, outputTokens: usage.output, cacheReadInputTokens: usage.cacheRead, cacheCreationInputTokens: 0, costUSD: costUsd } }, permissionDenials: [] };
}
module.exports = { runAgent, TOOLSETS };
+161
View File
@@ -0,0 +1,161 @@
'use strict';
// ============================================================================
// 사용자 제안·오류 보고 (v1.1.2)
// 사용자가 작업 중 개선 제안이나 오류를 올리면 저장하고, 서버가 고급 모델로 자동 분석(무엇인지·필요 작업량·중요도)해
// 그 글의 댓글로 붙인다. 분석에 든 비용은 사용자에게 청구하지 않고 글에 기록해 관리자가 본다.
// 저장: data/feedback.json { items: [{ id, userId, userName, siteId, siteTitle, type, title, body, page, env, at, status, comments: [], analysis, cost,
// planned, planNote, plannedAt, plannedBy, collectedAt, collectedBy }] }
// env : 신고 당시 브라우저·화면·언어·시간대·서비스 버전 등 (v1.1.6, 사용자에게 사전 고지)
// planned : 관리자가 AI 분석을 보고 "다음 작업에 반영" 으로 결정했는지 (v1.1.6)
// planNote : 반영할 내용 — 관리자가 구체적으로 적은 작업 지시. 작업 서버가 수집해 다음 릴리스 작업 목록으로 쓴다
// collectedAt/By : 작업 서버가 마지막으로 가져간 시각/주체
// type: inquiry(문의, v1.3.2) | suggestion(제안) | bug(오류) | request(요구) status: new | reviewing | done | rejected
// ============================================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const FILE = path.join(DATA_ROOT, 'feedback.json');
const TYPES = ['inquiry', 'suggestion', 'bug', 'request'];
const TYPE_LABEL = { inquiry: '문의', suggestion: '제안', bug: '오류', request: '요구' };
// v1.2.0 상태 흐름: new(접수) → reviewing(AI 분석 완료, 검토 중) → done(관리자 답변 = 처리 완료). 반려는 없다.
const STATUS = ['new', 'reviewing', 'done'];
const normStatus = (st) => (st === 'rejected' ? 'done' : (STATUS.includes(st) ? st : 'new')); // 예전 '반려' 는 처리 완료로 읽는다
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const clip = (v, n) => String(v == null ? '' : v).trim().slice(0, n);
/**
* 저장 파일 읽기.
* v1.2.0 이전에 관리자 답변이 달린 글은 상태가 "검토 중" 으로 남아 있다(당시에는 답변해도 상태가 바뀌지 않았음).
* 새 규칙(관리자 답변 = 처리 완료)에 맞춰, 답변 시각(answeredAt)이 없는 옛 글만 읽을 때 처리 완료로 맞춘다.
* answeredAt 이 있는 글은 1.2.0 이후 답변된 것이므로 관리자가 바꾼 상태를 그대로 둔다.
*/
function load() {
let d; try { d = JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (_) { return { items: [] }; }
for (const x of d.items || []) {
x.status = normStatus(x.status);
const adm = (x.comments || []).filter((c) => c.by === 'admin');
if (adm.length && !x.answeredAt) { x.status = 'done'; x.answeredAt = adm[adm.length - 1].at; }
}
return d;
}
function save(d) { fs.mkdirSync(DATA_ROOT, { recursive: true }); const tmp = FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(d, null, 1)); fs.renameSync(tmp, FILE); }
/** 새 글 등록 */
/** 신고 환경 정보 정리: 평평한 키/값만, 키 40개·값 300자 제한 (임의 데이터가 커지지 않게) */
function cleanEnv(env) {
if (!env || typeof env !== 'object') return null; const out = {}; let n = 0;
for (const [k, v] of Object.entries(env)) { if (n >= 40) break; if (!/^[\w.-]{1,40}$/.test(k)) continue; if (v === null || v === undefined) continue; out[k] = typeof v === 'number' || typeof v === 'boolean' ? v : clip(v, 300); n += 1; }
return n ? out : null;
}
function add({ userId, userName, siteId, siteTitle, type, title, body, page, env }) {
const t = TYPES.includes(type) ? type : 'suggestion'; title = clip(title, 120); body = clip(body, 5000);
if (!title && !body) throw httpError('내용을 입력하세요');
const d = load();
const item = { id: crypto.randomBytes(6).toString('hex'), userId, userName: clip(userName, 60), siteId: siteId || null, siteTitle: clip(siteTitle, 80), type: t, title: title || body.slice(0, 60), body, page: clip(page, 300), env: cleanEnv(env), at: new Date().toISOString(), status: 'new', comments: [], analysis: null, cost: { usd: 0, krw: 0 } };
d.items.push(item); if (d.items.length > 5000) d.items.splice(0, d.items.length - 5000); save(d); return item;
}
function get(id) { const x = load().items.find((i) => i.id === id); return x ? { ...x, status: normStatus(x.status) } : null; }
/** 관리자용 전체 목록(최신순), 사용자용은 본인 글만 */
function list(userId) { return load().items.filter((x) => !userId || x.userId === userId).map((x) => ({ ...x, status: normStatus(x.status) })).sort((a, b) => b.at.localeCompare(a.at)); }
/** 부분 갱신(상태, 분석 결과, 비용) */
function update(id, patch) { const d = load(); const x = d.items.find((i) => i.id === id); if (!x) throw httpError('글이 없습니다', 404); if (patch.status && STATUS.includes(patch.status)) x.status = patch.status; else x.status = normStatus(x.status); if (patch.analysis !== undefined) x.analysis = patch.analysis; if (patch.cost) x.cost = { usd: (x.cost.usd || 0) + (patch.cost.usd || 0), krw: (x.cost.krw || 0) + (patch.cost.krw || 0) }; x.updatedAt = new Date().toISOString(); save(d); return x; }
/** 댓글(AI 분석 또는 관리자 답변) 추가. by: 'ai' | 'admin' | 사용자ID */
/**
* 댓글 추가. by: 'ai'(분석) | 'admin'(관리자 답변).
* 관리자 답변이 달리면 상태를 '처리 완료' 로 바꾸고 answeredAt 을 남긴다(사용자 화면에 답변과 함께 표시). AI 분석이 붙으면 새 글 → 검토 중.
*/
function comment(id, { by, text, model, usd, krw, byName }) { const d = load(); const x = d.items.find((i) => i.id === id); if (!x) throw httpError('글이 없습니다', 404); x.status = normStatus(x.status); x.comments.push({ id: crypto.randomBytes(4).toString('hex'), at: new Date().toISOString(), by, byName: byName ? clip(byName, 60) : null, text: clip(text, 8000), model: model || null, usd: usd || 0, krw: krw || 0 });
if (by === 'admin') { x.status = 'done'; x.answeredAt = new Date().toISOString(); } else if (by === 'ai' && x.status === 'new') x.status = 'reviewing'; if (usd) x.cost = { usd: (x.cost.usd || 0) + usd, krw: (x.cost.krw || 0) + (krw || 0) }; x.updatedAt = new Date().toISOString(); save(d); return x; }
/**
* 다음 작업 반영 결정 (관리자). planned=false 로 바꾸면 작업 목록에서 빠진다.
* @param {string} id
* @param {{planned: boolean, planNote?: string, by: string}} p
*/
function setPlan(id, { planned, planNote, by }) {
const d = load(); const x = d.items.find((i) => i.id === id); if (!x) throw httpError('글이 없습니다', 404);
const note = clip(planNote, 4000);
if (planned && !note) throw httpError('반영할 내용을 구체적으로 적어 주세요');
x.planned = !!planned; x.planNote = note; x.plannedAt = planned ? new Date().toISOString() : null; x.plannedBy = planned ? by : null;
x.status = normStatus(x.status); if (planned && x.status === 'new') x.status = 'reviewing';
x.updatedAt = new Date().toISOString(); save(d); return x;
}
/** 다음 작업 목록: 반영 결정되었고 아직 처리 완료/반려가 아닌 글 (오래된 순) */
function backlog() {
return load().items.filter((x) => x.planned && !x.implementedAt).sort((a, b) => (a.plannedAt || '').localeCompare(b.plannedAt || ''))
.map((x) => ({ id: x.id, type: x.type, title: x.title, planNote: x.planNote, plannedAt: x.plannedAt, plannedBy: x.plannedBy, priority: x.analysis && x.analysis.priority, effort: x.analysis && x.analysis.effort, category: x.analysis && x.analysis.category, summary: x.analysis && x.analysis.summary, body: x.body, userId: x.userId, siteTitle: x.siteTitle, page: x.page, env: x.env || null, at: x.at, collectedAt: x.collectedAt || null }));
}
/** 릴리스에 반영 완료 표시 (작업 서버가 배포 후 호출). 사용자 화면에 "v… 에 반영됨" 으로 보인다 */
function markImplemented(ids, version, note) { const d = load(); const at = new Date().toISOString(); let n = 0; for (const x of d.items) if (ids.includes(x.id)) { x.implementedAt = at; x.implementedVersion = clip(version, 20); if (note) x.implementedNote = clip(note, 500); n += 1; } if (n) save(d); return n; }
/** 작업 서버가 가져간 표시 */
function markCollected(ids, by) { const d = load(); const at = new Date().toISOString(); let n = 0; for (const x of d.items) if (ids.includes(x.id)) { x.collectedAt = at; x.collectedBy = clip(by, 80); n += 1; } if (n) save(d); return n; }
/** 다음 작업 목록 → 마크다운 (작업 서버의 BACKLOG.md / 관리자 복사용) */
function backlogMarkdown(items, meta = {}) {
const T = TYPE_LABEL;
const lines = [`# 다음 작업 반영 목록${meta.server ? ' — ' + meta.server : ''}`, '', `수집 ${meta.at || new Date().toISOString()} · ${items.length}건 (관리자가 AI 분석 후 "다음 작업에 반영" 으로 결정한 사용자 제안·오류)`, ''];
items.forEach((x, i) => {
lines.push(`## ${i + 1}. [${T[x.type] || x.type}] ${x.title}`, '', `- 중요도: ${x.priority || '-'} · 작업량: ${x.effort || '-'} · 분류: ${x.category || '-'}`, `- 결정: ${x.plannedAt || ''} (${x.plannedBy || ''}) · 접수: ${x.at} · 사용자 ${x.userId}${x.siteTitle ? ' · 사이트 ' + x.siteTitle : ''}${x.page ? ' · 화면 ' + x.page : ''}`, '', '**반영할 내용 (관리자)**', '', x.planNote || '', '');
if (x.summary) lines.push(`AI 요약: ${x.summary}`, '');
lines.push('<details><summary>원문·환경</summary>', '', x.body || '', '', x.env ? '```\n' + Object.entries(x.env).map(([k, v]) => `${k}: ${v}`).join('\n') + '\n```' : '', '</details>', '');
});
return lines.join('\n');
}
function counts() { const items = load().items.map((x) => ({ ...x, status: normStatus(x.status) })); return { total: items.length, planned: items.filter((x) => x.planned && !x.implementedAt).length, implemented: items.filter((x) => x.implementedAt).length, new: items.filter((x) => x.status === 'new').length, reviewing: items.filter((x) => x.status === 'reviewing').length, done: items.filter((x) => x.status === 'done').length, costUsd: items.reduce((a, x) => a + ((x.cost && x.cost.usd) || 0), 0), costKrw: items.reduce((a, x) => a + ((x.cost && x.cost.krw) || 0), 0) }; }
/**
* 서비스 안내 요약 (v1.3.2): "문의" 1차 답변 초안을 만들 때 AI 가 참고하는 사용법. 화면이 바뀌면 함께 고친다.
*/
// i18n-ignore-start (AI 에게 주는 안내·지시문 — 화면 문구 아님)
const SERVICE_GUIDE = `AI Webmaker 사용 안내 (요약)
- 생성 탭: "AI에게 맡기기"(설명만 적으면 AI 가 기획→생성) / "내가 고르기"(업종·페이지·디자인 직접 선택) / 기존 사이트 모양으로 시작("사이트 복사하기": 웹 주소만으로 기본 HTML 페이지와 CSS·이미지를 복사해 새 사이트로 만듦. 로그인·게시판·신청·결제 같은 기능은 복사되지 않으며 필요하면 수정 지시로 빌더 모듈을 붙임. 서버 접속으로 가져오는 "사이트 관리하기" 는 1.3.4 에서 종료).
- 관리 탭: 수정 지시(모델 등급 기본·중급·고급 선택), 발행·배포(운영 주소·도메인 연결), 버전 되돌리기, 접속 통계, 공통 기능 모듈(회원·게시판·문의·설문·예약·갤러리·지도·후기·FAQ)과 사이트 관리자 페이지, 메일(운영자가 사이트 단위로 켜며 DNS 확인 전에는 "메일시스템 설정중").
- 캐시: 작업마다 사용한 만큼 차감, 잔액은 화면 위쪽 버전 줄 오른쪽. 소진되면 작업이 멈추고 충전 후 자동으로 이어서 진행. 충전은 관리자(운영팀)에게 요청.
- AI에게 맡기기는 발행 전 수정 횟수 한도가 있으며 한도에 이르면 발행 후 다시 수정할 수 있음.
- 언어: 화면 위쪽에서 화면 언어를 바꿀 수 있음(운영자가 올린 언어만).
- 문의·제안·오류는 설정 탭 "내 문의 내역" 에서 상태(접수→검토 중→처리 완료)와 운영팀 답변을 볼 수 있음.`;
/**
* AI 1차 분석 프롬프트 (v1.3.2: 글 종류별로 다르게).
* - inquiry(문의) : 질문 의도 파악 + 사용자에게 보낼 1차 답변 초안(서비스 안내 기준, 모르면 운영팀 확인 필요로 표시)
* - bug(오류) : 추정 원인·재현 조건·영향 범위·수정 방향·심각도
* - suggestion/request : 필요성·구현 방법·작업량·우선순위
* 공통 JSON 필드(category·summary·priority·effort·suggestion·replyDraft)는 같아서 관리자 화면·다음 작업 목록이 그대로 쓴다.
*/
function analysisPrompt(item, context) {
const kind = TYPE_LABEL[item.type] || '제안';
const focus = item.type === 'inquiry'
? `이 글은 사용 "문의" 입니다. 질문의 의도를 파악하고, 아래 서비스 안내를 근거로 사용자에게 보낼 1차 답변 초안을 작성하세요. 안내로 답할 수 없거나 계정·결제·데이터처럼 운영팀 확인이 필요한 내용이면 그 사실을 답변에 밝히고 needsOperator 를 true 로 하세요. 문의 속에 오류 신고나 기능 요청이 섞여 있으면 category 와 suggestion 에 함께 적으세요.\n\n${SERVICE_GUIDE}`
: item.type === 'bug'
? '이 글은 "오류" 보고입니다. 사용 환경과 사이트 정보를 근거로 추정 원인과 재현 조건을 정리하고, 영향 범위·심각도·수정 방향을 제시하세요. 사용자가 당장 시도할 수 있는 우회 방법이 있으면 답변 초안에 넣으세요.'
: `이 글은 "${kind}" 입니다. 필요성과 기대 효과, 구현 방법, 작업량, 기존 기능과의 관계(이미 있는 기능이면 사용 방법 안내)를 분석하세요.`;
return `다음은 웹사이트 빌더 서비스(AI Webmaker)의 사용자가 올린 "${kind}" 글입니다. 운영자가 처리 방향과 우선순위를 정할 수 있게 1차 분석을 해 JSON 으로만 답하세요.
${focus}
[종류] ${kind}
[제목] ${item.title}
[내용]
${item.body}
[작성 화면] ${item.page || '-'}
[사용 환경] ${item.env ? Object.entries(item.env).map(([k, v]) => `${k}=${v}`).join(', ') : '-'}
[관련 사이트] ${item.siteTitle || '-'}
${context || ''}
출력 JSON:
{"category": "사용 문의|요금·캐시|계정|버그|UI/UX|기능 추가|성능|문구/번역|배포/운영|메일|기타",
"summary": "무엇에 대한 글인지 2문장 이내",
"cause": "오류라면 추정 원인, 제안·요구라면 배경, 문의라면 질문의 핵심 (1~2문장)",
"reproduce": "오류일 때 재현 조건 (아니면 빈 문자열)",
"scope": "영향 범위: 어느 화면/기능과 관련되는지",
"effort": "없음|소|중|대", "effortNote": "필요한 작업 설명과 대략의 소요 (문의로 답변만 하면 되는 경우 '답변만 필요')",
"priority": "높음|중간|낮음", "priorityReason": "중요도 판단 이유",
"suggestion": "운영자에게 권장하는 처리 방안 (1~3문장)",
"replyDraft": "사용자에게 보낼 1차 답변 초안 (존댓말, 3~6문장, 사용자가 쓴 언어로)",
"needsOperator": true,
"questions": ["사용자에게 더 확인할 점이 있으면 0~2개"]}`;
}
// i18n-ignore-end
/** 분석 결과를 댓글 문장으로 */
function analysisText(a) {
return `[AI 1차 분석] 분류: ${a.category || '-'} · 중요도: ${a.priority || '-'} · 작업량: ${a.effort || '-'}${a.needsOperator ? ' · 운영팀 확인 필요' : ''}\n요약: ${a.summary || ''}\n${a.cause ? '원인/배경: ' + a.cause + '\n' : ''}${a.reproduce ? '재현 조건: ' + a.reproduce + '\n' : ''}영향 범위: ${a.scope || '-'}\n필요 작업: ${a.effortNote || '-'}\n중요도 근거: ${a.priorityReason || '-'}\n권장 처리: ${a.suggestion || '-'}${a.questions && a.questions.length ? '\n확인 필요: ' + a.questions.join(' / ') : ''}${a.replyDraft ? '\n\n[답변 초안]\n' + a.replyDraft : ''}`;
}
module.exports = { TYPE_LABEL, SERVICE_GUIDE, TYPES, STATUS, normStatus, add, get, list, update, comment, counts, setPlan, backlog, markCollected, markImplemented, backlogMarkdown, analysisPrompt, analysisText, cleanEnv };
+53
View File
@@ -0,0 +1,53 @@
'use strict';
// 생성 사이트의 문의 접수: 검증·스팸 차단·속도 제한 → 메일 발송 → 접수 기록(data/inquiries/<siteId>.json)
const fs = require('fs');
const path = require('path');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const DIR = path.join(DATA_ROOT, 'inquiries');
const sitemsg = require('./sitemsg'); // v1.3.16 방문자·소유자에게 보이는 글자는 id 로 (locales/site/ko.json)
fs.mkdirSync(DIR, { recursive: true });
const hits = new Map(); // key → timestamps
function rateLimited(key, limit, windowMs) { const now = Date.now(); const arr = (hits.get(key) || []).filter((t) => now - t < windowMs); if (arr.length >= limit) { hits.set(key, arr); return true; } arr.push(now); hits.set(key, arr); return false; }
function file(siteId) { return path.join(DIR, `${siteId}.json`); }
function list(siteId) { try { return JSON.parse(fs.readFileSync(file(siteId), 'utf8')); } catch (_) { return []; } }
function add(siteId, rec) { const l = list(siteId); l.push(rec); fs.writeFileSync(file(siteId), JSON.stringify(l.slice(-500), null, 1)); return rec; }
function validate(b) {
const s = (v, n) => String(v || '').trim().slice(0, n);
const d = { name: s(b.name, 80), email: s(b.email, 120), phone: s(b.phone, 40), subject: s(b.subject, 120), message: s(b.message, 5000), kind: s(b.kind, 20) || 'contact', page: s(b.page, 300), website: s(b.website, 100) };
// 위젯에 추가된 항목(회사명·업종 등): 이름 40자, 값 500자, 최대 12개까지 메일에 함께 담는다
const skip = new Set(['name', 'email', 'phone', 'subject', 'message', 'kind', 'page', 'website', 'agree']);
d.extra = {};
for (const [k, v] of Object.entries(b || {})) { if (skip.has(k) || Object.keys(d.extra).length >= 12) continue; const key = s(k, 40); const val = Array.isArray(v) ? v.map((x) => s(x, 200)).join(', ') : (typeof v === 'object' && v !== null ? '' : s(v, 500)); if (key && val) d.extra[key] = val; }
if (!d.name) throw sitemsg.err('err.formName');
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(d.email)) throw sitemsg.err('err.formEmail');
if (!d.message) throw sitemsg.err('err.formMessage');
return d;
}
function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
/**
* 사이트 소유자에게 보낼 문의 알림 메일.
* v1.3.16: 글자는 sitemsg(id) 로, 언어는 소유자(사이트 관리) 언어 lang, 시각은 사이트 시간대 tz 로 쓴다.
* @param {object} site
* @param {object} d validate() 결과(+ extra)
* @param {string} ip
* @param {object} [opt] { lang: 메일 언어(기본 'ko'), locale: 날짜 로케일(ko-KR 등), tz: 시간대(기본 Asia/Seoul) }
*/
function compose(site, d, ip, opt = {}) {
const t = sitemsg.forLang(opt.lang || 'ko'); const tz = opt.tz || 'Asia/Seoul';
const kindLabel = t(d.kind === 'booking' ? 'mail.kindBooking' : 'mail.kindContact');
let at; try { at = new Date().toLocaleString(opt.locale || 'ko-KR', { timeZone: tz, timeZoneName: 'short' }); } catch (_) { at = new Date().toISOString(); }
const subject = t('mail.contactSubject', { site: site.title, kind: kindLabel, subject: d.subject ? ': ' + d.subject : '', name: d.name });
const rows = [[t('common.name'), d.name], [t('common.email'), d.email], [t('common.phone'), d.phone || '-'], [t('common.subject'), d.subject || '-'], ...Object.entries(d.extra || {}), [t('mail.fieldKind'), kindLabel], [t('mail.fieldReceivedAt'), at], [t('mail.fieldPage'), d.page || '-'], ['IP', ip || '-']];
const intro = t('mail.contactIntro', { site: site.title, kind: kindLabel }); const hint = t('mail.replyHint', { email: d.email });
const text = `${intro}\n\n${rows.map(([k, v]) => `${k}: ${v}`).join('\n')}\n\n${t('mail.content')}\n${d.message}\n\n— ${hint}`;
const html = `<div style="font-family:-apple-system,'Segoe UI','Noto Sans KR',sans-serif;font-size:14px;color:#1f2933"><p>${esc(intro)}</p><table style="border-collapse:collapse">${rows.map(([k, v]) => `<tr><td style="padding:4px 10px 4px 0;color:#6b7684">${esc(k)}</td><td style="padding:4px 0">${esc(v)}</td></tr>`).join('')}</table><p style="white-space:pre-wrap;border-left:3px solid #c7d2fe;padding-left:10px;margin:14px 0">${esc(d.message)}</p><p style="color:#6b7684;font-size:12px">${esc(hint)}</p></div>`;
return { subject, text, html };
}
/** 접수 기록 한 건을 갱신한다 (재발송 결과 반영). idx 는 list() 순서(접수 순) 기준 */
function update(siteId, idx, patch) {
const all = list(siteId); if (!all[idx]) return null;
Object.assign(all[idx], patch);
fs.mkdirSync(DIR, { recursive: true }); const f = path.join(DIR, `${siteId}.json`); const tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(all, null, 1)); fs.renameSync(tmp, f);
return all[idx];
}
module.exports = { rateLimited, list, add, update, validate, compose };
+62
View File
@@ -0,0 +1,62 @@
'use strict';
// 원/달러 환율: 날짜별 캐시(data/fx.json). 자동 모드는 frankfurter(ECB 기준) → open.er-api 순으로 조회하고 스프레드를 더해 "구입 가격"을 근사한다.
const fs = require('fs');
const path = require('path');
const https = require('https');
const settings = require('./settings');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const FILE = path.join(DATA_ROOT, 'fx.json');
function loadCache() { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (_) { return {}; } }
function saveCache(c) { fs.mkdirSync(DATA_ROOT, { recursive: true }); fs.writeFileSync(FILE, JSON.stringify(c, null, 1)); }
function getJson(url) {
return new Promise((resolve, reject) => {
const req = https.get(url, { timeout: 8000, headers: { 'User-Agent': 'WebHostingAgent/1.0' } }, (res) => {
if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location) { res.resume(); return resolve(getJson(new URL(res.headers.location, url).toString())); }
let d = ''; res.on('data', (c) => { d += c; }); res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(new Error('응답 파싱 실패')); } });
});
req.on('timeout', () => req.destroy(new Error('시간 초과'))); req.on('error', reject);
});
}
/** KST 기준 날짜 문자열 (YYYY-MM-DD) */
function kstDate(d = new Date()) { return new Date(new Date(d).getTime() + 9 * 3600 * 1000).toISOString().slice(0, 10); }
async function fetchMid(date) {
const today = kstDate();
try { const j = await getJson(`https://api.frankfurter.dev/v1/${date === today ? 'latest' : date}?base=USD&symbols=KRW`); if (j && j.rates && j.rates.KRW) return { mid: j.rates.KRW, source: `ECB(frankfurter) ${j.date}` }; } catch (_) {}
if (date === today) { try { const j = await getJson('https://open.er-api.com/v6/latest/USD'); if (j && j.rates && j.rates.KRW) return { mid: j.rates.KRW, source: `open.er-api ${String(j.time_last_update_utc || '').slice(0, 16)}` }; } catch (_) {} }
throw new Error('환율 조회 실패');
}
/** 해당 날짜의 USD 구입가(원). 반환 {date, rate, mid, spreadPct, source} */
async function getRate(date) {
const s = settings.load(); date = date || kstDate();
if (s.fx.mode === 'manual' && s.fx.manualRate > 0) return { date, rate: s.fx.manualRate, mid: s.fx.manualRate, spreadPct: 0, source: '관리자 입력' };
const cache = loadCache();
let entry = cache[date];
const today = kstDate();
if (!entry || (date === today && Date.now() - Date.parse(entry.fetchedAt || 0) > 6 * 3600 * 1000)) {
try { const m = await fetchMid(date); entry = { mid: m.mid, source: m.source, fetchedAt: new Date().toISOString() }; cache[date] = entry; saveCache(cache); }
catch (e) { if (!entry) { const prev = Object.keys(cache).filter((k) => k < date).sort().pop(); if (prev) entry = { ...cache[prev], source: `${cache[prev].source} (${prev} 값 대체)` }; else if (s.fx.manualRate > 0) entry = { mid: s.fx.manualRate, source: '관리자 입력(조회 실패 대체)', fetchedAt: null }; else throw e; } }
}
const rate = Math.round(entry.mid * (1 + (s.fx.spreadPct || 0) / 100) * 100) / 100;
return { date, rate, mid: entry.mid, spreadPct: s.fx.spreadPct || 0, source: entry.source };
}
/**
* v1.4.0 다른 통화 1단위 = 몇 USD 인지 (도메인 가격은 EUR 등으로 온다). 하루 한 번 조회해 fx.json 에 'cross:<통화>:<날짜>' 로 둔다.
* 조회에 실패하면 가장 최근 값을 쓰고, 그것도 없으면 오류(가격을 모르고 청구하지 않게).
*/
async function usdPer(currency, date) {
const cur = String(currency || 'USD').toUpperCase(); if (cur === 'USD') return 1;
if (!/^[A-Z]{3}$/.test(cur)) throw new Error('통화 코드가 올바르지 않습니다');
date = date || kstDate(); const key = `cross:${cur}:${date}`; const cache = loadCache();
if (cache[key]) return cache[key].v;
let v = null;
try { const j = await getJson(`https://api.frankfurter.dev/v1/latest?base=${cur}&symbols=USD`); if (j && j.rates && j.rates.USD) v = j.rates.USD; } catch (_) {}
if (!v) { try { const j = await getJson(`https://open.er-api.com/v6/latest/${cur}`); if (j && j.rates && j.rates.USD) v = j.rates.USD; } catch (_) {} }
if (!v) { const prev = Object.keys(cache).filter((k) => k.startsWith(`cross:${cur}:`)).sort().pop(); if (prev) return cache[prev].v; throw new Error(`${cur} 환율 조회 실패`); }
cache[key] = { v, fetchedAt: new Date().toISOString() }; saveCache(cache); return v;
}
/** 금액(통화) → 원화 (USD 구입가 기준 — 스프레드 포함) */
async function toKrw(amount, currency, date) { const usd = Number(amount) * await usdPer(currency, date); const r = await getRate(date); return { krw: usd * r.rate, usd, rate: r.rate }; }
module.exports = { getRate, kstDate, loadCache, usdPer, toKrw };
+174
View File
@@ -0,0 +1,174 @@
'use strict';
// 사이트별 소스를 git 으로 보관하고 원격(Gitea)에 푸시한다.
// - 로컬 git 디렉터리: data/git/<siteId> (work-tree 는 /var/www/sites/<siteId>, .git 은 사이트 폴더에 두지 않음)
// - 원격 브랜치: sites/<siteId>/main 최신 상태
// sites/<siteId>/changes/NNNN-kind 변경(생성/AI 수정/수동 편집)마다 하나씩
// - 원격 저장소: v1.4.14 부터 서비스 서버 안의 전용 Gitea(deploy/install-gitea.sh 가 만드는 webmaker/sites, ssh 별칭 webmaker-gitea → 127.0.0.1:2230).
// 1.4.5 까지는 회사 소스 저장소(source.ibit.kr OZ-Group/WebHosting_AI_agent)에 섞어 두었고, 그 브랜치는 scripts/migrate-gitstore.sh 로 옮긴다.
// 다른 서버에서 복사해 온 저장소는 alignRepo 가 매 사용 때 origin 을 맞춘다.
// - v1.5.7 (요구 6): 접속 정보(푸시 주소·웹 주소·브랜치 접두어)와 오류 해석·상태 확인은 연동 모듈(lib/connectors/gitea.js)이 맡는다.
// 1.4.14~1.4.18 의 환경 변수 SITES_GIT_REMOTE/SITES_GIT_WEB/SITES_GIT_PREFIX 는 설정 파일(source.remote/web/prefix)로 옮겨졌고,
// 여기서는 호출할 때마다 모듈에서 읽으므로 설정 페이지에서 바꾼 값이 재시작 없이 다음 커밋부터 적용된다.
// git 명령 자체는 제품과 무관한 표준 동작이라 이 파일에 그대로 둔다 — 다른 git 서비스로 바꿀 때는 모듈만 바꾼다.
const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const connectors = require('./connectors');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const GIT_ROOT = path.join(DATA_ROOT, 'git');
fs.mkdirSync(GIT_ROOT, { recursive: true });
/** 지금 설정된 소스 보관 연동 정보. 연동 안 함(provider none)이면 remote 가 비어 원격 작업을 건너뛴다 */
function conn() {
const c = connectors.source(); if (!c) return { remote: '', web: '', prefix: 'sites', module: null };
const s = c.settings(); return { remote: s.remote || '', web: s.web || '', prefix: s.prefix || 'sites', module: c };
}
const NO_REMOTE = '소스 보관 연동 미설정 (설정 파일 source.remote 가 비어 있음 — 초기 설정 페이지에서 넣거나 deploy/install-gitea.sh 실행)';
function git(siteId, siteDir, args, opts = {}) {
return new Promise((resolve, reject) => {
const env = { ...process.env, GIT_DIR: path.join(GIT_ROOT, siteId), GIT_WORK_TREE: siteDir, GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new' };
execFile('git', args, { env, cwd: siteDir, timeout: opts.timeout || 60000, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
if (err) return reject(new Error(gitHint((stderr || err.message).trim().split('\n').map((l) => l.trim()).filter(Boolean).slice(-3).join(' '))));
resolve(stdout.trim());
});
});
}
function mainBranch(siteId) { return `${conn().prefix}/${siteId}/main`; }
function changeBranch(siteId, no, kind) { return `${conn().prefix}/${siteId}/changes/${String(no).padStart(4, '0')}-${kind}`; }
function branchUrl(branch) { const { module } = conn(); return module ? module.branchUrl(branch) : null; }
function commitUrl(sha) { const { module } = conn(); return module ? module.commitUrl(sha) : null; }
/** 사이트 git 저장소 초기화 (이미 있으면 무시) */
async function initSite(siteId, siteDir) {
const gitDir = path.join(GIT_ROOT, siteId);
if (fs.existsSync(path.join(gitDir, 'HEAD'))) return alignRepo(siteId, siteDir);
await git(siteId, siteDir, ['init', '-q', '-b', 'main']);
fs.mkdirSync(path.join(gitDir, 'info'), { recursive: true });
fs.writeFileSync(path.join(gitDir, 'info', 'exclude'), '.claude/\n.refs/\n*.tmp\n.unpublished\n');
const { remote } = conn(); if (remote) await git(siteId, siteDir, ['remote', 'add', 'origin', remote]);
}
/** 다른 서버에서 복사해 온 저장소는 origin 주소·work-tree 가 그 서버 기준이므로 이 서버 설정에 맞춘다 (매 사용 시, 비용 미미) */
async function alignRepo(siteId, siteDir) {
try {
const { remote } = conn();
const cur = await git(siteId, siteDir, ['config', '--get', 'remote.origin.url']).catch(() => '');
if (remote && cur !== remote) { if (cur) await git(siteId, siteDir, ['remote', 'set-url', 'origin', remote]); else await git(siteId, siteDir, ['remote', 'add', 'origin', remote]); }
const wt = await git(siteId, siteDir, ['config', '--get', 'core.worktree']).catch(() => '');
if (wt && wt !== siteDir) await git(siteId, siteDir, ['config', 'core.worktree', siteDir]);
// 예전 저장소의 제외 목록에 발행 취소 표식(.unpublished)이 없으면 추가 (v1.1.4)
const ex = path.join(GIT_ROOT, siteId, 'info', 'exclude'); try { const cur2 = fs.existsSync(ex) ? fs.readFileSync(ex, 'utf8') : ''; if (!/^\.unpublished$/m.test(cur2)) { fs.mkdirSync(path.dirname(ex), { recursive: true }); fs.writeFileSync(ex, cur2.replace(/\s*$/, '\n') + '.unpublished\n'); } } catch (_) {}
} catch (_) { /* 정렬 실패는 이후 작업의 오류로 드러난다 */ }
}
// 사이트별 직렬 실행 큐 (동시 커밋/푸시 방지)
const queues = new Map();
/** git/ssh 오류를 운영자가 조치할 수 있는 문구로 (연동 모듈이 제품에 맞게 해석, 모듈이 없으면 원문) */
function gitHint(msg) { const { module } = conn(); return module && module.gitHint ? module.gitHint(msg) : msg; }
/** 원격 저장소 연결 상태와 이 서버가 쓰는 공개키 (관리자 콘솔용) — 연동 모듈의 status() */
function remoteStatus() { return connectors.status('source'); }
function enqueue(siteId, fn) {
const prev = queues.get(siteId) || Promise.resolve();
const next = prev.then(fn, fn);
queues.set(siteId, next.catch(() => {}));
return next;
}
/**
* 변경 사항을 커밋하고 변경 브랜치 + main 브랜치로 푸시한다.
* @returns {Promise<{no, kind, branch, commit, message, pushed, error, at}|null>} 변경이 없으면 null
*/
function recordChange({ siteId, siteDir, no, kind, message, author }) {
return enqueue(siteId, async () => {
await initSite(siteId, siteDir);
await git(siteId, siteDir, ['add', '-A']);
const status = await git(siteId, siteDir, ['status', '--porcelain']);
if (!status) return null;
const authorArgs = ['-c', `user.name=${author.name || 'webhosting-agent'}`, '-c', `user.email=${author.email || 'webhosting-agent@local'}`];
await git(siteId, siteDir, [...authorArgs, 'commit', '-q', '-m', message]);
const commit = await git(siteId, siteDir, ['rev-parse', 'HEAD']);
const branch = changeBranch(siteId, no, kind);
await git(siteId, siteDir, ['branch', '-f', branch, 'HEAD']);
// 이 버전에서 바뀐 파일 목록 (A=추가, M=수정, D=삭제, R=이름변경) — 사용자에게 "어느 페이지를 어떻게 고쳤는지" 보여 주는 데 쓴다
let files = []; let stat = '';
try { files = (await git(siteId, siteDir, ['diff-tree', '--no-commit-id', '--name-status', '-r', '--root', 'HEAD'])).split('\n').filter(Boolean).slice(0, 100).map((l) => { const [st, ...rest] = l.split('\t'); return { status: st[0], path: rest[rest.length - 1] }; }); } catch (_) {}
try { stat = (await git(siteId, siteDir, ['show', '--shortstat', '--format=', 'HEAD'])).trim(); } catch (_) {}
const rec = { no, kind, branch, commit, message, pushed: false, error: null, at: new Date().toISOString(), url: branchUrl(branch), commitUrl: commitUrl(commit), files, stat };
const { remote } = conn();
if (remote) {
try {
await git(siteId, siteDir, ['push', '-q', 'origin', `+HEAD:refs/heads/${mainBranch(siteId)}`, `HEAD:refs/heads/${branch}`], { timeout: 120000 });
rec.pushed = true;
} catch (e) { rec.error = e.message; }
} else rec.error = NO_REMOTE;
return rec;
});
}
/** 배포 완료 후: 이력을 버리고 현재 트리 하나의 커밋만 남긴다(로컬·원격 변경 브랜치 삭제, main 강제 갱신) */
function squashToCurrent(siteId, siteDir, message) {
return enqueue(siteId, async () => {
await initSite(siteId, siteDir);
const { remote, prefix } = conn();
// 로컬 변경 브랜치 목록(원격 삭제용)
let localBranches = [];
try { localBranches = (await git(siteId, siteDir, ['for-each-ref', '--format=%(refname:short)', `refs/heads/${prefix}/${siteId}/changes/`])).split('\n').filter(Boolean); } catch (_) {}
// 고아 브랜치에 현재 상태 커밋 → main 교체
await git(siteId, siteDir, ['checkout', '-q', '--orphan', '_squash']);
await git(siteId, siteDir, ['add', '-A']);
await git(siteId, siteDir, ['-c', 'user.name=webhosting-agent', '-c', 'user.email=webhosting-agent@local', 'commit', '-q', '--allow-empty', '-m', message]);
const commit = await git(siteId, siteDir, ['rev-parse', 'HEAD']);
try { await git(siteId, siteDir, ['branch', '-D', 'main']); } catch (_) {}
await git(siteId, siteDir, ['branch', '-m', 'main']);
for (const b of localBranches) { try { await git(siteId, siteDir, ['branch', '-D', b]); } catch (_) {} }
try { await git(siteId, siteDir, ['reflog', 'expire', '--expire=now', '--all']); await git(siteId, siteDir, ['gc', '-q', '--prune=now']); } catch (_) {}
const rec = { no: 1, kind: 'deploy', branch: mainBranch(siteId), commit, message, pushed: false, error: null, at: new Date().toISOString(), url: branchUrl(mainBranch(siteId)), commitUrl: commitUrl(commit) };
if (remote) {
try {
// 원격 변경 브랜치 삭제 + main 강제 갱신
let remoteBranches = [];
try { remoteBranches = (await git(siteId, siteDir, ['ls-remote', '--heads', 'origin', `refs/heads/${prefix}/${siteId}/changes/*`], { timeout: 60000 })).split('\n').map((l) => l.split('\t')[1]).filter(Boolean); } catch (_) {}
await git(siteId, siteDir, ['push', '-q', '--force', 'origin', `HEAD:refs/heads/${mainBranch(siteId)}`], { timeout: 120000 });
for (let i = 0; i < remoteBranches.length; i += 20) { await git(siteId, siteDir, ['push', '-q', 'origin', ...remoteBranches.slice(i, i + 20).map((r) => `:${r}`)], { timeout: 120000 }); }
rec.pushed = true;
} catch (e) { rec.error = e.message; }
} else rec.error = NO_REMOTE;
return rec;
});
}
/** 푸시 실패분 재시도 */
function retryPush(siteId, siteDir, recs) {
return enqueue(siteId, async () => {
if (!conn().remote) return recs;
await initSite(siteId, siteDir);
// v1.3.17 (B07): 예전에는 실패했던 옛 기록마다 그 커밋을 main 에 강제로 올려, 새 버전이 이미 올라간 뒤에 main 이 옛 내용으로
// 되감길 수 있었다. 이제 옛 기록은 자기 변경 브랜치에만 올리고, main 은 늘 지금 HEAD(가장 새 버전)로 맞춘다.
for (const r of recs) {
if (r.pushed) continue;
try { if (r.kind !== 'deploy' && r.branch) await git(siteId, siteDir, ['push', '-q', 'origin', `${r.commit}:refs/heads/${r.branch}`], { timeout: 120000 }); r.pushed = true; r.error = null; }
catch (e) { r.error = e.message; }
}
try { await git(siteId, siteDir, ['push', '-q', 'origin', `+HEAD:refs/heads/${mainBranch(siteId)}`], { timeout: 120000 }); } catch (e) { for (const r of recs) if (r.pushed && !r.error) { r.pushed = false; r.error = e.message; } }
return recs;
});
}
/** 작업 트리를 지정 커밋의 내용으로 되돌린다(추적 파일 기준). 이후 recordChange 로 새 변경(restore)을 기록해야 한다. */
function restoreTo(siteId, siteDir, commit) {
return enqueue(siteId, async () => {
await initSite(siteId, siteDir);
await git(siteId, siteDir, ['read-tree', '-u', '--reset', commit]);
return true;
});
}
function info(siteId) {
const { remote, module } = conn();
return { remote: remote ? remote.replace(/^.*[:/]([^/]+\/[^/]+?)(\.git)?$/, '$1') : null, provider: module ? module.id : 'none', mainBranch: mainBranch(siteId), mainUrl: branchUrl(mainBranch(siteId)), enabled: !!remote };
}
module.exports = { recordChange, retryPush, restoreTo, squashToCurrent, info, mainBranch, remoteStatus, gitHint };
+70
View File
@@ -0,0 +1,70 @@
'use strict';
// ============================================================================
// 구글 검색 등록 자동화 (v1.3.10)
//
// 사람이 Search Console 화면에서 하던 일을 구글 공식 API 로 대신한다. 필요한 것은 관리자가 한 번 넣는
// "서비스 계정 키(JSON)" 하나 (관리자 콘솔 › 연동 키 › 구글 검색 등록 자동화). 이 키로
// 1) Site Verification API : 사이트 확인 토큰(meta 태그) 받기 → (서비스가 태그를 넣고 재배포) → 소유 확인
// 2) Search Console API : 속성(사이트) 추가 → 사이트맵 제출
// 를 모두 자동으로 한다. 확인이 끝나면 서비스 계정이 사이트 소유자가 되며, OWNER_EMAIL(관리자 구글 계정)을
// 소유자로 함께 넣어 관리자가 Search Console 에서도 볼 수 있게 한다.
//
// 인증: 서비스 계정 JWT(RS256) → OAuth 토큰 (외부 라이브러리 없이 crypto 로 서명). 토큰은 만료 1분 전까지 재사용.
// 시험용 대체 주소: GOOGLE_TOKEN_URL(토큰), GOOGLE_API_BASE(https://www.googleapis.com 대신)
// ============================================================================
const crypto = require('crypto');
const SCOPES = ['https://www.googleapis.com/auth/siteverification', 'https://www.googleapis.com/auth/webmasters'];
const API = () => (process.env.GOOGLE_API_BASE || 'https://www.googleapis.com').replace(/\/+$/, '');
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const b64u = (b) => Buffer.from(b).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
let cache = { key: '', token: '', exp: 0 }; // 토큰 캐시 (같은 키일 때만)
/** 키 JSON 해석 (보관소의 secret 문자열) */
function parseKey(secret) { let j = null; try { j = JSON.parse(secret || ''); } catch (_) {} if (!j || !j.client_email || !j.private_key) throw httpError('구글 서비스 계정 키가 없거나 형식이 올바르지 않습니다 (관리자 콘솔 › 연동 키 › 구글 검색 등록 자동화)', 409); return j; }
const accountOf = (secret) => { try { return parseKey(secret).client_email; } catch (_) { return ''; } };
/** 서비스 계정으로 OAuth 액세스 토큰 받기 */
async function accessToken(secret, scopes) { // v1.5.36: scopes 를 주면 그 범위로 (Cloud DNS 등), 없으면 검색 등록 범위
const SC = Array.isArray(scopes) && scopes.length ? scopes : SCOPES;
const k = parseKey(secret); const id = k.client_email + k.private_key_id + '|' + SC.join(' ');
if (cache.key === id && cache.exp - 60 > Date.now() / 1000) return cache.token;
const now = Math.floor(Date.now() / 1000); const aud = process.env.GOOGLE_TOKEN_URL || k.token_uri || 'https://oauth2.googleapis.com/token';
const head = b64u(JSON.stringify({ alg: 'RS256', typ: 'JWT', kid: k.private_key_id })); const body = b64u(JSON.stringify({ iss: k.client_email, scope: SC.join(' '), aud, iat: now, exp: now + 3600 }));
let sig; try { sig = b64u(crypto.sign('RSA-SHA256', Buffer.from(`${head}.${body}`), k.private_key)); } catch (e) { throw httpError(`서비스 계정 키의 private_key 로 서명하지 못했습니다: ${e.message}`, 409); }
let r; try { r = await fetch(aud, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: `${head}.${body}.${sig}` }), signal: AbortSignal.timeout(20000) }); } catch (e) { throw httpError(`구글 인증 서버에 연결하지 못했습니다: ${e.cause ? e.cause.code || e.message : e.message}`, 502); }
const j = await r.json().catch(() => ({}));
if (!r.ok || !j.access_token) throw httpError(`구글 인증 실패 (${r.status} ${j.error || ''}${j.error_description ? ': ' + j.error_description : ''}) — 키가 폐기되었거나 서비스 계정이 비활성일 수 있습니다`, 502);
cache = { key: id, token: j.access_token, exp: now + (j.expires_in || 3600) }; return j.access_token;
}
/** 구글 API 호출 (JSON). 오류는 사람이 읽을 수 있는 안내로 바꾼다 */
async function call(secret, method, pathname, body) {
const tok = await accessToken(secret);
let r; try { r = await fetch(API() + pathname, { method, headers: { Authorization: `Bearer ${tok}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(30000) }); } catch (e) { throw httpError(`구글 API 에 연결하지 못했습니다: ${e.cause ? e.cause.code || e.message : e.message}`, 502); }
const text = await r.text(); let j = {}; try { j = text ? JSON.parse(text) : {}; } catch (_) {}
if (!r.ok) {
const msg = (j.error && (j.error.message || j.error.status)) || text.slice(0, 200);
const hint = r.status === 403 && /has not been used|disabled|SERVICE_DISABLED/i.test(msg) ? ' — Google Cloud 콘솔에서 이 프로젝트의 "Site Verification API" 와 "Google Search Console API" 를 사용 설정하세요' : r.status === 400 && /verif/i.test(msg) ? ' — 운영 첫 페이지에 확인 태그가 아직 보이지 않습니다(재배포 반영을 기다린 뒤 다시 시도)' : r.status === 403 ? ' — 서비스 계정에 이 사이트 권한이 없습니다(소유 확인을 먼저 끝내야 합니다)' : '';
throw Object.assign(httpError(`구글 API 오류 ${r.status}: ${msg}${hint}`, 502), { googleStatus: r.status });
}
return j;
}
/** 1) 사이트 확인 토큰 (meta 태그의 content 값) */
async function verificationToken(secret, siteUrl) {
const j = await call(secret, 'POST', '/siteVerification/v1/token', { site: { type: 'SITE', identifier: siteUrl }, verificationMethod: 'META' });
const m = String(j.token || '').match(/content=["']([^"']+)["']/); if (!m) throw httpError('구글이 확인 토큰을 주지 않았습니다', 502); return m[1];
}
/** 2) 소유 확인 (운영 첫 페이지에 태그가 있어야 성공). ownerEmail 이 있으면 소유자로 함께 등록 */
async function verify(secret, siteUrl, ownerEmail) {
const res = await call(secret, 'POST', '/siteVerification/v1/webResource?verificationMethod=META', { site: { type: 'SITE', identifier: siteUrl } });
if (ownerEmail && res.id && !(res.owners || []).includes(ownerEmail)) {
try { await call(secret, 'PUT', `/siteVerification/v1/webResource/${encodeURIComponent(res.id)}`, { site: res.site || { type: 'SITE', identifier: siteUrl }, owners: [...new Set([...(res.owners || []), ownerEmail])] }); res.ownerAdded = ownerEmail; }
catch (e) { res.ownerError = e.message; }
}
return res;
}
/** 3) Search Console 속성 추가 + 사이트맵 제출 */
async function addSite(secret, siteUrl) { await call(secret, 'PUT', `/webmasters/v3/sites/${encodeURIComponent(siteUrl)}`); return true; }
async function submitSitemap(secret, siteUrl, sitemapUrl) { await call(secret, 'PUT', `/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent(sitemapUrl)}`); return true; }
module.exports = { accessToken, accountOf, verificationToken, verify, addSite, submitSitemap, parseKey };
+389
View File
@@ -0,0 +1,389 @@
'use strict';
// ============================================================================
// 다국어(로케일) 모듈 (v1.1.2)
//
// 방식: "원문(한국어) → 번역" 사전.
// - 카탈로그: 사용자 화면 파일(public/index.html, login.html, app.js, wizard.js, import.js, modules-ui.js,
// site-admin.html/js)에서 한국어 문자열을 모두 추출해 하나의 목록으로 만든다. 각 항목에는 번역에 참고할
// 속성(type: 제목/버튼이름/입력항목명/입력예시/도움말/선택항목/메시지/설명내용)과 출처(where)를 붙인다.
// - 내보내기: 관리자가 카탈로그를 한 JSON 파일로 내려받아 translation 칸만 채워 올린다.
// - 올리기: JSON 파일 여러 개 또는 ZIP(JSON 묶음). lang 코드별로 data/locales/<lang>.json 에 저장.
// - 유효성: 번역 파일의 sourceVersion 이 현재 서비스 버전보다 낮으면 "구버전" 으로 보고 노출을 중지한다
// (화면 문구가 바뀐 뒤 옛 번역이 섞여 표시되는 것을 막기 위해). 관리자가 켜고 끌 수도 있다.
// - 런타임(public/i18n.js): 사전을 받아 화면의 텍스트·placeholder·title·data-tip 을 치환한다.
// 코드에 번역 키를 심지 않고 원문 자체를 키로 쓰므로, 화면 문구를 바꾸면 그 문구의 번역만 사라진다.
// ============================================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const zlib = require('zlib');
const ROOT = path.resolve(__dirname, '..');
const DATA_ROOT = process.env.DATA_ROOT || path.join(ROOT, 'data');
const DIR = path.join(DATA_ROOT, 'locales');
fs.mkdirSync(DIR, { recursive: true });
const SOURCE = 'ko';
const VERSION = require(path.join(ROOT, 'package.json')).version;
// 추출 대상 파일과 종류
// 추출 대상 파일: [경로, 종류, 묶음(group)]. 묶음은 번역 파일에서 "어느 화면 문구인지" 를 알려 준다.
const FILES = [
['public/index.html', 'html', '빌더'], ['public/app.js', 'js', '빌더'], ['public/wizard.js', 'js', '빌더'], ['public/import.js', 'js', '빌더'], ['public/modules-ui.js', 'js', '빌더'], ['public/optimize.js', 'js', '빌더'], ['public/templates-ui.js', 'js', '빌더'], ['public/domains-ui.js', 'js', '빌더'], ['public/manage-ui.js', 'js', '빌더'], ['public/flow-ui.js', 'js', '빌더'], ['public/site-info.js', 'js', '빌더'], ['public/credits-ui.js', 'js', '빌더'], // v1.4.14 관리 창 · v1.6.40 작업 흐름 지도·캐시 충전
['public/login.html', 'html', '로그인'],
['public/site-admin.html', 'html', '사이트 관리자'], ['public/site-admin.js', 'js', '사이트 관리자'],
['public/webmail.html', 'html', '웹메일'], ['public/webmail.js', 'js', '웹메일'],
['public/admin.html', 'html', '관리자 콘솔'], ['public/admin.js', 'js', '관리자 콘솔'], // v1.3.10 관리자 페이지 다국어
['public/setup.html', 'html', '초기 설정'], ['public/setup.js', 'js', '초기 설정'], // v1.5.7 초기 설정 페이지(시스템 설정 탭과 공용)
];
/**
* v1.3.10: 서버가 화면으로 보내는 문구(모듈 이름·설명, 모델 등급·비용 안내, 업종·지역 목록, 상태·오류 메시지 등).
* catalog : 화면 표시용 목록 파일 — 표시 문맥(label: 등)의 문자열과 목록 배열 안의 문자열을 모두 뽑는다
* messages: 일반 서버 코드 — 사용자에게 보이는 문맥(error:, httpError(, new Error(, text:, userWarn( 등)의 문자열만
* AI 프롬프트 파일(lib/prompt.js, lib/planner.js)은 넣지 않는다(화면에 나오지 않음).
*/
const SERVER_FILES = [
['lib/config.js', 'catalog'], ['lib/setupapi.js', 'messages'], ['lib/connectors/gitea.js', 'messages'], ['lib/connectors/arimail.js', 'messages'], // v1.5.7 설정 항목표·연동 모듈 문구
['lib/mailusage.js', 'messages'], // v1.7.3 메일 사용량 연동 문구
['lib/setupstate.js', 'messages'], ['lib/setupauth.js', 'messages'], // v1.5.11 설치 점검표·설정 페이지 인증 문구
['lib/modules.js', 'catalog'], ['lib/brief.js', 'catalog'], ['lib/settings.js', 'catalog'], ['lib/seo.js', 'catalog'], ['lib/mailsys.js', 'catalog'], ['lib/logview.js', 'catalog'], ['lib/feedback.js', 'messages'],
['server.js', 'messages'], ['lib/deploy.js', 'messages'], ['lib/remote.js', 'messages'], ['lib/credits.js', 'messages'], ['lib/prepaid.js', 'messages'], ['lib/vault.js', 'messages'], ['lib/crawler.js', 'messages'], ['lib/auth.js', 'messages'], ['lib/uploads.js', 'messages'], ['lib/forms.js', 'messages'], ['lib/i18n.js', 'messages'], ['lib/mailer.js', 'messages'], ['lib/templates.js', 'catalog'], ['lib/multilang.js', 'catalog'], ['lib/google.js', 'messages'], ['lib/passpolicy.js', 'messages'], ['lib/netguard.js', 'messages'],
];
// 사용자에게 보이는 문맥 (바로 앞 코드가 이 모양이면 화면 문구로 본다)
const SHOWN_CTX = /(?:\b(?:label|desc|description|title|summary|fit|text|error|message|detail|reason|why|note|help|name|stateLabel|stateDetail|caption|hint|placeholder|state|next|mode|kindLabel|warning|guide)\s*:\s*|httpError\(\s*|new Error\(\s*|userWarn\([^,()]+,\s*|alert\(\s*|send\(res,\s*\d+,\s*\{\s*error:\s*|\?\s*|:\s*)$/;
const ARRAY_CTX = /[\[,]\s*$/; // 목록 배열 안 (catalog 파일만)
const PROMPT_CTX = /(?:prompt|Prompt|PROMPT|SYSTEM|focus|instruction)\s*[=:+]\s*(?:\(?\s*)?$|return\s*$/; // 프롬프트로 보이는 문자열은 제외
const HANGUL = /[가-힣]/;
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const idOf = (text) => crypto.createHash('sha1').update(text).digest('hex').slice(0, 10);
// ---------- 추출 ----------
/** 태그 이름으로 문구 속성을 추정 */
function typeForTag(tag, cls) {
if (/^h[1-6]$|^summary$|^legend$/.test(tag)) return '제목';
if (tag === 'button' || (tag === 'a' && /\bbtn\b/.test(cls || ''))) return '버튼이름';
if (tag === 'label') return '입력항목명';
if (tag === 'option') return '선택항목';
if (tag === 'th') return '표머리';
if (/field-label/.test(cls || '')) return '제목';
return '설명내용';
}
/** HTML 파일에서 텍스트 노드와 번역 대상 속성(placeholder, title, data-tip, alt)을 뽑는다 (의존성 없는 간단한 토크나이저) */
function extractHtml(src, where) {
const out = []; const stack = []; let i = 0; const push = (text, type, extra) => { const t = text.replace(/\s+/g, ' ').trim(); if (t && HANGUL.test(t)) out.push({ text: t, type, where: `${where}${extra ? ' ' + extra : ''}` }); };
while (i < src.length) {
const lt = src.indexOf('<', i); if (lt < 0) { push(src.slice(i), typeForTag(stack[stack.length - 1] || '', '')); break; }
if (lt > i) { const top = stack[stack.length - 1] || {}; push(src.slice(i, lt), typeForTag(top.tag || '', top.cls)); }
const gt = src.indexOf('>', lt); if (gt < 0) break;
const tagSrc = src.slice(lt + 1, gt);
if (tagSrc.startsWith('!--')) { const end = src.indexOf('-->', lt); i = end < 0 ? src.length : end + 3; continue; }
if (tagSrc.startsWith('/')) { stack.pop(); i = gt + 1; continue; }
const m = tagSrc.match(/^([a-zA-Z][\w-]*)/); const tag = m ? m[1].toLowerCase() : '';
const cls = (tagSrc.match(/\sclass="([^"]*)"/) || [])[1] || '';
for (const [attr, type] of [['placeholder', '입력예시'], ['title', '도움말'], ['data-tip', '도움말'], ['alt', '설명내용'], ['aria-label', '도움말']]) { const am = tagSrc.match(new RegExp(`\\s${attr}="([^"]*)"`)); if (am) push(am[1].replace(/&#10;/g, ' '), type, `[${attr}]`); }
if (tag === 'script' || tag === 'style') { const close = src.indexOf(`</${tag}`, gt); if (tag === 'script') out.push(...extractJs(src.slice(gt + 1, close < 0 ? src.length : close), where + ' <script>')); i = close < 0 ? src.length : close; continue; }
if (!tagSrc.endsWith('/') && !['br', 'hr', 'img', 'input', 'meta', 'link'].includes(tag)) stack.push({ tag, cls });
i = gt + 1;
}
return out;
}
/**
* 템플릿 리터럴(`…${식}…`) 찾기 (v1.3.2).
* 식 부분(${…})은 괄호 깊이와 그 안의 문자열·중첩 템플릿을 건너뛰며 끝을 찾는다. 중첩 템플릿도 따로 돌려준다.
* @returns {Array<{index, parts: string[]}>} parts: 식을 뺀 글자 조각들 (조각 수 = 식 수 + 1)
*/
function scanTemplates(src) {
const out = [];
const skipString = (i, q) => { i++; while (i < src.length && src[i] !== q) { if (src[i] === '\\') i++; if (src[i] === '\n') break; i++; } return i + 1; };
// 정규식 리터럴(/"/g 같은)의 따옴표를 문자열로 오해하지 않도록: 앞 글자가 연산자·여는 괄호면 정규식으로 보고 건너뛴다
const isRegexStart = (i) => { let j = i - 1; while (j >= 0 && /\s/.test(src[j])) j--; return j < 0 || /[(,=:[!&|?{};+\-*%<>~^]/.test(src[j]) || /\b(return|typeof|case|in|of)$/.test(src.slice(Math.max(0, j - 6), j + 1)); };
const skipRegex = (i) => { i++; let cls = false; while (i < src.length && src[i] !== '\n') { const c = src[i]; if (c === '\\') { i += 2; continue; } if (c === '[') cls = true; else if (c === ']') cls = false; else if (c === '/' && !cls) { i++; while (/[a-z]/i.test(src[i] || '')) i++; return i; } i++; } return i; };
function template(i) { // src[i] === '`'
const start = i; const parts = ['']; i++;
while (i < src.length && src[i] !== '`') {
if (src[i] === '\\') { parts[parts.length - 1] += src[i] + (src[i + 1] || ''); i += 2; continue; }
if (src[i] === '$' && src[i + 1] === '{') {
i += 2; let depth = 1;
while (i < src.length && depth > 0) { const c = src[i]; if (c === '{') depth++; else if (c === '}') depth--; else if (c === "'" || c === '"') { i = skipString(i, c); continue; } else if (c === '`') { i = template(i); continue; } else if (c === '/' && src[i + 1] !== '/' && src[i + 1] !== '*' && isRegexStart(i)) { i = skipRegex(i); continue; } if (depth > 0) i++; }
i++; parts.push(''); continue;
}
parts[parts.length - 1] += src[i]; i++;
}
out.push({ index: start, end: i + 1, parts }); return i + 1;
}
let i = 0;
while (i < src.length) {
const c = src[i];
if (c === '/' && src[i + 1] === '/') { const nl = src.indexOf('\n', i); i = nl < 0 ? src.length : nl; continue; }
if (c === '/' && src[i + 1] === '*') { const e = src.indexOf('*/', i + 2); i = e < 0 ? src.length : e + 2; continue; }
if (c === "'" || c === '"') { i = skipString(i, c); continue; }
if (c === '`') { i = template(i); continue; }
if (c === '/' && isRegexStart(i)) { i = skipRegex(i); continue; }
i++;
}
return out;
}
/** JS 문자열 조각의 이스케이프 풀기 + 공백 정리 */
const cleanJs = (t) => t.replace(/\\n/g, ' ').replace(/\\'/g, "'").replace(/\\"/g, '"').replace(/\\`/g, '`').replace(/\s+/g, ' ');
/** 앞 코드로 문구 속성(type) 추정 — 따옴표 문자열과 템플릿에 함께 쓴다 */
function typeFromContext(before) {
let type = '설명내용';
if (/btn\(\s*$|mkBtn\(\s*$|textContent = \s*$|\.textContent\s*=\s*$|'button',\s*(['"][^'"]*['"],\s*)?$/.test(before)) type = '버튼이름';
if (/alert\(\s*$|confirm\(\s*$|prompt\(\s*$|text:\s*$|\.textContent = \s*$/.test(before) && /alert|confirm|prompt|text:/.test(before.slice(-12))) type = '메시지';
if (/h3\(\s*$|el\('h[1-6]',\s*(['"][^'"]*['"],\s*)?$|'field-label',\s*$|el\('summary',[^,]*,\s*$/.test(before)) type = '제목';
if (/placeholder\s*=\s*$/.test(before)) type = '입력예시';
if (/title\s*=\s*$|\.title = \s*$|tip = \s*$|dataset\.tip = \s*$/.test(before)) type = '도움말';
if (/new Option\(\s*$/.test(before)) type = '선택항목';
if (/el\('th',[^,]*,\s*$|el\('td', 'muted small',\s*$/.test(before)) type = '표머리';
return type;
}
/**
* 템플릿 리터럴 → 번역 패턴 (v1.3.2).
* `현재 사이트: ${current.title} · 화면: ${tabLabel()} (함께 전달됩니다)` → "현재 사이트: {1} · 화면: {2} (함께 전달됩니다)"
* 화면(public/i18n.js)은 이 패턴으로 실제 문장을 맞춰 보고 {1}·{2} 자리에 원래 값을 넣은 번역문으로 바꾼다.
* HTML 조각(<…>)을 만드는 템플릿, 한글이 식 안에만 있는 템플릿은 제외한다.
*/
/**
* HTML 을 만드는 템플릿(`<td>약 ${a} ~ ${b}</td>` 등)을 태그 경계로 나눠 화면에 보일 글자 조각만 돌려준다 (v1.3.10).
* 1.3.9 까지는 태그가 든 템플릿을 통째로 건너뛰어 표 머리·비용 안내 같은 문구가 번역 파일에 없었다.
* 조각 안의 ${식} 은 조각마다 {1}·{2} 로 다시 번호를 매기고, 태그 속성(title="…" 등)의 문구도 따로 뽑는다.
* @param {string[]} statics 식을 뺀 글자 조각 (식 자리는 사이사이)
* @returns {Array<{text: string, pattern: boolean}>}
*/
function splitHtmlTemplate(statics) {
const MARK = '\u0000'; const joined = statics.map((x, i) => (i ? `${MARK}${i}${MARK}` : '') + x).join('');
const out = []; const norm = (t) => { let n = 0; const map = {}; const txt = t.replace(new RegExp(`${MARK}(\\d+)${MARK}`, 'g'), (a, k) => `{${map[k] || (map[k] = ++n)}}`).replace(/&[a-z]+;|&#\d+;/g, ' ').replace(/\s+/g, ' ').trim(); return { text: txt, pattern: n > 0 }; };
// 태그 속성 안의 문구
for (const m of joined.matchAll(/\s(?:title|placeholder|data-tip|aria-label|alt)="([^"]*)"/g)) { const r = norm(m[1]); if (HANGUL.test(r.text) && !/^\{\d+\}$/.test(r.text)) out.push(r); }
for (const seg of joined.replace(/<[^>]*>/g, '\u0001').split('\u0001')) { const r = norm(seg); if (r.text && HANGUL.test(r.text) && r.text.length <= 400 && !/^\{\d+\}$/.test(r.text)) out.push(r); }
return out;
}
function extractTemplates(src, where) {
const out = [];
for (const t of scanTemplates(src)) {
const statics = t.parts.map(cleanJs);
if (!statics.some((x) => HANGUL.test(x))) continue;
if (statics.some((x) => /<[a-zA-Z\/!][^>]*>/.test(x))) { const type = typeFromContext(src.slice(Math.max(0, t.index - 60), t.index)); for (const r of splitHtmlTemplate(statics)) out.push({ text: r.text, type, where, pattern: r.pattern }); continue; }
let text = statics.map((x, i) => (i ? `{${i}}` : '') + x).join('').replace(/\s+/g, ' ').trim();
if (!text || text.length > 400) continue;
if (/^\{\d+\}$/.test(text)) continue;
out.push({ text, type: typeFromContext(src.slice(Math.max(0, t.index - 60), t.index)), where, pattern: t.parts.length > 1 });
}
return out;
}
/** JS 코드에서 한글이 든 문자열 리터럴('…', "…")과 템플릿 리터럴(v1.3.2, 변수 자리는 {1}…)을 뽑는다. */
function extractJs(src, where) {
const out = []; const re = /(['"])((?:\\.|(?!\1)[^\\\n])*)\1/g; let m;
while ((m = re.exec(src))) {
let text = m[2]; if (!HANGUL.test(text) || text.includes('${')) continue;
text = cleanJs(text).trim(); if (!text || text.length > 400) continue;
out.push({ text, type: typeFromContext(src.slice(Math.max(0, m.index - 60), m.index)), where });
}
out.push(...extractTemplates(src, where));
return out;
}
/**
* 서버 코드에서 화면 문구만 뽑는다 (v1.3.10). 따옴표 문자열과 템플릿 문자열({1} 패턴) 모두 문맥으로 거른다.
* 300자를 넘는 문자열은 화면 문구가 아니라 프롬프트·HTML 조각으로 보고 뺀다.
*/
function extractServer(src, where, mode) {
const out = []; const take = (text, idx, pattern) => {
const before = src.slice(Math.max(0, idx - 80), idx);
if (mode !== 'catalog' && PROMPT_CTX.test(before)) return;
const ok = mode === 'catalog' || SHOWN_CTX.test(before); // 목록 파일은 화면용 문구 모음이라 모두 (프롬프트 템플릿·무시 구역은 위에서 제외)
if (!ok) return; text = text.replace(/\s+/g, ' ').trim(); if (!text || text.length > 300 || !HANGUL.test(text)) return;
if (/^[^가-힣]*(?:[가-힣]+[^가-힣]*){0,1}$/.test(text) && text.length < 2) return;
out.push({ text, type: '서버 메시지', where, ...(pattern ? { pattern: true } : {}) });
};
// 300자가 넘는 템플릿 문자열(=AI 프롬프트·HTML 조각) 안의 따옴표 문자열은 화면 문구가 아니다
// 소스에 // i18n-ignore-start … // i18n-ignore-end 로 감싼 구역(AI 지시문·파일 내용 등)은 번역 대상이 아니다
const ign = []; { const r = /i18n-ignore-start[\s\S]*?i18n-ignore-end/g; let k; while ((k = r.exec(src))) ign.push([k.index, k.index + k[0].length]); }
const tpls = scanTemplates(src); const big = tpls.filter((t) => t.end - t.index > 300).map((t) => [t.index, t.end]);
const inBig = (i) => big.some(([a, b]) => i > a && i < b) || ign.some(([a, b]) => i > a && i < b);
const re = /(['"])((?:\\.|(?!\1)[^\\\n])*)\1/g; let m;
while ((m = re.exec(src))) { if (!HANGUL.test(m[2]) || m[2].includes('${') || inBig(m.index)) continue; take(cleanJs(m[2]), m.index, false); }
for (const t of tpls) { if (inBig(t.index)) continue; const statics = t.parts.map(cleanJs); if (!statics.some((x) => HANGUL.test(x))) continue; if (statics.some((x) => /<[a-zA-Z\/!][^>]*>/.test(x))) { for (const r of splitHtmlTemplate(statics)) take(r.text, t.index, r.pattern); continue; } const text = statics.map((x, i) => (i ? `{${i}}` : '') + x).join(''); if (/^\{\d+\}$/.test(text.trim())) continue; take(text, t.index, t.parts.length > 1); }
return out;
}
let catalogCache = null;
/** 전체 카탈로그 (중복 제거, 첫 출현 기준 속성). 서버 기동 중에는 파일이 바뀌지 않으므로 캐시 */
function buildCatalog(force) {
if (catalogCache && !force) return catalogCache;
const map = new Map();
const add = (it, group) => { const prev = map.get(it.text); if (!prev) map.set(it.text, { id: idOf(it.text), group, text: it.text, type: it.type, where: it.where, ...(it.pattern || /\{\d+\}/.test(it.text) ? { pattern: true } : {}) }); else if (prev.type === '설명내용' && it.type !== '설명내용') prev.type = it.type; };
for (const [rel, kind, group] of FILES) {
let src; try { src = fs.readFileSync(path.join(ROOT, rel), 'utf8'); } catch (_) { continue; }
for (const it of (kind === 'html' ? extractHtml(src, rel) : extractJs(src, rel))) add(it, group);
}
// v1.3.10: 서버가 보내는 화면 문구 (화면 파일에 이미 있는 문구는 그 묶음을 유지)
for (const [rel, mode] of SERVER_FILES) {
let src; try { src = fs.readFileSync(path.join(ROOT, rel), 'utf8'); } catch (_) { continue; }
for (const it of extractServer(src, rel, mode)) add(it, mode === 'catalog' ? '서버 목록(모듈·등급·업종 등)' : '서버 메시지');
}
// v1.3.16: 생성 사이트 시스템 문구(locales/site/ko.json) 중 자리 표시가 없는 것 — 사이트 관리자 화면에 모듈 오류 등으로 그대로 보이므로 함께 번역
try { for (const [id, m] of Object.entries(require('./sitemsg').source())) if (!/\{\w+\}/.test(m.text) && HANGUL.test(m.text)) add({ text: m.text, type: '사이트 시스템 문구', where: `locales/site/ko.json ${id}` }, '사이트 시스템 문구'); } catch (_) {}
const entries = [...map.values()];
const hash = crypto.createHash('sha1').update(entries.map((e) => e.text).join('\n')).digest('hex').slice(0, 12);
catalogCache = { version: VERSION, date: new Date().toISOString().slice(0, 10), hash, count: entries.length, entries };
return catalogCache;
}
/** 번역자가 내려받는 파일: 구조를 유지하고 translation 만 채우면 된다 */
function exportFile(lang, existing) {
const c = buildCatalog(); const dict = existing ? existing.dict : {};
const holes = (t) => (t.match(/\{\d+\}/g) || []);
const entries = c.entries.map((e) => { const tr = dict[e.text] || ''; return { id: e.id, status: tr ? '번역됨' : '번역 필요', group: e.group, type: e.type, where: e.where, ...(e.pattern ? { pattern: true, placeholders: holes(e.text) } : {}), text: e.text, translation: tr }; });
const need = entries.filter((e) => e.status === '번역 필요').length;
return {
format: 'ai-webmaker-locale', formatVersion: 2,
// v1.3.10: 번역할 곳을 분명히 — 모든 항목에 status(번역 필요/번역됨)·group(화면)을 달고, 맨 위에 요약을 둔다
guide: {
'무엇을 번역하나요': '모든 항목의 "translation" 칸. "text" 는 원문(한국어)이라 고치지 않습니다. status 가 "번역 필요" 인 항목이 아직 번역되지 않은 것입니다.',
'status': '번역 필요 = translation 이 비어 있음 / 번역됨 = 이미 번역이 있음(올리면 그대로 쓰임)',
'group': '문구가 보이는 화면: 빌더·로그인·사이트 관리자·웹메일·관리자 콘솔·서버 목록(모듈·등급·업종 등)·서버 메시지',
'pattern / placeholders': '{1}·{2} 는 화면에서 값(이름·숫자 등)이 들어가는 자리입니다. 번역문에도 같은 {숫자}를 모두 넣으세요(순서는 바꿔도 됨).',
'type': '제목·버튼이름·입력항목명·입력예시·도움말·선택항목·표머리·메시지·설명내용·서버 메시지 — 번역 길이·말투 참고용',
'길이': '번역문이 길어도 화면이 자동으로 줄바꿈합니다. 단어를 줄여 쓸 필요는 없습니다.',
'올리기': '상단 lang(언어 코드, 예: en, ja, zh-CN)과 name(언어 표시 이름)을 채운 뒤 관리자 콘솔 › 언어 에 올립니다.',
},
stats: { total: entries.length, '번역 필요': need, '번역됨': entries.length - need },
source: SOURCE, lang: lang || '', name: existing ? existing.name : '', sourceVersion: c.version, sourceDate: c.date, sourceHash: c.hash, count: c.count,
entries,
};
}
// ---------- 저장소 ----------
function fileOf(lang) { return path.join(DIR, `${lang}.json`); }
function loadLocale(lang) { try { return JSON.parse(fs.readFileSync(fileOf(lang), 'utf8')); } catch (_) { return null; } }
/** 버전 비교: a < b 이면 true (semver 숫자 비교) */
function semverLt(a, b) { const pa = String(a || '0').split('.').map((x) => parseInt(x, 10) || 0); const pb = String(b || '0').split('.').map((x) => parseInt(x, 10) || 0); for (let i = 0; i < 3; i++) { if ((pa[i] || 0) < (pb[i] || 0)) return true; if ((pa[i] || 0) > (pb[i] || 0)) return false; } return false; }
function isOutdated(loc) { return semverLt(loc.sourceVersion, VERSION); }
const LANG_RE = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/;
function publicInfo(loc) { const c = buildCatalog(); const translated = Object.values(loc.dict || {}).filter(Boolean).length; return { lang: loc.lang, name: loc.name || loc.lang, enabled: loc.enabled !== false, sourceVersion: loc.sourceVersion, sourceDate: loc.sourceDate, uploadedAt: loc.uploadedAt, translated, total: c.count, coverage: c.count ? Math.round(translated / c.count * 100) : 0, outdated: isOutdated(loc), active: loc.enabled !== false && !isOutdated(loc), serviceVersion: VERSION, ...stateOf(loc) }; }
/**
* 언어의 실제 표시 상태 (v1.3.5). 켜기 스위치와 버전 일치 여부를 함께 보고 사용자 화면에 정말 보이는지를 알려 준다.
* 1.3.4 까지 관리자 화면은 상태와 스위치가 섞여, 버전이 올라가 숨겨진 언어도 "끄기" 버튼과 함께 보여 헷갈렸다.
* state: shown(표시 중) | outdated(버전 불일치 — 숨김) | off(꺼짐) | off-outdated(꺼짐 · 버전 불일치)
*/
function stateOf(loc) {
const on = loc.enabled !== false; const old = isOutdated(loc);
if (on && !old) return { state: 'shown', stateLabel: '표시 중', stateDetail: `사용자 화면 언어 선택에 보입니다 (번역 기준 v${loc.sourceVersion} = 서비스 v${VERSION})` };
if (on && old) return { state: 'outdated', stateLabel: '버전 불일치 — 숨김', stateDetail: `켜져 있지만 번역 기준 v${loc.sourceVersion} 가 서비스 v${VERSION} 보다 낮아 사용자 화면에 보이지 않습니다. "현재 번역 내려받기" 로 최신 원본에 기존 번역을 채운 파일을 받아 빈 항목을 번역해 다시 올리세요.` };
if (!on && old) return { state: 'off-outdated', stateLabel: '꺼짐 · 버전 불일치', stateDetail: `관리자가 껐고, 번역 기준 v${loc.sourceVersion} 도 서비스 v${VERSION} 보다 낮습니다.` };
return { state: 'off', stateLabel: '꺼짐', stateDetail: '관리자가 꺼 두어 사용자 화면에 보이지 않습니다. "켜기" 를 누르면 바로 보입니다.' };
}
function listLanguages() { return fs.readdirSync(DIR).filter((f) => f.endsWith('.json')).map((f) => loadLocale(f.slice(0, -5))).filter(Boolean).map(publicInfo).sort((a, b) => a.lang.localeCompare(b.lang)); }
/** 사용자 화면용: 켜져 있고 구버전이 아닌 언어만 */
/** 사용자 화면용: 켜져 있고 구버전이 아닌 언어만. stamp 는 브라우저가 사전을 캐시해 두고 바뀌었는지 비교하는 값(v1.3.10) */
function activeLanguages() { return [{ lang: SOURCE, name: '한국어', active: true }, ...listLanguages().filter((l) => l.active).map((l) => ({ lang: l.lang, name: l.name, active: true, stamp: `${l.sourceVersion}|${l.uploadedAt}`, dir: RTL.has(l.lang.split('-')[0]) ? 'rtl' : 'ltr' }))]; }
const RTL = new Set(['ar', 'he', 'fa', 'ur']); // 오른쪽→왼쪽 언어
function dictionary(lang) {
if (lang === SOURCE) return {}; const loc = loadLocale(lang); if (!loc || loc.enabled === false || isOutdated(loc)) return null;
// v1.3.17 (S14): 이미 올라와 있던 번역에 태그·스크립트가 섞여 있으면 브라우저로 보내지 않는다(화면 일부가 innerHTML 로 그리므로)
const out = {}; for (const [k, v] of Object.entries(loc.dict || {})) if (!(MARKUP.test(v) && !MARKUP.test(k))) out[k] = v; return out;
}
function setEnabled(lang, on) { const loc = loadLocale(lang); if (!loc) throw httpError('언어가 없습니다', 404); loc.enabled = !!on; fs.writeFileSync(fileOf(lang), JSON.stringify(loc)); return publicInfo(loc); }
function remove(lang) { try { fs.unlinkSync(fileOf(lang)); } catch (_) {} return true; }
/** 올린 JSON 한 건을 검증·저장 */
function importJson(obj, filename) {
if (!obj || typeof obj !== 'object') throw httpError(`${filename}: JSON 객체가 아닙니다`);
if (obj.format !== 'ai-webmaker-locale' || !Array.isArray(obj.entries)) throw httpError(`${filename}: AI Webmaker 로케일 파일 형식이 아닙니다 (format/entries 없음)`);
const lang = String(obj.lang || (filename.match(/([a-z]{2,3}(?:-[A-Za-z]{2,4})?)\.json$/i) || [])[1] || '').trim();
if (!LANG_RE.test(lang) || lang === SOURCE) throw httpError(`${filename}: lang(언어 코드)이 없거나 올바르지 않습니다 (예: en, ja, zh-CN)`);
const dict = {}; let n = 0; const badPatterns = [];
// v1.3.2: {1}·{2} 자리표시가 있는 항목은 번역문에도 같은 자리표시가 모두 있어야 한다 (빠지면 값이 사라지므로 건너뜀)
const holes = (t) => (t.match(/\{\d+\}/g) || []).sort().join(',');
for (const e of obj.entries) { if (!e || typeof e.text !== 'string') continue; const tr = typeof e.translation === 'string' ? e.translation.trim() : ''; if (!tr || tr === e.text) continue; if (holes(e.text) !== holes(tr)) { badPatterns.push(e.text); continue; } if (MARKUP.test(tr) && !MARKUP.test(e.text)) { badPatterns.push(e.text); continue; } dict[e.text] = tr; n += 1; }
if (!n) throw httpError(`${filename}: 번역된 항목이 없습니다 (translation 이 모두 비어 있음)`);
const prev = loadLocale(lang);
const loc = { lang, name: String(obj.name || (prev && prev.name) || lang).slice(0, 40), sourceVersion: String(obj.sourceVersion || '0'), sourceDate: String(obj.sourceDate || ''), sourceHash: obj.sourceHash || '', enabled: prev ? prev.enabled !== false : true, uploadedAt: new Date().toISOString(), dict };
fs.writeFileSync(fileOf(lang), JSON.stringify(loc));
return { file: filename, lang, name: loc.name, translated: n, outdated: isOutdated(loc), sourceVersion: loc.sourceVersion, badPatterns: badPatterns.slice(0, 20), badPatternCount: badPatterns.length };
}
/** 최소 ZIP 해제 (저장/deflate 만). 중앙 디렉터리를 읽어 파일 목록을 얻는다. */
function unzip(buf) {
const out = []; let eocd = buf.length - 22; while (eocd >= 0 && buf.readUInt32LE(eocd) !== 0x06054b50) eocd--; if (eocd < 0) throw httpError('ZIP 형식이 아닙니다');
const count = buf.readUInt16LE(eocd + 10); let off = buf.readUInt32LE(eocd + 16);
for (let k = 0; k < count; k++) {
if (buf.readUInt32LE(off) !== 0x02014b50) break;
const method = buf.readUInt16LE(off + 10); const csize = buf.readUInt32LE(off + 20); const nlen = buf.readUInt16LE(off + 28); const xlen = buf.readUInt16LE(off + 30); const clen = buf.readUInt16LE(off + 32); const lho = buf.readUInt32LE(off + 42);
const name = buf.slice(off + 46, off + 46 + nlen).toString('utf8');
const lnlen = buf.readUInt16LE(lho + 26); const lxlen = buf.readUInt16LE(lho + 28); const start = lho + 30 + lnlen + lxlen; const data = buf.slice(start, start + csize);
const flags = buf.readUInt16LE(off + 8);
// v1.3.0: 저장(0)·Deflate(8) 외의 방식(Deflate64=9, bzip2=12, AES 암호=99)이나 암호 걸린 ZIP 은 풀 수 없으므로
// 쓰레기 바이트를 JSON 으로 읽어 "문법 오류" 가 나는 대신 이유를 알려 준다
const METHOD = { 9: 'Deflate64', 12: 'bzip2', 14: 'LZMA', 93: 'Zstandard', 95: 'XZ', 99: 'AES 암호화' };
if (name.endsWith('/')) { /* 폴더 */ }
else if (flags & 1) out.push({ name, error: '암호가 걸린 ZIP 은 풀 수 없습니다. 암호 없이 다시 압축하세요' });
else if (method !== 0 && method !== 8) out.push({ name, error: `지원하지 않는 압축 방식(${METHOD[method] || method})입니다. 일반 ZIP(Deflate)으로 다시 압축하세요` });
else if (csize < 50 * 1024 * 1024) { try { out.push({ name, data: method === 8 ? zlib.inflateRawSync(data) : data }); } catch (e) { out.push({ name, error: `압축을 풀지 못했습니다 (${e.message})` }); } }
off += 46 + nlen + xlen + clen;
}
return out;
}
/**
* 업로드된 바이트를 JSON 으로 읽는다 (v1.3.0).
* 번역 도구·메모장·엑셀에서 저장하면 UTF-8 BOM 이 붙거나 UTF-16 으로 저장되는 경우가 많아 그대로 JSON.parse 하면 실패한다.
* BOM 을 보고 인코딩을 고르고, 실패하면 몇 번째 줄·칸에서 틀렸는지 알려 준다.
*/
function parseJsonBuffer(buf, filename) {
let text;
if (buf[0] === 0xff && buf[1] === 0xfe) text = buf.slice(2).toString('utf16le');
else if (buf[0] === 0xfe && buf[1] === 0xff) { const b = Buffer.from(buf.slice(2)); b.swap16(); text = b.toString('utf16le'); }
else if (buf.length > 1 && buf[0] !== 0 && buf[1] === 0) text = buf.toString('utf16le'); // BOM 없는 UTF-16LE
else text = buf.toString('utf8');
text = text.replace(/^\uFEFF/, '');
try { return JSON.parse(text); }
catch (e) {
const m = String(e.message).match(/position (\d+)/); let where = '';
if (m) { const pos = +m[1]; const before = text.slice(0, pos); const line = before.split('\n').length; const col = pos - before.lastIndexOf('\n'); where = ` (${line}번째 줄 ${col}번째 칸 근처: "${text.slice(Math.max(0, pos - 20), pos + 20).replace(/\s+/g, ' ')}")`; }
throw httpError(`${filename}: JSON 문법 오류${where}. 따옴표·쉼표·괄호를 확인하세요 (마지막 항목 뒤 쉼표, 번역문 안의 큰따옴표는 \\" 로 적어야 함)`);
}
}
/** 업로드 처리: JSON 파일들 또는 ZIP. 파일별 결과를 돌려준다(한 파일이 틀려도 나머지는 저장) */
function importFiles(files) {
const results = [];
for (const f of files) {
const name = f.name || 'file'; const buf = f.data;
try {
if (/\.zip$/i.test(name) || (buf[0] === 0x50 && buf[1] === 0x4b)) { const entries = unzip(buf); if (!entries.some((z) => /\.json$/i.test(z.name) && !/(^|\/)\./.test(z.name))) results.push({ ok: false, file: name, error: `${name}: ZIP 안에 .json 파일이 없습니다` }); for (const z of entries) { if (!/\.json$/i.test(z.name) || /(^|\/)\./.test(z.name) || /(^|\/)__MACOSX\//.test(z.name)) continue; if (z.error) { results.push({ ok: false, file: `${name}/${z.name}`, error: `${name}/${z.name}: ${z.error}` }); continue; } try { results.push({ ok: true, ...importJson(parseJsonBuffer(z.data, `${name}/${z.name}`), `${name}/${z.name}`) }); } catch (e) { results.push({ ok: false, file: `${name}/${z.name}`, error: e.message }); } } }
else results.push({ ok: true, ...importJson(parseJsonBuffer(buf, name), name) });
} catch (e) { results.push({ ok: false, file: name, error: e.message.startsWith(name) ? e.message : `${name}: ${e.message}` }); }
}
return results;
}
/**
* 정적 HTML 을 서버에서 미리 번역 (v1.3.10, 속도 개선).
* 사용자가 고른 언어(쿠키 wm_lang)로 페이지의 텍스트 노드와 번역 대상 속성을 바꿔 보낸다. 브라우저 런타임(public/i18n.js)은
* 동적으로 그려지는 부분만 번역하면 되므로 첫 화면이 한국어로 깜빡이지 않고, 큰 화면에서 치환 작업이 줄어든다.
* 결과는 (언어, 파일, 사전 시각) 별로 메모리에 캐시한다.
*/
const pageCache = new Map();
/** v1.3.17 (S14): HTML 태그·스크립트처럼 보이는 번역 (원문에 태그가 없는데 번역에 있으면 받지 않는다) */
const MARKUP = /<\s*[a-zA-Z!\/]|javascript:|on\w+\s*=/i;
/** 번역문을 HTML 글자로 넣을 때 이스케이프 (번역 파일은 신뢰 경계 밖에서 오므로 마크업으로 해석되지 않게) */
const escText = (v) => String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escAttr = (v) => escText(v).replace(/"/g, '&quot;');
function translateHtml(html, lang, cacheKey) {
if (!lang || lang === SOURCE) return html;
const loc = loadLocale(lang); if (!loc || loc.enabled === false || isOutdated(loc)) return html;
const key = `${lang}|${cacheKey}|${loc.uploadedAt}`; if (pageCache.has(key)) return pageCache.get(key);
const dict = loc.dict || {};
// v1.3.17 (S14): 번역문은 글자로만 넣는다 (텍스트는 escText, 속성은 escAttr) — 번역 파일에 <script> 등이 있어도 실행되지 않는다
const tr = (t, esc = escText) => { const k = t.trim(); if (!k || !HANGUL.test(k)) return t; const v = dict[k]; return v ? t.replace(k, () => esc(v)) : t; };
let out = ''; let i = 0; let skip = null;
while (i < html.length) {
const lt = html.indexOf('<', i); if (lt < 0) { out += skip ? html.slice(i) : tr(html.slice(i)); break; }
out += skip ? html.slice(i, lt) : tr(html.slice(i, lt));
const gt = html.indexOf('>', lt); if (gt < 0) { out += html.slice(lt); break; }
let tag = html.slice(lt, gt + 1); const m = tag.match(/^<\/?([a-zA-Z][\w-]*)/); const name = m ? m[1].toLowerCase() : '';
if (tag.startsWith('<!--')) { const e = html.indexOf('-->', lt); out += html.slice(lt, e < 0 ? html.length : e + 3); i = e < 0 ? html.length : e + 3; continue; }
if (tag.startsWith('</')) { if (skip === name) skip = null; out += tag; i = gt + 1; continue; }
if (!skip) tag = tag.replace(/\s(placeholder|title|data-tip|aria-label|alt)="([^"]*)"/g, (all, a, v) => { const v1 = v.replace(/&#10;/g, ' '); const t1 = tr(v1, escAttr); return ` ${a}="${t1 === v1 ? v : t1}"`; });
if (name === 'html') tag = tag.replace(/\slang="[^"]*"/, ` lang="${lang}"`).replace(/>$/, RTL.has(lang.split('-')[0]) && !/\sdir=/.test(tag) ? ' dir="rtl">' : '>');
if (['script', 'style', 'textarea'].includes(name) && !tag.endsWith('/>')) skip = name;
out += tag; i = gt + 1;
}
pageCache.set(key, out); if (pageCache.size > 200) pageCache.delete(pageCache.keys().next().value);
return out;
}
module.exports = { translateHtml, RTL, SOURCE, VERSION, buildCatalog, exportFile, listLanguages, activeLanguages, dictionary, setEnabled, remove, importFiles, importJson, unzip, semverLt, loadLocale };
+26
View File
@@ -0,0 +1,26 @@
'use strict';
// ============================================================================
// AI 자식 프로세스 PID 기록 (v1.6.31, 요구 12) — 서비스가 죽거나 재시작돼도 뒤에 남은 claude/codex 작업을 다음 기동 때 정리한다.
// 자식은 detached 프로세스 그룹으로 실행되므로(취소 때 훅·하위까지 한 번에 종료) 서비스가 비정상 종료되면 그대로 살아남을 수 있었다.
// 저장: data/run/jobs.json { pid: { kind, siteDir, at } }. 기동 때 sweep(): 아직 살아 있고 시작 시각이 일치하는 프로세스 그룹에 SIGTERM → 2초 뒤 SIGKILL.
// ============================================================================
const fs = require('fs'); const path = require('path');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const FILE = path.join(DATA_ROOT, 'run', 'jobs.json');
function load() { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')) || {}; } catch (_) { return {}; } }
function save(d) { try { fs.mkdirSync(path.dirname(FILE), { recursive: true }); fs.writeFileSync(FILE, JSON.stringify(d)); } catch (_) {} }
function add(pid, info) { const d = load(); d[pid] = { ...info, at: Date.now(), host: process.pid }; save(d); }
function remove(pid) { const d = load(); if (d[pid]) { delete d[pid]; save(d); } }
function alive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } }
/** 기동 때: 이전 서비스 프로세스가 남긴 자식 중 살아 있는 것을 끝낸다. @returns {number} 정리한 수 */
function sweep() {
const d = load(); let n = 0;
for (const [pidStr, info] of Object.entries(d)) {
const pid = Number(pidStr); if (!pid || info.host === process.pid) continue;
if (alive(pid)) { let cmd = ''; try { cmd = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8'); } catch (_) {}
if (!cmd || /claude|codex/.test(cmd)) { try { process.kill(-pid, 'SIGTERM'); } catch (_) { try { process.kill(pid, 'SIGTERM'); } catch (_) {} } setTimeout(() => { if (alive(pid)) { try { process.kill(-pid, 'SIGKILL'); } catch (_) { try { process.kill(pid, 'SIGKILL'); } catch (_) {} } } }, 2000).unref(); n += 1; } }
delete d[pidStr];
}
save(d); return n;
}
module.exports = { add, remove, sweep, alive, FILE };
+59
View File
@@ -0,0 +1,59 @@
'use strict';
// ============================================================================
// JSON 저장소 공통 — 원자적 쓰기 · 파일별 직렬화 잠금 · 손상 감지 (v1.6.31, 요구 10)
//
// 왜: 여러 저장소(users·settings·credits·domains·sites·modules)가 각자 fs.writeFileSync 를 써서
// (1) 쓰는 도중 프로세스가 죽으면 반쪽 JSON 이 남고, (2) 같은 파일을 두 요청이 읽고-고치고-쓰면 뒤의 것이 앞의 변경을 덮고,
// (3) 읽기 실패를 조용히 빈 값으로 바꿔 "정상인 것처럼" 동작했다(검토 보고서 6.1·패치 3).
// 제공
// writeJsonAtomic(file, data, {mode, keepBackup}) : 임시 파일에 쓰고 fsync 한 뒤 rename — 중간 상태가 보이지 않는다. keepBackup 이면 직전 정상본을 .bak 으로 남긴다.
// readJson(file, fallback, {strict}) : 없으면 fallback. 있는데 못 읽으면 손상 목록(corrupt)에 올리고 .bak 을 시도한다. strict 면 예외.
// withLock(key, fn) : 같은 키(파일 경로)의 비동기 읽기-수정-쓰기를 한 줄로 세운다(프로세스 안 직렬화).
// health() : 손상이 감지된 파일 목록 (관리자 콘솔 운영 복구 화면·기동 로그)
// 단일 프로세스 서비스라 프로세스 안 잠금으로 충분하다. 프로세스가 여럿이면 파일 잠금이 더 필요하다(지금 구조에서는 없음).
// ============================================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const locks = new Map(); // key → Promise(마지막 작업)
const corrupt = new Map(); // file → { at, error, recoveredFromBackup }
function writeJsonAtomic(file, data, opts = {}) {
const dir = path.dirname(file); fs.mkdirSync(dir, { recursive: true });
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
const text = typeof data === 'string' ? data : JSON.stringify(data, null, opts.indent == null ? 1 : opts.indent);
let fd = null;
try {
fd = fs.openSync(tmp, 'w', opts.mode || 0o600); fs.writeFileSync(fd, text, 'utf8'); try { fs.fsyncSync(fd); } catch (_) {} fs.closeSync(fd); fd = null;
if (opts.keepBackup && fs.existsSync(file)) { try { fs.copyFileSync(file, file + '.bak'); } catch (_) {} }
fs.renameSync(tmp, file);
if (opts.mode) { try { fs.chmodSync(file, opts.mode); } catch (_) {} }
} catch (e) { if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} } try { fs.unlinkSync(tmp); } catch (_) {} throw e; }
}
/**
* JSON 읽기. 파일이 없으면 fallback(정상). 파일이 있는데 깨졌으면 손상으로 기록하고 .bak 을 읽어 본다 — 그것도 안 되면 strict 일 때 예외, 아니면 fallback 을 돌려주되
* health() 에 남아 관리자가 알 수 있다(요구 10: "빈 데이터로 바꿔 정상처럼 처리하지 않는다").
*/
function readJson(file, fallback, opts = {}) {
let raw; try { raw = fs.readFileSync(file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return fallback; corrupt.set(file, { at: new Date().toISOString(), error: e.message }); if (opts.strict) throw e; return fallback; }
try { const v = JSON.parse(raw); corrupt.delete(file); return v; }
catch (e) {
let recovered = false; let val = fallback;
try { val = JSON.parse(fs.readFileSync(file + '.bak', 'utf8')); recovered = true; } catch (_) {}
corrupt.set(file, { at: new Date().toISOString(), error: `JSON 파싱 실패: ${e.message}`, recoveredFromBackup: recovered, size: raw.length });
if (!recovered && opts.strict) throw Object.assign(new Error(`${path.basename(file)} 이(가) 손상되었습니다 (${e.message}). 백업(.bak)도 없습니다 — 관리자 확인 필요`), { status: 500, corruptFile: file });
return val;
}
}
/** 같은 키의 비동기 작업을 순서대로 */
async function withLock(key, fn) {
const prev = locks.get(key) || Promise.resolve(); let release; const mine = new Promise((r) => { release = r; });
const chained = prev.then(() => mine); locks.set(key, chained);
await prev;
try { return await fn(); } finally { release(); if (locks.get(key) === chained) locks.delete(key); }
}
/** 손상 파일 목록 */
function health() { return [...corrupt.entries()].map(([file, v]) => ({ file, ...v })); }
function clearCorrupt(file) { corrupt.delete(file); }
module.exports = { writeJsonAtomic, readJson, withLock, health, clearCorrupt };
+75
View File
@@ -0,0 +1,75 @@
'use strict';
// ============================================================================
// 관리자 "로그 보기" (v1.3.2, 1.3.1 까지의 "오류 로그" 를 확장)
//
// 모든 사이트의 작업 기록(commands)·작업 상세 로그(commands[].log)·배포 기록(deployments)을 한 목록으로 모아
// 종류·기간·회사(회원)·사이트·검색어로 걸러 보여 준다. 사용자에게는 보이지 않는 상세 원인(debug 로그)도 포함한다.
//
// 종류(type)
// error : 실패한 작업, 실패한 배포, 오류·실패 문구가 든 상세 로그(도구 오류, 엔진 준비 실패, 시간 초과 등)
// warn : 경고 로그(warn 레벨)
// cancelled : 중단된 작업(사용자 중단, 캐시 소진으로 멈춤)
// deploy : 성공한 배포·발행 취소
// done : 완료된 작업
// debug : 그 밖의 상세 로그
// ============================================================================
const TYPES = { error: '오류', warn: '경고', cancelled: '중단', deploy: '배포', done: '완료', debug: '상세' };
// 상세 로그 문장이 오류인지 판단하는 표현 (debug·warn 레벨 안에서)
const ERROR_RE = /오류|실패|error|fail|denied|거부|ENOENT|EACCES|timeout|시간 초과|exception|unauthorized|forbidden/i;
/**
* 한 사이트의 기록을 로그 항목으로 펼친다.
* @param {object} site store.load() 결과
* @param {Map} owners 회원 아이디 → 이름(회사명)
*/
function entriesOf(site, owners) {
const base = { siteId: site.id, siteTitle: site.title, ownerId: site.ownerId || '', ownerName: owners.get(site.ownerId) || site.ownerName || '' };
const out = [];
for (const c of site.commands || []) {
const cmd = { ...base, no: c.no, kind: c.kind };
const at = c.finishedAt || c.startedAt;
if (c.status === 'error') out.push({ ...cmd, at, type: 'error', text: c.error || '작업 실패' });
else if (c.status === 'cancelled' || c.status === 'paused') out.push({ ...cmd, at, type: 'cancelled', text: c.error || (c.status === 'paused' ? '캐시 소진으로 일시 중지' : '작업 중단') });
else if (c.status === 'done') out.push({ ...cmd, at, type: 'done', text: `완료 — ${String(c.instruction || '').slice(0, 160)}${c.chargeKrw ? ` (캐시 ${require('./credits').fmt(c.chargeKrw)})` : ''}` });
for (const l of c.log || []) {
if (l.level === 'assistant' || l.level === 'tool' || !l.text) continue; // AI 의 말·도구 호출 원문은 제외(사이트 상세에서 봄)
const isErr = (l.level === 'debug' || l.level === 'warn' || l.level === 'error') && ERROR_RE.test(l.text);
const type = isErr || l.level === 'error' ? 'error' : l.level === 'warn' ? 'warn' : l.level === 'debug' ? 'debug' : null;
if (type) out.push({ ...cmd, at: l.ts ? new Date(l.ts).toISOString() : at, type, text: String(l.text).slice(0, 2000) });
}
}
for (const d of site.deployments || []) {
out.push({ ...base, no: null, kind: d.kind === 'unpublish' ? 'unpublish' : 'deploy', at: d.at, type: d.ok ? 'deploy' : 'error', text: `${d.kind === 'unpublish' ? '발행 취소' : '배포'} ${d.ok ? '성공' : '실패'}${d.targetName ? ` (${d.targetName})` : ''}${d.detail ? ' — ' + String(d.detail).slice(0, 300) : ''}` });
}
return out.filter((x) => x.at);
}
/**
* 걸러서 돌려준다.
* @param {object[]} sites
* @param {object[]} users auth.listUsers()
* @param {{types?: string[], from?: string, to?: string, owner?: string, site?: string, q?: string, limit?: number}} f
* from/to: YYYY-MM-DD (KST 하루 기준, to 는 그날 끝까지 포함)
* @returns {{items, total, counts, owners, sites, types}}
* counts: 종류를 뺀 나머지 조건으로 센 종류별 건수(종류 버튼에 숫자 표시용)
*/
function query(sites, users, f = {}) {
const owners = new Map(users.map((u) => [u.id, u.name]));
const all = []; for (const s of sites) all.push(...entriesOf(s, owners));
const kst = (d, end) => new Date(`${d}T${end ? '23:59:59.999' : '00:00:00'}+09:00`).getTime();
const from = f.from ? kst(f.from) : -Infinity; const to = f.to ? kst(f.to, true) : Infinity;
const q = String(f.q || '').trim().toLowerCase();
const base = all.filter((x) => { const t = new Date(x.at).getTime(); if (t < from || t > to) return false; if (f.owner && x.ownerId !== f.owner) return false; if (f.site && x.siteId !== f.site) return false; if (q && !`${x.text} ${x.siteTitle} ${x.ownerName} ${x.ownerId}`.toLowerCase().includes(q)) return false; return true; });
const counts = Object.fromEntries(Object.keys(TYPES).map((k) => [k, base.filter((x) => x.type === k).length]));
const types = f.types && f.types.length ? f.types : ['error', 'warn', 'cancelled'];
const items = base.filter((x) => types.includes(x.type)).sort((a, b) => b.at.localeCompare(a.at));
const limit = Math.min(Number(f.limit) || 1000, 5000);
return {
items: items.slice(0, limit), total: items.length, counts, types: TYPES,
owners: [...new Map(all.map((x) => [x.ownerId, x.ownerName])).entries()].filter(([id]) => id).map(([id, name]) => ({ id, name })).sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id)),
sites: sites.map((s) => ({ id: s.id, title: s.title, ownerId: s.ownerId })).sort((a, b) => (a.title || '').localeCompare(b.title || '')),
};
}
module.exports = { TYPES, query, entriesOf };
+262
View File
@@ -0,0 +1,262 @@
'use strict';
// ============================================================================
// 만세력 계산 (v1.4.18, 요구 1 — 일정관리 모듈의 음력·간지 표시)
//
// 목적: 양력 날짜 ↔ 음력 날짜 변환과 사주식 간지(연주·월주·일주)를 외부 자료표 없이 계산한다.
// 일정관리 캘린더에 "음 9.3 · 병오년 무술월 신유일" 같은 표시를 넣고, 음력 기준 일정(음력 생일·제사·명절)을
// 해마다 양력 날짜로 바꿔 보여 주기 위해 쓴다.
//
// 방식 (한국천문연구원 만세력과 같은 규칙, 한국 표준시 KST 기준):
// - 음력 달은 합삭(朔, 새달) 순간이 든 날(KST)에 시작한다. 합삭 시각은 Meeus 「천문 알고리즘」 49장(달의 위상) 급수로 구한다.
// - 달의 번호는 중기(中氣: 우수·춘분·곡우·소만·하지·대서·처서·추분·상강·소설·동지·대한 = 태양 황경 30° 배수)로 정한다.
// 동지가 든 달이 11월이고, 동지와 다음 동지 사이(한 "세(歲)")에 달이 13개 있으면 그 사이에서 중기가 없는 첫 달이 윤달이다.
// - 태양 황경은 VSOP87 지구 급수(Meeus 부록 III 축약, 정확도 약 1″)로 구하고 장동·광행차를 더한 시황경을 쓴다.
// 절기(24절기) 시각은 황경이 15° 배수가 되는 순간을 반복법으로 찾는다. 역표시(TT)와 세계시(UT)의 차 ΔT 는 Espenak-Meeus 다항식.
// - 간지: 일주(日柱)는 60일 주기(율리우스일 기준, 2000-01-01 = 무오일), 연주(年柱)는 입춘(황경 315°) 순간에 바뀌고,
// 월주(月柱)는 절기(節氣: 입춘·경칩·청명·입하·망종·소서·입추·백로·한로·입동·대설·소한 = 황경 15°+30°k)에 바뀌며
// 월간(月干)은 오호둔(五虎遁: 갑·기년 → 병인월, 을·경년 → 무인월, 병·신년 → 경인월, 정·임년 → 임인월, 무·계년 → 갑인월)으로 정한다.
// 캘린더는 날짜 단위로 보이므로 절입(節入) 당일은 새 달의 간지로 표시한다(만세력 책과 같은 관행).
//
// 정확도: 합삭·절기 시각 오차는 수십 초 이내라 KASI 공식 음력과 거의 같다. 다만 합삭이나 절기가 자정(KST) 수십 초 안쪽에 드는
// 극히 드문 경우에는 하루 차이가 날 수 있다(1900~2100년 사이 몇 번 수준). 유효 범위는 1900~2100년이다.
//
// 공개 함수
// solarToLunar(y, m, d) → { year, month, day, leap, monthDays } (음력)
// lunarToSolar(ly, lm, ld, leap, clamp) → { y, m, d } (양력) 또는 null (그 날이 없을 때; clamp=true 면 그 달 마지막 날로)
// ganji(y, m, d) → { year, month, day } 각각 { idx, hangul, hanja } + text
// dayInfo(y, m, d) → 위 둘 + 그날 절기 이름 (캘린더 한 칸에 보일 정보)
// solarTerms(year) → 그 해 24절기 [{ name, lon, at(ISO KST), date }]
// lunarYearMonths(ly) → 음력 한 해의 달 목록 [{ month, leap, days, start:{y,m,d} }]
// ============================================================================
const D2R = Math.PI / 180;
const R2D = 180 / Math.PI;
const J2000 = 2451545.0; // 2000-01-01 12:00 TT 의 율리우스일
const KST_OFFSET = 9 / 24; // 한국 표준시 = UT + 9시간 (율리우스일 단위)
const RANGE = [1900, 2100]; // 계산을 허용하는 양력 연도 범위
// ---------- 날짜 ↔ 율리우스일 (그레고리력, 정오 기준 JDN 은 정수) ----------
/** 양력 날짜 → 율리우스일 번호(정수, 그날 정오). 역산은 dateFromJdn. */
function jdn(y, m, d) {
const a = Math.floor((14 - m) / 12); const yy = y + 4800 - a; const mm = m + 12 * a - 3;
return d + Math.floor((153 * mm + 2) / 5) + 365 * yy + Math.floor(yy / 4) - Math.floor(yy / 100) + Math.floor(yy / 400) - 32045;
}
/** 율리우스일 번호(정수) → 양력 날짜 */
function dateFromJdn(j) {
const a = j + 32044; const b = Math.floor((4 * a + 3) / 146097); const c = a - Math.floor(146097 * b / 4);
const d = Math.floor((4 * c + 3) / 1461); const e = c - Math.floor(1461 * d / 4); const m = Math.floor((5 * e + 2) / 153);
return { y: 100 * b + d - 4800 + Math.floor(m / 10), m: m + 3 - 12 * Math.floor(m / 10), d: e - Math.floor((153 * m + 2) / 5) + 1 };
}
/** 세계시(UT) 율리우스일 → 그 순간이 속한 KST 날짜의 JDN (자정 기준: JD 는 정오에 바뀌므로 0.5 를 더해 내림) */
const kstDayOf = (jdUt) => Math.floor(jdUt + KST_OFFSET + 0.5);
/** 율리우스일(UT) → ISO 문자열(KST, 분 단위) — 절기 시각 표시용 */
function isoKst(jdUt) {
const local = jdUt + KST_OFFSET; const j = Math.floor(local + 0.5); const frac = local + 0.5 - j; const { y, m, d } = dateFromJdn(j);
const mins = Math.round(frac * 1440) % 1440; const hh = Math.floor(mins / 60); const mi = mins % 60; const p = (n) => String(n).padStart(2, '0');
return `${y}-${p(m)}-${p(d)}T${p(hh)}:${p(mi)}+09:00`;
}
// ---------- ΔT (역표시 − 세계시, 초) : Espenak & Meeus 다항식 ----------
/** 연도(소수 가능) → ΔT 초. 천문 급수는 역표시(TT) 기준이라 UT 로 바꿀 때 뺀다. 몇 초 오차는 날짜 판정에 영향 없다. */
function deltaT(year) {
const y = year;
if (y < 1920) { const t = y - 1900; return -2.79 + 1.494119 * t - 0.0598939 * t * t + 0.0061966 * t ** 3 - 0.000197 * t ** 4; }
if (y < 1941) { const t = y - 1920; return 21.20 + 0.84493 * t - 0.076100 * t * t + 0.0020936 * t ** 3; }
if (y < 1961) { const t = y - 1950; return 29.07 + 0.407 * t - t * t / 233 + t ** 3 / 2547; }
if (y < 1986) { const t = y - 1975; return 45.45 + 1.067 * t - t * t / 260 - t ** 3 / 718; }
if (y < 2005) { const t = y - 2000; return 63.86 + 0.3345 * t - 0.060374 * t * t + 0.0017275 * t ** 3 + 0.000651814 * t ** 4 + 0.00002373599 * t ** 5; }
if (y < 2050) { const t = y - 2000; return 62.92 + 0.32217 * t + 0.005589 * t * t; }
if (y < 2150) { return -20 + 32 * ((y - 1820) / 100) ** 2 - 0.5628 * (2150 - y); }
const u = (y - 1820) / 100; return -20 + 32 * u * u;
}
/** 역표시 율리우스일(JDE) → 세계시 율리우스일 */
const jdeToUt = (jde) => jde - deltaT(2000 + (jde - J2000) / 365.25) / 86400;
// ---------- 태양 시황경 (VSOP87 지구 급수 축약, Meeus 부록 III) ----------
// 각 항 [A, B, C] : A·cos(B + C·τ), τ = J2000 기준 율리우스 천년. 단위 1e-8 라디안.
const L0 = [[175347046, 0, 0], [3341656, 4.6692568, 6283.07585], [34894, 4.6261, 12566.1517], [3497, 2.7441, 5753.3849], [3418, 2.8289, 3.5231], [3136, 3.6277, 77713.7715], [2676, 4.4181, 7860.4194], [2343, 6.1352, 3930.2097], [1324, 0.7425, 11506.7698], [1273, 2.0371, 529.691], [1199, 1.1096, 1577.3435], [990, 5.233, 5884.927], [902, 2.045, 26.298], [857, 3.508, 398.149], [780, 1.179, 5223.694], [753, 2.533, 5507.553], [505, 4.583, 18849.228], [492, 4.205, 775.523], [357, 2.92, 0.067], [317, 5.849, 11790.629], [284, 1.899, 796.298], [271, 0.315, 10977.079], [243, 0.345, 5486.778], [206, 4.806, 2544.314], [205, 1.869, 5573.143], [202, 2.458, 6069.777], [156, 0.833, 213.299], [132, 3.411, 2942.463], [126, 1.083, 20.775], [115, 0.645, 0.98], [103, 0.636, 4694.003], [102, 0.976, 15720.839], [102, 4.267, 7.114], [99, 6.21, 2146.17], [98, 0.68, 155.42], [86, 5.98, 161000.69], [85, 1.3, 6275.96], [85, 3.67, 71430.7], [80, 1.81, 17260.15], [79, 3.04, 12036.46], [75, 1.76, 5088.63], [74, 3.5, 3154.69], [74, 4.68, 801.82], [70, 0.83, 9437.76], [62, 3.98, 8827.39], [61, 1.82, 7084.9], [57, 2.78, 6286.6], [56, 4.39, 14143.5], [56, 3.47, 6279.55], [52, 0.19, 12139.55], [52, 1.33, 1748.02], [51, 0.28, 5856.48], [49, 0.49, 1194.45], [41, 5.37, 8429.24], [41, 2.4, 19651.05], [39, 6.17, 10447.39], [37, 6.04, 10213.29], [37, 2.57, 1059.38], [36, 1.71, 2352.87], [36, 1.78, 6812.77], [33, 0.59, 17789.85], [30, 0.44, 83996.85], [30, 2.74, 1349.87], [25, 3.16, 4690.48]];
const L1 = [[628331966747, 0, 0], [206059, 2.678235, 6283.07585], [4303, 2.6351, 12566.1517], [425, 1.59, 3.523], [119, 5.796, 26.298], [109, 2.966, 1577.344], [93, 2.59, 18849.23], [72, 1.14, 529.69], [68, 1.87, 398.15], [67, 4.41, 5507.55], [59, 2.89, 5223.69], [56, 2.17, 155.42], [45, 0.4, 796.3], [36, 0.47, 775.52], [29, 2.65, 7.11], [21, 5.34, 0.98], [19, 1.85, 5486.78], [19, 4.97, 213.3], [17, 2.99, 6275.96], [16, 0.03, 2544.31], [16, 1.43, 2146.17], [15, 1.21, 10977.08], [12, 2.83, 1748.02], [12, 3.26, 5088.63], [12, 5.27, 1194.45], [12, 2.08, 4694], [11, 0.77, 553.57], [10, 1.3, 6286.6], [10, 4.24, 1349.87], [9, 2.7, 242.73], [9, 5.64, 951.72], [8, 5.3, 2352.87], [6, 2.65, 9437.76], [6, 4.67, 4690.48]];
const L2 = [[52919, 0, 0], [8720, 1.0721, 6283.0758], [309, 0.867, 12566.152], [27, 0.05, 3.52], [16, 5.19, 26.3], [16, 3.68, 155.42], [10, 0.76, 18849.23], [9, 2.06, 77713.77], [7, 0.83, 775.52], [5, 4.66, 1577.34], [4, 1.03, 7.11], [4, 3.44, 5573.14], [3, 5.14, 796.3], [3, 6.05, 5507.55], [3, 1.19, 242.73], [3, 6.12, 529.69], [3, 0.31, 398.15], [3, 2.28, 553.57], [2, 4.38, 5223.69], [2, 3.75, 0.98]];
const L3 = [[289, 5.844, 6283.076], [35, 0, 0], [17, 5.49, 12566.15], [3, 5.2, 155.42], [1, 4.72, 3.52], [1, 5.3, 18849.23], [1, 5.97, 242.73]];
const L4 = [[114, 3.142, 0], [8, 4.13, 6283.08], [1, 3.84, 12566.15]];
const L5 = [[1, 3.14, 0]];
const series = (terms, tau) => terms.reduce((s, [a, b, c]) => s + a * Math.cos(b + c * tau), 0);
const norm360 = (x) => ((x % 360) + 360) % 360;
/**
* 태양의 시황경(겉보기 황경, 도). 지구 일심 황경 + 180°, FK5 보정, 장동(Δψ), 광행차를 더한 값.
* @param {number} jde 역표시 율리우스일
*/
function sunLongitude(jde) {
const tau = (jde - J2000) / 365250; const T = tau * 10;
const L = (series(L0, tau) + series(L1, tau) * tau + series(L2, tau) * tau ** 2 + series(L3, tau) * tau ** 3 + series(L4, tau) * tau ** 4 + series(L5, tau) * tau ** 5) * 1e-8;
let lon = norm360(L * R2D + 180);
lon -= 0.09033 / 3600; // VSOP 동역학 좌표계 → FK5 보정
// 장동(황경 성분): Meeus 22장 축약식 (0.5″ 이내)
const om = (125.04452 - 1934.136261 * T) * D2R; const ls = (280.4665 + 36000.7698 * T) * D2R; const lm = (218.3165 + 481267.8813 * T) * D2R;
const dpsi = (-17.2 * Math.sin(om) - 1.32 * Math.sin(2 * ls) - 0.23 * Math.sin(2 * lm) + 0.21 * Math.sin(2 * om)) / 3600;
// 광행차 −20.4898″/R : 지구-태양 거리 R 은 Meeus 25장의 간이식(오차 0.01″ 이하)
const M = (357.52911 + 35999.05029 * T - 0.0001537 * T * T) * D2R; const e = 0.016708634 - 0.000042037 * T - 0.0000001267 * T * T;
const C = (1.914602 - 0.004817 * T - 0.000014 * T * T) * Math.sin(M) + (0.019993 - 0.000101 * T) * Math.sin(2 * M) + 0.000289 * Math.sin(3 * M);
const R = 1.000001018 * (1 - e * e) / (1 + e * Math.cos(M + C * D2R));
return norm360(lon + dpsi - 20.4898 / R / 3600);
}
/** 두 각의 차를 −180~180 으로 */
const angDiff = (a, b) => ((a - b + 540) % 360) - 180;
/**
* 해당 연도에서 태양 황경이 lon(도)이 되는 순간(JDE). 평균 공전 속도로 초기값을 잡고 뉴턴법으로 수렴시킨다.
* lon 285°(소한, 1월 초)부터 270°(동지, 12월 하순)까지를 "그 해의 절기" 로 본다.
*/
function termJde(year, lon) {
const offset = norm360(lon - 285); // 소한을 0 으로 둔 황경 간격
let jde = jdn(year, 1, 5) + 0.5 + offset / 360 * 365.2422;
for (let i = 0; i < 10; i++) { const diff = angDiff(lon, sunLongitude(jde)); if (Math.abs(diff) < 1e-7) break; jde += diff / 360 * 365.2422; }
return jde;
}
// ---------- 합삭(새달) 시각 : Meeus 49장 ----------
/**
* k 번째 합삭(0 = 2000-01-06)의 역표시 율리우스일. 평균 합삭에 달·태양 궤도 보정과 행성 섭동 보정을 더한다.
* @param {number} k 정수
*/
function newMoonJde(k) {
const T = k / 1236.85; const T2 = T * T; const T3 = T2 * T; const T4 = T3 * T;
let jde = 2451550.09766 + 29.530588861 * k + 0.00015437 * T2 - 0.00000015 * T3 + 0.00000000073 * T4;
const E = 1 - 0.002516 * T - 0.0000074 * T2;
const M = (2.5534 + 29.1053567 * k - 0.0000014 * T2 - 0.00000011 * T3) * D2R; // 태양 평균 근점이각
const Mp = (201.5643 + 385.81693528 * k + 0.0107582 * T2 + 0.00001238 * T3 - 0.000000058 * T4) * D2R; // 달 평균 근점이각
const F = (160.7108 + 390.67050284 * k - 0.0016118 * T2 - 0.00000227 * T3 + 0.000000011 * T4) * D2R; // 달 위도 인수
const Om = (124.7746 - 1.56375588 * k + 0.0020672 * T2 + 0.00000215 * T3) * D2R; // 승교점 황경
const s = Math.sin;
jde += -0.4072 * s(Mp) + 0.17241 * E * s(M) + 0.01608 * s(2 * Mp) + 0.01039 * s(2 * F) + 0.00739 * E * s(Mp - M) - 0.00514 * E * s(Mp + M) + 0.00208 * E * E * s(2 * M)
- 0.00111 * s(Mp - 2 * F) - 0.00057 * s(Mp + 2 * F) + 0.00056 * E * s(2 * Mp + M) - 0.00042 * s(3 * Mp) + 0.00042 * E * s(M + 2 * F) + 0.00038 * E * s(M - 2 * F)
- 0.00024 * E * s(2 * Mp - M) - 0.00017 * s(Om) - 0.00007 * s(Mp + 2 * M) + 0.00004 * s(2 * Mp - 2 * F) + 0.00004 * s(3 * M) + 0.00003 * s(Mp + M - 2 * F)
+ 0.00003 * s(2 * Mp + 2 * F) - 0.00003 * s(Mp + M + 2 * F) + 0.00003 * s(Mp - M + 2 * F) - 0.00002 * s(Mp - M - 2 * F) - 0.00002 * s(3 * Mp + M) + 0.00002 * s(4 * Mp);
// 행성 섭동 보정 (14개 인수)
const A = [[299.77, 0.107408, -0.009173], [251.88, 0.016321, 0], [251.83, 26.651886, 0], [349.42, 36.412478, 0], [84.66, 18.206239, 0], [141.74, 53.303771, 0], [207.14, 2.453732, 0], [154.84, 7.30686, 0], [34.52, 27.261239, 0], [207.19, 0.121824, 0], [291.34, 1.844379, 0], [161.72, 24.198154, 0], [239.56, 25.513099, 0], [331.55, 3.592518, 0]];
const coef = [0.000325, 0.000165, 0.000164, 0.000126, 0.00011, 0.000062, 0.00006, 0.000056, 0.000047, 0.000042, 0.00004, 0.000037, 0.000035, 0.000023];
for (let i = 0; i < A.length; i++) jde += coef[i] * s((A[i][0] + A[i][1] * k + A[i][2] * T2) * D2R);
return jde;
}
/** KST 날짜(JDN) 이하에서 가장 늦은 합삭의 k */
function newMoonKOnOrBefore(dayJdn) {
let k = Math.floor((dayJdn - 2451550.09766) / 29.530588861);
while (kstDayOf(jdeToUt(newMoonJde(k + 1))) <= dayJdn) k += 1;
while (kstDayOf(jdeToUt(newMoonJde(k))) > dayJdn) k -= 1;
return k;
}
// ---------- 세(歲: 동지 → 다음 동지) 단위로 음력 달 만들기 ----------
const suiCache = new Map();
/**
* 연도 Y 의 세: (Y−1)년 동지가 든 달(11월)부터 Y년 동지가 든 달 직전까지의 음력 달 목록.
* 달이 13개면 중기가 없는 첫 달을 윤달로 둔다. 각 달: { month, leap, start(JDN), end(JDN, 다음 달 1일) }
*/
function sui(Y) {
if (suiCache.has(Y)) return suiCache.get(Y);
const ws1 = kstDayOf(jdeToUt(termJde(Y - 1, 270))); const ws2 = kstDayOf(jdeToUt(termJde(Y, 270)));
const k0 = newMoonKOnOrBefore(ws1); const starts = [];
for (let k = k0; ; k++) { const s = kstDayOf(jdeToUt(newMoonJde(k))); if (s > ws2) break; starts.push(s); }
// starts 의 마지막은 Y년 동지가 든 달(다음 세의 11월) 시작 — 이번 세의 끝으로만 쓴다
const n = starts.length - 1;
// 중기(황경 30° 배수) 날짜: (Y−1) 동지, Y 년 대한(300)·우수(330)·춘분(0)·…·소설(240), Y 동지
const majors = [ws1]; for (const lon of [300, 330, 0, 30, 60, 90, 120, 150, 180, 210, 240]) majors.push(kstDayOf(jdeToUt(termJde(Y, lon)))); majors.push(ws2);
const hasMajor = (i) => majors.some((t) => t >= starts[i] && t < starts[i + 1]);
let leapIdx = -1; if (n === 13) { for (let i = 1; i < n; i++) if (!hasMajor(i)) { leapIdx = i; break; } }
const months = []; let num = 10; // 첫 달이 11월이 되도록
for (let i = 0; i < n; i++) {
if (i === leapIdx) months.push({ month: num, leap: true, start: starts[i], end: starts[i + 1] });
else { num = num % 12 + 1; months.push({ month: num, leap: false, start: starts[i], end: starts[i + 1] }); }
}
suiCache.set(Y, months); return months;
}
const yearCache = new Map();
/** 음력 ly 년의 달 목록(1월 … 12월, 윤달 포함) — 세(Y)의 1~10월 + 세(Y+1)의 11·12월 */
function lunarYear(ly) {
if (yearCache.has(ly)) return yearCache.get(ly);
const a = sui(ly); const b = sui(ly + 1);
const i1 = a.findIndex((m) => m.month === 1 && !m.leap); const j1 = b.findIndex((m) => m.month === 1 && !m.leap);
const months = a.slice(i1).concat(b.slice(0, j1)).map((m) => ({ month: m.month, leap: m.leap, start: m.start, end: m.end, days: m.end - m.start }));
yearCache.set(ly, months); return months;
}
function checkRange(y) { if (!(y >= RANGE[0] && y <= RANGE[1])) throw new Error(`음력 계산은 ${RANGE[0]}~${RANGE[1]}년만 지원합니다`); }
/**
* 양력 → 음력.
* @returns {{ year:number, month:number, day:number, leap:boolean, monthDays:number }}
*/
function solarToLunar(y, m, d) {
checkRange(y); const j = jdn(y, m, d);
for (const ly of [y, y - 1, y + 1]) {
const ms = lunarYear(ly); const hit = ms.find((x) => j >= x.start && j < x.end);
if (hit) return { year: ly, month: hit.month, day: j - hit.start + 1, leap: hit.leap, monthDays: hit.days };
}
throw new Error('음력 변환 실패');
}
/**
* 음력 → 양력. 윤달을 요청했는데 그 해에 그 윤달이 없으면 평달로 본다.
* @param {boolean} clamp 그 달에 없는 날(30일)이면 마지막 날(29일)로 당길지 — 음력 생일처럼 해마다 반복하는 일정에 쓴다
* @returns {{ y, m, d }|null}
*/
function lunarToSolar(ly, lm, ld, leap = false, clamp = false) {
checkRange(ly); const ms = lunarYear(ly);
const mo = ms.find((x) => x.month === lm && x.leap === !!leap) || ms.find((x) => x.month === lm && !x.leap); if (!mo) return null;
let day = ld; if (day > mo.days) { if (!clamp) return null; day = mo.days; } if (day < 1) return null;
return dateFromJdn(mo.start + day - 1);
}
/** 음력 한 해의 달 목록 (관리 화면·검증용) */
function lunarYearMonths(ly) { checkRange(ly); return lunarYear(ly).map((m) => ({ month: m.month, leap: m.leap, days: m.days, start: dateFromJdn(m.start) })); }
// ---------- 간지 ----------
const STEMS_H = '갑을병정무기경신임계'; const STEMS_C = '甲乙丙丁戊己庚辛壬癸';
const BRANCH_H = '자축인묘진사오미신유술해'; const BRANCH_C = '子丑寅卯辰巳午未申酉戌亥';
const ZODIAC = ['쥐', '소', '호랑이', '토끼', '용', '뱀', '말', '양', '원숭이', '닭', '개', '돼지'];
/** 60갑자 번호(0=갑자) → { idx, hangul, hanja } */
const gz = (idx) => { const i = ((idx % 60) + 60) % 60; return { idx: i, hangul: STEMS_H[i % 10] + BRANCH_H[i % 12], hanja: STEMS_C[i % 10] + BRANCH_C[i % 12] }; };
/** 절기 이름 (황경 순: 소한 285° 부터) */
const TERM_NAMES = { 285: '소한', 300: '대한', 315: '입춘', 330: '우수', 345: '경칩', 0: '춘분', 15: '청명', 30: '곡우', 45: '입하', 60: '소만', 75: '망종', 90: '하지', 105: '소서', 120: '대서', 135: '입추', 150: '처서', 165: '백로', 180: '추분', 195: '한로', 210: '상강', 225: '입동', 240: '소설', 255: '대설', 270: '동지' };
/** 절기 한자 이름 (한국어가 아닌 페이지에 표시) */
const TERM_HANJA = { 285: '小寒', 300: '大寒', 315: '立春', 330: '雨水', 345: '驚蟄', 0: '春分', 15: '淸明', 30: '穀雨', 45: '立夏', 60: '小滿', 75: '芒種', 90: '夏至', 105: '小暑', 120: '大暑', 135: '立秋', 150: '處暑', 165: '白露', 180: '秋分', 195: '寒露', 210: '霜降', 225: '立冬', 240: '小雪', 255: '大雪', 270: '冬至' };
const TERM_ORDER = [285, 300, 315, 330, 345, 0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165, 180, 195, 210, 225, 240, 255, 270];
const termCache = new Map();
/** 그 해 24절기의 KST 날짜(JDN)와 시각. [{ name, lon, jdn, at }] 황경 순(소한 → 동지) */
function termsOfYear(year) {
if (termCache.has(year)) return termCache.get(year);
const out = TERM_ORDER.map((lon) => { const ut = jdeToUt(termJde(year, lon)); return { name: TERM_NAMES[lon], hanja: TERM_HANJA[lon], lon, jdn: kstDayOf(ut), at: isoKst(ut) }; });
termCache.set(year, out); return out;
}
/** 그 해 24절기 (공개용) */
function solarTerms(year) { checkRange(year); return termsOfYear(year).map((t) => { const { y, m, d } = dateFromJdn(t.jdn); return { name: t.name, hanja: t.hanja, lon: t.lon, at: t.at, date: `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}` }; }); }
/**
* 사주식 간지 (연주·월주·일주). 날짜 단위라 절입 당일부터 새 달·새 해로 본다.
* @returns {{ year, month, day, text, textHanja, zodiac }}
*/
function ganji(y, m, d) {
checkRange(y); const j = jdn(y, m, d);
const terms = termsOfYear(y);
// 연주: 입춘(315°) 전이면 전년도 간지. (연도 − 4) mod 60 → 1984 = 갑자년
const ipchun = terms.find((t) => t.lon === 315).jdn; const pillarYear = j < ipchun ? y - 1 : y;
const yearGz = gz(pillarYear - 4);
// 월주: 이 날 이전(당일 포함)의 마지막 절기(15°+30°k). 전년도 절기(대설·소한 등)까지 보아야 1월 초를 처리할 수 있다
const nodes = [...termsOfYear(y - 1), ...terms].filter((t) => t.lon % 30 === 15 && t.jdn <= j);
const last = nodes[nodes.length - 1];
const k = ((last.lon - 315 + 360) % 360) / 30; // 인월=0 … 축월=11
const monthStem = ((yearGz.idx % 10) % 5 * 2 + 2 + k) % 10; const monthBranch = (2 + k) % 12; // 오호둔: 월간, 월지(인=2)
const month = gz(6 * monthStem - 5 * monthBranch); // 천간 s·지지 b 를 모두 만족하는 60갑자 번호 = (6s − 5b) mod 60
const day = gz(j + 49); // 일주: 2000-01-01(JDN 2451545) = 무오(54)
return { year: yearGz, month, day, text: `${yearGz.hangul}년 ${month.hangul}월 ${day.hangul}일`, textHanja: `${yearGz.hanja}年 ${month.hanja}月 ${day.hanja}日`, zodiac: ZODIAC[yearGz.idx % 12] };
}
/**
* 캘린더 한 칸에 보일 정보: 음력 날짜, 간지, 그날 절기.
* @returns {{ date, lunar:{year,month,day,leap,monthDays,text}, ganji, term:string|null }}
*/
function dayInfo(y, m, d) {
const lunar = solarToLunar(y, m, d); const g = ganji(y, m, d); const j = jdn(y, m, d);
const term = termsOfYear(y).find((t) => t.jdn === j);
return { date: `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`, lunar: { ...lunar, text: `${lunar.leap ? '윤' : ''}${lunar.month}.${lunar.day}` }, ganji: g, term: term ? term.name : null, termHanja: term ? term.hanja : null };
}
module.exports = { solarToLunar, lunarToSolar, lunarYearMonths, ganji, dayInfo, solarTerms, jdn, dateFromJdn, RANGE, STEMS_H, BRANCH_H, STEMS_C, BRANCH_C, ZODIAC };
+60
View File
@@ -0,0 +1,60 @@
'use strict';
// 메일 발송: Resend API. 키는 암호화 보관소(lib/vault.js, v1.3.1)에서 읽는다. 관리자가 키 파일 경로를 주면 한 번 읽어 보관소에 암호화해 넣는다.
// 발신자는 webmaster@<사이트 도메인>(Resend 에 검증된 도메인일 때) 아니면 webmaster@<기본 발신 도메인>.
const fs = require('fs');
const settings = require('./settings');
const providers = require('./providers');
const KEY_NAMES = ['RESEND_API_KEY', 'RESEND_KEY', 'resend_api_key', 'MAIL_API_KEY', 'API_KEY', 'api_key'];
const FROM_NAMES = ['MAIL_FROM', 'FROM', 'SENDER', 'ID', 'EMAIL'];
function parseKeyFile(text) {
const out = {};
try { const j = JSON.parse(text); if (j && typeof j === 'object') { for (const [k, v] of Object.entries(j)) if (typeof v === 'string') out[k] = v; return out; } } catch (_) {}
for (const raw of text.split(/\r?\n/)) { const line = raw.trim(); if (!line || line.startsWith('#')) continue; const m = line.match(/^(?:export\s+)?([A-Za-z0-9_.\-]+)\s*[=:]\s*(.*)$/); if (m) out[m[1]] = m[2].trim().replace(/^["']|["']$/g, ''); }
return out;
}
function mailConfig() { const s = settings.load(); return { keyFile: '', apiKey: '', defaultDomain: '', fromName: '', formBase: '', ...(s.mail || {}) }; }
/**
* API 키 결정. {key, source, keyName, fromDomain}
* v1.3.1: 암호화 보관소(lib/vault.js 'resend') 우선. 1.3.0 이전 방식(설정 입력값, 평문 키 파일)은 보관소로 옮기기 전 호환용.
*/
function resolveKey() {
const c = mailConfig();
try { const v = require('./vault').resolve('resend'); if (v && v.secret) { let fromDomain = ''; for (const f of FROM_NAMES) { const m = String((v.settings || {})[f] || '').match(/@([a-z0-9.-]+\.[a-z]{2,})$/i); if (m) { fromDomain = m[1].toLowerCase(); break; } } return { key: v.secret, source: 'vault', keyName: v.source, fromDomain }; } } catch (_) {}
if (c.apiKey) { try { return { key: providers.dec(c.apiKey), source: 'settings' }; } catch (_) {} }
if (c.keyFile) { try { const kv = parseKeyFile(fs.readFileSync(c.keyFile, 'utf8')); for (const n of KEY_NAMES) if (kv[n]) { let fromDomain = ''; for (const f of FROM_NAMES) { const v = kv[f] || ''; const m = v.match(/@([a-z0-9.-]+\.[a-z]{2,})$/i); if (m) { fromDomain = m[1].toLowerCase(); break; } } return { key: kv[n], source: 'file', keyName: n, fromDomain }; } return { key: '', source: 'file', error: `키 파일에서 ${KEY_NAMES.join('/')} 항목을 찾지 못했습니다 (항목: ${Object.keys(kv).join(', ') || '없음'})` }; } catch (e) { return { key: '', source: 'file', error: '키 파일을 읽을 수 없습니다: ' + e.message }; } }
return { key: '', source: null, error: '메일 발송 키가 설정되지 않았습니다 (관리자 콘솔 › 설정 › 메일 발송)' };
}
async function resend(path, method, body) {
const k = resolveKey(); if (!k.key) throw Object.assign(new Error(k.error || 'mail_not_configured'), { status: 503 });
const r = await fetch((process.env.RESEND_API_BASE || 'https://api.resend.com') + path, { method: method || 'GET', headers: { Authorization: 'Bearer ' + k.key, 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000) });
const data = await r.json().catch(() => ({}));
if (!r.ok) throw Object.assign(new Error(`메일 발송 실패 (Resend ${r.status}): ${String(data.message || data.name || '').slice(0, 200)}`), { status: 502, providerStatus: r.status });
return data;
}
let domainCache = { at: 0, list: [] };
async function verifiedDomains(force) { if (!force && Date.now() - domainCache.at < 300000) return domainCache.list; try { const d = await resend('/domains'); domainCache = { at: Date.now(), list: (d.data || []).filter((x) => x.status === 'verified').map((x) => String(x.name).toLowerCase()) }; } catch (_) { if (!domainCache.at) domainCache = { at: Date.now(), list: [] }; } return domainCache.list; }
/** 발신 주소 선택 */
async function pickFrom(siteDomains) {
const c = mailConfig(); const verified = await verifiedDomains(false); const name = c.fromName || 'Webmaster';
const cands = [...(siteDomains || []).map((d) => String(d).toLowerCase().replace(/^www\./, '')), ...(siteDomains || []).map((d) => String(d).toLowerCase())];
// v1.5.27 (요구 8): 사이트 도메인이 검증된 도메인의 서브 도메인(sample.ariweb.net ⊂ ariweb.net)이면 발신 주소는 검증된 상위 도메인으로 — Resend 는 서브 도메인을 따로
// 검증해야 해서 webmaster@sample.ariweb.net 으로 보내면 403 "The sample.ariweb.net domain is not verified" 가 났다(webmaker.ariweb.net 2026-10-15 08:08 UTC)
for (const d of cands) { if (verified.includes(d)) return { address: `webmaster@${d}`, name, verified: true }; const parent = verified.find((v) => d.endsWith('.' + v)); if (parent) return { address: `webmaster@${parent}`, name, verified: true, replyDomain: d }; }
// 기본 발신 도메인 → 검증된 도메인만 사용. 설정값이 미검증이면 검증된 첫 도메인, 그것도 없으면 설정값/키 파일 도메인
const fileDomain = (resolveKey().fromDomain || '');
const def = (c.defaultDomain || fileDomain || '').toLowerCase();
if (def && verified.includes(def)) return { address: `webmaster@${def}`, name, verified: true };
if (verified.length) return { address: `webmaster@${verified[0]}`, name, verified: true };
if (def) return { address: `webmaster@${def}`, name, verified: false };
return { address: `webmaster@${(siteDomains || [])[0] || 'localhost'}`, name, verified: false };
}
/**
* 메일 발송. attachments: [{ filename, content(Buffer|base64 문자열) }] — Resend 는 base64 본문을 받는다(총 40MB 제한).
*/
async function send({ from, to, subject, text, html, replyTo, attachments }) {
const att = (attachments || []).slice(0, 10).map((a) => ({ filename: String(a.filename || 'file').slice(0, 120), content: Buffer.isBuffer(a.content) ? a.content.toString('base64') : String(a.content || '') }));
return resend('/emails', 'POST', { from: from.name ? `${from.name} <${from.address}>` : from.address, to: Array.isArray(to) ? to : [to], subject, text, html, ...(att.length ? { attachments: att } : {}), ...(replyTo ? { reply_to: replyTo } : {}) });
}
async function status() { const k = resolveKey(); const c = mailConfig(); const out = { configured: !!k.key, source: k.source, keyName: k.keyName || null, fileDomain: k.fromDomain || null, keyFile: c.keyFile, defaultDomain: c.defaultDomain, fromName: c.fromName, formBase: c.formBase, error: k.error || null, verifiedDomains: [] }; if (k.key) { try { out.verifiedDomains = await verifiedDomains(true); } catch (e) { out.error = e.message; } } return out; }
module.exports = { mailConfig, resolveKey, send, pickFrom, verifiedDomains, status, parseKeyFile, resend }; // resend: Resend API 직접 호출 (v1.3.1 메일 시스템이 도메인 등록·확인에 사용)
+350
View File
@@ -0,0 +1,350 @@
'use strict';
// ============================================================================
// 메일 서버(Arimail) 등록부 — v1.6.38 (요구 2)
//
// 1.6.35 까지는 Arimail 을 "한 대" 만, 그것도 설정 파일(lib/config.js mail.url / mail.apiUrl / mail.domain / mail.adminLogin,
// 비밀번호는 보관소 'arimail')로만 연결할 수 있었다. 웹메이커(예: webmaker.ibit.kr = 121.152.149.45)와 Arimail
// (예: webmail.ibit.kr = 115.68.194.192)을 다른 서버에 나눠 설치하고, 메일 도메인마다 다른 Arimail 을 붙이려면
// 여러 대를 등록하고 사이트마다 고를 수 있어야 한다. 이 모듈이 그 등록부다.
//
// 구성
// - 기본 서버(id 'primary') : 설정 파일 mail.* 에 적힌 서버. deploy/install-arimail.sh 가 등록하는 곳이라 그대로 둔다.
// 닉네임·표시용 IP 는 mail.nickname / mail.ip (1.6.38 추가 항목).
// - 추가 서버 : data/mail-servers.json 의 items (비밀 아닌 값만). 관리자 비밀번호는 보관소(lib/vault.js)
// 종류 'arimail', 식별자 'arimail-<id>' 로 암호화 보관한다 — 비밀값은 이 파일에 쓰지 않는다.
// - defaultId : 사이트가 서버를 따로 고르지 않고 메일 도메인이 맞는 서버도 없을 때 쓰는 서버.
//
// 서버 고르기(pick): 사이트 메일 설정(site.webmail.mailServer)에 고른 서버 > 사이트 메일 도메인과 같은 도메인을 다루는 서버 > 기본 서버.
// Arimail 한 설치는 한 도메인만 다루므로(환경 MAILBOX=admin@도메인) "도메인이 같은 서버" 가 자연스러운 기본 선택이다.
//
// 비밀값(관리자 비밀번호)은 pub() 에서 빼고 hasPassword 만 내보낸다 — 화면·작업 기록·오류 문구에 비밀번호가 나가지 않게.
// ============================================================================
const fs = require('fs');
const path = require('path');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const FILE = path.join(DATA_ROOT, 'mail-servers.json');
const PRIMARY = 'primary'; // 설정 파일(mail.*)의 서버를 가리키는 고정 id
const ID_RE = /^[a-z0-9][a-z0-9-]{1,40}$/;
const IP_RE = /^(\d{1,3})(\.\d{1,3}){3}$|^[0-9a-f]*:[0-9a-f:.]+$/i; // IPv4 또는 IPv6 (표시·구분용이라 느슨하게)
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
// ---------- 저장소 ----------
// v1.6.39 (요구 2): lib/jsonstore 로 읽고 쓴다 — 직전 정상본을 .bak 으로 남기고, 깨졌으면 .bak 을 읽는다
const jsx = require('./jsonstore');
function load() { const d = jsx.readJson(FILE, null); if (!d || typeof d !== 'object') return { version: 1, items: {}, defaultId: '' }; d.items = d.items || {}; d.defaultId = d.defaultId || ''; return d; }
function save(d) { jsx.writeJsonAtomic(FILE, d, { mode: 0o600, keepBackup: true, indent: 1 }); }
/** 추가 서버의 비밀번호가 들어 있는 보관소 항목 id */
const vaultId = (id) => (id === PRIMARY ? 'arimail' : `arimail-${id}`);
/**
* v1.6.39 (요구 2): "이전 비밀번호" 보관소 항목 id. 비밀번호를 새 값으로 덮어쓸 때 직전 값을 여기 옮겨 두어 "이전 비밀번호로 되돌리기" 가 되게 한다.
* 등록부 서버 id 는 vaultId 로 arimail-<id> 가 되므로 prev-arimail-… 는 어떤 서버 id 와도 겹치지 않는다.
*/
const prevVaultId = (id) => (id === PRIMARY ? 'prev-arimail' : `prev-arimail-${id}`);
/**
* v1.7.3 (요구 2): 사용량 API 키(Arimail 1.0.9 `au_…`)의 보관소 항목 id. 관리자 비밀번호와 다른 항목에 두어 비밀번호 되돌리기·초기화와 섞이지 않게 한다.
* 기본 서버는 'arimail-usage', 추가 서버는 'arimail-usage-<id>'. 키 원문은 등록부 파일(mail-servers.json)에 쓰지 않는다.
*/
const usageVaultId = (id) => (id === PRIMARY ? 'arimail-usage' : `arimail-usage-${id}`);
/** 보관소에서 사용량 API 키 원문 (없으면 '') — 서버 내부 전용 */
function usageKeyOf(id) { try { return require('./vault').secretOf(usageVaultId(id)); } catch (_) { return ''; } }
/** 보관소 항목의 힌트(앞뒤 몇 글자) — 화면 표시용 */
function usageKeyHint(id) { try { const v = require('./vault').get(usageVaultId(id)); return v && v.hasSecret ? v.secretHint : ''; } catch (_) { return ''; } }
/** Arimail 로그인 잠금(15분 10회 제한) 길이 — Arimail 의 제한 창과 같게 */
const LOCK_MS = 15 * 60 * 1000;
/** 주소에서 호스트 이름만 */
function hostOf(url) { try { return new URL(url).hostname; } catch (_) { return ''; } }
/** 설정 파일(mail.*)의 기본 서버. 주소도 도메인도 없으면 null(등록 안 됨) */
function primary() {
const config = require('./config');
const url = String(config.get('mail.url') || '').replace(/\/+$/, '');
const domain = String(config.get('mail.domain') || '').trim().toLowerCase();
if (!url && !domain) return null;
const host = hostOf(url); const d = load();
return {
id: PRIMARY, source: 'config',
nickname: String(config.get('mail.nickname') || '').trim() || (host ? `${host} (설정 파일)` : '기본 메일 서버'),
ip: String(config.get('mail.ip') || '').trim(),
url, apiUrl: String(config.get('mail.apiUrl') || '').replace(/\/+$/, '') || url, host, domain,
adminLogin: String(config.get('mail.adminLogin') || 'admin').trim(), adminPassword: config.get('mail.adminPassword') || '',
lastTest: d.primaryTest || null,
lockedUntil: d.primaryLockedUntil || null, prevPasswordAt: d.primaryPrevAt || null, // v1.6.39: 로그인 잠금 해제 시각 · 이전 비밀번호를 보관한 시각
usageKey: usageKeyOf(PRIMARY), usageKeyAt: d.primaryUsageKeyAt || null, // v1.7.3: 사용량 API 키(보관소) · 키를 저장한 시각
};
}
/** 등록부 항목 → 서버 객체(비밀번호 포함, 서버 내부 전용) */
function fromItem(x) {
let pw = ''; try { pw = require('./vault').secretOf(vaultId(x.id)); } catch (_) {}
const url = String(x.url || '').replace(/\/+$/, '');
return { id: x.id, source: 'registry', nickname: x.nickname || x.id, ip: x.ip || '', url, apiUrl: String(x.apiUrl || '').replace(/\/+$/, '') || url, host: hostOf(url), domain: String(x.domain || '').toLowerCase(), adminLogin: x.adminLogin || 'admin', adminPassword: pw, createdAt: x.createdAt, updatedAt: x.updatedAt, lastTest: x.lastTest || null, lockedUntil: x.lockedUntil || null, prevPasswordAt: x.prevPasswordAt || null, usageKey: usageKeyOf(x.id), usageKeyAt: x.usageKeyAt || null /* v1.7.3 */ };
}
/** v1.6.39: 이전 비밀번호가 보관되어 있는지 (원문은 돌려주지 않는다) */
function hasPrev(id) { try { return !!require('./vault').secretOf(prevVaultId(id)); } catch (_) { return false; } }
/** v1.6.39: 지금 잠금 중이면 해제 시각(ISO), 아니면 null — 지난 잠금은 무시 */
function lockActive(s) { return s && s.lockedUntil && Date.parse(s.lockedUntil) > Date.now() ? s.lockedUntil : null; }
/**
* 모든 서버(비밀번호 포함). isDefault: defaultId 로 지정한 서버, 없으면 첫 서버(기본 서버가 있으면 그것).
* @returns {Array<object>}
*/
function all() {
const d = load(); const list = [];
const p = primary(); if (p) list.push(p);
for (const x of Object.values(d.items)) list.push(fromItem(x));
const defId = list.some((s) => s.id === d.defaultId) ? d.defaultId : (list[0] ? list[0].id : '');
for (const s of list) s.isDefault = s.id === defId;
return list;
}
/** 화면용(비밀번호 제외) */
function pub(s) {
if (!s) return null; const { adminPassword, usageKey, ...rest } = s;
// v1.6.39: hasPrevPassword(되돌리기 가능) · lockedUntil(연결 확인 버튼을 막을 해제 시각) · adminUrl(보기/숨기기로 본 비밀번호로 들어갈 Arimail 관리자 페이지)
// v1.7.3: hasUsageKey·usageKeyHint(사용량 API 키 유무·앞뒤 글자) · usageEndpoint(부르는 주소) — 키 원문은 내려보내지 않는다
return { ...rest, hasPassword: !!adminPassword, adminSync: adminSyncView(s.id) /* v1.7.3 추가: 관리자 비밀번호 동기화 기록 */, hasUsageKey: !!usageKey, usageKeyHint: usageKey ? usageKeyHint(s.id) : '', usageEndpoint: s.apiUrl ? `${s.apiUrl}/api/usage` : null, hasPrevPassword: hasPrev(s.id), lockedUntil: lockActive(s), adminUrl: s.url ? `${s.url}/admin` : null, configured: !!(s.url && s.domain && s.adminLogin && adminPassword), label: `${s.nickname}${s.ip ? ` — ${s.ip}` : ''}${s.domain ? ` (${s.domain})` : ''}` };
}
function list() { return all().map(pub); }
function get(id) { return all().find((s) => s.id === String(id || '')) || null; }
function defaultServer() { return all().find((s) => s.isDefault) || null; }
/** 메일 도메인을 다루는 서버 (여러 대면 기본 서버 우선) */
function forDomain(domain) { const d = String(domain || '').toLowerCase().replace(/^www\./, ''); if (!d) return null; const hits = all().filter((s) => s.domain === d); return hits.find((s) => s.isDefault) || hits[0] || null; }
/**
* 사이트에 쓸 메일 서버. 사이트가 고른 서버(site.webmail.mailServer) > 메일 도메인이 같은 서버 > 기본 서버.
* @param {object} [site] 없으면 기본 서버
*/
function pick(site) {
const w = (site && site.webmail) || {};
if (w.mailServer) { const s = get(w.mailServer); if (s) return s; }
if (w.domain) { const s = forDomain(w.domain); if (s) return s; }
return defaultServer();
}
// ---------- 추가·수정·삭제 ----------
/**
* 서버 등록·수정.
* @param {{id?, nickname, ip?, url, apiUrl?, domain, adminLogin?, password?}} input
* id 비우면 닉네임에서 만든다(영문 소문자·숫자·-). 'primary' 면 설정 파일(mail.*)에 쓴다.
* password 비우면 기존 비밀번호 유지(새 서버는 필수 — 없으면 계정 관리가 안 되므로 등록은 되되 "보기 전용" 안내)
* @param {string} by 작업자(설정 파일 updatedBy 와 보관소 source 에 남김)
*/
function upsert(input, by = 'admin') {
if (!input || typeof input !== 'object') throw httpError('저장할 값이 없습니다');
const nickname = String(input.nickname || '').trim().slice(0, 40);
const url = String(input.url || '').trim().replace(/\/+$/, '');
const apiUrl = String(input.apiUrl || '').trim().replace(/\/+$/, '');
const ip = String(input.ip || '').trim();
const domain = String(input.domain || '').trim().toLowerCase().replace(/\.$/, '');
const adminLogin = String(input.adminLogin || '').trim() || 'admin';
const password = typeof input.password === 'string' ? input.password : '';
if (!url) throw httpError('Arimail 주소를 입력하세요 (예: https://webmail.example.com) — 비워 두면 웹메일 주소를 안내할 수 없고 계정 관리 API 도 부를 수 없습니다');
if (!/^https?:\/\/[^\s/]+/i.test(url)) throw httpError('Arimail 주소는 http:// 또는 https:// 로 시작해야 합니다');
if (apiUrl && !/^https?:\/\/[^\s/]+/i.test(apiUrl)) throw httpError('API 주소는 http:// 또는 https:// 로 시작해야 합니다 (같은 서버면 http://127.0.0.1:8790 처럼 로컬 주소)');
if (!domain) throw httpError('메일 도메인을 입력하세요 (예: example.com) — 이 서버의 계정은 아이디@메일도메인 이 되고, 같은 메일 도메인의 사이트에 자동으로 연결됩니다');
if (!DOMAIN_RE.test(domain)) throw httpError('메일 도메인 형식이 올바르지 않습니다 (예: example.com)');
if (ip && !IP_RE.test(ip)) throw httpError('서버 IP 형식이 올바르지 않습니다 (예: 115.68.194.192)');
let id = String(input.id || '').trim().toLowerCase();
if (id === PRIMARY) {
// 기본 서버는 설정 파일에 쓴다 (install-arimail.sh 와 같은 자리). provider 가 none 이면 함께 켠다
const config = require('./config');
const patch = { 'mail.provider': 'arimail', 'mail.url': url, 'mail.apiUrl': apiUrl, 'mail.domain': domain, 'mail.adminLogin': adminLogin, 'mail.nickname': nickname, 'mail.ip': ip };
if (password) { keepPrevious(PRIMARY, password, by); patch['mail.adminPassword'] = password; }
config.set(patch, by);
if (typeof input.usageKey === 'string' && input.usageKey.trim()) writeUsageKey(PRIMARY, input.usageKey, by); else if (input.clearUsageKey === true || input.clearUsageKey === 'true') clearUsageKey(PRIMARY); // v1.7.3
return pub(get(PRIMARY));
}
if (!id) id = (nickname || hostOf(url) || domain).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || `srv${Date.now().toString(36)}`;
if (id.length < 2) id = `${id}-srv`;
if (!ID_RE.test(id)) throw httpError('서버 id 는 영문 소문자·숫자·- 2~41자로 입력하세요');
const d = load(); const prev = d.items[id] || null;
if (!nickname) throw httpError('닉네임을 입력하세요 — 메일 서버를 고를 때 IP 와 함께 이 이름으로 구분합니다');
const dup = all().find((s) => s.id !== id && s.url === url && s.domain === domain); if (dup) throw httpError(`같은 주소·도메인의 서버가 이미 등록되어 있습니다: ${dup.nickname}`, 409);
if (!prev && !password) throw httpError('관리자 비밀번호를 입력하세요 — 없으면 Arimail 에 로그인해 메일 계정을 만들고 지울 수 없습니다 (Arimail 관리자 페이지 /admin 의 관리자 역할 계정)');
const now = new Date().toISOString();
d.items[id] = { ...(prev || {}), id, nickname, ip, url, apiUrl, domain, adminLogin, createdAt: prev ? prev.createdAt : now, updatedAt: now, lastTest: prev && !password ? prev.lastTest : null };
if (password) { const pa = keepPrevious(id, password, by); if (pa) d.items[id].prevPasswordAt = pa; require('./vault').set({ kind: 'arimail', id: vaultId(id), label: `Arimail 관리자 비밀번호 — ${nickname}`, url, settings: { ADMIN_LOGIN: adminLogin, MAIL_DOMAIN: domain }, secret: password }, `mailserver:${by}`); }
if (!d.defaultId && !primary()) d.defaultId = id; // 첫 서버는 기본 서버로
save(d);
// v1.7.3 (요구 2): 사용량 API 키 — 폼에 넣었으면 저장(비우면 유지), clearUsageKey 면 지운다
if (typeof input.usageKey === 'string' && input.usageKey.trim()) writeUsageKey(id, input.usageKey, by); else if (input.clearUsageKey === true || input.clearUsageKey === 'true') clearUsageKey(id);
// 연동 모듈이 꺼져 있으면(mail.provider=none) 서버를 등록한 뜻이 분명하므로 켠다
try { const config = require('./config'); if ((config.get('mail.provider') || 'none') === 'none') config.set({ 'mail.provider': 'arimail' }, by); } catch (_) {}
return pub(get(id));
}
/** 추가 서버 삭제 (기본 서버 'primary' 는 설정 파일이라 여기서 지우지 않는다). 이 서버를 고른 사이트는 자동 선택(도메인·기본)으로 돌아간다 */
function remove(id) {
if (id === PRIMARY) throw httpError('설정 파일의 기본 서버는 여기서 지울 수 없습니다 — 시스템 설정 › 메일 연동에서 주소를 비우거나 연동 모듈을 "연동 안 함" 으로 바꾸세요');
const d = load(); if (!d.items[id]) throw httpError('그 메일 서버가 없습니다', 404);
delete d.items[id]; if (d.defaultId === id) d.defaultId = '';
save(d);
try { require('./vault').remove(vaultId(id)); } catch (_) {}
try { require('./vault').remove(usageVaultId(id)); } catch (_) {} // v1.7.3: 사용량 API 키도 함께
try { require('./mailusage').invalidate(id); } catch (_) {}
return true;
}
function setDefault(id) { if (!get(id)) throw httpError('그 메일 서버가 없습니다', 404); const d = load(); d.defaultId = id; save(d); return list(); }
/**
* 연결 확인 결과를 서버에 기록. v1.6.39: 결과 코드가 rate_limited(Arimail 로그인 잠금)면 해제 시각을 함께 적고, 성공하면 잠금을 지운다
*/
function recordTest(id, result) {
const d = load(); const r = { ok: !!result.ok, detail: String(result.detail || '').slice(0, 300), at: new Date().toISOString(), code: result.code || null };
const lock = result.code === 'rate_limited' ? (result.lockedUntil || new Date(Date.now() + LOCK_MS).toISOString()) : (result.ok ? null : undefined); // undefined = 그대로
if (id === PRIMARY) { d.primaryTest = r; if (lock !== undefined) d.primaryLockedUntil = lock; }
else if (d.items[id]) { d.items[id].lastTest = r; if (lock !== undefined) d.items[id].lockedUntil = lock; }
else return;
save(d);
}
/**
* v1.6.39 (요구 2): Arimail 이 "로그인 시도가 너무 많다(rate_limited)" 고 답하면 연동 모듈이 호출한다 — 해제 시각(지금 + 15분)을 적어 두고
* 그때까지 "연결 확인" 을 받지 않는다(그 전에 다시 시도하면 잠금이 길어지기만 한다). 등록부에 없는 서버 id(임시 접속)면 무시.
* @returns {string|null} 해제 시각(ISO)
*/
function noteLocked(id) {
const d = load(); const until = new Date(Date.now() + LOCK_MS).toISOString();
if (id === PRIMARY) d.primaryLockedUntil = until; else if (d.items[id]) d.items[id].lockedUntil = until; else return null;
save(d); return until;
}
/** 잠금 해제 시각을 사람이 읽는 시:분 (서버 시간대) */
const hhmm = (iso) => { const t = new Date(iso); return `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`; };
/** 연결 확인 (Arimail health → 관리자 로그인 → 계정 목록). 결과는 lastTest 로 남는다. 잠금 중이면 Arimail 을 부르지 않고 바로 알린다 */
async function test(id) {
const s = get(id); if (!s) throw httpError('그 메일 서버가 없습니다', 404);
const lock = lockActive(s);
if (lock) return { ok: false, locked: true, lockedUntil: lock, code: 'rate_limited', detail: `Arimail 로그인 잠금 중 — ${hhmm(lock)} 이후 다시 확인하세요 (그 전에 시도하면 잠금이 길어집니다). 비밀번호가 틀렸던 경우 잠금 해제 전에 "수정" 으로 맞는 비밀번호를 저장해 두세요`, server: pub(s) };
const st = await require('./connectors/arimail').forServer(s).status();
recordTest(id, st);
return { ...st, locked: st.code === 'rate_limited', server: pub(get(id)) };
}
// ---------- v1.6.39 (요구 2·4): 비밀번호 보관·되돌리기·보기·초기화 ----------
/** 지금 저장된 비밀번호 쓰기 (기본 서버는 설정 파일 → 보관소 arimail, 추가 서버는 보관소 arimail-<id>) */
function writePassword(id, password, by) {
if (id === PRIMARY) { require('./config').set({ 'mail.adminPassword': password }, by); return; }
const s = get(id); require('./vault').set({ kind: 'arimail', id: vaultId(id), label: `Arimail 관리자 비밀번호 — ${s ? s.nickname : id}`, url: s ? s.url : '', settings: s ? { ADMIN_LOGIN: s.adminLogin, MAIL_DOMAIN: s.domain } : {}, secret: password }, `mailserver:${by}`);
}
/**
* 비밀번호를 새 값으로 바꾸기 직전에 호출 — 지금 값이 있고 새 값과 다르면 "이전 비밀번호" 로 옮겨 둔다.
* (2026-10-21 webmaker.ibit.kr: 동작하던 비밀번호를 틀린 값으로 덮어써 되돌릴 길이 없었다)
* @returns {string|null} 보관한 시각(ISO) — 보관하지 않았으면 null
*/
function keepPrevious(id, newPassword, by) {
const s = get(id); const cur = s ? s.adminPassword : '';
if (!cur || cur === newPassword) return null;
const at = new Date().toISOString();
require('./vault').set({ kind: 'arimail', id: prevVaultId(id), label: `이전 Arimail 관리자 비밀번호 — ${s.nickname}`, url: s.url, settings: { ADMIN_LOGIN: s.adminLogin, REPLACED_AT: at }, secret: cur }, `mailserver:${by}`);
const d = load(); if (id === PRIMARY) d.primaryPrevAt = at; else if (d.items[id]) d.items[id].prevPasswordAt = at; save(d);
return at;
}
/**
* "이전 비밀번호로 되돌리기" — 보관해 둔 이전 값을 현재로, 지금 값은 이전으로 맞바꾼다(되돌리기를 다시 되돌릴 수 있게). 잠금 기록은 그대로(Arimail 쪽 잠금이라 바뀌지 않는다)
*/
function revertPassword(id, by = 'admin') {
const s = get(id); if (!s) throw httpError('그 메일 서버가 없습니다', 404);
let prev = ''; try { prev = require('./vault').secretOf(prevVaultId(id)); } catch (_) {}
if (!prev) throw httpError('보관된 이전 비밀번호가 없습니다 — 비밀번호를 바꾼 적이 없거나(1.6.39 이전에 바꿈) 이미 되돌렸습니다. "비밀번호 초기화" 로 새 비밀번호를 만들 수 있습니다', 404);
const cur = s.adminPassword; const at = new Date().toISOString();
writePassword(id, prev, by);
if (cur && cur !== prev) require('./vault').set({ kind: 'arimail', id: prevVaultId(id), label: `이전 Arimail 관리자 비밀번호 — ${s.nickname}`, url: s.url, settings: { ADMIN_LOGIN: s.adminLogin, REPLACED_AT: at }, secret: cur }, `mailserver:${by}`);
else { try { require('./vault').remove(prevVaultId(id)); } catch (_) {} }
const d = load(); if (id === PRIMARY) d.primaryPrevAt = cur && cur !== prev ? at : null; else if (d.items[id]) d.items[id].prevPasswordAt = cur && cur !== prev ? at : null; save(d);
return pub(get(id));
}
/**
* v1.6.39 (요구 4): 관리자 콘솔 [보이기] — 저장된 Arimail 관리자 비밀번호 원문. 관리자 전용 경로에서만 부르고 호출은 작업 기록(mailserver.reveal)에 남긴다(비밀번호는 기록하지 않음).
* 초기 설정 뒤 운영자가 Arimail /admin 에 직접 들어갈 때 필요하다.
*/
function revealPassword(id) {
const s = get(id); if (!s) throw httpError('그 메일 서버가 없습니다', 404);
if (!s.adminPassword) throw httpError('저장된 비밀번호가 없습니다 — "수정" 에서 입력하거나 "비밀번호 초기화" 로 새로 만드세요', 404);
return { id: s.id, adminLogin: s.adminLogin, password: s.adminPassword, adminUrl: s.url ? `${s.url}/admin` : null, webmailUrl: s.url ? `${s.url}/` : null };
}
/** 무작위 비밀번호 — 대문자·소문자·숫자·기호가 모두 들어가는 16자 (Arimail 기본 비밀번호 정책을 넘도록) */
function generatePassword() {
const crypto = require('crypto'); const sets = ['ABCDEFGHJKLMNPQRSTUVWXYZ', 'abcdefghijkmnopqrstuvwxyz', '23456789', '!@#$%^*-_+='];
const pick = (str) => str[crypto.randomInt(str.length)];
const chars = sets.map(pick); const all = sets.join('');
while (chars.length < 16) chars.push(pick(all));
for (let i = chars.length - 1; i > 0; i--) { const j = crypto.randomInt(i + 1); [chars[i], chars[j]] = [chars[j], chars[i]]; } // 섞기
return chars.join('');
}
/**
* v1.6.39 (요구 4): 연동 관리자 계정(adminLogin)의 비밀번호 초기화 — 새 무작위 비밀번호를 만들어 Arimail 에 설정하고 보관소에 저장한다.
* - 기본: 저장된 비밀번호로 로그인해 자기 계정의 비밀번호를 바꾼다(운영자가 비밀번호를 잊었지만 웹메이커는 알고 있는 경우).
* - via {login,password}: 저장된 비밀번호도 틀릴 때, Arimail 의 다른 관리자 계정(예: 초기 admin)으로 한 번 로그인해 연동 계정의 비밀번호를 바꾼다.
* via 의 비밀번호는 저장하지 않는다. 연동 계정이 Arimail 에 없으면 관리자 역할로 새로 만든다.
* 바꾼 뒤 이전 비밀번호는 "이전 비밀번호" 로 보관되고, 새 비밀번호는 응답으로 한 번 돌려준다(화면의 보기/숨기기로도 다시 볼 수 있다).
* @returns {Promise<{server:object, password:string, created:boolean, how:string}>}
*/
async function resetPassword(id, { via } = {}, by = 'admin') {
const s = get(id); if (!s) throw httpError('그 메일 서버가 없습니다', 404);
if (!s.url) throw httpError('Arimail 주소가 없어 접속할 수 없습니다 — 먼저 "수정" 에서 주소를 넣으세요');
const arimail = require('./connectors/arimail');
const useVia = !!(via && String(via.login || '').trim());
if (!useVia && !s.adminPassword) throw httpError('저장된 비밀번호가 없어 로그인할 수 없습니다 — "다른 관리자 계정으로 재설정" 에 Arimail 관리자(예: admin) 아이디·비밀번호를 넣으세요', 409);
const actor = useVia ? arimail.forServer({ ...s, adminLogin: String(via.login).trim(), adminPassword: String(via.password || '') }) : arimail.forServer(s);
const how = useVia ? `다른 관리자 계정(${String(via.login).trim()})으로 로그인해` : '저장된 비밀번호로 로그인해';
const newPw = generatePassword(); let created = false;
let u; try { u = await actor.findUser(s.adminLogin); }
catch (e) { throw Object.assign(httpError(`Arimail 로그인 실패 — ${e.message}${useVia ? '' : ' (저장된 비밀번호가 틀리면 "다른 관리자 계정으로 재설정" 을 쓰세요)'}`, e.status === 502 ? 502 : 400), { code: e.code || null }); }
if (!u) {
if (!useVia) throw httpError(`Arimail 에 연동 관리자 계정 "${s.adminLogin}" 이 없습니다 (로그인은 됐지만 목록에 없음)`, 409);
const r = await actor.createUser({ login: s.adminLogin, name: 'Webmaker 연동 관리자', password: newPw, role: 'admin' }); u = { id: r.id }; created = true;
} else await actor.updateUser(u.id, { password: newPw, role: 'admin', status: 'active' }); // 관리자 역할·활성 상태를 함께 보장 (역할을 올리는 쪽이라 보호 규칙에 걸리지 않음)
keepPrevious(id, newPw, by); writePassword(id, newPw, by);
// 새 비밀번호로 실제 로그인되는지 바로 확인해 lastTest 에 남긴다 (실패해도 초기화 자체는 끝난 것이므로 예외로 올리지 않는다)
let test = null; try { test = await arimail.forServer(get(id)).status(); recordTest(id, test); } catch (_) {}
return { server: pub(get(id)), password: newPw, created, how, test: test ? { ok: !!test.ok, detail: test.detail } : null };
}
// ---------- v1.7.3 추가(운영자 요청 2026-10-24): 웹메이커 관리자 ↔ Arimail 같은 ID 계정 비밀번호 동기화 기록 ----------
/** 서버별 동기화 기록 { <로그인>: { fp, at, error, errorAt } } — 비밀번호 원문·해시는 넣지 않는다(지문만) */
function adminSyncOf(serverId) { const d = load(); return (serverId === PRIMARY ? d.primaryAdminSync : (d.items[serverId] || {}).adminSync) || {}; }
function setAdminSync(serverId, login, patch) {
const d = load(); const cur = serverId === PRIMARY ? (d.primaryAdminSync || {}) : ((d.items[serverId] || {}).adminSync || {});
cur[login] = { ...(cur[login] || {}), ...patch };
if (serverId === PRIMARY) d.primaryAdminSync = cur; else if (d.items[serverId]) d.items[serverId].adminSync = cur; else return null;
save(d); return cur[login];
}
/** 화면용 요약: [{login, at, error}] */
function adminSyncView(serverId) { return Object.entries(adminSyncOf(serverId)).map(([login, x]) => ({ login, at: x.at || null, error: x.error || null, errorAt: x.errorAt || null, skipped: x.skipped || null })); }
// ---------- v1.7.3 (요구 2): 사용량 API 키 ----------
/** 키 모양 검사 — Arimail 은 'au_' + 32바이트(base64url) 를 만들지만 다른 길이도 허용(최소 16자, 공백·따옴표 없이) */
const USAGE_KEY_RE = /^[A-Za-z0-9_\-]{16,200}$/;
/**
* 사용량 API 키 저장 (보관소 arimail-usage[-<id>]) + 저장 시각을 등록부에 기록. 키 원문은 등록부·작업 기록·오류 문구에 쓰지 않는다.
* @param {string} id 서버 id ('primary' 포함)
* @param {string} key Arimail 전체 설정 › 사용량 API 카드에서 만든 키(au_…) 또는 issueUsageKey 가 받은 키
*/
function writeUsageKey(id, key, by = 'admin') {
const s = get(id); if (!s) throw httpError('그 메일 서버가 없습니다', 404);
const k = String(key || '').trim(); if (!USAGE_KEY_RE.test(k)) throw httpError('사용량 API 키 형식이 올바르지 않습니다 — Arimail 전체 설정 › 사용량 API 카드에서 만든 키(au_ 로 시작, 공백 없이)를 그대로 붙여 넣으세요');
require('./vault').set({ kind: 'arimail-usage', id: usageVaultId(id), label: `Arimail 사용량 API 키 — ${s.nickname}`, url: s.url, settings: { MAIL_DOMAIN: s.domain }, secret: k }, `mailserver:${by}`);
const d = load(); const at = new Date().toISOString(); if (id === PRIMARY) d.primaryUsageKeyAt = at; else if (d.items[id]) d.items[id].usageKeyAt = at; save(d);
try { require('./mailusage').invalidate(id); } catch (_) {}
return pub(get(id));
}
/** 사용량 API 키 삭제 (Arimail 쪽 키는 그대로 — Arimail 에서 지우려면 전체 설정 화면의 "키 삭제") */
function clearUsageKey(id) {
if (!get(id)) throw httpError('그 메일 서버가 없습니다', 404);
try { require('./vault').remove(usageVaultId(id)); } catch (_) {}
const d = load(); if (id === PRIMARY) d.primaryUsageKeyAt = null; else if (d.items[id]) d.items[id].usageKeyAt = null; save(d);
try { require('./mailusage').invalidate(id); } catch (_) {}
return pub(get(id));
}
/**
* 사용량 API 키 발급 — 저장된 관리자 계정으로 Arimail 에 로그인해 `POST /api/setup/usage-key {action:'generate'}` 를 부르고(기본 도메인 관리자 세션으로 허용됨)
* 받은 키를 바로 보관한다. Arimail 은 키를 만들 때 한 번만 돌려주므로 이 흐름이면 운영자가 키를 옮겨 적을 일이 없다. 이미 키가 있으면 Arimail 쪽 키가 새 키로 바뀐다(이전 키 즉시 무효).
* 실패 사유: Arimail 1.0.9 미만(API 없음) · 등록 계정이 기본 도메인 관리자가 아님(primary_only) · 로그인 실패/잠금.
* @returns {Promise<{server:object, endpoint:string|null, replaced:boolean}>}
*/
async function issueUsageKey(id, by = 'admin') {
const s = get(id); if (!s) throw httpError('그 메일 서버가 없습니다', 404);
if (!s.url) throw httpError('Arimail 주소가 없어 접속할 수 없습니다 — 먼저 "수정" 에서 주소를 넣으세요');
if (!s.adminPassword) throw httpError('저장된 관리자 비밀번호가 없어 Arimail 에 로그인할 수 없습니다 — 비밀번호를 넣거나 Arimail 전체 설정 화면에서 만든 키를 직접 붙여 넣으세요', 409);
const replaced = !!s.usageKey;
const r = await require('./connectors/arimail').forServer(s).issueUsageKey();
const server = writeUsageKey(id, r.key, by);
return { server, endpoint: r.endpoint || null, replaced };
}
module.exports = { FILE, PRIMARY, LOCK_MS, load, all, list, get, pub, defaultServer, forDomain, pick, upsert, remove, setDefault, recordTest, noteLocked, test, vaultId, prevVaultId, revertPassword, revealPassword, resetPassword, generatePassword, usageVaultId, usageKeyOf, writeUsageKey, clearUsageKey, issueUsageKey, adminSyncOf, setAdminSync, adminSyncView };
+468
View File
@@ -0,0 +1,468 @@
'use strict';
// ============================================================================
// 사이트 메일 시스템 (v1.3.0)
//
// 사용자 사이트의 도메인으로 "아이디@사이트도메인" 메일 주소를 만들어 웹메일(/webmail)로 쓰게 하는 기능의
// 빌더 쪽 관리 계층이다. 구성은 세 단계로 나뉜다.
//
// 1) 운영자(빌더 관리자) : 관리자 콘솔 › 설정 › 메일 시스템 에서 전체 기능을 켜고 끈다(settings.mailSystem).
// 메일 서버 주소(MX 로 가리킬 호스트)와 SPF 에 넣을 include 값도 여기서 정한다.
// 2) 사이트 관리자 : 사이트 관리자 페이지 › 메일 탭에서 이 사이트의 웹메일을 켠다(site.webmail).
// 켤 때 사이트 도메인의 DNS 에 넣을 레코드(MX·SPF·DMARC)를 안내하고, DNS 확인이 끝나야
// 메일 수발신을 허용한다. DNS 가 미완료면 웹메일 안에 "설정 문제로 수발신 제한" 상태를 표시한다.
// 3) 계정 : 사이트 관리자 계정(회원 role=admin + loginId)마다 "메일 사용" 을 켜면 같은 아이디·비밀번호로
// 메일 계정이 생긴다(웹메일 로그인 = 사이트 관리자 로그인, 계정을 공유). 끄면 메일함을 지운다.
//
// v1.3.1: 보내기는 메일 발송 키(Resend, 암호화 보관소 lib/vault.js 'resend')로 한다. 사이트 웹메일을 켜면 그 도메인을
// Resend 에 등록하고 Resend 가 요구하는 DNS 레코드(DKIM·send 하위 도메인 MX/SPF)를 안내 표에 함께 넣는다.
// Resend 가 도메인을 확인(verified)하면 웹메일에서 "아이디@사이트도메인" 으로 보낼 수 있다.
// 받기(SMTP 수신·보관)는 OZ-Group/mail-gateway 가 맡을 부분이다. 1.3.0 개발 시점에 그 저장소를
// 읽을 권한이 없어, 여기서는 백엔드를 어댑터(backend)로 분리해 두고 기본값 "미연결(none)" 으로 동작한다.
// 미연결 상태에서는 계정·DNS·웹메일 화면은 모두 동작하지만 메일 수발신은 막히고 그 이유를 화면에 표시한다.
// mail-gateway 연동 시 backendFor() 에 구현을 추가하면 된다(createMailbox / deleteMailbox / list / read / send).
//
// 저장:
// - settings.mailSystem : { enabled, mxHost, spfInclude, dmarc }
// - site.webmail : { enabled, domain, enabledAt, dns: { checkedAt, ok, records[] } }
// - data/modules/<site>.json members[] : mail(true/false), mailAddress, mailEnabledAt
// - data/mail/<site>/<memberId>.json : 메일함(백엔드가 채움). 메일 사용을 끄면 이 파일을 지운다.
//
// v1.3.2: 메일 시스템 켜기를 "사이트(호스팅 도메인) 단위" 로 바꿨다.
// - 운영자가 관리자 콘솔 › 메일 시스템 탭에서 사이트마다 "메일 시스템 사용" 을 켜고 메일 도메인·메일 서버 주소(MX)·
// SPF include·DMARC 를 입력한다(site.webmail). 전역 settings.mailSystem 은 이 값들의 "기본값" 으로만 쓴다.
// - 입력이 끝나도 DNS 전파·보내기 인증이 확인되기 전에는 사용자(빌더 관리 탭, 사이트 관리자 페이지, 웹메일)에게
// "메일시스템 설정중" 으로 보이고, 확인되면 "사용 가능" 이 된다.
// - 사이트 관리자는 더 이상 메일 시스템을 켜고 끄지 않는다(계정별 메일 사용과 DNS 다시 확인만).
// ============================================================================
const fs = require('fs');
const path = require('path');
const dns = require('dns').promises;
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const MAIL_DIR = path.join(DATA_ROOT, 'mail');
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
/** SPF include 대상: DNS 이름이라 밑줄로 시작하는 라벨(_spf 등)도 허용 (v1.3.17 B11 — 안내 예시 _spf.example.com 이 거부되던 문제) */
const SPF_RE = /^(?=.{1,253}$)([a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
const LOCAL_RE = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/; // 메일 주소 @ 앞부분으로 쓸 수 있는 아이디
// ---------- 운영자 설정 ----------
/** 전역 기본값 (v1.3.2: 켜기/끄기는 사이트 단위라 enabled 는 쓰지 않는다). mxHost 가 없으면 사이트 설정에서 꼭 입력해야 한다 */
const DEFAULTS = { mxHost: '', spfInclude: '', dmarc: 'v=DMARC1; p=none' };
/** settings.mailSystem 을 기본값과 합친 값 (settings 모듈은 순환 참조를 피하려고 호출 시점에 읽는다) */
function config() { const s = require('./settings').load(); const c = { ...DEFAULTS, ...(s.mailSystem || {}) }; delete c.enabled; return c; } // enabled: 1.3.1 이전 전역 켜기 값(무시)
/** 관리자 입력 검증 → 저장할 값 */
function normalizeConfig(input, prev) {
const c = { ...DEFAULTS, ...(prev || {}) };
delete c.enabled; // 1.3.0~1.3.1 의 전역 켜기 값은 더 이상 쓰지 않음
if (input.mxHost !== undefined) { const h = String(input.mxHost || '').trim().toLowerCase().replace(/\.$/, ''); if (h && !DOMAIN_RE.test(h)) throw httpError('메일 서버 주소(MX 호스트)는 mail.example.com 같은 도메인 이름이어야 합니다'); c.mxHost = h; }
if (input.spfInclude !== undefined) { const v = String(input.spfInclude || '').trim().toLowerCase(); if (v && !SPF_RE.test(v)) throw httpError('SPF include 값은 도메인 이름이어야 합니다 (예: _spf.example.com)'); c.spfInclude = v; }
if (input.dmarc !== undefined) { const v = String(input.dmarc || '').trim(); if (v && !/^v=DMARC1;/i.test(v)) throw httpError('DMARC 값은 "v=DMARC1;" 로 시작해야 합니다'); c.dmarc = v || DEFAULTS.dmarc; }
return c;
}
/**
* 사이트에 적용되는 메일 설정: 사이트에 입력한 값 > 전역 기본값.
* @returns {{mxHost, spfInclude, dmarc}}
*/
function siteConfig(site) {
const c = config(); const w = (site && site.webmail) || {};
// v1.6.38 (요구 2): 사이트 값도 전역 기본값도 없으면 이 사이트에 고른(또는 메일 도메인이 같은) 메일 서버의 호스트를 MX 로 쓴다 — Arimail 호스트가 SMTP 수신도 맡기 때문
let mx = w.mxHost || c.mxHost; if (!mx) { try { const sv = require('./mailservers').pick(site); if (sv && sv.host) mx = sv.host; } catch (_) {} }
return { mxHost: mx, spfInclude: w.spfInclude != null && w.spfInclude !== '' ? w.spfInclude : c.spfInclude, dmarc: w.dmarc || c.dmarc || DEFAULTS.dmarc };
}
// ---------- 백엔드 ----------
/**
* 메일 백엔드 상태와 동작.
* - 보내기(canSend): 메일 발송 키(Resend)가 있으면 가능. 실제로 보내려면 사이트 도메인이 Resend 에서 확인돼야 한다.
* - 받기(canReceive): mail-gateway 연동 후 가능. 접속 정보(보관소 'mail-gateway')가 등록돼 있어도 연동 코드가 아직 없다.
*/
/**
* v1.5.7 (요구 5): 받는 메일 백엔드는 연동 모듈(lib/connectors/arimail.js)이 맡는다.
* - 설정 파일 mail.provider = arimail 이고 주소·도메인·관리자 계정이 있으면 "연결됨". 사이트의 메일 도메인이 Arimail 의 도메인과
* 같을 때만 그 사이트의 받는 메일·웹메일·메일 계정이 Arimail 로 이어진다(Arimail 한 설치 = 한 도메인).
* - 계정: 메일 사용을 켤 때 Arimail 계정(같은 로그인 ID)을 만들고, 끌 때 지운다. 비밀번호는 켤 때 사용자가 정한다.
* - 웹메일: Arimail 의 웹메일(주소 mail.url)을 쓴다. 이 서비스의 /webmail/<사이트>/ 는 그곳으로 보낸다.
* - 보내기는 그대로 Resend 키(관리자 콘솔 › 설정 › 메일 발송). Arimail 도 같은 Resend 로 보내므로 발신 인증(DKIM) 레코드는 공통이다.
* Arimail 연결 여부는 health 로 60초마다 확인한다(매 요청마다 네트워크를 타지 않게).
* @param {object} [site] 사이트 — 도메인이 맞는지 보려면 필요. 없으면 "서비스 전체" 기준 상태
*/
/**
* v1.6.38 (요구 2): Arimail 이 여러 대(다른 서버) 등록될 수 있다 — 등록부(lib/mailservers.js)에서 이 사이트에 맞는 서버를 고르고,
* 그 서버에 묶인 연동 모듈(arimail.forServer)을 돌려준다. health 는 서버마다 60초 캐시.
* @param {object} [site] 사이트 — 사이트가 고른 서버(site.webmail.mailServer) 또는 메일 도메인이 같은 서버. 없으면 기본 서버
* @returns {{module, server, configured, ok, pending, version, settings, detail}}
*/
const healthCache = new Map(); // 서버 id → { at, ok, detail, version, pending }
function arimailState(site) {
let m = null; try { m = require('./connectors').mail(); } catch (_) {}
if (!m || m.id !== 'arimail') return { module: null, server: null, configured: false, ok: false, detail: '메일 서버 연동 모듈이 꺼져 있습니다 (설정 파일 mail.provider 또는 관리자 콘솔 › 메일 시스템 › 메일 서버 연동에서 서버 등록)' };
let server = null; try { server = require('./mailservers').pick(site); } catch (_) {}
if (!server) return { module: m, server: null, configured: false, ok: false, settings: m.settings(), detail: '등록된 메일 서버(Arimail)가 없습니다 — 관리자 콘솔 › 메일 시스템 › 메일 서버 연동에서 서버(다른 서버에 설치된 Arimail 도 가능)를 등록하세요' };
const bound = m.forServer(server); const s = bound.settings(); const configured = bound.configured();
const who = `${s.nickname || s.host}${s.ip ? ` ${s.ip}` : ''}`;
if (!configured) return { module: bound, server, configured: false, ok: false, settings: s, detail: s.url ? `메일 서버 "${who}" 의 도메인 또는 관리자 비밀번호가 없습니다 (메일 서버 연동에서 수정)` : `메일 서버 "${who}" 의 주소가 없습니다` };
let h = healthCache.get(server.id) || { at: 0, ok: false, detail: '' };
if (Date.now() - h.at > 60000) { h = { at: Date.now(), ok: h.ok, detail: h.detail, version: h.version, pending: true }; healthCache.set(server.id, h); bound.health().then((r) => { healthCache.set(server.id, { at: Date.now(), ok: !!r.ok, detail: r.detail || '', version: r.version || null }); }).catch((e) => { healthCache.set(server.id, { at: Date.now(), ok: false, detail: e.message }); }); }
return { module: bound, server, configured: true, ok: h.ok, pending: !!h.pending, version: h.version || null, settings: s, detail: h.ok ? `Arimail ${h.version || ''} 연결됨 (${who} · ${s.host}, 도메인 ${s.domain})` : (h.detail ? `Arimail(${who}) 응답 없음 — ${h.detail}` : `Arimail(${who}) 상태 확인 중`) };
}
// 기동 직후·설정 변경 직후 첫 화면이 "상태 확인 중" 으로 보이지 않게 미리 한 번 확인해 둔다 (실패해도 무시)
setTimeout(() => { try { arimailState(); } catch (_) {} }, 1500).unref();
function backendFor(site) {
let sendKey = false; try { sendKey = !!require('./mailer').resolveKey().key; } catch (_) {}
const am = arimailState(site); const siteDomain = site && site.webmail && site.webmail.domain ? String(site.webmail.domain).toLowerCase() : null;
const domainMatch = !!(am.configured && siteDomain && am.settings.domain === siteDomain);
const receive = am.configured && am.ok && (site ? domainMatch : true);
let reason;
if (!am.module) reason = '받는 메일: 메일 서버(Arimail) 연동이 꺼져 있어 받을 수 없습니다 (운영자: 초기 설정 페이지 › 메일 연동).';
else if (!am.configured) reason = `받는 메일: ${am.detail} — 운영자가 메일 서버를 등록하면 받을 수 있습니다.`;
else if (!am.ok) reason = `받는 메일: ${am.detail}`;
else if (site && !domainMatch) reason = `받는 메일: 메일 서버 "${am.settings.nickname || am.settings.host}"(${am.settings.host}${am.settings.ip ? ' / ' + am.settings.ip : ''})는 ${am.settings.domain} 도메인만 다루는데 이 사이트의 메일 도메인은 ${siteDomain || '(없음)'} 입니다. ${siteDomain || '그'} 도메인을 다루는 Arimail 을 메일 서버 연동에 등록하고 이 사이트의 메일 설정에서 그 서버를 고르세요.`;
else reason = '';
const mod = am.module;
return {
id: receive ? 'arimail' : (sendKey ? 'resend' : 'none'), canSend: sendKey, canReceive: receive, connected: !!(am.configured && am.ok), gatewayRegistered: am.configured,
arimail: am.configured ? { id: am.settings.id, nickname: am.settings.nickname, ip: am.settings.ip, url: am.settings.url, host: am.settings.host, domain: am.settings.domain, ok: am.ok, version: am.version, detail: am.detail, webmail: mod.webmailUrl(), admin: mod.adminUrl(), client: mod.clientSettings(), domainMatch } : null,
reason: sendKey ? reason : ('메일 발송 키가 없어 보낼 수 없습니다. ' + reason.replace('받는 메일: ', '받는 메일: ')).trim() + (sendKey ? '' : ' 운영자에게 문의하세요.'),
/** 계정 메일 사용 켜기 → Arimail 계정 생성 (비밀번호 필요). Arimail 이 아니면 로컬 메일함만 */
async createMailbox({ login, name, password }) {
if (!receive) return { ok: true, note: sendKey ? '보내기는 도메인 확인 후 바로 가능합니다. 받기는 메일 서버(Arimail) 연결 뒤 메일함이 연결됩니다.' : '메일 서버 연결 뒤 메일함이 연결됩니다.' };
if (!password) throw httpError('메일 비밀번호를 입력하세요. 메일 서버(Arimail)에 이 아이디의 계정이 만들어지며 웹메일·IMAP·POP·SMTP 에 그 비밀번호를 씁니다', 400);
// v1.6.31 (요구 9): 같은 로그인 이름이 다른 사이트에 등록돼 있으면 거절. 이 사이트의 기존 계정(등록부)이면 그 id 로 비밀번호 재설정만
const other = ownerOfLogin(login); if (other && !(other.siteId === arguments[0].siteId && other.memberId === arguments[0].memberId)) throw httpError(`아이디 "${login}" 는 다른 사이트의 메일 계정으로 등록되어 있습니다. 다른 아이디를 쓰세요`, 409);
const mine = accountOf(arguments[0].siteId, arguments[0].memberId);
const r = await mod.createUser({ login, name, password, role: 'user', reuseId: mine && mine.providerId });
setAccount(arguments[0].siteId, arguments[0].memberId, { login, address: r.mailbox, providerId: r.id, provider: 'arimail', state: 'active', lastError: null });
const c = mod.clientSettings();
return { ok: true, address: r.mailbox, existing: r.existing, note: `${r.existing ? '메일 서버에 이미 있던 계정의 비밀번호를 새로 정했습니다' : '메일 서버에 계정을 만들었습니다'}: ${r.mailbox}. 웹메일 ${mod.webmailUrl()} · 메일 프로그램: 받는 서버 ${c.imap.host} (IMAP 993 / POP3 995), 보내는 서버 ${c.smtp.host} (465/587), 사용자 이름 ${login}` };
},
/** 계정 메일 사용 끄기 → Arimail 계정 삭제 (메일은 Arimail 서버 저장소에 남는다 — 관리자 페이지에서 정리) */
/**
* v1.6.31 (요구 9): 삭제는 등록부에 적힌 "이 사이트·회원의 공급자 계정 id" 로만 한다. 등록부가 없거나(1.5.x 이전 계정) 로그인 이름이 다르면 지우지 않고 needs-admin 으로 남긴다.
* 공급자 삭제가 실패하면 로컬 기록을 지우지 않고 deprovision_pending 으로 남겨 운영 복구 화면에서 다시 시도한다(서버에 계정이 남는데 웹메이커에서 먼저 사라지는 일 방지).
*/
async deleteMailbox({ siteId, memberId, login }) {
if (!receive || !login) return { ok: true };
const acc = accountOf(siteId, memberId);
if (!acc || !acc.providerId) { setAccount(siteId, memberId, { login, state: 'needs-admin', lastError: '등록부에 공급자 계정 id 가 없어(이전 버전에서 만든 계정) 자동 삭제하지 않았습니다 — 관리자가 메일 서버에서 소유를 확인한 뒤 처리' }); return { ok: false, needsAdmin: true, warning: '이 메일 계정은 어느 사이트 것인지 자동으로 확인할 수 없어 메일 서버에서 지우지 않았습니다. 관리자에게 확인을 요청했습니다' }; }
try { const pu = await mod.getUserById(acc.providerId); if (pu && String(pu.login).toLowerCase() !== String(acc.login || login).toLowerCase()) { setAccount(siteId, memberId, { state: 'needs-admin', lastError: `공급자 계정(${acc.providerId})의 로그인 이름이 등록부와 다릅니다 (${pu.login})` }); return { ok: false, needsAdmin: true, warning: '메일 서버 계정 정보가 기록과 달라 지우지 않았습니다. 관리자 확인이 필요합니다' }; } if (pu) await mod.deleteUserById(acc.providerId); }
catch (e) { setAccount(siteId, memberId, { state: 'deprovision_pending', lastError: e.message }); return { ok: false, pending: true, warning: `메일 서버에서 계정을 지우지 못했습니다(${e.message}). 기록을 남겨 두었고 관리자 콘솔 › 운영 복구에서 다시 시도할 수 있습니다` }; }
removeAccount(siteId, memberId); return { ok: true };
},
async send({ from, to, subject, text }) { if (!sendKey) throw httpError('메일 발송 키가 없어 보낼 수 없습니다', 503); return require('./mailer').send({ from, to, subject, text }); },
};
}
/** Resend API 호출 (mailer 의 키·주소 사용) */
const resendApi = (p, method, body) => require('./mailer').resend(p, method, body);
/**
* 사이트 도메인을 Resend 에 등록(이미 있으면 재사용)하고 상태·레코드를 돌려준다.
* Resend 레코드 이름은 도메인 기준 상대 이름(예: send, resend._domainkey)이다.
* @returns {{id, status, records, checkedAt, error?}}
*/
async function ensureResendDomain(domain, prev) {
try {
let id = prev && prev.id;
if (!id) { const list = await resendApi('/domains'); const hit = (list.data || []).find((x) => String(x.name).toLowerCase() === domain); id = hit ? hit.id : (await resendApi('/domains', 'POST', { name: domain })).id; }
if (prev && prev.id) { try { await resendApi(`/domains/${id}/verify`, 'POST', {}); } catch (_) {} } // 다시 확인 요청 (Resend 가 비동기로 DNS 조회)
const d = await resendApi(`/domains/${id}`);
return { id, status: d.status || 'unknown', records: (d.records || []).map((r) => ({ record: r.record, name: r.name, type: r.type, value: r.value, priority: r.priority, status: r.status })), checkedAt: new Date().toISOString() };
} catch (e) { return { ...(prev || {}), error: e.message, checkedAt: new Date().toISOString() }; }
}
// ---------- DNS 안내·확인 ----------
/**
* 도메인에 넣어야 할 DNS 레코드 목록.
* - MX : 받는 메일을 메일 서버로 보낸다 (필수)
* - SPF : 이 메일 서버가 이 도메인 이름으로 보내도 된다고 밝힌다 (필수, 없으면 상대 서버가 스팸 처리)
* - DMARC : SPF·DKIM 실패 메일 처리 정책 (권장)
* DKIM 은 메일 게이트웨이가 도메인별 키를 만들어야 하므로 게이트웨이 연결 후 추가된다.
*/
function dnsRecords(domain, resendInfo, cfg) {
const c = cfg || config();
if (!c.mxHost) throw httpError('메일 서버 주소(MX 호스트)가 설정되지 않았습니다. 관리자 콘솔 › 메일 시스템에서 사이트 설정 또는 기본값에 입력하세요', 409);
const spf = `v=spf1 mx${c.spfInclude ? ' include:' + c.spfInclude : ''} ~all`;
return [
{ key: 'mx', type: 'MX', name: domain, host: '@', value: c.mxHost, priority: 10, required: true, why: '받는 메일을 이 서비스의 메일 서버로 보냅니다' },
{ key: 'spf', type: 'TXT', name: domain, host: '@', value: spf, required: true, why: '이 메일 서버가 이 도메인 이름으로 메일을 보낼 수 있다고 알립니다(없으면 보낸 메일이 스팸 처리됩니다). 이미 SPF(v=spf1…) 레코드가 있으면 새로 만들지 말고 mx 와 include 를 기존 값에 추가하세요' },
{ key: 'dmarc', type: 'TXT', name: `_dmarc.${domain}`, host: '_dmarc', value: c.dmarc, required: false, why: '인증에 실패한 위조 메일의 처리 정책입니다(권장)' },
// v1.3.1: 보내기용 Resend 레코드 (DKIM 서명, send 하위 도메인의 반송 처리 MX·SPF). 루트 도메인의 MX·SPF 와 겹치지 않는다
...((resendInfo && resendInfo.records) || []).map((r, i) => ({ key: `resend-${i}`, type: r.type, name: r.name === '@' || !r.name ? domain : `${r.name}.${domain}`, host: r.name || '@', value: r.value, priority: r.priority || null, required: true, purpose: 'send', why: `보내는 메일 인증(${r.record || r.type}) — Resend 가 확인합니다`, providerStatus: r.status || null })),
];
}
/** DNS 조회 (5초 제한). 실패하면 빈 목록 */
async function q(fn, name) { try { return await Promise.race([fn(name), new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 5000))]); } catch (_) { return []; } }
/**
* 도메인 DNS 를 실제로 조회해 레코드별 확인 결과를 만든다.
* @returns {{checkedAt, ok, records: Array<{...record, ok, found}>}} ok = 필수 레코드(MX·SPF) 모두 확인됨
*/
async function checkDns(domain, resendInfo, cfg) {
const c = cfg || config(); const want = dnsRecords(domain, resendInfo, c);
const mx = await q(dns.resolveMx, domain); const txt = (await q(dns.resolveTxt, domain)).map((r) => r.join('')); const dm = (await q(dns.resolveTxt, `_dmarc.${domain}`)).map((r) => r.join(''));
const records = want.map((r) => {
if (r.purpose === 'send') return { ...r, found: r.providerStatus || '확인 전', ok: r.providerStatus === 'verified' };
if (r.key === 'mx') { const hosts = mx.map((m) => m.exchange.toLowerCase().replace(/\.$/, '')); return { ...r, found: hosts.join(', ') || '(없음)', ok: hosts.includes(c.mxHost) }; }
if (r.key === 'spf') { const spf = txt.find((t) => /^v=spf1\b/i.test(t)) || ''; const ok = !!spf && (/\smx(\s|$)/i.test(spf) || spf.includes(`a:${c.mxHost}`) || (!!c.spfInclude && spf.includes(`include:${c.spfInclude}`))); return { ...r, found: spf || '(없음)', ok }; }
const v = dm.find((t) => /^v=DMARC1/i.test(t)) || ''; return { ...r, found: v || '(없음)', ok: !!v };
});
// receiveOk: 받기용(MX·SPF) 확인, sendOk: Resend 도메인 확인(verified), ok: 1.3.0 호환(받기용 기준)
const receiveOk = records.filter((r) => r.required && r.purpose !== 'send').every((r) => r.ok);
const sendOk = !!(resendInfo && resendInfo.status === 'verified');
return { checkedAt: new Date().toISOString(), ok: receiveOk, receiveOk, sendOk, records };
}
// ---------- 사이트 웹메일 상태 ----------
/** 사이트에 연결된 도메인 중 메일 도메인으로 쓸 수 있는 것 (www. 는 떼고 중복 제거) */
function domainChoices(site) { return [...new Set((site.domains || []).map((d) => String(d).toLowerCase().replace(/^www\./, '')).filter((d) => DOMAIN_RE.test(d)))]; }
/**
* 사이트 메일 시스템의 현재 상태와 사용자에게 보여 줄 문구 (v1.3.2).
* level : off(이 사이트는 사용 안 함) | setting-up(메일시스템 설정중) | send-only(보내기만 가능) | ready(주고받기 가능)
* label : 사용자 화면에 짧게 보일 상태 이름, message: 자세한 설명
* canMail: 웹메일에서 메일을 쓸 수 있는지(보내기 가능 이상)
*/
function status(site) {
const w = site.webmail || {}; const be = backendFor(site); const dnsInfo = w.dns || null;
const sendReady = !!(be.canSend && dnsInfo && dnsInfo.sendOk); const receiveReady = !!(be.canReceive && dnsInfo && dnsInfo.receiveOk);
// v1.5.7: mailServer — 연결된 메일 서버(Arimail) 정보(웹메일 주소·메일 프로그램 설정값). 화면은 backend === 'arimail' 이면 웹메일 버튼을 그 주소로 연다
const base = { systemEnabled: !!w.enabled, enabled: !!w.enabled, domain: w.domain || null, dns: dnsInfo, settings: w.enabled ? siteConfig(site) : null, configuredAt: w.configuredAt || null, resend: w.resend ? { status: w.resend.status, error: w.resend.error || null } : null, backend: be.id, backendConnected: be.connected, canSend: sendReady, canReceive: receiveReady, domains: domainChoices(site), mailServer: be.arimail ? { provider: 'arimail', id: be.arimail.id, nickname: be.arimail.nickname, ip: be.arimail.ip, host: be.arimail.host, domain: be.arimail.domain, ok: be.arimail.ok, webmail: be.arimail.webmail, client: be.arimail.client, domainMatch: be.arimail.domainMatch } : null };
if (!w.enabled) return { ...base, level: 'off', label: '사용 안 함', canMail: false, message: '이 사이트는 메일 시스템을 사용하지 않습니다. 사용하려면 운영자에게 요청하세요.' };
if (sendReady && receiveReady) return { ...base, level: 'ready', label: '사용 가능', canMail: true, message: '메일을 주고받을 수 있습니다.' };
if (sendReady) return { ...base, level: 'send-only', label: '사용 가능 (보내기)', canMail: true, message: `보내기는 가능합니다. ${dnsInfo.receiveOk ? be.reason : `받는 메일: ${w.domain} 의 MX·SPF 가 아직 확인되지 않아 설정 문제로 수신이 제한되고 있습니다.`}` };
const pending = [];
if (!dnsInfo || !dnsInfo.receiveOk) pending.push('MX·SPF');
if (be.canSend && !(dnsInfo && dnsInfo.sendOk)) pending.push(w.resend && w.resend.error ? `보내기 도메인 등록 실패(${w.resend.error})` : '보내기 인증(DKIM 등)');
const detail = pending.length ? `${w.domain} 의 DNS 설정(${pending.join(', ')})이 아직 전파·확인되지 않아 메일 수발신이 제한되고 있습니다. DNS 를 등록한 뒤 "DNS 다시 확인" 을 누르거나 자동 확인(30분 간격)을 기다리세요.` : be.reason;
return { ...base, level: 'setting-up', label: '메일시스템 설정중', canMail: false, pending, message: `메일시스템 설정중 — ${detail}` };
}
/**
* 운영자가 사이트의 메일 시스템을 켜거나 설정을 바꾼다 (v1.3.2).
* @param {object} site
* @param {{domain, mxHost?, spfInclude?, dmarc?}} input 메일 도메인과 이 사이트 전용 메일 서버 설정(비우면 기본값)
* @param {string} by 설정한 관리자 아이디
* 메일 도메인은 사이트에 연결된 도메인이 기본이지만, 운영자는 다른 도메인(예: 회사 대표 도메인)을 직접 입력할 수 있다.
*/
async function enableSite(site, input = {}, by = '') {
const d = String(input.domain || domainChoices(site)[0] || '').trim().toLowerCase().replace(/^www\./, '').replace(/\.$/, '');
if (!d) throw httpError('메일 도메인을 입력하세요. 사이트에 연결된 도메인이 없으면 "example.com" 처럼 직접 입력합니다. 메일 주소는 "아이디@메일도메인" 이 됩니다', 400);
if (!DOMAIN_RE.test(d)) throw httpError('메일 도메인 형식이 올바르지 않습니다 (예: example.com)');
const own = normalizeConfig({ mxHost: input.mxHost || '', spfInclude: input.spfInclude || '', dmarc: input.dmarc || '' }, {});
const prev = site.webmail || {};
site.webmail = { ...prev, enabled: true, domain: d, mxHost: own.mxHost, spfInclude: own.spfInclude, dmarc: input.dmarc ? own.dmarc : '', configuredAt: new Date().toISOString(), configuredBy: by, enabledAt: prev.enabledAt || new Date().toISOString() };
// v1.6.38 (요구 2): 이 사이트가 쓸 메일 서버(등록부 id). 빈 값이면 자동 — 메일 도메인이 같은 서버, 없으면 기본 서버
if (input.mailServer !== undefined) { const sid = String(input.mailServer || '').trim(); if (sid && !require('./mailservers').get(sid)) { site.webmail = prev; throw httpError('고른 메일 서버가 등록부에 없습니다 — 메일 시스템 탭 › 메일 서버 연동에서 먼저 등록하세요'); } site.webmail.mailServer = sid; }
const cfg = siteConfig(site); if (!cfg.mxHost) { site.webmail.enabled = prev.enabled || false; throw httpError('메일 서버 주소(MX 호스트)를 입력하세요. 비워 두면 기본값을 쓰는데 기본값도 비어 있어 DNS 안내를 만들 수 없습니다'); }
if (prev.domain && prev.domain !== d) { site.webmail.dns = null; }
if (backendFor(site).canSend && (!site.webmail.resend || site.webmail.resendDomain !== d)) { site.webmail.resend = await ensureResendDomain(d, null); site.webmail.resendDomain = d; }
site.webmail.dns = await checkDns(d, site.webmail.resend, cfg);
return site.webmail;
}
/** DNS 다시 확인: Resend 에 재확인 요청 → 우리 레코드 조회 (자동 재확인도 이 함수를 쓴다) */
async function recheck(site) {
const w = site.webmail; if (!w || !w.enabled || !w.domain) throw httpError('이 사이트는 메일 시스템이 켜져 있지 않습니다', 409);
if (backendFor(site).canSend) { w.resend = await ensureResendDomain(w.domain, w.resendDomain === w.domain ? w.resend : null); w.resendDomain = w.domain; }
w.dns = await checkDns(w.domain, w.resend, siteConfig(site)); return w;
}
/**
* 웹메일에서 보내기: 발송 후 보낸 편지함에 사본을 남긴다.
* @param {object} member 사이트 관리자 회원 (mailAddress, name)
*/
async function sendAs(site, member, { to, subject, text }) {
const st = status(site); if (!st.canSend) throw httpError(st.message, 409);
const rcpt = String(to || '').split(/[,;\s]+/).filter(Boolean); if (!rcpt.length || rcpt.some((x) => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(x))) throw httpError('받는 사람 메일 주소를 확인하세요');
const r = await backendFor(site).send({ from: { address: member.mailAddress, name: member.name || '' }, to: rcpt, subject: String(subject || '(제목 없음)').slice(0, 300), text: String(text || '').slice(0, 100000) });
const f = boxFile(site.id, member.id); const box = loadBox(site.id, member.id);
box.messages.push({ id: (r && r.id) || Date.now().toString(36), folder: 'sent', from: member.mailAddress, to: rcpt.join(', '), subject: String(subject || '(제목 없음)').slice(0, 300), text: String(text || '').slice(0, 100000), at: new Date().toISOString(), read: true });
fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, JSON.stringify(box));
return { ok: true, id: r && r.id };
}
/** 사이트 메일 시스템 끄기 — 운영자 전용 (계정의 메일 설정·메일함은 그대로 두며, 다시 켜면 이어서 쓴다) */
function disableSite(site) { site.webmail = { ...(site.webmail || {}), enabled: false, disabledAt: new Date().toISOString() }; return site.webmail; }
// ---------- v1.6.31 (요구 9) 메일 계정 등록부 — 어느 사이트·회원의 계정인지, 공급자 계정 id, 상태 ----------
// data/mail-accounts.json: { "<siteId>:<memberId>": { siteId, memberId, login, address, providerId, provider, createdAt, state, lastError } }
// state: active | deprovision_pending(공급자 삭제 실패 — 다시 시도) | needs-admin(소유가 불명확 — 관리자 확인)
const jsx = require('./jsonstore'); const ACCOUNTS = path.join(DATA_ROOT, 'mail-accounts.json');
function accounts() { const d = jsx.readJson(ACCOUNTS, null, { strict: true }); return d && typeof d === 'object' ? d : {}; }
function saveAccounts(d) { jsx.writeJsonAtomic(ACCOUNTS, d, { mode: 0o600, keepBackup: true }); }
function accountKey(siteId, memberId) { return `${siteId}:${memberId}`; }
function accountOf(siteId, memberId) { return accounts()[accountKey(siteId, memberId)] || null; }
function setAccount(siteId, memberId, patch) { const d = accounts(); const k = accountKey(siteId, memberId); d[k] = { ...(d[k] || { siteId, memberId, createdAt: new Date().toISOString() }), ...patch, updatedAt: new Date().toISOString() }; saveAccounts(d); return d[k]; }
function removeAccount(siteId, memberId) { const d = accounts(); delete d[accountKey(siteId, memberId)]; saveAccounts(d); }
/** 관리자 화면용: 모든 계정 (사이트·회원·상태) */
function listAccounts(filter = {}) { return Object.values(accounts()).filter((a) => (!filter.siteId || a.siteId === filter.siteId) && (!filter.state || a.state === filter.state)); }
/** 공급자 계정 하나가 어느 사이트 것인지 (login 또는 providerId) */
function ownerOfLogin(login) { const l = String(login || '').toLowerCase(); return Object.values(accounts()).find((a) => a.login === l) || null; }
// ---------- 계정 메일함 ----------
function boxFile(siteId, memberId) { return path.join(MAIL_DIR, siteId, `${memberId}.json`); }
/** 메일함 읽기: { messages: [{id, folder, from, to, subject, text, at, read}] } */
function loadBox(siteId, memberId) { try { return JSON.parse(fs.readFileSync(boxFile(siteId, memberId), 'utf8')); } catch (_) { return { messages: [] }; } }
/** 사이트 관리자 계정의 메일 주소 (아이디@메일도메인). 아이디가 메일 주소로 쓸 수 없는 문자면 오류 */
function addressFor(site, member) {
const w = site.webmail || {}; if (!w.enabled || !w.domain) throw httpError('이 사이트는 메일 시스템이 아직 켜지지 않았습니다. 운영자에게 요청하세요', 409);
const local = String(member.loginId || '').toLowerCase(); if (!LOCAL_RE.test(local)) throw httpError(`아이디 "${member.loginId}" 는 메일 주소로 쓸 수 없습니다 (영문 소문자·숫자·. _ - 만)`);
return `${local}@${w.domain}`;
}
/**
* 계정 메일 사용 켜기 → 메일함 생성.
* v1.5.7: Arimail 이 연결된 사이트는 메일 비밀번호(opts.password)로 메일 서버 계정을 만든다 (createMailbox 가 없으면 400 으로 알린다)
* v1.6.39 (요구 3): opts.synced 가 참이면 그 비밀번호가 사이트 관리자 비밀번호와 같다는 뜻 — 등록부에 동기화 기준(관리자 비밀번호 해시)을 적어 둔다.
* 거짓이면 "임시 메일 비밀번호" 로 보고, 관리자가 다음에 사이트 관리자 페이지에 로그인할 때 syncPassword 가 관리자 비밀번호로 맞춘다.
*/
async function enableMember(site, member, opts = {}) {
const addr = addressFor(site, member); const r = await backendFor(site).createMailbox({ siteId: site.id, memberId: member.id, address: addr, login: String(member.loginId || '').toLowerCase(), name: member.name || member.loginId, password: opts.password || '' });
fs.mkdirSync(path.dirname(boxFile(site.id, member.id)), { recursive: true }); if (!fs.existsSync(boxFile(site.id, member.id))) fs.writeFileSync(boxFile(site.id, member.id), JSON.stringify({ address: addr, createdAt: new Date().toISOString(), messages: [] }));
let note = r.note || '';
if (r.address && accountOf(site.id, member.id)) {
const now = new Date().toISOString();
setAccount(site.id, member.id, opts.synced ? { pwSyncHash: member.hash || null, pwSyncAt: now, pwSyncError: null } : { pwSyncHash: null, pwSyncAt: null, pwSyncError: null });
note += opts.synced ? ' 메일 비밀번호는 이 계정의 사이트 관리자 비밀번호와 같습니다(같은 아이디·비밀번호로 웹메일 로그인).' : ' 지금 넣은 비밀번호는 임시입니다 — 이 관리자가 사이트 관리자 페이지에 로그인하면 메일 비밀번호가 그 계정의 비밀번호로 자동으로 맞춰집니다.';
}
return { address: addr, note: note.trim(), synced: !!opts.synced };
}
/**
* v1.6.39 (요구 3): 사이트 관리자 비밀번호 ↔ 메일(Arimail) 계정 비밀번호 동기화.
* 웹메이커는 관리자 비밀번호를 scrypt 해시로만 갖고 있어 Arimail 로 그대로 옮길 수 없다. 그래서 원문을 아는 순간 — 사이트 관리자 페이지·웹메일 로그인 성공 직후 —
* 이 함수를 불러 "마지막으로 Arimail 에 맞춘 해시(pwSyncHash)" 와 지금 해시가 다르면 Arimail 계정 비밀번호를 그 원문으로 바꾼다.
* 분리 설치(웹메이커와 Arimail 이 다른 서버)에서도 한 아이디·비밀번호로 웹메일·IMAP/POP/SMTP 를 쓰게 하는 장치.
* 호출자는 결과를 기다리지 않아도 된다(로그인 응답을 늦추지 않도록 catch 만). 비밀번호 원문은 기록에 남기지 않는다.
* @param {object} site 사이트
* @param {object} member 사이트 관리자 레코드(해시 포함 — modules.getSiteAdmin)
* @param {string} plainPw 지금 확인된 비밀번호 원문
* @param {string} [by] 작업 기록용 주체
* @returns {Promise<{ok?:boolean, skipped?:string, error?:string}>}
*/
async function syncPassword(site, member, plainPw, by = '') {
if (!site || !member || !plainPw || !member.mail) return { skipped: 'mail-off' };
const acc = accountOf(site.id, member.id);
if (!acc || acc.provider !== 'arimail' || !acc.providerId || acc.state !== 'active') return { skipped: 'no-arimail-account' };
if (acc.pwSyncHash && acc.pwSyncHash === member.hash) return { skipped: 'already-synced' };
const am = arimailState(site); if (!am.module || !am.configured) return { skipped: 'server-not-configured', detail: am.detail };
const audit = require('./audit');
try {
await am.module.updateUser(acc.providerId, { password: plainPw });
setAccount(site.id, member.id, { pwSyncHash: member.hash || null, pwSyncAt: new Date().toISOString(), pwSyncError: null });
audit.log({ by: by || member.loginId, action: 'mail.account.sync', targetType: 'site', target: site.id, siteId: site.id, userId: site.ownerId, detail: { member: member.loginId, server: am.settings.nickname || am.settings.host } });
return { ok: true };
} catch (e) {
// 자주 보는 원인: Arimail 비밀번호 정책(weak_password)이 웹메이커 규칙보다 엄격 — 관리자 비밀번호를 정책에 맞게 바꾸면 다음 로그인 때 맞춰진다
setAccount(site.id, member.id, { pwSyncError: e.message, pwSyncErrorAt: new Date().toISOString() });
audit.log({ by: by || member.loginId, action: 'mail.account.sync', targetType: 'site', target: site.id, siteId: site.id, userId: site.ownerId, ok: false, detail: { member: member.loginId, error: e.message } });
return { ok: false, error: e.message };
}
}
/**
* v1.7.3 추가(운영자 요청 2026-10-24): 웹메이커 **서비스 관리자**(role admin, 예: admin) 비밀번호 ↔ Arimail 의 같은 로그인 ID 계정 비밀번호 동기화.
* 배경: webmail.ibit.kr 의 admin 계정과 webmaker.ibit.kr 의 admin 계정이 같은 비밀번호로 열리길 바라는데, 웹메이커는 해시만 가지므로
* "원문을 아는 순간"(관리자 로그인, server.js /api/auth/login)에 등록된 모든 메일 서버에서 같은 ID 의 계정을 찾아 비밀번호를 맞춘다.
* - 같은 ID 의 Arimail 계정이 없는 서버는 건너뛴다(계정을 만들지는 않는다 — 메일함이 생기는 일은 운영자가 정한다).
* - 지문(opts.fingerprint = lib/auth.passwordFingerprint)이 마지막 동기화 때와 같으면 Arimail 을 부르지 않는다(로그인마다 PATCH 하지 않게).
* - 비밀번호 변경만 보내므로 Arimail 의 관리자 보호 규칙(역할·상태 내리기 금지)에 걸리지 않는다. 실패(정책 weak_password 등)는 기록에 남기고 다음 로그인 때 다시 시도한다.
* - 결과는 등록부(adminSync)와 작업 기록(mail.admin.sync)에 남긴다 — 비밀번호 원문·해시는 어디에도 쓰지 않는다. 예외를 던지지 않는다(로그인을 막지 않게).
* @param {{id:string, role:string}} user 로그인한 웹메이커 사용자
* @param {string} plainPw 지금 확인된 비밀번호 원문
* @param {{fingerprint?:string|null, by?:string}} [opts]
* @returns {Promise<Array<{server:string, login:string, ok?:boolean, skipped?:string, error?:string}>>}
*/
async function syncServiceAdmin(user, plainPw, opts = {}) {
const out = []; if (!user || user.role !== 'admin' || !plainPw) return out;
const login = String(user.id || '').toLowerCase(); const fp = opts.fingerprint || null; const audit = require('./audit');
let ms; try { ms = require('./mailservers'); } catch (_) { return out; }
let m = null; try { m = require('./connectors').mail(); } catch (_) {}
if (!m || m.id !== 'arimail') return out;
for (const server of ms.all()) {
const rec = { server: server.nickname || server.id, login };
try {
if (!(server.url && server.domain && server.adminPassword)) { out.push({ ...rec, skipped: 'server-not-configured' }); continue; }
const prev = ms.adminSyncOf(server.id)[login];
if (fp && prev && prev.fp === fp && !prev.error) { out.push({ ...rec, skipped: 'already-synced' }); continue; }
const bound = m.forServer(server);
const u = await bound.findUser(login);
if (!u) { ms.setAdminSync(server.id, login, { skipped: 'no-account', skippedAt: new Date().toISOString() }); out.push({ ...rec, skipped: 'no-account' }); continue; }
await bound.updateUser(u.id, { password: plainPw });
ms.setAdminSync(server.id, login, { fp, at: new Date().toISOString(), error: null, errorAt: null, skipped: null, providerId: u.id });
audit.log({ by: opts.by || login, action: 'mail.admin.sync', targetType: 'setting', target: server.id, detail: { login, server: server.nickname || server.host } });
out.push({ ...rec, ok: true });
} catch (e) {
try { ms.setAdminSync(server.id, login, { error: e.message, errorAt: new Date().toISOString() }); } catch (_) {}
audit.log({ by: opts.by || login, action: 'mail.admin.sync', targetType: 'setting', target: server.id, ok: false, detail: { login, error: e.message } });
out.push({ ...rec, ok: false, error: e.message });
}
}
return out;
}
/**
* v1.6.39 (요구 3): 사이트 관리자 페이지 "메일 계정" 표용 동기화 상태.
* @returns {{state:'synced'|'pending'|'error'|'n/a', at?:string, error?:string}}
*/
function syncState(site, member) {
const acc = accountOf(site.id, member.id);
if (!member.mail || !acc || acc.provider !== 'arimail') return { state: 'n/a' };
if (acc.pwSyncError) return { state: 'error', error: acc.pwSyncError, at: acc.pwSyncErrorAt || null };
if (acc.pwSyncHash && acc.pwSyncHash === member.hash) return { state: 'synced', at: acc.pwSyncAt || null };
return { state: 'pending' };
}
/** 계정 메일 사용 끄기 → 메일함과 보관된 메일을 모두 지운다 (호출 전에 확인·비밀번호 재입력을 받는다) */
async function disableMember(site, member) {
let r = { ok: true }; if (member.mailAddress) r = await backendFor(site).deleteMailbox({ siteId: site.id, memberId: member.id, address: member.mailAddress, login: String(member.loginId || '').toLowerCase() });
if (r && r.ok === false) throw Object.assign(httpError(r.warning || '메일 서버 계정을 지우지 못했습니다', 502), { pending: !!r.pending, needsAdmin: !!r.needsAdmin }); // v1.6.31: 서버 계정이 남아 있으면 로컬도 지우지 않는다
const n = loadBox(site.id, member.id).messages.length; try { fs.unlinkSync(boxFile(site.id, member.id)); } catch (_) {}
return { deletedMessages: n };
}
/**
* v1.6.31 (요구 9·20): 운영 복구 화면에서 관리자가 처리하는 메일 계정 정리.
* mode 'retry' — deprovision_pending: 공급자 삭제를 다시 시도(멱등: 이미 없으면 성공으로 본다) 뒤 등록부·메일함 정리
* mode 'confirmed' — needs-admin: 관리자가 메일 서버에서 소유를 확인했으므로 등록부의 providerId(없으면 로그인 이름)로 삭제
* mode 'forget' — 서버 계정은 그대로 두고 웹메이커 쪽 기록(등록부·메일함)만 정리
*/
async function adminResolveAccount(site, memberId, mode) {
const acc = accountOf(site.id, memberId); if (!acc) throw httpError('등록부에 그 계정이 없습니다', 404);
const am = arimailState(site); let detail = ''; // v1.6.38: 이 사이트가 쓰는 메일 서버
if (mode !== 'forget') {
if (!am.module || !am.configured) throw httpError('메일 서버(Arimail) 연동이 꺼져 있어 삭제할 수 없습니다', 503);
const mod = am.module; let removed = false;
if (acc.providerId) { const pu = await mod.getUserById(acc.providerId); if (pu) { await mod.deleteUserById(acc.providerId); removed = true; } else detail = '공급자에 이미 없음'; }
else if (mode === 'confirmed' && acc.login) { removed = await mod.deleteUserByLogin(acc.login); if (!removed) detail = '공급자에 이미 없음'; }
else throw httpError('등록부에 공급자 계정 id 가 없어 자동 삭제할 수 없습니다 — "소유 확인 후 삭제" 또는 "기록만 정리" 를 쓰세요', 409);
if (removed) detail = `메일 서버 계정 삭제됨(${acc.providerId || acc.login})`;
} else detail = '서버 계정은 유지, 웹메이커 기록만 정리';
const n = loadBox(site.id, memberId).messages.length; try { fs.unlinkSync(boxFile(site.id, memberId)); } catch (_) {}
removeAccount(site.id, memberId);
return { ok: true, detail: `${detail} · 보관 메일 ${n}건 정리` };
}
/** 사이트 폐기 시 메일 데이터 전체 삭제 */
function removeSite(siteId) { try { fs.rmSync(path.join(MAIL_DIR, siteId), { recursive: true, force: true }); } catch (_) {} }
// ---------- 사이트 폴더의 /webmail 진입 파일 ----------
/**
* "사용자사이트/webmail" 로 들어오면 빌더 서비스의 웹메일(<공개주소>/webmail/<사이트ID>/)로 보내는 안내 파일.
* 사이트 폴더에 넣어 두면 어느 배포 서버·도메인에 올라가도 웹서버 설정 변경 없이 동작한다.
* 웹메일이 꺼지면 지운다.
*/
const STUB_MARK = 'data-wm-webmail-stub';
function stubHtml(siteId, base) {
const url = `${base.replace(/\/+$/, '')}/webmail/${siteId}/`;
return `<!doctype html>
<!-- AI Webmaker 웹메일 진입 페이지 (자동 생성 — 수정하지 마세요). 웹메일을 끄면 자동으로 지워집니다.
이 사이트의 /webmail 주소로 들어온 방문자를 빌더 서비스의 웹메일 화면으로 보냅니다. -->
<html lang="ko" ${STUB_MARK}><head><meta charset="utf-8"><meta name="robots" content="noindex"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>웹메일</title><meta http-equiv="refresh" content="0; url=${url}"></head>
<body style="font-family:sans-serif;padding:40px;text-align:center"><p>웹메일로 이동합니다… <a href="${url}">이동하지 않으면 여기를 누르세요</a></p></body></html>
`;
}
/** 사이트 폴더에 진입 파일을 두거나(웹메일 켜짐) 지운다(꺼짐). 사용자가 같은 이름으로 만든 파일은 건드리지 않는다 */
function syncStub(site, siteDir, base) {
// v1.4.0: 메일 접속 주소를 https://사이트도메인/mail 로도 — 같은 안내 파일을 mail/ 에도 둔다 (사용자가 만든 mail/index.html 은 건드리지 않음)
syncStubAt(site, siteDir, base, 'mail');
return syncStubAt(site, siteDir, base, 'webmail');
}
function syncStubAt(site, siteDir, base, folder) {
const dir = path.join(siteDir, folder); const f = path.join(dir, 'index.html');
let cur = null; try { cur = fs.readFileSync(f, 'utf8'); } catch (_) {}
const ours = cur === null || cur.includes(STUB_MARK);
const want = !!(site.webmail && site.webmail.enabled); // v1.3.2: 사이트 단위 켜기
if (want && ours) { fs.mkdirSync(dir, { recursive: true, mode: 0o755 }); fs.writeFileSync(f, stubHtml(site.id, base), { mode: 0o644 }); return 'written'; }
if (!want && cur !== null && cur.includes(STUB_MARK)) { try { fs.unlinkSync(f); fs.rmdirSync(dir); } catch (_) {} return 'removed'; }
return ours ? 'none' : 'user-file';
}
module.exports = { syncServiceAdmin, adminResolveAccount, listAccounts, accountOf, setAccount, removeAccount, ownerOfLogin, siteConfig, recheck, sendAs, ensureResendDomain, DEFAULTS, config, normalizeConfig, backendFor, dnsRecords, checkDns, domainChoices, status, enableSite, disableSite, addressFor, enableMember, disableMember, syncPassword, syncState, loadBox, removeSite, syncStub, stubHtml, LOCAL_RE };
+125
View File
@@ -0,0 +1,125 @@
'use strict';
// ============================================================================
// 메일 사용량 연동 — v1.7.3 (요구 2)
//
// Arimail 1.0.9 의 "사용량 정보 제공 API"(AriMail docs/USAGE-API.md) 를 읽어 메일함 디스크 사용량·메일 수·발송/수신량을
// 사이트 정보 페이지(디스크 사용량의 "메일 데이터")·메일 화면(한눈에 보기)·관리자 콘솔(메일 서버 연동 표)에 보여 준다.
//
// - 호출: lib/connectors/arimail.forServer(server).usage(domain, {days}) — 키는 등록부(lib/mailservers.js)가 보관소에서 꺼낸 server.usageKey.
// GET /api/usage(모든 도메인 요약) · GET /api/usage/<도메인>(계정 목록·일별 추이 포함).
// - 캐시: Arimail 은 IP 마다 1분 120회 제한이 있고 화면마다 부르면 금방 찬다 — 서버·도메인·기간별로 5분(성공) / 1분(실패) 캐시한다.
// 관리자가 "새로 고침" 을 누르면(force) 캐시를 건너뛴다. 키를 바꾸거나 서버를 지우면 invalidate 로 비운다.
// - 사이트 화면(forSite): 사이트의 메일 도메인(site.webmail.domain)을 다루는 서버에서 그 도메인 보고서를 받고, 계정 목록은
// "이 사이트가 만든 메일 계정"(lib/mailsys 등록부의 login/address)만 남긴다 — 같은 도메인을 쓰는 다른 사이트·게이트웨이 계정의 이름은 보이지 않게.
// - 응답에는 수(통·바이트)·제한·계정 이름만 있고 비밀값은 없다(Arimail 쪽도 같다). 오류는 ok:false + code + message 로 돌려 화면이 그대로 보여 준다.
// (예외를 던지지 않는다 — 통계 화면이 메일 서버 장애로 깨지면 안 된다.)
// ============================================================================
const TTL_OK = 5 * 60 * 1000; // 성공 응답 캐시
const TTL_ERR = 60 * 1000; // 실패 응답 캐시(연결 실패·키 오류를 매번 다시 두드리지 않게)
const cache = new Map(); // 키 "<서버 id>|<도메인>|<days>" → { at, ttl, data?, error? }
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
/** 서버 식별 — id 문자열이면 등록부에서 찾는다 */
function serverOf(idOrServer) { if (idOrServer && typeof idOrServer === 'object') return idOrServer; try { return require('./mailservers').get(idOrServer); } catch (_) { return null; } }
/** 화면에 내려보낼 서버 요약(비밀값 없음) */
function serverLite(s) { return s ? { id: s.id, nickname: s.nickname || s.host || s.id, host: s.host || '', ip: s.ip || '', domain: s.domain || '' } : null; }
/** 캐시 비우기 — 서버 id 를 주면 그 서버만, 없으면 전부 */
function invalidate(serverId) { if (!serverId) { cache.clear(); return; } for (const k of [...cache.keys()]) if (k.startsWith(`${serverId}|`)) cache.delete(k); }
/**
* Arimail 사용량 API 를 캐시를 거쳐 부른다.
* @returns {Promise<{ok:boolean, at:string, cached:boolean, data?:object, error?:{code:string|null, message:string, status:number}}>}
*/
async function fetchUsage(server, domain, days, force) {
const key = `${server.id}|${String(domain || '').toLowerCase()}|${days}`; const hit = cache.get(key);
if (!force && hit && Date.now() - hit.at < hit.ttl) return { ...hit.res, cached: true };
let res;
try { const data = await require('./connectors/arimail').forServer(server).usage(domain, { days }); res = { ok: true, at: new Date().toISOString(), data }; cache.set(key, { at: Date.now(), ttl: TTL_OK, res }); }
catch (e) { res = { ok: false, at: new Date().toISOString(), error: { code: e.code || null, message: e.message, status: e.status || 500 } }; cache.set(key, { at: Date.now(), ttl: TTL_ERR, res }); }
return { ...res, cached: false };
}
/** traffic 의 한 기간({sent, received, total, sentBytes, receivedBytes, start, end}) 정리 */
function period(p) { const x = p || {}; return { sent: num(x.sent), received: num(x.received), total: num(x.total) || num(x.sent) + num(x.received), sentBytes: num(x.sentBytes), receivedBytes: num(x.receivedBytes), start: x.start || null, end: x.end || null }; }
/** 계정 한 줄 — 문서의 accountList 항목에서 화면에 쓰는 값만 (비밀값 없음) */
function account(a) { return { login: a.login || '', name: a.name || '', mailbox: a.mailbox || '', role: a.role || '', source: a.source || '', status: a.status || '', messages: num(a.messages), bytes: num(a.bytes), limit: num(a.limit), percent: a.percent == null ? null : num(a.percent), lastLoginAt: a.lastLoginAt || null, sent: num(a.sent), received: num(a.received), sentBytes: num(a.sentBytes), receivedBytes: num(a.receivedBytes) }; }
/**
* 도메인 보고서 정리 — Arimail 응답(domains[] 항목 또는 /api/usage/<도메인>)을 화면이 쓰는 모양으로. 없는 값은 0/null.
* @param {object} rep Arimail 도메인 보고서
* @param {{withAccounts?:boolean}} [o] 계정 목록 포함 여부(요약 표에서는 뺀다)
*/
function domainReport(rep, o = {}) {
const r = rep || {}; const st = r.storage || {}; const ac = r.accounts || {}; const ms = r.messages || {}; const tr = r.traffic || {}; const lf = st.largeFiles || {};
const out = {
domain: r.domain || '', primary: !!r.primary, enabled: r.enabled !== false, error: r.error || null, hosts: Array.isArray(r.hosts) ? r.hosts : [], authMode: r.authMode || null, timezone: r.timezone || null, generatedAt: r.generatedAt || null,
accounts: { local: num(ac.local), external: num(ac.external), total: num(ac.total) || num(ac.local) + num(ac.external), limit: num(ac.limit), percent: ac.percent == null ? null : num(ac.percent) },
storage: { bytes: num(st.bytes), limit: num(st.limit), percent: st.percent == null ? null : num(st.percent), largeFiles: { count: num(lf.count), bytes: num(lf.bytes), sent: num(lf.sent) } },
messages: { total: num(ms.total), inbound: num(ms.inbound), outbound: num(ms.outbound), drafts: num(ms.drafts), failed: num(ms.failed) },
traffic: { day: period(tr.day), week: period(tr.week), month: period(tr.month), monthlyLimit: num(tr.monthlyLimit), monthlyPercent: tr.monthlyPercent == null ? null : num(tr.monthlyPercent) },
period: r.period || null,
daily: Array.isArray(r.daily) ? r.daily.map((d) => ({ date: d.date, sent: num(d.sent), received: num(d.received), sentBytes: num(d.sentBytes), receivedBytes: num(d.receivedBytes) })) : [],
};
if (o.withAccounts) out.accountList = Array.isArray(r.accountList) ? r.accountList.map(account) : [];
return out;
}
/**
* 서버 전체 요약 (관리자 콘솔 › 메일 서버 연동 › "사용량"): 모든 도메인의 합계와 도메인별 보고서(계정 목록 없음).
* @param {string|object} idOrServer 서버 id 또는 등록부 서버 객체
* @param {{force?:boolean, days?:number}} [o]
*/
async function forServer(idOrServer, o = {}) {
const s = serverOf(idOrServer); if (!s) return { ok: false, server: null, at: new Date().toISOString(), error: { code: 'no_server', message: '그 메일 서버가 없습니다', status: 404 } };
const days = Math.min(366, Math.max(1, Number(o.days) || 30));
const r = await fetchUsage(s, '', days, !!o.force);
if (!r.ok) return { ok: false, server: serverLite(s), at: r.at, cached: r.cached, hasKey: !!s.usageKey, error: r.error };
const d = r.data || {}; const t = d.totals || {};
return {
ok: true, server: serverLite(s), at: r.at, cached: r.cached, hasKey: true, version: d.version || null, primary: d.primary || null, generatedAt: d.generatedAt || null,
totals: { domains: num(t.domains), accounts: num(t.accounts), storageBytes: num(t.storageBytes), largeFileBytes: num(t.largeFileBytes), messages: num(t.messages), sentMonth: num(t.sentMonth), receivedMonth: num(t.receivedMonth) },
domains: (Array.isArray(d.domains) ? d.domains : []).map((x) => domainReport(x)),
};
}
/**
* 도메인 하나의 상세(계정 목록·일별 추이 포함).
* @param {string|object} idOrServer
* @param {string} domain
* @param {{force?:boolean, days?:number}} [o]
*/
async function forDomain(idOrServer, domain, o = {}) {
const s = serverOf(idOrServer); if (!s) return { ok: false, server: null, at: new Date().toISOString(), error: { code: 'no_server', message: '그 메일 서버가 없습니다', status: 404 } };
const dom = String(domain || '').toLowerCase().replace(/^www\./, ''); if (!dom) return { ok: false, server: serverLite(s), at: new Date().toISOString(), error: { code: 'no_domain', message: '메일 도메인이 없습니다', status: 400 } };
const days = Math.min(366, Math.max(1, Number(o.days) || 30));
const r = await fetchUsage(s, dom, days, !!o.force);
if (!r.ok) return { ok: false, server: serverLite(s), domain: dom, at: r.at, cached: r.cached, hasKey: !!s.usageKey, error: r.error };
return { ok: true, server: serverLite(s), domain: dom, at: r.at, cached: r.cached, hasKey: true, version: (r.data && r.data.version) || null, report: domainReport(r.data, { withAccounts: true }) };
}
/**
* 사이트 화면용 — 이 사이트의 메일 도메인 보고서. 계정 목록은 이 사이트가 만든 메일 계정(lib/mailsys 등록부)만 남기고 `mine:true` 로 표시한다.
* 메일 시스템이 꺼져 있거나 서버·키가 없으면 ok:false 와 이유(code/message) — 화면은 그 문구를 그대로 보여 준다.
* @param {object} site 사이트 기록(webmail.enabled / webmail.domain / webmail.mailServer)
* @param {{force?:boolean, days?:number}} [o]
*/
async function forSite(site, o = {}) {
const w = (site && site.webmail) || {};
if (!w.enabled) return { ok: false, code: 'mail_off', message: '이 사이트는 메일 시스템이 켜져 있지 않습니다', at: new Date().toISOString() };
if (!w.domain) return { ok: false, code: 'no_domain', message: '이 사이트의 메일 도메인이 없습니다', at: new Date().toISOString() };
let s = null; try { s = require('./mailservers').pick(site); } catch (_) {}
if (!s) return { ok: false, code: 'no_server', message: '등록된 메일 서버(Arimail)가 없습니다 — 운영자가 메일 서버를 등록하면 사용량이 보입니다', domain: w.domain, at: new Date().toISOString() };
if (s.domain && s.domain !== String(w.domain).toLowerCase()) return { ok: false, code: 'domain_mismatch', message: `메일 서버 "${s.nickname || s.host}" 는 ${s.domain} 도메인을 다루는데 이 사이트의 메일 도메인은 ${w.domain} 입니다 — 운영자가 이 도메인을 다루는 메일 서버를 등록·선택해야 합니다`, domain: w.domain, server: serverLite(s), at: new Date().toISOString() };
const r = await forDomain(s, w.domain, o);
if (!r.ok) return { ok: false, code: (r.error && r.error.code) || 'error', message: (r.error && r.error.message) || '사용량을 받지 못했습니다', domain: w.domain, server: r.server, at: r.at, hasKey: r.hasKey };
// 이 사이트의 계정만 — 등록부(login/address)와 맞춰 본다
let mine = []; try { mine = require('./mailsys').listAccounts({ siteId: site.id }); } catch (_) {}
const logins = new Set(mine.map((a) => String(a.login || '').toLowerCase()).filter(Boolean)); const boxes = new Set(mine.map((a) => String(a.address || '').toLowerCase()).filter(Boolean));
const rep = r.report; const all = rep.accountList || [];
const accountList = all.filter((a) => logins.has(String(a.login || '').toLowerCase()) || boxes.has(String(a.mailbox || '').toLowerCase())).map((a) => ({ ...a, mine: true }));
const myBytes = accountList.reduce((n, a) => n + a.bytes, 0);
return {
ok: true, domain: rep.domain || w.domain, server: r.server, at: r.at, cached: r.cached, version: r.version, generatedAt: rep.generatedAt,
storage: rep.storage, accounts: { ...rep.accounts, mine: accountList.length }, messages: rep.messages, traffic: rep.traffic, period: rep.period, daily: rep.daily,
accountList, myBytes, // myBytes: 이 사이트 계정들의 메일함 합계 (도메인 전체 storage.bytes 와 구분)
};
}
module.exports = { forServer, forDomain, forSite, invalidate, domainReport, TTL_OK, TTL_ERR, _cache: cache };
+41
View File
@@ -0,0 +1,41 @@
'use strict';
// ============================================================================
// 발행 취소 안내 페이지 ("사이트 수정 중") — v1.5.27 (요구 5)
//
// 발행을 취소한 사이트의 운영 주소에 보이는 페이지. 세 곳이 같은 내용을 써야 해서 한 모듈로 모았다.
// 1) server.js deployMaintenance — 원격(ssh) 배포 서버에는 이 HTML 을 index.html 로 올린다
// 2) lib/deploy.js vhostBlock — 서비스 서버 자신(같은 경로) 배포 서버의 사이트 vhost: 작업 폴더에 .unpublished 표식이 있으면 nginx 가 이 HTML 을 503 으로 돌려준다
// 3) deploy/nginx-webhosting.conf · remote-install.sh — 서비스 공개 주소(https://서비스/<사이트ID>/)의 nginx 규칙(설치 때 생성, 같은 문구를 손으로 맞춘다)
//
// 1.5.19 까지는 한국어 한 줄("사이트 수정 중입니다")뿐이었다. 이제 영어를 크게, 그 아래 한국어·일본어·중국어·스페인어 안내, 마지막 줄에
// "Webmaker is working .... supported by <도메인>" 을 둔다. <도메인> 은 플랫폼 도메인(서비스 주소에서 유도, 예: ariweb.net).
// ============================================================================
/** 안내 문구 (언어별). 영어는 제목, 나머지는 본문 줄 */
const HEADLINE = "We're updating our site. Please check back soon.";
const LINES = [
['ko', '사이트 개정 작업 중입니다. 잠시 후에 다시 접속해주세요.'],
['ja', 'サイトの改訂作業中です。しばらくしてから再度アクセスしてください。'],
['zh', '网站正在改版中,请稍后再访问。'],
['es', 'El sitio está en proceso de revisión. Vuelva a intentarlo en unos minutos.'],
];
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
/** 서비스 도메인(플랫폼 도메인): 설정값 → 서비스 공개 주소 호스트에서 유도 → 빈 값이면 'Webmaker' */
function serviceDomain() {
try { const d = require('./domains').config().platformDomain; if (d) return d; } catch (_) {}
try { const u = process.env.PUBLIC_BASE_URL || require('./config').get('service.publicBaseUrl'); const h = new URL(u).hostname; if (h && !require('net').isIP(h)) return h.split('.').length > 2 ? h.split('.').slice(1).join('.') : h; } catch (_) {}
return '';
}
/**
* 안내 페이지 HTML.
* @param {{title?:string, domain?:string}} [o] title: 사이트 이름(<title> 에만), domain: 마지막 줄의 "supported by" 뒤 도메인(없으면 serviceDomain())
*/
function html(o = {}) {
const dom = o.domain || serviceDomain(); const t = esc(o.title || '');
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>${t ? t + ' — ' : ''}Updating</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;font-family:-apple-system,"Segoe UI","Noto Sans KR","Noto Sans JP","Noto Sans SC",sans-serif;background:#f4f5f7;color:#1f2933}main{text-align:center;padding:32px 24px;max-width:640px}h1{font-size:28px;line-height:1.3;margin:0 0 18px}p{color:#4b5563;margin:6px 0;line-height:1.6;font-size:15px}.f{margin-top:26px;font-size:12px;color:#9aa3af}</style></head><body><main><h1>${HEADLINE}</h1>${LINES.map(([l, s]) => `<p lang="${l}">${s}</p>`).join('')}<p class="f">Webmaker is working .... supported by ${esc(dom || 'Webmaker')}</p></main></body></html>`;
}
/** nginx `return 503 '…'` 안에 넣을 수 있게 작은따옴표를 피한 문자열 (HTML 에는 작은따옴표를 쓰지 않았지만 안전하게) */
function nginxString(o) { return html(o).replace(/'/g, '&#39;').replace(/\r?\n/g, ''); }
module.exports = { html, nginxString, serviceDomain, HEADLINE, LINES };
+518
View File
@@ -0,0 +1,518 @@
'use strict';
// ============================================================================
// 공통 기능 모듈 (v1.0.3)
//
// 생성 사이트는 정적 HTML 이므로 게시판·문의·설문·예약·후기·FAQ·갤러리·회원 같은 "동적" 기능은
// 이 빌더 서비스가 백엔드(API + 데이터 저장소 + 관리자 페이지)를 맡고, 사이트 쪽에는
// (1) modules/<이름>/widget.html 의 표준 마크업(사이트 디자인에 맞게 CSS 만 바꿔 씀)
// (2) <script src="<서비스>/wm-modules.js"> 런타임(데이터 속성으로 설정)
// 만 들어간다. 사이트마다 다른 값(사이트 ID, API 주소, 스타일, 권한, 카테고리)은 모두
// data-* 속성과 이 모듈의 "설정"(관리자 페이지에서 변경)으로 분리되어 하드코딩되지 않는다.
//
// 저장: data/modules/<siteId>.json
// { enabled: {모듈: true}, settings: {모듈: {...}}, members: [], posts: [], comments: [], surveys: [], responses: [],
// bookings: [], reviews: [], faq: [], gallery: [], blogPosts: [], blogComments: [], libraryFiles: [], events: [] }
// 일정(v1.4.18): events — 반복·음력 일정의 규칙과 발생 전개는 lib/calendar.js, 음력·간지 계산은 lib/lunar.js
// 첨부 파일(문의): data/modules/<siteId>/files/<id>-<원본명>
// 자료실 파일(v1.4.0): data/modules/<siteId>/library/<저장 이름> — 사이트 폴더 밖(공개 배포 안 됨), API 로만 내려받는다(권한·횟수 기록)
//
// 인증
// - 사이트 회원(방문자): members 모듈. 비밀번호는 scrypt, 토큰은 HMAC(session.secret) 서명 — 사이트 범위.
// - 사이트 관리자 페이지: 빌더 로그인 세션(소유자/운영자) 또는 role=admin 회원 토큰.
// ============================================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const https = require('https');
const ROOT = path.resolve(__dirname, '..');
const DATA_ROOT = process.env.DATA_ROOT || path.join(ROOT, 'data');
const DIR = path.join(DATA_ROOT, 'modules');
const TEMPLATES = path.join(ROOT, 'modules');
fs.mkdirSync(DIR, { recursive: true });
function httpError(msg, status = 400) { return Object.assign(new Error(msg), { status }); }
/** v1.3.16: 사이트 방문자에게 보이는 오류는 문구 id 로 만든다 (글자는 locales/site/ko.json, API 가 요청 언어로 바꿔 보냄) */
const sitemsg = require('./sitemsg');
const calendar = require('./calendar'); // v1.4.18 일정관리 모듈 규칙
const E = (id, status = 400, vars) => sitemsg.err(id, status, vars);
const nowIso = () => new Date().toISOString();
const newId = () => crypto.randomBytes(8).toString('hex');
const clip = (v, n) => String(v == null ? '' : v).trim().slice(0, n);
// ---------- 모듈 카탈로그 ----------
// label/desc: 화면 표시. options: 관리자 페이지에서 편집하는 설정 스키마(type: boolean|number|text|select|list|time).
// collections: 관리자 페이지 표에 보이는 데이터와 열. feature: Brief 의 features 키(생성 프롬프트 연결).
const CATALOG = {
members: { label: '회원', desc: '사이트 방문자의 회원가입·로그인. 게시판 회원 전용 글쓰기 등에 쓰입니다.', feature: 'members',
options: [{ key: 'allowSignup', label: '회원가입 허용', type: 'boolean', def: true }, { key: 'approve', label: '가입 승인제(관리자 승인 후 활동)', type: 'boolean', def: false }],
collections: { members: { label: '회원', columns: ['name', 'email', 'role', 'approved', 'createdAt'] } } },
board: { label: '게시판', desc: '회원/비회원 글쓰기·읽기 제한, 비밀글, 댓글·답글(비밀 댓글 포함). 리스트형/포럼형.', feature: 'board',
options: [{ key: 'style', label: '표시 형식', type: 'select', values: [['list', '리스트형'], ['forum', '포럼형(본문 일부 노출)']], def: 'list' }, { key: 'readRole', label: '글 읽기', type: 'select', values: [['all', '누구나'], ['member', '회원만']], def: 'all' }, { key: 'writeRole', label: '글쓰기', type: 'select', values: [['all', '누구나(비회원 비밀번호)'], ['member', '회원만'], ['admin', '관리자만(공지형)']], def: 'all' }, { key: 'commentRole', label: '댓글', type: 'select', values: [['all', '누구나'], ['member', '회원만'], ['none', '댓글 없음']], def: 'all' }, { key: 'allowSecret', label: '비밀글 허용', type: 'boolean', def: true }, { key: 'categories', label: '말머리(카테고리, 쉼표 구분)', type: 'list', def: ['공지', '일반', '질문'] }, { key: 'pageSize', label: '한 페이지 글 수', type: 'number', def: 15 }],
collections: { posts: { label: '글', columns: ['title', 'author', 'category', 'secret', 'views', 'createdAt'] }, comments: { label: '댓글', columns: ['postId', 'author', 'body', 'secret', 'createdAt'] } } },
inquiry: { label: '문의폼', desc: '유형·발신자·제목·내용·첨부파일을 받아 사이트 관리자 페이지와 빌더 관리 화면에 기록하고 이메일로 보냅니다.', feature: 'contactForm',
options: [{ key: 'categories', label: '문의 유형(쉼표 구분)', type: 'list', def: ['일반 문의', '견적 문의', '제휴', '기타'] }, { key: 'notifyEmail', label: '이메일로 받기', type: 'boolean', def: true }, { key: 'allowFiles', label: '첨부파일 허용', type: 'boolean', def: true }, { key: 'maxFileMb', label: '첨부 최대 크기(MB)', type: 'number', def: 10 }],
collections: { inquiries: { label: '문의', columns: ['category', 'name', 'email', 'subject', 'sent', 'at'] } } },
survey: { label: '설문조사폼', desc: '구글폼 방식의 설문·참가 신청 양식. 결과는 관리자 페이지에서 보고 xlsx 로 내려받거나 이메일로 받습니다.', feature: 'survey',
options: [{ key: 'notifyEmail', label: '응답을 이메일로 받기', type: 'boolean', def: false }, { key: 'oneResponsePerMember', label: '회원당 1회 응답', type: 'boolean', def: false }],
collections: { surveys: { label: '설문 양식', columns: ['title', 'active', 'responses', 'createdAt'] }, responses: { label: '응답', columns: ['surveyId', 'respondent', 'createdAt'] } } },
booking: { label: '예약', desc: '방문자가 날짜·시간·내용을 골라 예약. 관리자가 가능 요일·시간대·간격(1시간/2시간 등)·정원을 설정합니다.', feature: 'booking',
options: [{ key: 'weekdays', label: '예약 가능 요일(0=일 … 6=토, 쉼표)', type: 'list', def: ['1', '2', '3', '4', '5'] }, { key: 'start', label: '시작 시각', type: 'time', def: '10:00' }, { key: 'end', label: '종료 시각', type: 'time', def: '18:00' }, { key: 'interval', label: '예약 간격(분)', type: 'select', values: [['30', '30분'], ['60', '1시간'], ['90', '1시간 30분'], ['120', '2시간'], ['180', '3시간']], def: '60' }, { key: 'capacity', label: '시간대별 정원', type: 'number', def: 1 }, { key: 'leadDays', label: '최소 며칠 전부터 예약', type: 'number', def: 0 }, { key: 'maxDays', label: '최대 며칠 뒤까지 예약', type: 'number', def: 60 }, { key: 'autoConfirm', label: '자동 확정(끄면 관리자 확인 후 확정)', type: 'boolean', def: true }, { key: 'notifyEmail', label: '예약을 이메일로 받기', type: 'boolean', def: true }, { key: 'closedDates', label: '휴무일(YYYY-MM-DD, 쉼표)', type: 'list', def: [] }],
collections: { bookings: { label: '예약', columns: ['date', 'time', 'name', 'contact', 'status', 'createdAt'] } } },
gallery: { label: '갤러리', desc: '사진·이미지 모음(포트폴리오로도 사용). 관리자 페이지에서 이미지를 올리고 제목·설명·태그를 붙입니다.', feature: 'gallery',
options: [{ key: 'columns', label: '열 수', type: 'select', values: [['2', '2열'], ['3', '3열'], ['4', '4열']], def: '3' }, { key: 'lightbox', label: '클릭 시 크게 보기', type: 'boolean', def: true }],
collections: { gallery: { label: '이미지', columns: ['title', 'tags', 'src', 'createdAt'] } } },
map: { label: '지도', desc: '주소를 지도에 표시(확대·축소). OpenStreetMap 기반이라 API 키가 필요 없습니다.', feature: 'map',
options: [{ key: 'address', label: '주소', type: 'text', def: '' }, { key: 'lat', label: '위도(비우면 주소로 자동)', type: 'text', def: '' }, { key: 'lng', label: '경도', type: 'text', def: '' }, { key: 'zoom', label: '확대 수준(10~19)', type: 'number', def: 16 }, { key: 'label', label: '마커 문구', type: 'text', def: '' }],
collections: {} },
reviews: { label: '후기', desc: '방문자 후기를 받아 관리자 승인 후 리스트 또는 칸반보드 형식으로 보여 줍니다.', feature: 'reviews',
options: [{ key: 'style', label: '표시 형식', type: 'select', values: [['list', '리스트'], ['kanban', '칸반보드']], def: 'list' }, { key: 'kanbanBy', label: '칸반 열 기준', type: 'select', values: [['rating', '별점'], ['category', '분류']], def: 'rating' }, { key: 'categories', label: '분류(쉼표 구분)', type: 'list', def: ['서비스', '가격', '친절'] }, { key: 'approve', label: '관리자 승인 후 공개', type: 'boolean', def: true }, { key: 'notifyEmail', label: '새 후기를 이메일로 받기', type: 'boolean', def: false }],
collections: { reviews: { label: '후기', columns: ['name', 'rating', 'category', 'text', 'approved', 'createdAt'] } } },
// v1.4.0 블로그: 복잡한 블로그 기능(테마·구독·예약 발행 등)은 빼고 "글 쓰기·보여 주기" 에 집중 — 목록·검색·분류·태그·글 보기·댓글
blog: { label: '블로그', desc: '관리자가 글을 쓰면 목록·검색·글 보기로 보여 줍니다. 분류·태그, 방문자 댓글(관리자 승인 선택)을 지원합니다.', feature: 'blog',
options: [{ key: 'pageSize', label: '한 페이지 글 수', type: 'number', def: 10 }, { key: 'categories', label: '분류(쉼표 구분, 비우면 분류 없음)', type: 'list', def: [] }, { key: 'commentRole', label: '댓글', type: 'select', values: [['all', '누구나'], ['member', '회원만'], ['none', '받지 않음']], def: 'all' }, { key: 'approveComments', label: '댓글은 관리자 승인 후 공개', type: 'boolean', def: false }, { key: 'showAuthor', label: '글쓴이 표시', type: 'boolean', def: true }],
collections: { blogPosts: { label: '블로그 글', columns: ['title', 'category', 'published', 'views', 'createdAt'] }, blogComments: { label: '블로그 댓글', columns: ['postId', 'name', 'body', 'approved', 'createdAt'] } } },
// v1.4.0 자료실: 원본 파일(문서·이미지·압축·바이너리)을 올리고 방문자가 내려받는다 — 회원만 내려받기 설정 가능
library: { label: '자료실', desc: '문서·이미지·압축 파일 등 원본 파일을 올려 두고 방문자가 검색·내려받기 합니다. 회원만 내려받게 할 수 있고 내려받은 횟수를 셉니다.', feature: 'library',
options: [{ key: 'downloadRole', label: '내려받기', type: 'select', values: [['all', '누구나'], ['member', '회원만']], def: 'all' }, { key: 'categories', label: '분류(쉼표 구분, 비우면 분류 없음)', type: 'list', def: [] }, { key: 'maxFileMb', label: '올릴 수 있는 파일 크기(MB)', type: 'number', def: 100 }, { key: 'showDownloads', label: '내려받은 횟수 표시', type: 'boolean', def: true }],
collections: { libraryFiles: { label: '자료', columns: ['title', 'originalName', 'category', 'size', 'downloads', 'published', 'createdAt'] } } },
// v1.4.18 일정관리: 월간·주간·일간 캘린더에 일정을 표시. 만세력 기준 음력 날짜와 사주식 간지(연주·월주·일주), 24절기를 옵션으로 보이고
// 일정은 시·분 단위 / 일 단위 / 주 단위, 반복(매일·매주·매월·매년), 음력 기준(음력 생일·제사·명절) 등록을 지원한다 (lib/calendar.js, lib/lunar.js)
calendar: { label: '일정관리', desc: '월간·주간·일간 캘린더에 예정된 일정을 보여 줍니다. 음력 날짜·만세력 간지(사주식 연·월·일주)·24절기 표시를 켤 수 있고, 반복 일정과 음력 기준 일정을 등록합니다.', feature: 'calendar',
options: [{ key: 'defaultView', label: '기본 보기', type: 'select', values: [['month', '월간'], ['week', '주간'], ['day', '일간']], def: 'month' }, { key: 'showLunar', label: '음력 날짜 표시', type: 'boolean', def: true }, { key: 'showGanji', label: '만세력 간지 표시(사주식 연주·월주·일주, 예: 병오년 무술월 신유일)', type: 'boolean', def: false }, { key: 'showTerms', label: '24절기 표시', type: 'boolean', def: true }, { key: 'weekStart', label: '한 주의 시작', type: 'select', values: [['0', '일요일'], ['1', '월요일']], def: '0' }, { key: 'dayStart', label: '주간·일간 보기 시작 시각(0~23)', type: 'number', def: 8 }, { key: 'dayEnd', label: '주간·일간 보기 끝 시각(1~24)', type: 'number', def: 21 }, { key: 'categories', label: '일정 분류(쉼표 구분)', type: 'list', def: ['행사', '모임', '휴무'] }],
collections: { events: { label: '일정', columns: ['title', 'date', 'unit', 'category', 'published', 'createdAt'] } } },
faq: { label: 'FAQ', desc: '관리자가 질문과 답변을 입력하면 아코디언으로 보여 줍니다.', feature: 'faq',
options: [{ key: 'categories', label: '분류(쉼표 구분, 비우면 분류 없음)', type: 'list', def: [] }],
collections: { faq: { label: '질문·답변', columns: ['q', 'category', 'order', 'createdAt'] } } },
};
const MODULE_IDS = Object.keys(CATALOG);
const COLLECTIONS = ['members', 'posts', 'comments', 'surveys', 'responses', 'bookings', 'reviews', 'faq', 'gallery', 'blogPosts', 'blogComments', 'libraryFiles', 'events'];
// ---------- 저장소 ----------
function fileOf(siteId) { return path.join(DIR, `${siteId}.json`); }
function filesDir(siteId) { const d = path.join(DIR, siteId, 'files'); fs.mkdirSync(d, { recursive: true }); return d; }
/** v1.4.0 자료실 파일 폴더 (사이트 폴더 밖 — 웹서버가 직접 내보내지 않음) */
function libraryDir(siteId) { const d = path.join(DIR, siteId, 'library'); fs.mkdirSync(d, { recursive: true }); return d; }
function defaults(moduleId) { return Object.fromEntries((CATALOG[moduleId].options || []).map((o) => [o.key, Array.isArray(o.def) ? o.def.slice() : o.def])); }
function load(siteId) {
let d; try { d = JSON.parse(fs.readFileSync(fileOf(siteId), 'utf8')); } catch (_) { d = { enabled: {}, settings: {} }; }
for (const c of COLLECTIONS) if (!Array.isArray(d[c])) d[c] = [];
d.enabled = d.enabled || {}; d.settings = d.settings || {};
for (const m of MODULE_IDS) d.settings[m] = { ...defaults(m), ...(d.settings[m] || {}) };
return d;
}
function save(siteId, d) { const tmp = fileOf(siteId) + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(d)); fs.renameSync(tmp, fileOf(siteId)); }
function remove(siteId) { try { fs.unlinkSync(fileOf(siteId)); } catch (_) {} try { fs.rmSync(path.join(DIR, siteId), { recursive: true, force: true }); } catch (_) {} }
/** 사이트 ID 가 유효하고 모듈 데이터가 있는지와 무관하게 항상 로드 가능 (모듈이 꺼져 있으면 API 는 404) */
function isEnabled(d, m) { return !!d.enabled[m]; }
// ---------- 설정 ----------
/** 관리자 입력을 스키마에 맞춰 정리(타입 강제, 목록은 쉼표 분리) */
function normalizeSettings(moduleId, input) {
const out = {}; const cat = CATALOG[moduleId]; if (!cat) throw E('err.unknownModule');
for (const o of cat.options || []) {
let v = input[o.key]; if (v === undefined) continue;
if (o.type === 'boolean') v = v === true || v === 'true' || v === 'on' || v === 1;
else if (o.type === 'number') { v = Number(v); if (!Number.isFinite(v)) continue; }
else if (o.type === 'list') v = (Array.isArray(v) ? v : String(v).split(/[,\n]/)).map((x) => clip(x, 60)).filter(Boolean).slice(0, 50);
else if (o.type === 'select') { if (!(o.values || []).some((p) => p[0] === String(v))) continue; v = String(v); }
else v = clip(v, o.type === 'time' ? 5 : 500);
out[o.key] = v;
}
return out;
}
/** 방문자(위젯)에게 공개해도 되는 설정만 */
function publicSettings(d, m) {
const s = d.settings[m] || {};
if (m === 'board') return { style: s.style, readRole: s.readRole, writeRole: s.writeRole, commentRole: s.commentRole, allowSecret: s.allowSecret, categories: s.categories, pageSize: s.pageSize };
if (m === 'inquiry') return { categories: s.categories, allowFiles: s.allowFiles, maxFileMb: s.maxFileMb };
if (m === 'booking') return { weekdays: s.weekdays, start: s.start, end: s.end, interval: s.interval, capacity: s.capacity, leadDays: s.leadDays, maxDays: s.maxDays, closedDates: s.closedDates };
if (m === 'reviews') return { style: s.style, kanbanBy: s.kanbanBy, categories: s.categories };
if (m === 'gallery') return { columns: s.columns, lightbox: s.lightbox };
if (m === 'map') return { address: s.address, lat: s.lat, lng: s.lng, zoom: s.zoom, label: s.label };
if (m === 'faq') return { categories: s.categories };
if (m === 'members') return { allowSignup: s.allowSignup, approve: s.approve };
if (m === 'blog') return { pageSize: s.pageSize, categories: s.categories, commentRole: s.commentRole, approveComments: s.approveComments, showAuthor: s.showAuthor };
if (m === 'library') return { downloadRole: s.downloadRole, categories: s.categories, showDownloads: s.showDownloads };
// v1.4.18 일정: 시작·끝 시각은 0~24 범위로 정리해 보낸다 (관리자가 거꾸로 넣으면 기본값)
if (m === 'calendar') { const ds = Math.min(23, Math.max(0, Number(s.dayStart) || 0)); let de = Math.min(24, Math.max(1, Number(s.dayEnd) || 24)); if (de <= ds) de = Math.min(24, ds + 8); return { defaultView: s.defaultView, showLunar: s.showLunar !== false, showGanji: !!s.showGanji, showTerms: s.showTerms !== false, weekStart: s.weekStart === '1' ? 1 : 0, dayStart: ds, dayEnd: de, categories: s.categories || [] }; }
return {};
}
// ---------- 회원 인증 (사이트 범위) ----------
function secret() { try { return fs.readFileSync(path.join(DATA_ROOT, 'session.secret'), 'utf8').trim(); } catch (_) { return 'wm-modules'; } }
function hashPw(pw, salt) { return crypto.scryptSync(String(pw), salt, 64).toString('hex'); }
function makeToken(siteId, member) {
const payload = Buffer.from(JSON.stringify({ s: siteId, m: member.id, r: member.role, exp: Date.now() + 30 * 86400000 })).toString('base64url');
const sig = crypto.createHmac('sha256', secret()).update(payload).digest('base64url');
return `${payload}.${sig}`;
}
/** 토큰 검증 → 회원 레코드 (없거나 만료/위조면 null) */
function memberFromToken(d, siteId, token) {
if (!token || typeof token !== 'string' || !token.includes('.')) return null;
const [payload, sig] = token.split('.'); const want = crypto.createHmac('sha256', secret()).update(payload).digest('base64url');
if (sig.length !== want.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want))) return null;
let p; try { p = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); } catch (_) { return null; }
if (p.s !== siteId || !Number.isFinite(p.exp) || p.exp < Date.now()) return null;
const m = d.members.find((x) => x.id === p.m) || null;
// v1.3.17 (S06): 관리자가 승인을 취소한(approved=false) 회원의 토큰은 이미 발급된 것이라도 쓰지 못한다 — 모든 회원 확인이 이 함수를 거친다
return m && m.approved !== false ? m : null;
}
const pubMember = (m) => (m ? { id: m.id, name: m.name, email: m.email, role: m.role, approved: m.approved !== false, createdAt: m.createdAt, mail: !!m.mail, mailAddress: m.mailAddress || null } : null);
/**
* 회원 비밀번호 확인 (v1.3.0) — 메일 사용 끄기처럼 되돌릴 수 없는 작업 전에 본인 확인용.
* @returns {boolean}
*/
function verifyPassword(siteId, memberId, pw) {
const m = load(siteId).members.find((x) => x.id === memberId); if (!m || !pw) return false;
const h = hashPw(String(pw), m.salt); return h.length === m.hash.length && crypto.timingSafeEqual(Buffer.from(h), Buffer.from(m.hash));
}
/** 사이트 관리자 계정 한 건 (내부용, 해시 포함) */
function getSiteAdmin(siteId, memberId) { const m = load(siteId).members.find((x) => x.id === memberId); return m && m.role === 'admin' && m.loginId ? m : null; }
/**
* 계정의 메일 사용 표시 저장 (v1.3.0). 실제 메일함 생성·삭제는 lib/mailsys.js 가 하고, 여기서는 회원 레코드만 바꾼다.
* @param {boolean} on 메일 사용 여부
* @param {string} address 메일 주소 (on 일 때)
*/
function setMemberMail(siteId, memberId, on, address) {
const d = load(siteId); const m = d.members.find((x) => x.id === memberId); if (!m) throw E('err.noAccount', 404);
m.mail = !!on; m.mailAddress = on ? address : null; m.mailEnabledAt = on ? nowIso() : null; save(siteId, d); return pubMember(m);
}
function signup(siteId, body) {
const d = load(siteId); const s = d.settings.members;
if (!isEnabled(d, 'members') || s.allowSignup === false) throw E('err.signupClosed', 403);
const email = clip(body.email, 120).toLowerCase(); const name = clip(body.name, 60); const pw = String(body.password || '');
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw E('err.emailFormat'); if (!name) throw E('err.nameRequired'); if (pw.length < 6) throw E('err.passwordMin6');
if (d.members.some((m) => m.email === email)) throw E('err.emailTaken', 409);
const salt = crypto.randomBytes(16).toString('hex');
const m = { id: newId(), name, email, salt, hash: hashPw(pw, salt), role: 'member', approved: !s.approve, createdAt: nowIso() };
d.members.push(m); save(siteId, d);
return { member: pubMember(m), token: m.approved ? makeToken(siteId, m) : null, pending: !m.approved };
}
function login(siteId, body) {
const d = load(siteId); const email = clip(body.email || body.id, 120).toLowerCase();
const m = d.members.find((x) => x.email === email || x.loginId === email); if (!m) throw E('err.loginFailed', 401);
const h = hashPw(String(body.password || ''), m.salt); if (h.length !== m.hash.length || !crypto.timingSafeEqual(Buffer.from(h), Buffer.from(m.hash))) throw E('err.loginFailed', 401);
if (m.approved === false) throw E('err.approvalPending', 403);
return { member: pubMember(m), token: makeToken(siteId, m) };
}
/** 사이트 관리자 계정(별도 관리자 페이지 로그인용) 생성 — 소유자가 관리 화면에서 만든다 */
function createSiteAdmin(siteId, { loginId, password, name }) {
const d = load(siteId); loginId = clip(loginId, 60).toLowerCase(); if (!loginId || !password) throw E('err.siteAdminInput');
require('./passpolicy').check(password, { id: loginId }); // v1.3.17 관리자 콘솔의 비밀번호 규칙 (기본 6자 이상 — 이전과 같음)
if (d.members.some((m) => m.loginId === loginId || m.email === loginId)) throw E('err.idTaken', 409);
const salt = crypto.randomBytes(16).toString('hex');
const m = { id: newId(), name: clip(name, 60) || loginId, email: '', loginId, salt, hash: hashPw(password, salt), role: 'admin', approved: true, createdAt: nowIso() };
d.members.push(m); save(siteId, d); return pubMember(m);
}
// ---------- 게시판 ----------
function guestHash(pw) { return pw ? crypto.createHash('sha256').update(secret() + '|' + pw).digest('hex') : null; }
const pubPost = (p, { full = false, canSee = true } = {}) => ({ id: p.id, title: p.secret && !canSee ? '🔒 비밀글입니다' : p.title, author: p.author, category: p.category, secret: !!p.secret, views: p.views || 0, createdAt: p.createdAt, updatedAt: p.updatedAt, memberId: p.memberId || null, pinned: !!p.pinned, excerpt: !p.secret || canSee ? clip(p.body, 160) : '', body: full && canSee ? p.body : undefined, comments: p.commentCount || 0 });
function canRead(s, member) { return s.readRole !== 'member' || !!member; }
function canWrite(s, member) { if (s.writeRole === 'admin') return !!(member && member.role === 'admin'); if (s.writeRole === 'member') return !!member; return true; }
function canComment(s, member) { if (s.commentRole === 'none') return false; if (s.commentRole === 'member') return !!member; return true; }
function listPosts(siteId, q, member) {
const d = load(siteId); const s = d.settings.board; if (!isEnabled(d, 'board')) throw E('err.noBoard', 404);
if (!canRead(s, member)) throw E('err.membersOnly', 401);
const size = Math.min(50, Math.max(1, parseInt(q.size, 10) || s.pageSize || 15)); const page = Math.max(1, parseInt(q.page, 10) || 1);
let items = d.posts.filter((p) => !q.cat || p.category === q.cat);
// v1.3.17 (S03): 검색은 부르는 사람이 볼 수 있는 글자에서만 — 볼 수 없는 비밀글은 제목·본문 모두 검색 대상에서 뺀다
// (예전에는 가려진 비밀글 본문까지 검색해, 어떤 낱말이 들어 있는지 알아낼 수 있었다)
if (q.q) { const k = String(q.q).toLowerCase(); items = items.filter((p) => (!p.secret || isAuthorOrAdmin(p, member, null)) && (p.title + ' ' + p.body).toLowerCase().includes(k)); }
items.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0) || b.createdAt.localeCompare(a.createdAt));
const total = items.length; const slice = items.slice((page - 1) * size, page * size);
for (const p of slice) p.commentCount = d.comments.filter((c) => c.postId === p.id).length;
return { total, page, size, items: slice.map((p) => pubPost(p, { canSee: !p.secret || isAuthorOrAdmin(p, member, null) })), settings: publicSettings(d, 'board'), me: pubMember(member), canWrite: canWrite(s, member), canComment: canComment(s, member) };
}
function isAuthorOrAdmin(p, member, pw) { if (member && (member.role === 'admin' || (p.memberId && p.memberId === member.id))) return true; if (pw && p.guestHash && guestHash(pw) === p.guestHash) return true; return false; }
function getPost(siteId, id, member, pw) {
const d = load(siteId); const s = d.settings.board; if (!isEnabled(d, 'board')) throw E('err.noBoard', 404);
if (!canRead(s, member)) throw E('err.membersOnly', 401);
const p = d.posts.find((x) => x.id === id); if (!p) throw E('err.noPost', 404);
const canSee = !p.secret || isAuthorOrAdmin(p, member, pw);
if (p.secret && !canSee) return { post: pubPost(p, { canSee: false }), locked: true, needPassword: !!p.guestHash };
p.views = (p.views || 0) + 1; save(siteId, d);
const comments = d.comments.filter((c) => c.postId === id).sort((a, b) => a.createdAt.localeCompare(b.createdAt)).map((c) => { const see = !c.secret || isAuthorOrAdmin(p, member, pw) || (member && c.memberId === member.id) || (pw && c.guestHash && guestHash(pw) === c.guestHash); return { id: c.id, parentId: c.parentId || null, author: c.author, secret: !!c.secret, body: see ? c.body : '🔒 비밀 댓글입니다', createdAt: c.createdAt, memberId: c.memberId || null }; });
return { post: pubPost(p, { full: true, canSee: true }), comments, canComment: canComment(s, member), canEdit: isAuthorOrAdmin(p, member, pw) };
}
function createPost(siteId, body, member) {
const d = load(siteId); const s = d.settings.board; if (!isEnabled(d, 'board')) throw E('err.noBoard', 404);
if (!canWrite(s, member)) throw httpError(s.writeRole === 'admin' ? '관리자만 글을 쓸 수 있습니다' : '회원만 글을 쓸 수 있습니다', 401);
const title = clip(body.title, 120); const text = clip(body.body, 20000); if (!title || !text) throw E('err.titleBody');
const guestPw = member ? '' : String(body.password || ''); if (!member && guestPw.length < 4) throw E('err.guestPwPost');
const p = { id: newId(), title, body: text, author: member ? member.name : (clip(body.name, 40) || '손님'), memberId: member ? member.id : null, guestHash: member ? null : guestHash(guestPw), category: (s.categories || []).includes(body.category) ? body.category : ((s.categories || [])[0] || ''), secret: !!(s.allowSecret && (body.secret === true || body.secret === 'true' || body.secret === 'on')), views: 0, pinned: false, createdAt: nowIso(), updatedAt: nowIso() };
d.posts.push(p); save(siteId, d); return pubPost(p, { full: true });
}
function updatePost(siteId, id, body, member) {
const d = load(siteId); const p = d.posts.find((x) => x.id === id); if (!p) throw E('err.noPost', 404);
if (!isAuthorOrAdmin(p, member, body.password)) throw E('err.noEditPerm', 403);
if (body.title !== undefined) p.title = clip(body.title, 120) || p.title; if (body.body !== undefined) p.body = clip(body.body, 20000) || p.body; if (body.category !== undefined) p.category = clip(body.category, 40); if (body.secret !== undefined) p.secret = !!(body.secret === true || body.secret === 'true'); if (member && member.role === 'admin' && body.pinned !== undefined) p.pinned = !!body.pinned;
p.updatedAt = nowIso(); save(siteId, d); return pubPost(p, { full: true });
}
function deletePost(siteId, id, body, member) {
const d = load(siteId); const i = d.posts.findIndex((x) => x.id === id); if (i < 0) throw E('err.noPost', 404);
if (!isAuthorOrAdmin(d.posts[i], member, body.password)) throw E('err.noDeletePerm', 403);
d.posts.splice(i, 1); d.comments = d.comments.filter((c) => c.postId !== id); save(siteId, d); return { ok: true };
}
function addComment(siteId, postId, body, member) {
const d = load(siteId); const s = d.settings.board; const p = d.posts.find((x) => x.id === postId); if (!p) throw E('err.noPost', 404);
if (!canComment(s, member)) throw httpError(s.commentRole === 'none' ? '댓글을 받지 않는 게시판입니다' : '회원만 댓글을 쓸 수 있습니다', 401);
const text = clip(body.body, 3000); if (!text) throw E('err.commentRequired');
const guestPw = member ? '' : String(body.password || ''); if (!member && guestPw.length < 4) throw E('err.guestPwComment');
const parent = body.parentId ? d.comments.find((c) => c.id === body.parentId && c.postId === postId) : null;
const c = { id: newId(), postId, parentId: parent ? parent.id : null, body: text, author: member ? member.name : (clip(body.name, 40) || '손님'), memberId: member ? member.id : null, guestHash: member ? null : guestHash(guestPw), secret: !!(body.secret === true || body.secret === 'true' || body.secret === 'on'), createdAt: nowIso() };
d.comments.push(c); save(siteId, d); return { id: c.id, parentId: c.parentId, author: c.author, body: c.body, secret: c.secret, createdAt: c.createdAt };
}
function deleteComment(siteId, id, body, member) {
const d = load(siteId); const i = d.comments.findIndex((x) => x.id === id); if (i < 0) throw E('err.noComment', 404); const c = d.comments[i];
const ok = (member && (member.role === 'admin' || c.memberId === member.id)) || (body.password && c.guestHash && guestHash(body.password) === c.guestHash);
if (!ok) throw E('err.noDeletePerm', 403);
d.comments = d.comments.filter((x) => x.id !== id && x.parentId !== id); save(siteId, d); return { ok: true };
}
// ---------- 설문 ----------
// 설문 양식: { id, title, description, active, questions: [{ id, type: short|long|single|multi|scale|date|email|number, label, required, options: [], min, max }] }
function normalizeSurvey(body) {
const qs = (Array.isArray(body.questions) ? body.questions : []).slice(0, 60).map((q) => ({ id: q.id && /^[a-z0-9]{4,16}$/i.test(q.id) ? q.id : newId().slice(0, 8), type: ['short', 'long', 'single', 'multi', 'scale', 'date', 'email', 'number'].includes(q.type) ? q.type : 'short', label: clip(q.label, 300), required: !!q.required, options: (Array.isArray(q.options) ? q.options : String(q.options || '').split('\n')).map((o) => clip(o, 100)).filter(Boolean).slice(0, 30), min: Number(q.min) || 1, max: Number(q.max) || 5 })).filter((q) => q.label);
return { title: clip(body.title, 120) || '설문', description: clip(body.description, 2000), active: body.active !== false && body.active !== 'false', questions: qs, thanks: clip(body.thanks, 300) || '응답해 주셔서 감사합니다.' };
}
function submitResponse(siteId, surveyId, body, member, ip) {
const d = load(siteId); const s = d.settings.survey; if (!isEnabled(d, 'survey')) throw E('err.noSurvey', 404);
const f = d.surveys.find((x) => x.id === surveyId); if (!f || !f.active) throw E('err.surveyClosed', 404);
if (s.oneResponsePerMember && member && d.responses.some((r) => r.surveyId === surveyId && r.memberId === member.id)) throw E('err.alreadyAnswered', 409);
const answers = {}; const src = body.answers || {};
for (const q of f.questions) {
let v = src[q.id]; if (q.type === 'multi') v = (Array.isArray(v) ? v : (v ? [v] : [])).map((x) => clip(x, 100)).filter((x) => q.options.includes(x)); else if (q.type === 'single') v = q.options.includes(v) ? v : ''; else if (q.type === 'scale' || q.type === 'number') v = v === '' || v == null ? '' : Number(v); else v = clip(v, q.type === 'long' ? 5000 : 300);
const empty = v === '' || v == null || (Array.isArray(v) && !v.length) || (typeof v === 'number' && Number.isNaN(v));
if (q.required && empty) throw httpError(`"${q.label}" 은(는) 필수 항목입니다`);
answers[q.id] = v;
}
const r = { id: newId(), surveyId, answers, respondent: member ? member.name : clip(body.respondent, 60) || '익명', memberId: member ? member.id : null, ip, createdAt: nowIso() };
d.responses.push(r); save(siteId, d);
return { response: r, survey: f, notify: !!s.notifyEmail };
}
/** 설문 응답 → xlsx 행 (헤더: 응답일시, 응답자, 질문들) */
function surveyRows(siteId, surveyId) {
const d = load(siteId); const f = d.surveys.find((x) => x.id === surveyId); if (!f) throw E('err.noSurvey', 404);
const rows = [['응답 일시', '응답자', ...f.questions.map((q) => q.label)]];
for (const r of d.responses.filter((x) => x.surveyId === surveyId)) rows.push([new Date(r.createdAt).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }), r.respondent, ...f.questions.map((q) => { const v = r.answers[q.id]; return Array.isArray(v) ? v.join(', ') : (v == null ? '' : v); })]);
return { title: f.title, rows };
}
// ---------- 예약 ----------
const pad = (n) => String(n).padStart(2, '0');
/** 날짜의 예약 가능 시간대와 남은 정원 */
function slotsFor(siteId, date) {
const d = load(siteId); const s = d.settings.booking; if (!isEnabled(d, 'booking')) throw E('err.noBooking', 404);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw E('err.dateFormat');
const day = new Date(date + 'T00:00:00+09:00'); const today = new Date(new Date().toLocaleString('en-US', { timeZone: 'Asia/Seoul' })); today.setHours(0, 0, 0, 0);
const diffDays = Math.round((day - today) / 86400000);
const closed = (s.closedDates || []).includes(date) || !(s.weekdays || []).map(String).includes(String(day.getDay())) || diffDays < (s.leadDays || 0) || diffDays > (s.maxDays || 60);
const [sh, sm] = String(s.start || '10:00').split(':').map(Number); const [eh, em] = String(s.end || '18:00').split(':').map(Number); const step = parseInt(s.interval, 10) || 60;
const slots = [];
if (!closed) for (let t = sh * 60 + sm; t + step <= eh * 60 + em; t += step) { const time = `${pad(Math.floor(t / 60))}:${pad(t % 60)}`; const taken = d.bookings.filter((b) => b.date === date && b.time === time && b.status !== 'cancelled').length; slots.push({ time, left: Math.max(0, (s.capacity || 1) - taken) }); }
return { date, closed, slots, settings: publicSettings(d, 'booking') };
}
function reserve(siteId, body, member, ip) {
const { closed, slots } = slotsFor(siteId, String(body.date || '')); const d = load(siteId); const s = d.settings.booking;
if (closed) throw E('err.bookingClosedDate'); const slot = slots.find((x) => x.time === body.time); if (!slot) throw E('err.slotRequired'); if (slot.left <= 0) throw E('err.slotFull', 409);
const name = clip(body.name, 60) || (member && member.name); const contact = clip(body.contact, 120); if (!name || !contact) throw E('err.nameContact');
const b = { id: newId(), date: body.date, time: body.time, name, contact, note: clip(body.note, 1000), memberId: member ? member.id : null, status: s.autoConfirm ? 'confirmed' : 'pending', ip, createdAt: nowIso() };
d.bookings.push(b); save(siteId, d); return { booking: b, notify: !!s.notifyEmail };
}
// ---------- 후기 / FAQ / 갤러리 ----------
function submitReview(siteId, body, member, ip) {
const d = load(siteId); const s = d.settings.reviews; if (!isEnabled(d, 'reviews')) throw E('err.noReviews', 404);
const text = clip(body.text, 2000); const rating = Math.min(5, Math.max(1, parseInt(body.rating, 10) || 5)); if (!text) throw E('err.reviewRequired');
const r = { id: newId(), name: clip(body.name, 40) || (member ? member.name : '익명'), rating, text, category: (s.categories || []).includes(body.category) ? body.category : '', approved: !s.approve, memberId: member ? member.id : null, ip, createdAt: nowIso() };
d.reviews.push(r); save(siteId, d); return { review: r, notify: !!s.notifyEmail, pending: !r.approved };
}
function listReviews(siteId) { const d = load(siteId); if (!isEnabled(d, 'reviews')) throw E('err.noReviews', 404); return { items: d.reviews.filter((r) => r.approved).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).map((r) => ({ id: r.id, name: r.name, rating: r.rating, text: r.text, category: r.category, createdAt: r.createdAt })), settings: publicSettings(d, 'reviews') }; }
/**
* v1.4.18 일정 캘린더 조회 (방문자): 기간의 날짜 정보(음력·간지·절기)와 공개 일정의 발생.
* @param {{from, to, cat}} q from·to 'YYYY-MM-DD' (최대 70일), cat 분류 필터
*/
function listCalendar(siteId, q) { const d = load(siteId); if (!isEnabled(d, 'calendar')) throw E('err.noCalendar', 404); return { ...calendar.range(d.events, q.from, q.to, { category: q.cat || '', publishedOnly: true }), settings: publicSettings(d, 'calendar') }; }
/** v1.4.18 일정 모듈이 켜져 있어야 쓰는 날짜 조회·음력 변환 (위젯·관리자 입력 보조) */
function calendarDay(siteId, date) { const d = load(siteId); if (!isEnabled(d, 'calendar')) throw E('err.noCalendar', 404); return calendar.dayInfo(date); }
function calendarLunar(siteId, q) { const d = load(siteId); if (!isEnabled(d, 'calendar')) throw E('err.noCalendar', 404); return calendar.lunarToSolar(q); }
function listFaq(siteId) { const d = load(siteId); if (!isEnabled(d, 'faq')) throw E('err.noFaq', 404); return { items: d.faq.slice().sort((a, b) => (a.order || 0) - (b.order || 0) || a.createdAt.localeCompare(b.createdAt)).map((f) => ({ id: f.id, q: f.q, a: f.a, category: f.category || '' })), settings: publicSettings(d, 'faq') }; }
function listGallery(siteId) { const d = load(siteId); if (!isEnabled(d, 'gallery')) throw E('err.noGallery', 404); return { items: d.gallery.slice().sort((a, b) => (a.order || 0) - (b.order || 0) || a.createdAt.localeCompare(b.createdAt)).map((g) => ({ id: g.id, src: g.src, thumb: g.thumb || g.src, title: g.title, caption: g.caption, tags: g.tags || [] })), settings: publicSettings(d, 'gallery') }; }
// ---------- 블로그 (v1.4.0) ----------
/** 방문자에게 보이는 블로그 글 (본문은 글 보기에서만) */
const pubBlog = (p, s, full) => ({ id: p.id, title: p.title, summary: p.summary || clip(String(p.body || '').replace(/\s+/g, ' '), 200), cover: p.cover || '', category: p.category || '', tags: p.tags || [], author: s.showAuthor === false ? '' : (p.author || ''), views: p.views || 0, comments: p.commentCount || 0, createdAt: p.createdAt, updatedAt: p.updatedAt, body: full ? p.body : undefined });
/**
* 블로그 글 목록: 공개(published)된 글만, 최신순. q 는 제목·요약·본문·태그에서 찾는다(여러 낱말이면 모두 포함), cat·tag 로 거른다.
* @param {object} q { q, cat, tag, page, size }
*/
function listBlog(siteId, q) {
const d = load(siteId); const s = d.settings.blog; if (!isEnabled(d, 'blog')) throw E('err.noBlog', 404);
const size = Math.min(50, Math.max(1, parseInt(q.size, 10) || s.pageSize || 10)); const page = Math.max(1, parseInt(q.page, 10) || 1);
let items = d.blogPosts.filter((p) => p.published !== false && (!q.cat || p.category === q.cat) && (!q.tag || (p.tags || []).includes(q.tag)));
const words = String(q.q || '').toLowerCase().split(/\s+/).filter(Boolean).slice(0, 8);
if (words.length) items = items.filter((p) => { const hay = `${p.title} ${p.summary || ''} ${p.body} ${(p.tags || []).join(' ')}`.toLowerCase(); return words.every((w) => hay.includes(w)); });
items.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0) || b.createdAt.localeCompare(a.createdAt));
const total = items.length; const slice = items.slice((page - 1) * size, page * size);
for (const p of slice) p.commentCount = d.blogComments.filter((c) => c.postId === p.id && c.approved !== false).length;
const tags = {}; for (const p of d.blogPosts) if (p.published !== false) for (const t of p.tags || []) tags[t] = (tags[t] || 0) + 1;
return { total, page, size, items: slice.map((p) => pubBlog(p, s, false)), tags: Object.entries(tags).sort((a, b) => b[1] - a[1]).slice(0, 30).map(([t, n]) => ({ tag: t, n })), settings: publicSettings(d, 'blog') };
}
/** 블로그 글 보기 (조회수 +1, 공개된 댓글 포함) */
function getBlog(siteId, id, member) {
const d = load(siteId); const s = d.settings.blog; if (!isEnabled(d, 'blog')) throw E('err.noBlog', 404);
const p = d.blogPosts.find((x) => x.id === id && x.published !== false); if (!p) throw E('err.noPost', 404);
p.views = (p.views || 0) + 1; save(siteId, d);
const comments = d.blogComments.filter((c) => c.postId === id && c.approved !== false).sort((a, b) => a.createdAt.localeCompare(b.createdAt)).map((c) => ({ id: c.id, name: c.name, body: c.body, createdAt: c.createdAt }));
return { post: pubBlog(p, s, true), comments, canComment: canComment(s, member), settings: publicSettings(d, 'blog') };
}
/** 블로그 댓글 쓰기 — 설정에 따라 누구나/회원만/받지 않음, 승인제면 관리자 승인 뒤 공개 */
function addBlogComment(siteId, postId, body, member, ip) {
const d = load(siteId); const s = d.settings.blog; if (!isEnabled(d, 'blog')) throw E('err.noBlog', 404);
if (!d.blogPosts.some((p) => p.id === postId && p.published !== false)) throw E('err.noPost', 404);
if (s.commentRole === 'none') throw E('err.commentsClosed', 403); if (s.commentRole === 'member' && !member) throw E('err.loginRequired', 401);
const text = clip(body.body, 3000); if (!text) throw E('err.commentRequired');
const name = member ? member.name : clip(body.name, 40); if (!name) throw E('err.nameRequired');
const c = { id: newId(), postId, name, body: text, memberId: member ? member.id : null, ip: ip || null, approved: !s.approveComments, createdAt: nowIso() };
d.blogComments.push(c); save(siteId, d);
return { id: c.id, pending: !c.approved, comment: c.approved ? { id: c.id, name: c.name, body: c.body, createdAt: c.createdAt } : null };
}
// ---------- 자료실 (v1.4.0) ----------
const pubFile = (f, s) => ({ id: f.id, title: f.title || f.originalName, description: f.description || '', category: f.category || '', tags: f.tags || [], originalName: f.originalName, size: f.size, mime: f.mime || '', downloads: s.showDownloads === false ? undefined : (f.downloads || 0), createdAt: f.createdAt, updatedAt: f.updatedAt });
/** 자료 목록 (공개 자료만, 최신순, 제목·설명·파일 이름·태그 검색) */
function listLibrary(siteId, q) {
const d = load(siteId); const s = d.settings.library; if (!isEnabled(d, 'library')) throw E('err.noLibrary', 404);
let items = d.libraryFiles.filter((f) => f.published !== false && (!q.cat || f.category === q.cat));
const words = String(q.q || '').toLowerCase().split(/\s+/).filter(Boolean).slice(0, 8);
if (words.length) items = items.filter((f) => { const hay = `${f.title} ${f.description || ''} ${f.originalName} ${(f.tags || []).join(' ')}`.toLowerCase(); return words.every((w) => hay.includes(w)); });
items.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0) || b.createdAt.localeCompare(a.createdAt));
return { items: items.slice(0, 500).map((f) => pubFile(f, s)), settings: publicSettings(d, 'library') };
}
/**
* 내려받기 준비: 권한(회원만) 확인 후 파일 경로와 원래 이름을 돌려주고 횟수를 센다.
* @returns {{ path: string, name: string, mime: string, size: number }}
*/
function libraryDownload(siteId, id, member) {
const d = load(siteId); const s = d.settings.library; if (!isEnabled(d, 'library')) throw E('err.noLibrary', 404);
const f = d.libraryFiles.find((x) => x.id === id && x.published !== false); if (!f) throw E('err.noItem', 404);
if (s.downloadRole === 'member' && !member) throw E('err.loginRequired', 401);
const fp = path.join(libraryDir(siteId), f.stored); if (!f.stored || path.basename(f.stored) !== f.stored || !fs.existsSync(fp)) throw E('err.fileMissing', 404);
f.downloads = (f.downloads || 0) + 1; save(siteId, d);
return { path: fp, name: f.originalName, mime: f.mime || 'application/octet-stream', size: f.size };
}
/**
* 관리자 자료 올리기. 파일은 사이트 폴더 밖 library/ 에 무작위 이름으로 저장하고(원래 이름은 기록에만), 크기 상한은 설정 maxFileMb.
* @param {Array<{name, size, type, buf}>} files 이미 읽은 파일
* @param {object} meta { title, description, category, tags, published }
*/
function addLibraryFiles(siteId, files, meta = {}) {
const d = load(siteId); const s = d.settings.library; const max = Math.max(1, Number(s.maxFileMb) || 100) * 1048576; const added = []; const skipped = [];
for (const f of files) {
if (!f.buf || f.size > max) { skipped.push({ name: f.name, reason: `크기 초과(${Math.round(max / 1048576)}MB)` }); continue; }
const original = clip(path.basename(String(f.name || 'file')), 200) || 'file'; const ext = (path.extname(original).toLowerCase().match(/^\.[a-z0-9]{1,10}$/) || [''])[0];
const stored = `${Date.now().toString(36)}-${crypto.randomBytes(6).toString('hex')}${ext}`;
fs.writeFileSync(path.join(libraryDir(siteId), stored), f.buf, { mode: 0o640 });
const item = { id: newId(), title: clip(meta.title, 200) && files.length === 1 ? clip(meta.title, 200) : original.replace(/\.[^.]+$/, ''), description: clip(meta.description, 2000), category: clip(meta.category, 40), tags: (Array.isArray(meta.tags) ? meta.tags : String(meta.tags || '').split(',')).map((t) => clip(t, 30)).filter(Boolean), originalName: original, stored, size: f.size, mime: clip(f.type, 120) || 'application/octet-stream', downloads: 0, published: meta.published !== false && meta.published !== 'false', createdAt: nowIso(), updatedAt: nowIso() };
d.libraryFiles.push(item); added.push(item);
}
save(siteId, d); return { added, skipped };
}
// ---------- 지도: 주소 → 좌표 (Nominatim, 캐시) ----------
const geoCache = new Map();
function geocode(q) {
q = clip(q, 200); if (!q) return Promise.resolve(null); if (geoCache.has(q)) return Promise.resolve(geoCache.get(q));
return new Promise((resolve) => {
const url = `https://nominatim.openstreetmap.org/search?format=json&limit=1&q=${encodeURIComponent(q)}`;
const req = https.get(url, { headers: { 'User-Agent': 'webmaker-map/1.0 (site builder)' }, timeout: 10000 }, (res) => { let d = ''; res.on('data', (c) => { d += c; }); res.on('end', () => { try { const j = JSON.parse(d); const r = j[0] ? { lat: parseFloat(j[0].lat), lng: parseFloat(j[0].lon), display: j[0].display_name } : null; geoCache.set(q, r); resolve(r); } catch (_) { resolve(null); } }); });
req.on('error', () => resolve(null)); req.on('timeout', () => { req.destroy(); resolve(null); });
});
}
// ---------- 관리자(소유자/사이트 관리자) 용 범용 CRUD ----------
const COL_FIELDS = {
members: ['name', 'email', 'role', 'approved'], posts: ['title', 'body', 'category', 'secret', 'pinned', 'author'], comments: ['body', 'secret'],
surveys: null, responses: [], bookings: ['date', 'time', 'name', 'contact', 'note', 'status'], reviews: ['name', 'rating', 'text', 'category', 'approved'],
faq: ['q', 'a', 'category', 'order'], gallery: ['src', 'thumb', 'title', 'caption', 'tags', 'order'],
blogPosts: ['title', 'summary', 'body', 'cover', 'category', 'tags', 'author', 'published', 'pinned'], blogComments: ['name', 'body', 'approved'],
libraryFiles: ['title', 'description', 'category', 'tags', 'published', 'pinned'],
events: null, // v1.4.18 일정은 lib/calendar.js normalizeEvent 가 통째로 정리한다
};
function adminList(siteId, coll) {
const d = load(siteId); if (!COLLECTIONS.includes(coll)) throw E('err.unknownItem', 404);
const items = d[coll].slice().sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''));
if (coll === 'members') return items.map((m) => ({ ...pubMember(m), loginId: m.loginId || null }));
if (coll === 'surveys') return items.map((f) => ({ ...f, responses: d.responses.filter((r) => r.surveyId === f.id).length }));
if (coll === 'posts') return items.map((p) => ({ ...pubPost(p, { full: true }), body: p.body }));
// v1.4.18 일정: 시작일 순으로, 오늘 이후 첫 발생일(next)을 붙여 준다 (반복·음력 일정의 실제 날짜 확인용)
if (coll === 'events') return items.sort((a, b) => (a.date || '').localeCompare(b.date || '') || (a.time || '').localeCompare(b.time || '')).map((e) => ({ ...e, next: calendar.nextOccurrence(e) }));
return items.map((x) => { const { guestHash: _g, salt: _s, hash: _h, ...rest } = x; return rest; });
}
function adminCreate(siteId, coll, body) {
const d = load(siteId); if (!COLLECTIONS.includes(coll)) throw E('err.unknownItem', 404);
let item;
if (coll === 'surveys') item = { id: newId(), ...normalizeSurvey(body), createdAt: nowIso() };
else if (coll === 'posts') item = { id: newId(), title: clip(body.title, 120), body: clip(body.body, 20000), author: clip(body.author, 40) || '관리자', category: clip(body.category, 40), secret: !!body.secret, pinned: !!body.pinned, views: 0, createdAt: nowIso(), updatedAt: nowIso() };
else if (coll === 'faq') item = { id: newId(), q: clip(body.q, 300), a: clip(body.a, 5000), category: clip(body.category, 40), order: Number(body.order) || 0, createdAt: nowIso() };
else if (coll === 'gallery') item = { id: newId(), src: clip(body.src, 500), thumb: clip(body.thumb, 500), title: clip(body.title, 120), caption: clip(body.caption, 500), tags: (Array.isArray(body.tags) ? body.tags : String(body.tags || '').split(',')).map((t) => clip(t, 30)).filter(Boolean), order: Number(body.order) || 0, createdAt: nowIso() };
else if (coll === 'reviews') item = { id: newId(), name: clip(body.name, 40) || '익명', rating: Math.min(5, Math.max(1, parseInt(body.rating, 10) || 5)), text: clip(body.text, 2000), category: clip(body.category, 40), approved: body.approved !== false, createdAt: nowIso() };
else if (coll === 'blogPosts') item = { id: newId(), title: clip(body.title, 200), summary: clip(body.summary, 500), body: clip(body.body, 100000), cover: clip(body.cover, 500), category: clip(body.category, 40), tags: (Array.isArray(body.tags) ? body.tags : String(body.tags || '').split(',')).map((t) => clip(t, 30)).filter(Boolean), author: clip(body.author, 40) || '관리자', published: body.published !== false && body.published !== 'false', pinned: body.pinned === true || body.pinned === 'true', views: 0, createdAt: nowIso(), updatedAt: nowIso() };
else if (coll === 'events') item = { ...calendar.normalizeEvent(body), id: newId(), createdAt: nowIso(), updatedAt: nowIso() }; // v1.4.18 일정
else if (coll === 'bookings') item = { id: newId(), date: clip(body.date, 10), time: clip(body.time, 5), name: clip(body.name, 60), contact: clip(body.contact, 120), note: clip(body.note, 1000), status: ['pending', 'confirmed', 'cancelled'].includes(body.status) ? body.status : 'confirmed', createdAt: nowIso() };
else throw E('err.cannotAddHere');
if ((coll === 'posts' && !item.title) || (coll === 'faq' && !item.q) || (coll === 'gallery' && !item.src) || (coll === 'blogPosts' && (!item.title || !item.body))) throw E('err.requiredFields');
d[coll].push(item); save(siteId, d); return item;
}
function adminUpdate(siteId, coll, id, body) {
const d = load(siteId); if (!COLLECTIONS.includes(coll)) throw E('err.unknownItem', 404); const x = d[coll].find((i) => i.id === id); if (!x) throw E('err.noItem', 404);
if (coll === 'surveys') Object.assign(x, normalizeSurvey({ ...x, ...body }));
else if (coll === 'events') { const n = calendar.normalizeEvent(body, x); for (const k of Object.keys(n)) if (k !== 'id') x[k] = n[k]; } // v1.4.18 일정: 보내지 않은 항목은 기존 값 유지
else for (const k of COL_FIELDS[coll] || []) if (body[k] !== undefined) { let v = body[k]; if (['secret', 'pinned', 'approved', 'published'].includes(k)) v = v === true || v === 'true'; else if (['rating', 'order'].includes(k)) v = Number(v) || 0; else if (k === 'tags') v = (Array.isArray(v) ? v : String(v).split(',')).map((t) => clip(t, 30)).filter(Boolean); else if (k === 'role') v = v === 'admin' ? 'admin' : 'member'; else if (k === 'status') v = ['pending', 'confirmed', 'cancelled'].includes(v) ? v : x.status; else v = clip(v, k === 'body' || k === 'a' ? 20000 : 1000); x[k] = v; }
x.updatedAt = nowIso(); save(siteId, d); return x;
}
function adminDelete(siteId, coll, id) {
const d = load(siteId); if (!COLLECTIONS.includes(coll)) throw E('err.unknownItem', 404); const i = d[coll].findIndex((x) => x.id === id); if (i < 0) throw E('err.noItem', 404);
const [removed] = d[coll].splice(i, 1);
if (coll === 'posts') d.comments = d.comments.filter((c) => c.postId !== id); if (coll === 'surveys') d.responses = d.responses.filter((r) => r.surveyId !== id);
if (coll === 'blogPosts') d.blogComments = d.blogComments.filter((c) => c.postId !== id);
// 자료실 기록을 지우면 저장된 파일도 지운다 (이름은 우리가 만든 무작위 이름만 — 경로 문자 없음)
if (coll === 'libraryFiles' && removed && removed.stored && path.basename(removed.stored) === removed.stored) { try { fs.unlinkSync(path.join(libraryDir(siteId), removed.stored)); } catch (_) {} }
save(siteId, d); return { ok: true };
}
function setEnabled(siteId, moduleId, on) { if (!CATALOG[moduleId]) throw E('err.unknownModule'); const d = load(siteId); d.enabled[moduleId] = !!on; if (on && moduleId === 'board' && !d.settings.members) d.settings.members = defaults('members'); save(siteId, d); return d.enabled; }
function updateSettings(siteId, moduleId, body) { const d = load(siteId); d.settings[moduleId] = { ...d.settings[moduleId], ...normalizeSettings(moduleId, body || {}) }; save(siteId, d); return d.settings[moduleId]; }
/** 관리자 페이지 첫 화면: 켜진 모듈, 설정, 건수 */
function overview(siteId) {
const d = load(siteId);
const counts = Object.fromEntries(COLLECTIONS.map((c) => [c, d[c].length]));
counts.pendingBookings = d.bookings.filter((b) => b.status === 'pending').length; counts.pendingReviews = d.reviews.filter((r) => !r.approved).length; counts.pendingMembers = d.members.filter((m) => m.approved === false).length;
return { enabled: d.enabled, settings: d.settings, counts, catalog: catalogPublic() };
}
function catalogPublic() { return MODULE_IDS.map((id) => ({ id, label: CATALOG[id].label, desc: CATALOG[id].desc, feature: CATALOG[id].feature, options: CATALOG[id].options, collections: CATALOG[id].collections })); }
// ---------- 생성 프롬프트용 템플릿 ----------
/** modules/<id>/widget.html 을 읽어 사이트 값으로 치환 (없으면 빈 문자열) */
function widgetTemplate(moduleId, { siteId, apiBase, settings, lang }) { // lang: 사이트 본문 언어 — {{t:id}} 자리를 그 언어 글자로 (v1.3.16)
const f = path.join(TEMPLATES, moduleId, 'widget.html'); let html; try { html = sitemsg.renderTemplate(fs.readFileSync(f, 'utf8'), lang || 'ko'); } catch (_) { return ''; }
const s = settings || {};
return html.replace(/\{\{SITE_ID\}\}/g, siteId).replace(/\{\{API\}\}/g, `${apiBase}/api/m/${siteId}`).replace(/\{\{SCRIPT\}\}/g, `${apiBase}/wm-modules.js`).replace(/\{\{(\w+)\}\}/g, (m0, k) => (s[k.toLowerCase()] !== undefined ? String(Array.isArray(s[k.toLowerCase()]) ? s[k.toLowerCase()].join(',') : s[k.toLowerCase()]) : m0));
}
/** 사이트에 켜진(또는 Brief 기능으로 요청된) 모듈의 표준 코드와 사용 규칙을 프롬프트 섹션으로 */
function promptSection(siteId, apiBase, wanted, lang) {
const d = load(siteId); const ids = MODULE_IDS.filter((m) => d.enabled[m] || (wanted || []).includes(m));
if (!ids.length) return '';
const parts = ids.map((m) => `--- ${CATALOG[m].label} 모듈 (modules/${m}/widget.html) ---\n${widgetTemplate(m, { siteId, apiBase, settings: d.settings[m], lang })}`);
// v1.5.7: 쓸 수 있는 모듈 id 를 분명히 적는다 — AI 가 비슷한 이름(schedule 등)의 모듈을 새로 만들어 넣지 않게 (2026-10-14 운영 사이트 사례)
return `[공통 기능 모듈 — 표준 코드 그대로 삽입]\n아래 모듈 코드는 빌더가 관리하는 표준 위젯입니다. 각 코드의 주석(재사용 설정)을 따르고, data-wm-module·data-site·data-api 등 data-* 속성과 <script src> 는 바꾸지 않습니다. 런타임이 아는 모듈 id 는 ${MODULE_IDS.join(', ')} 뿐입니다 — 다른 id(schedule, events 등)로 새 위젯을 만들거나 모듈 동작을 직접 구현하지 말고, 요청한 기능에 맞는 아래 모듈 코드를 넣습니다(예: 일정·캘린더·행사 일정 → calendar). 모양은 wm- 로 시작하는 클래스에 CSS 를 더해 사이트 디자인에 맞춥니다. 한 페이지에 같은 모듈을 두 번 넣지 않습니다. 모듈이 들어갈 페이지는 Brief 의 페이지·섹션 구성을 따릅니다(예: 문의 → contact 섹션, 게시판 → news/board 페이지).\n\n${parts.join('\n\n')}\n`;
}
module.exports = { CATALOG, MODULE_IDS, COLLECTIONS, load, save, remove, filesDir, isEnabled, publicSettings, normalizeSettings, updateSettings, setEnabled, overview, catalogPublic,
signup, login, memberFromToken, pubMember, createSiteAdmin, verifyPassword, getSiteAdmin, setMemberMail,
listPosts, getPost, createPost, updatePost, deletePost, addComment, deleteComment,
normalizeSurvey, submitResponse, surveyRows, slotsFor, reserve, submitReview, listReviews, listFaq, listGallery, geocode,
adminList, adminCreate, adminUpdate, adminDelete, widgetTemplate, promptSection,
listBlog, getBlog, addBlogComment, listLibrary, libraryDownload, addLibraryFiles, libraryDir,
listCalendar, calendarDay, calendarLunar }; // v1.4.18 일정관리
+453
View File
@@ -0,0 +1,453 @@
'use strict';
// ============================================================================
// 다국어 사이트 생성 (v1.3.10, v1.3.16 로케일 원칙·x-default·현지 형식·품질 게이트)
//
// 원칙 (v1.3.16 에서 문서로 고정 — README "다국어 사이트" 참고)
// 1) URL 은 로케일 코드로 구분한다: /<로케일 코드>/<페이지>. 로케일 코드는 BCP 47 언어 태그(ISO 639-1 언어 코드,
// 필요할 때만 지역 코드를 붙임: zh-CN, zh-TW, en-GB, pt-PT, es-MX)이며, 기본 언어도 예외 없이 폴더를 쓴다(/ko/).
// 공용 CSS·JS·이미지는 /assets/ 에 두고 ../assets/ 로 쓴다. 사이트 맨 위 주소(/)는 언어 선택 페이지다.
// 2) x-default: 어떤 언어에도 맞지 않는 방문자에게 보일 언어를 사이트마다 명시한다(site.langs.xDefault, 기본 = 기본 언어).
// hreflang="x-default", 언어 선택 페이지의 자동 이동, sitemap 대체 주소에 모두 이 값을 쓴다.
// 3) AI 는 기본 언어 폴더만 만든다. 다른 언어 페이지는 서비스가 기본 언어 HTML 을 그대로 복사하면서 "글자만" 번역해 만든다
// → 언어별 페이지 구조(섹션·순서·파일 이름·링크)가 기본 구조와 항상 같다.
// 4) 현지 형식: 날짜·시각(시간대)·숫자·통화·주소·전화번호·이름 순서·법적 고지를 data-wm-* 속성의 원래 값에서
// 언어마다 그 지역 형식으로 다시 쓴다(renderFormats). 아랍어·히브리어·페르시아어는 dir="rtl".
// 5) 번역은 문장 단위로 사이트별 저장소(data/site-i18n/<사이트ID>/<언어>.json)에 쌓고, 수정 후에는 바뀐 문장만 번역한다.
// 시스템 문구(위젯·오류·메일)는 lib/sitemsg.js 카탈로그(id)를 쓴다.
// 6) 언어별 SEO·AEO·GEO 는 lib/seo.js 가 언어 폴더마다 따로 만든다. 품질 게이트(quality)가 번역 누락률·원문 잔존율·
// 링크 대응·언어별 검수 상태를 검증한다.
// ============================================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const sitemsg = require('./sitemsg');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const STORE = path.join(DATA_ROOT, 'site-i18n');
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
/**
* 지원 로케일. 키(code)가 URL 폴더 이름.
* html: <html lang>·hreflang 값(BCP 47) locale: Intl 날짜·숫자 로케일 og: og:locale dir: 글 방향
* name: 그 언어로 쓴 이름(언어 선택용) label: 빌더 화면용 이름 country: 기본 국가(주소·전화 표기 기준)
* tz: 기본 시간대 currency: 기본 통화 nameOrder: 'fg'(성 이름) | 'gf'(이름 성) nameSep: 성·이름 사이 글자
* addr: 주소 순서 'big'(국가→시도→도로명, 동아시아) | 'small'(도로명→도시→지역 우편번호→국가)
*/
const LOCALES = {
ko: { name: '한국어', html: 'ko', locale: 'ko-KR', og: 'ko_KR', dir: 'ltr', label: '한국어', country: 'KR', tz: 'Asia/Seoul', currency: 'KRW', nameOrder: 'fg', nameSep: '', addr: 'big' },
en: { name: 'English', html: 'en', locale: 'en-US', og: 'en_US', dir: 'ltr', label: '영어', country: 'US', tz: 'America/New_York', currency: 'USD', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
'en-GB': { name: 'English (UK)', html: 'en-GB', locale: 'en-GB', og: 'en_GB', dir: 'ltr', label: '영어(영국)', country: 'GB', tz: 'Europe/London', currency: 'GBP', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
ja: { name: '日本語', html: 'ja', locale: 'ja-JP', og: 'ja_JP', dir: 'ltr', label: '일본어', country: 'JP', tz: 'Asia/Tokyo', currency: 'JPY', nameOrder: 'fg', nameSep: ' ', addr: 'big' },
'zh-CN': { name: '简体中文', html: 'zh-Hans', locale: 'zh-CN', og: 'zh_CN', dir: 'ltr', label: '중국어(간체)', country: 'CN', tz: 'Asia/Shanghai', currency: 'CNY', nameOrder: 'fg', nameSep: '', addr: 'big' },
'zh-TW': { name: '繁體中文', html: 'zh-Hant', locale: 'zh-TW', og: 'zh_TW', dir: 'ltr', label: '중국어(번체)', country: 'TW', tz: 'Asia/Taipei', currency: 'TWD', nameOrder: 'fg', nameSep: '', addr: 'big' },
vi: { name: 'Tiếng Việt', html: 'vi', locale: 'vi-VN', og: 'vi_VN', dir: 'ltr', label: '베트남어', country: 'VN', tz: 'Asia/Ho_Chi_Minh', currency: 'VND', nameOrder: 'fg', nameSep: ' ', addr: 'small' },
th: { name: 'ไทย', html: 'th', locale: 'th-TH', og: 'th_TH', dir: 'ltr', label: '태국어', country: 'TH', tz: 'Asia/Bangkok', currency: 'THB', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
id: { name: 'Bahasa Indonesia', html: 'id', locale: 'id-ID', og: 'id_ID', dir: 'ltr', label: '인도네시아어', country: 'ID', tz: 'Asia/Jakarta', currency: 'IDR', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
mn: { name: 'Монгол', html: 'mn', locale: 'mn-MN', og: 'mn_MN', dir: 'ltr', label: '몽골어', country: 'MN', tz: 'Asia/Ulaanbaatar', currency: 'MNT', nameOrder: 'fg', nameSep: ' ', addr: 'small' },
ru: { name: 'Русский', html: 'ru', locale: 'ru-RU', og: 'ru_RU', dir: 'ltr', label: '러시아어', country: 'RU', tz: 'Europe/Moscow', currency: 'RUB', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
fr: { name: 'Français', html: 'fr', locale: 'fr-FR', og: 'fr_FR', dir: 'ltr', label: '프랑스어', country: 'FR', tz: 'Europe/Paris', currency: 'EUR', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
de: { name: 'Deutsch', html: 'de', locale: 'de-DE', og: 'de_DE', dir: 'ltr', label: '독일어', country: 'DE', tz: 'Europe/Berlin', currency: 'EUR', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
es: { name: 'Español', html: 'es', locale: 'es-ES', og: 'es_ES', dir: 'ltr', label: '스페인어', country: 'ES', tz: 'Europe/Madrid', currency: 'EUR', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
'es-MX': { name: 'Español (México)', html: 'es-MX', locale: 'es-MX', og: 'es_MX', dir: 'ltr', label: '스페인어(멕시코)', country: 'MX', tz: 'America/Mexico_City', currency: 'MXN', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
pt: { name: 'Português (Brasil)', html: 'pt-BR', locale: 'pt-BR', og: 'pt_BR', dir: 'ltr', label: '포르투갈어(브라질)', country: 'BR', tz: 'America/Sao_Paulo', currency: 'BRL', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
'pt-PT': { name: 'Português (Portugal)', html: 'pt-PT', locale: 'pt-PT', og: 'pt_PT', dir: 'ltr', label: '포르투갈어(포르투갈)', country: 'PT', tz: 'Europe/Lisbon', currency: 'EUR', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
ar: { name: 'العربية', html: 'ar', locale: 'ar', og: 'ar_AR', dir: 'rtl', label: '아랍어', country: 'SA', tz: 'Asia/Riyadh', currency: 'SAR', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
he: { name: 'עברית', html: 'he', locale: 'he-IL', og: 'he_IL', dir: 'rtl', label: '히브리어', country: 'IL', tz: 'Asia/Jerusalem', currency: 'ILS', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
fa: { name: 'فارسی', html: 'fa', locale: 'fa-IR', og: 'fa_IR', dir: 'rtl', label: '페르시아어', country: 'IR', tz: 'Asia/Tehran', currency: 'IRR', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
tr: { name: 'Türkçe', html: 'tr', locale: 'tr-TR', og: 'tr_TR', dir: 'ltr', label: '터키어', country: 'TR', tz: 'Europe/Istanbul', currency: 'TRY', nameOrder: 'gf', nameSep: ' ', addr: 'small' },
};
/**
* 아무 언어 표기(ko, ko-KR, zh-Hans, pt-BR, en_us …) → 서비스 로케일 코드. 모르는 것은 'ko'.
* 위젯이 보내는 <html lang> 값이나 브라우저 언어를 폴더 코드로 맞출 때 쓴다.
*/
function codeOf(tag) {
const t = String(tag || '').trim().replace('_', '-'); if (!t) return 'ko';
if (LOCALES[t]) return t;
const low = t.toLowerCase(); for (const [c, L] of Object.entries(LOCALES)) if (c.toLowerCase() === low || L.html.toLowerCase() === low || L.locale.toLowerCase() === low) return c;
const base = low.split('-')[0]; if (base === 'zh') return /tw|hk|hant/.test(low) ? 'zh-TW' : 'zh-CN'; if (base === 'pt') return /pt-pt/.test(low) ? 'pt-PT' : 'pt';
return LOCALES[base] ? base : 'ko';
}
/**
* 생성·변경 옵션 값 정리: { default, list, xDefault } — 기본 언어가 목록 맨 앞, 2개 이상일 때만 다국어.
* xDefault: 목록 안의 로케일 코드 또는 'root'(언어 선택 페이지). 없거나 잘못되면 기본 언어.
*/
function normalize(input) {
let o = input; if (typeof o === 'string') { try { o = JSON.parse(o); } catch (_) { o = {}; } }
o = o || {}; const list = [...new Set((Array.isArray(o.list) ? o.list : []).filter((c) => LOCALES[c]))];
const def = LOCALES[o.default] ? o.default : (list[0] || 'ko'); if (!list.includes(def)) list.unshift(def); else { list.splice(list.indexOf(def), 1); list.unshift(def); }
const l2 = list.slice(0, 12); const xDefault = o.xDefault === 'root' || l2.includes(o.xDefault) ? o.xDefault : def;
return { default: def, list: l2, xDefault };
}
/** x-default 가 가리키는 언어 (root 이면 언어 선택 페이지 → 자동 이동 실패 시 기본 언어) */
const xDefaultOf = (site) => { const L = site.langs || {}; return L.xDefault && (L.xDefault === 'root' || (L.list || []).includes(L.xDefault)) ? L.xDefault : L.default; };
/**
* 사이트 현지 설정 (site.locale): 영업 시간대·표시 통화·사업장 국가. 기본은 기본 언어 로케일의 값.
* 가격·영업시간은 "사업장 기준" 값이라 언어가 바뀌어도 통화·시간대는 그대로 두고 표기 형식만 바꾼다.
*/
function siteLocale(site) { const def = (site.langs && site.langs.default) || codeOf(site.uiLang); const L = LOCALES[def] || LOCALES.ko; const s = site.locale || {}; return { timeZone: s.timeZone || L.tz, currency: s.currency || L.currency, country: s.country || L.country }; }
const isMulti = (site) => !!(site && site.langs && site.langs.list && site.langs.list.length > 1);
/** 생성·수정 프롬프트에 붙는 다국어 규칙 (AI 는 기본 언어 폴더만 만든다) */
function promptRules(site) {
const L = site.langs; const d = L.default; const others = L.list.slice(1).map((c) => `${LOCALES[c].label}(${c})`).join(', ');
return `[다국어 사이트 규칙 — 반드시 지킴]
- 이 사이트는 ${L.list.map((c) => `${LOCALES[c].label}(${c})`).join(', ')} 다국어 사이트입니다. 당신은 기본 언어 ${LOCALES[d].label}(${d}) 페이지만 만들고 고칩니다. ${others} 페이지는 서비스가 기본 언어 페이지를 그대로 번역해 자동으로 만들므로 직접 만들거나 고치지 마세요(만들어도 덮어써집니다).
- 기본 언어 페이지는 모두 "${d}/" 폴더 안에 둡니다 (예: ${d}/index.html, ${d}/about.html). 사이트 맨 위 index.html 은 만들지 않습니다(언어 선택 페이지는 서비스가 만듭니다).
- 공용 CSS·JS·이미지·글꼴은 "assets/" 폴더에 두고 페이지에서 "../assets/…" 로 참조합니다(예: assets/css/style.css → <link href="../assets/css/style.css">). 첨부 파일은 "../uploads/…" 로 참조합니다. 페이지끼리는 같은 폴더 상대 경로(about.html)로 연결합니다.
- 다른 규칙의 "메인 페이지 index.html", "css/style.css" 는 이 사이트에서는 각각 "${d}/index.html", "assets/css/style.css" 를 뜻합니다(이 다국어 규칙이 우선).
- 각 페이지 머리글(header)에 언어 선택 자리 <nav data-wm-langs aria-label="Language"></nav> 를 하나 둡니다(서비스가 채움). 기본은 드롭다운(select)입니다. 사용자가 언어 선택을 링크 나열 등 다른 방식으로 지시한 경우에만 <nav data-wm-langs="links" aria-label="Language"></nav> 로 둡니다. 드롭다운 모양은 .wm-lang-select 에 CSS 로 꾸밉니다.
- 언어·지역마다 표기가 다른 값은 글자로 박지 말고 원래 값을 속성에 둡니다(서비스가 언어별 현지 형식으로 바꿈, 안의 글자는 기본 언어 표기로):
날짜 <time data-wm-date="2026-03-01">…</time> · 날짜+시각 <time data-wm-datetime="2026-03-01T10:00:00+09:00">…</time>(사업장 시간대로 표시) · 숫자 <span data-wm-number="1500000">…</span>
가격 <span data-wm-price="15000" data-currency="KRW">…</span> · 전화 <a data-wm-tel="+82-2-1234-5678">…</a>
주소 <address data-wm-address='{"street":"테헤란로 1","locality":"강남구","region":"서울특별시","postal":"06000","country":"KR"}'>…</address> · 사람 이름 <span data-wm-name='{"family":"홍","given":"길동"}'>…</span>
법적 고지 <span data-wm-legal="copyright" data-name="상호">…</span>, <a href="privacy.html" data-wm-legal="privacy">…</a>, <a data-wm-legal="terms">…</a>, 쿠키 안내 <p data-wm-legal="cookie-notice">…</p>, 사업자 정보 <p data-wm-legal="business-info" data-name="상호" data-owner="대표" data-biz-no="000-00-00000">…</p>
- 화살표처럼 방향이 있는 아이콘에는 data-wm-mirror 를 붙입니다(오른쪽→왼쪽 언어에서 좌우 반전).
- CSS 는 오른쪽→왼쪽 언어(아랍어 등)에서도 그대로 맞도록 논리 속성(margin-inline-start, padding-inline, inset-inline, text-align: start/end)을 씁니다. 왼쪽/오른쪽을 고정하는 left/right 정렬은 꼭 필요할 때만.`;
}
// ---------- HTML 문장 뽑기·바꿔 넣기 ----------
/** lib/seo.js 의 관리 구역 (번역 원본에서는 걷어 낸다 — 언어별로 따로 넣기 때문) */
const MANAGED_RE = { seo: /<!-- wm-seo:start -->[\s\S]*?<!-- wm-seo:end -->\n/, faq: /<!-- wm-aeo-faq:start -->[\s\S]*?<!-- wm-aeo-faq:end -->\n/ };
const SKIP_TAGS = new Set(['script', 'style', 'code', 'pre', 'textarea', 'svg', 'noscript']);
// v1.5.36 (요구 2): 모듈 위젯이 화면에 그대로 쓰는 data-* 속성(data-title="문의하기", data-button="문의 보내기", data-label, data-note-label …)도 번역한다.
// 1.5.31 까지는 alt·title·placeholder·aria-label 만 번역해 다른 언어 페이지의 문의폼 제목·버튼이 한국어로 남았다(sample.ariweb.net/en/ 2026-10-15).
const ATTR_RE = /\s(alt|title|placeholder|aria-label|data-title|data-button|data-label|data-note-label|data-desc|data-empty|data-placeholder|data-submit)="([^"]*)"/g;
/** 번역할 만한 글자인지: 글자가 있고 숫자·기호·URL·메일만으로 된 것이 아님 */
const translatable = (t) => { const s = t.replace(/&[a-z#0-9]+;/gi, ' ').trim(); return s.length > 0 && /\p{L}/u.test(s) && !/^(https?:|mailto:|tel:|www\.)\S*$/i.test(s) && !/^[\w.+-]+@[\w.-]+$/.test(s); };
/**
* HTML 을 훑으며 번역 대상 조각마다 fn(원문) 을 불러 바꾼 HTML 을 돌려준다(fn 이 null 이면 그대로).
* 텍스트 노드, 번역 대상 속성, <title>, meta description / og:title / og:description 의 content.
*/
function mapText(html, fn) {
let out = ''; let i = 0; let skip = null; const seg = (t) => { if (!translatable(t)) return t; const core = t.trim(); const r = fn(core); return r == null ? t : t.replace(core, r); };
while (i < html.length) {
const lt = html.indexOf('<', i); if (lt < 0) { out += skip ? html.slice(i) : seg(html.slice(i)); break; }
out += skip ? html.slice(i, lt) : seg(html.slice(i, lt));
if (html.startsWith('<!--', lt)) { const e = html.indexOf('-->', lt); const end = e < 0 ? html.length : e + 3; out += html.slice(lt, end); i = end; continue; }
const gt = html.indexOf('>', lt); if (gt < 0) { out += html.slice(lt); break; }
let tag = html.slice(lt, gt + 1); const m = tag.match(/^<\/?([a-zA-Z][\w-]*)/); const name = m ? m[1].toLowerCase() : '';
if (tag.startsWith('</')) { if (skip === name) skip = null; out += tag; i = gt + 1; continue; }
if (!skip) {
tag = tag.replace(ATTR_RE, (all, a, v) => ` ${a}="${translatable(v) ? (fn(v.trim()) || v).replace(/"/g, '&quot;') : v}"`);
if (name === 'meta' && /\s(?:name|property)="(?:description|og:title|og:description|twitter:title|twitter:description)"/i.test(tag)) tag = tag.replace(/\scontent="([^"]*)"/, (all, v) => ` content="${translatable(v) ? (fn(v.trim()) || v).replace(/"/g, '&quot;') : v}"`);
if (name === 'input' && /\stype="(?:submit|button|reset)"/i.test(tag)) tag = tag.replace(/\svalue="([^"]*)"/, (all, v) => ` value="${translatable(v) ? (fn(v.trim()) || v).replace(/"/g, '&quot;') : v}"`);
}
if (SKIP_TAGS.has(name) && !tag.endsWith('/>')) skip = name;
// v1.3.16: 현지 형식 요소(data-wm-date·price·address …) 안의 글자는 서비스가 언어마다 다시 쓰므로 번역하지 않는다
if (/\sdata-wm-(?:date|datetime|number|price|tel|address|name|legal)\b/i.test(tag) && !tag.endsWith('/>')) { const close = html.indexOf('<', gt + 1); if (close > gt) { out += tag + html.slice(gt + 1, close); i = close; continue; } }
out += tag; i = gt + 1;
}
return out;
}
/** 페이지에서 번역할 문장 목록 (중복 제거) */
function segments(html) { const set = new Set(); mapText(html, (t) => { set.add(t); return null; }); return [...set]; }
// ---------- 모듈 위젯 실행 중 문구 ----------
// v1.3.16: 위젯·오류·메일 문구는 lib/sitemsg.js 카탈로그(id)로 옮겼다. 다른 언어 페이지에는 그 언어 표(id → 글자)를
// <script type="application/json" data-wm-i18n> 로 넣고 public/wm-modules.js 의 L(id) 가 찾아 쓴다.
const usesWidgets = (html) => /data-wm-module|data-wm-contact/.test(html);
const DICT_RE = /<script type="application\/json" data-wm-i18n>[\s\S]*?<\/script>\n?/g;
// ---------- 사이트별 번역 저장소 ----------
const fileOf = (siteId, lang) => path.join(STORE, siteId, `${lang}.json`);
function loadMemo(siteId, lang) { try { return JSON.parse(fs.readFileSync(fileOf(siteId, lang), 'utf8')); } catch (_) { return {}; } }
function saveMemo(siteId, lang, memo) { fs.mkdirSync(path.join(STORE, siteId), { recursive: true }); fs.writeFileSync(fileOf(siteId, lang), JSON.stringify(memo)); }
function removeSite(siteId) { try { fs.rmSync(path.join(STORE, siteId), { recursive: true, force: true }); } catch (_) {} }
/** 번역 프롬프트: 문장 배열 → 같은 길이·같은 순서의 번역 배열 (JSON) */
function translatePrompt(texts, from, to, context) {
return `웹사이트 문구를 ${LOCALES[from].label}(${from})에서 ${LOCALES[to].label}(${to})로 번역합니다. 아래 JSON 배열의 각 문장을 번역해, 같은 개수·같은 순서의 배열을 {"t": [...]} 로만 답하세요.
규칙: 웹사이트 문구답게 자연스럽게, 고유명사(상호·인명·주소의 고유 부분)는 원래대로 또는 그 언어에서 통용되는 표기로, 전화번호·이메일·URL·숫자는 그대로, 원문에 없는 내용은 덧붙이지 않습니다. HTML 태그는 없습니다.
${context ? `[사이트] ${context}\n` : ''}${JSON.stringify(texts)}`;
}
// ---------- 언어 페이지 만들기 ----------
/** 언어 폴더 안 HTML 목록 (base 폴더 기준 상대 경로) */
function htmlUnder(dir) { const out = []; const walk = (d, rel) => { let ents = []; try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch (_) { return; } for (const e of ents) { if (e.name.startsWith('.')) continue; const r = rel ? `${rel}/${e.name}` : e.name; if (e.isDirectory()) walk(path.join(d, e.name), r); else if (/\.html?$/i.test(e.name)) out.push(r); } }; walk(dir, ''); return out; }
/** 언어 선택 링크 HTML (현재 언어 표시) */
/**
* 언어 선택 HTML. v1.4.0: 따로 정하지 않으면 드롭다운(<select>)이고, 스크립트가 꺼진 브라우저를 위해 <noscript> 링크를 함께 둔다.
* 사용자가 다른 방식(링크 나열)을 지시해 AI 가 <nav data-wm-langs="links"> 로 만든 경우에만 링크로 그린다.
* @param {string} style 'select'(기본) | 'links'
*/
function switcherHtml(langs, cur, file, style) {
if (style === 'links') return switcher(langs, cur, file);
const up = '../'.repeat(String(file).split('/').length);
const opts = langs.map((c) => `<option value="${up}${c}/${file}" lang="${LOCALES[c].html}"${c === cur ? ' selected' : ''}>${LOCALES[c].name}</option>`).join('');
return `<select class="wm-lang-select" aria-label="Language" onchange="if(this.value)location.href=this.value">${opts}</select><noscript>${switcher(langs, cur, file)}</noscript>`;
}
function switcher(langs, cur, file) { const up = '../'.repeat(String(file).split('/').length); /* 언어 폴더 밖으로: sub/about.html → ../../ */ return langs.map((c) => `<a href="${up}${c}/${file}" hreflang="${LOCALES[c].html}" lang="${LOCALES[c].html}"${c === cur ? ' aria-current="true"' : ''}>${LOCALES[c].name}</a>`).join(' '); }
/**
* v1.4.0 복구: 1.3.16 에서 만든 번역 페이지의 언어 선택 링크 중 "원문 언어" 링크가 그 페이지 언어 폴더를 가리키던 문제를 고친다.
* 번역을 다시 하지 않고(비용 없음) 각 언어 폴더 페이지의 <nav data-wm-langs> 안만 다시 그린다. 이미 맞으면 파일을 건드리지 않는다.
* @returns {number} 고친 파일 수
*/
function repairSwitchers(site, siteDir) {
if (!isMulti(site)) return 0; const { default: def, list } = site.langs; let fixed = 0;
for (const c of list) {
if (c === def) continue; const dir = path.join(siteDir, c);
for (const f of htmlUnder(dir)) {
const fp = path.join(dir, f); let html; try { html = fs.readFileSync(fp, 'utf8'); } catch (_) { continue; }
const out = html.replace(/(<nav\b[^>]*data-wm-langs(?:=["']([\w-]*)["'])?[^>]*>)[\s\S]*?(<\/nav>)/gi, (all, a, style, b) => `${a}${switcherHtml(list, c, f, style)}${b}`);
if (out !== html) { fs.writeFileSync(fp, out); fixed += 1; }
}
}
return fixed;
}
// ---------- 현지 형식 (v1.3.16) ----------
const escH = (v) => String(v == null ? '' : v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const unescH = (v) => String(v).replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
/** 속성 값 읽기 (큰따옴표·작은따옴표 모두) */
const attrOf = (tag, name) => { const m = tag.match(new RegExp(`\\s${name}=(?:"([^"]*)"|'([^']*)')`, 'i')); return m ? unescH(m[1] !== undefined ? m[1] : m[2]) : null; };
/** JSON 속성 값 읽기 (data-wm-address='{"street":…}') */
const jsonAttr = (tag, name) => { const v = attrOf(tag, name); if (!v) return null; try { return JSON.parse(v); } catch (_) { return null; } };
/** 나라별 국가 번호 (전화번호 표기용, 자주 쓰는 것만) */
const CALLING = { KR: '82', US: '1', GB: '44', JP: '81', CN: '86', TW: '886', VN: '84', TH: '66', ID: '62', MN: '976', RU: '7', FR: '33', DE: '49', ES: '34', MX: '52', BR: '55', PT: '351', SA: '966', IL: '972', IR: '98', TR: '90' };
/**
* 전화번호: 원래 값(+82-2-1234-5678 또는 사업장 국가 기준 02-1234-5678)을
* - 페이지 지역이 사업장 국가와 같으면 국내 표기(02-1234-5678)
* - 다르면 국제 표기(+82 2-1234-5678)
* 로 보이고, 링크(tel:)는 항상 국제 번호로 쓴다.
*/
function formatTel(raw, pageCountry, homeCountry) {
const v = String(raw).trim(); const cc = CALLING[homeCountry] || '';
let intl; let nat;
if (v.startsWith('+')) { const m = v.match(/^\+(\d{1,3})[\s-]?(.*)$/); const code = m ? m[1] : cc; const rest = m ? m[2] : v.slice(1); intl = `+${code} ${rest}`; nat = (code === '1' ? '' : '0') + rest; if (code !== (CALLING[pageCountry] || '')) nat = null; }
else { const rest = v.replace(/^0/, ''); intl = cc ? `+${cc} ${rest}` : v; nat = v; if (pageCountry !== homeCountry) nat = null; }
return { text: nat || intl, href: 'tel:' + intl.replace(/[^\d+]/g, '') };
}
/** 국가 이름 (그 언어로, Intl.DisplayNames) */
function countryName(code, locale) { try { return new Intl.DisplayNames([locale], { type: 'region' }).of(code); } catch (_) { return code; } }
/**
* 주소: 부분 값 {street, locality, region, postal, country} 를 그 언어의 순서로 잇는다.
* big(동아시아): 국가 우편번호 시·도 시·군·구 도로명 / small(그 외): 도로명, 도시, 지역 우편번호, 국가
* 사업장 국가와 페이지 지역이 같으면 국가는 생략한다.
*/
function formatAddress(a, L, homeCountry) {
const c = a.country || homeCountry; const country = c && c !== L.country ? countryName(c, L.locale) : '';
if (L.addr === 'big') return [country, a.postal && c === 'JP' ? `〒${a.postal}` : '', a.region, a.locality, a.street, a.postal && c !== 'JP' ? `(${a.postal})` : ''].filter(Boolean).join(L.country === 'KR' ? ' ' : ''); // 한국어는 띄어 쓰고, 일본어·중국어는 붙여 쓴다
return [a.street, a.locality, [a.region, a.postal].filter(Boolean).join(' '), country].filter(Boolean).join(', ');
}
/** 이름: {family, given} → 성·이름 순서(fg) 또는 이름·성(gf) */
const formatName = (n, L) => (L.nameOrder === 'fg' ? [n.family, n.given] : [n.given, n.family]).filter(Boolean).join(L.nameSep);
/** 형식을 바꿀 수 있는 속성들: 요소 안이 글자만일 때 그 글자를 바꾼다 (<span data-wm-price="15000">15000</span>) */
const FORMAT_RE = /(<([a-z][\w-]*)\b([^>]*\sdata-wm-(date|datetime|number|price|tel|address|name|legal)\b[^>]*)>)([^<]*)(<\/\2>)/gi;
/**
* 페이지의 data-wm-* 값을 언어 to 의 형식으로 다시 쓴다 (기본 언어 페이지에도 적용 — 원래 값은 속성에 남아 있어 몇 번을 다시 써도 같다).
* @param {string} html
* @param {string} to 로케일 코드
* @param {object} ctx { dict: 번역 메모(주소·이름 부분 값 번역), site, home: siteLocale(site) }
*/
function renderFormats(html, to, ctx) {
const L = LOCALES[to]; const home = ctx.home || { timeZone: L.tz, currency: L.currency, country: L.country }; const dict = ctx.dict || {}; const tt = (v) => (v && dict[v]) || v;
const msg = (id, vars) => sitemsg.tr(to, id, vars);
let out = html.replace(FORMAT_RE, (all, open, tag, attrs, kind, inner, close) => {
const v = attrOf(open, `data-wm-${kind}`); let text = null; let o = open;
try {
if (kind === 'date') { const d = new Date(v); if (!Number.isNaN(d.getTime())) text = new Intl.DateTimeFormat(L.locale, { dateStyle: 'long', timeZone: /T/.test(v) ? home.timeZone : 'UTC' }).format(d); }
else if (kind === 'datetime') { const d = new Date(v); if (!Number.isNaN(d.getTime())) text = new Intl.DateTimeFormat(L.locale, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: attrOf(open, 'data-tz') || home.timeZone, timeZoneName: 'short' }).format(d); } // dateStyle 과 timeZoneName 은 함께 쓸 수 없어 항목별로 지정
else if (kind === 'number') { const n = Number(v); if (Number.isFinite(n)) text = new Intl.NumberFormat(L.locale).format(n); }
else if (kind === 'price') { const n = Number(v); if (Number.isFinite(n)) text = new Intl.NumberFormat(L.locale, { style: 'currency', currency: attrOf(open, 'data-currency') || home.currency, currencyDisplay: 'symbol' }).format(n); }
else if (kind === 'tel') { const t = formatTel(v, L.country, home.country); text = t.text; if (tag.toLowerCase() === 'a') o = /\shref=/.test(o) ? o.replace(/\shref=("[^"]*"|'[^']*')/, ` href="${t.href}"`) : o.replace(/>$/, ` href="${t.href}">`); o = /\sdir=/.test(o) ? o : o.replace(/>$/, ' dir="ltr">'); }
else if (kind === 'address') { const a = jsonAttr(open, 'data-wm-address'); if (a) text = formatAddress({ street: tt(a.street), locality: tt(a.locality), region: tt(a.region), postal: a.postal, country: a.country }, L, home.country); }
else if (kind === 'name') { const n = jsonAttr(open, 'data-wm-name'); if (n) text = formatName({ family: tt(n.family), given: tt(n.given) }, L); }
else if (kind === 'legal') {
const nm = attrOf(open, 'data-name') || (ctx.site && ctx.site.title) || '';
const ids = { copyright: 'legal.copyright', privacy: 'legal.privacy', terms: 'legal.terms', 'cookie-notice': 'legal.cookieNotice', 'cookie-accept': 'legal.cookieAccept', 'cookie-decline': 'legal.cookieDecline', consent: 'consent.privacy', 'business-info': 'legal.businessInfo' };
if (ids[v]) text = msg(ids[v], { year: new Date().getFullYear(), name: tt(nm), owner: tt(attrOf(open, 'data-owner') || ''), bizNo: attrOf(open, 'data-biz-no') || '' });
}
} catch (_) { text = null; }
return text === null ? all : `${o}${escH(text)}${close}`;
});
// 오른쪽→왼쪽 언어: 숫자·전화·주소가 뒤집혀 보이지 않게 하고, 방향이 있는 아이콘(data-wm-mirror)은 좌우 반전
out = out.replace(/<style data-wm-rtl>[\s\S]*?<\/style>\n?/g, '');
if (L.dir === 'rtl') out = out.replace(/<\/head>/i, '<style data-wm-rtl>[data-wm-tel],[data-wm-price],[data-wm-number]{unicode-bidi:isolate}html[dir=rtl] [data-wm-mirror]{transform:scaleX(-1)}</style>\n</head>');
return out;
}
/** 주소·이름 JSON 속성의 번역할 부분 값 (번역 메모에 함께 넣는다) */
function formatSegments(html) {
const out = []; const re = /<[a-z][\w-]*\b[^>]*\sdata-wm-(address|name)=(?:"[^"]*"|'[^']*')[^>]*>/gi; let m;
while ((m = re.exec(html))) { const o = jsonAttr(m[0], `data-wm-${m[1]}`); if (o) for (const k of ['street', 'locality', 'region', 'family', 'given']) if (o[k] && /\p{L}/u.test(o[k])) out.push(String(o[k])); }
const nm = /\sdata-wm-legal=["'](?:copyright|business-info)["'][^>]*\sdata-(?:name|owner)=["']([^"']+)["']/gi; while ((m = nm.exec(html))) out.push(unescH(m[1]));
return out;
}
/**
* 기본 언어 HTML → 대상 언어 HTML. 글자만 번역해 넣고 구조는 그대로 둔다.
* - <html lang dir data-wm-lang>, 언어 선택 nav 채우기, 같은 사이트 안 다른 언어 폴더 링크(/ko/ 등) 바꾸기
* - 현지 형식(renderFormats), 위젯 번역표(data-wm-i18n: 문구 id → 그 언어 글자, 'v:' 로 시작하는 키는 모듈 설정 값 번역)
* @param {object} [ctx] { site, home(siteLocale), values: 번역할 모듈 설정 값 목록 }
*/
function buildPage(html, from, to, dict, langs, file, ctx = {}) {
const L = LOCALES[to];
let out = html.replace(DICT_RE, '');
if (to !== from) out = mapText(out, (t) => dict[t] || null);
if (usesWidgets(out) && to !== 'ko') {
// 위젯 실행 중 문구: 문구 id → 이 언어 글자 표 + 모듈 설정 값(문의 유형 등) 번역. 예전 문의 위젯 스크립트의 한국어 고정 문구도 바꾼다
const table = sitemsg.clientTable(to); for (const v of ctx.values || []) if (dict[v]) table['v:' + v] = dict[v];
const src = sitemsg.source(); for (const id of LEGACY_WIDGET_IDS) { const ko = src[id] && src[id].text; const tr = sitemsg.tr(to, id); if (ko && tr !== ko) out = out.split(`'${ko}'`).join(`'${tr.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, ' ')}'`); }
const tag = `<script type="application/json" data-wm-i18n>${JSON.stringify(table).replace(/</g, '\\u003c')}</script>\n`; out = /<\/body>/i.test(out) ? out.replace(/<\/body>/i, `${tag}</body>`) : out + tag;
}
out = out.replace(/<html\b([^>]*)>/i, (all, attrs) => `<html${attrs.replace(/\s(lang|dir|data-wm-lang)="[^"]*"/g, '')} lang="${L.html}" dir="${L.dir}" data-wm-lang="${to}">`);
// v1.4.0 수정: 원문 언어 폴더를 가리키는 내부 링크(../ko/…)를 이 언어 폴더로 바꾸는 일은 언어 선택 링크를 넣기 "전에" 한다.
// (1.3.16 에서는 선택 링크를 넣은 뒤에 바꿔, 영어 페이지의 "한국어" 링크까지 ../en/ 으로 바뀌어 원문 언어로 돌아갈 수 없었다)
if (to !== from) out = out.replace(new RegExp(`(\\s(?:href|src)=")(\\/|\\.\\.\\/)(${from.replace(/[-]/g, '\\-')})\\/`, 'g'), `$1$2${to}/`);
out = out.replace(/(<nav\b[^>]*data-wm-langs(?:=["']([\w-]*)["'])?[^>]*>)[\s\S]*?(<\/nav>)/gi, (all, a, style, b) => `${a}${switcherHtml(langs, to, file, style)}${b}`);
if (!/data-wm-langs/.test(out)) out = out.replace(/<body\b[^>]*>/i, (b) => `${b}\n<nav data-wm-langs aria-label="Language" style="text-align:end;padding:6px 12px;font-size:14px">${switcherHtml(langs, to, file, 'select')}</nav>`);
return renderFormats(out, to, { ...ctx, dict });
}
/** 예전(1.3.10 이전) 문의 위젯 스크립트에 한국어로 박혀 있던 문구의 id — 다른 언어 페이지에서 글자를 바로 바꾼다 */
const LEGACY_WIDGET_IDS = ['common.requiredCheck', 'common.sending', 'common.receivedContact', 'common.sendFailed', 'common.networkFailed'];
/**
* 사이트 맨 위 언어 선택 페이지. 브라우저 언어와 맞는 언어 폴더로 이동하고, 맞는 언어가 없으면 x-default 언어로 간다.
* x-default 가 'root' 이면 브라우저 언어와 맞더라도 이동하지 않고 이 선택 페이지를 그대로 보인다(v1.3.17 B30 — 예전에는 맞는 언어면 이동했다).
* 스크립트가 없어도 링크로 고를 수 있다.
*/
const ROOT_MARK = 'data-wm-lang-root';
function rootPage(site, langs, def, xDefault) {
const title = (site.title || '').replace(/</g, '&lt;'); const fallback = xDefault && xDefault !== 'root' ? xDefault : null;
return `<!doctype html>
<!-- AI Webmaker 다국어 사이트 언어 선택 페이지 (자동 생성 — 언어 목록·x-default 가 바뀌면 다시 만들어집니다) -->
<html lang="${LOCALES[fallback || def].html}" ${ROOT_MARK}><head><!-- hreflang 대체 링크·검색사이트 확인 태그는 배포 때 운영 주소 기준으로 들어갑니다 (lib/seo.js) --><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title>
${fallback ? '' : '<!-- x-default = 언어 선택 페이지: 자동 이동하지 않는다 (v1.3.17 B30) -->'}${!fallback ? '' : `<script>(function(){var L=${JSON.stringify(langs.map((c) => [c, LOCALES[c].html.toLowerCase()]))};var w=(navigator.languages||[navigator.language||'']).map(function(x){return String(x).toLowerCase()});for(var i=0;i<w.length;i++){for(var j=0;j<L.length;j++){if(w[i]===L[j][1]||w[i]===L[j][0].toLowerCase()){location.replace(L[j][0]+'/');return;}}}for(i=0;i<w.length;i++){for(j=0;j<L.length;j++){if(w[i].split('-')[0]===L[j][1].split('-')[0]){location.replace(L[j][0]+'/');return;}}}location.replace(${JSON.stringify(fallback + '/')});})();</script>`}
${fallback ? `<noscript><meta http-equiv="refresh" content="0; url=${fallback}/"></noscript>` : ''}</head>
<body style="font-family:system-ui,sans-serif;text-align:center;padding:40px"><h1>${title}</h1><p>${langs.map((c) => `<a href="${c}/" hreflang="${LOCALES[c].html}" lang="${LOCALES[c].html}">${LOCALES[c].name}</a>`).join(' · ')}</p></body></html>
`;
}
/**
* 다국어 맞추기: 기본 언어 폴더의 모든 HTML 을 다른 언어 폴더로 번역·복사한다.
* @param {object} site
* @param {string} siteDir
* @param {(texts: string[], to: string, from: string) => Promise<string[]>} translateFn AI 번역(서버 syncLanguages 가 구현, 비용 청구)
* @param {(msg: string) => void} log
* @param {object} [opts] { values: 번역할 모듈 설정 값(문의 유형·후기 분류 등 방문자에게 보이는 값) }
* @returns {Promise<{languages: object, translated: number, runtime: number, pages: number}>}
*/
async function sync(site, siteDir, translateFn, log = () => {}, opts = {}) {
if (!isMulti(site)) return null;
const { default: def, list } = site.langs; const baseDir = path.join(siteDir, def); const xd = xDefaultOf(site);
if (!fs.existsSync(baseDir)) throw httpError(`기본 언어 폴더(${def}/)가 없습니다. 다국어 사이트는 페이지를 ${def}/ 폴더에 만들어야 합니다`, 409);
const files = htmlUnder(baseDir); if (!files.length) throw httpError(`기본 언어 폴더(${def}/)에 HTML 이 없습니다`, 409);
// 번역 원본은 기본 언어 페이지에서 검색·AI 최적화 관리 구역(언어별로 따로 넣음)을 걷어 낸 것
const raw = Object.fromEntries(files.map((f) => [f, fs.readFileSync(path.join(baseDir, f), 'utf8')]));
const sources = Object.fromEntries(files.map((f) => [f, raw[f].replace(MANAGED_RE.seo, '').replace(MANAGED_RE.faq, '').replace(/(<nav\b[^>]*data-wm-langs[^>]*>)[\s\S]*?(<\/nav>)/gi, '$1$2')])); // 언어 선택 링크(언어 이름)는 번역하지 않고 다시 채운다
const values = [...new Set((opts.values || []).filter((v) => typeof v === 'string' && /\p{L}/u.test(v)))];
const all = [...new Set([...files.flatMap((f) => [...segments(sources[f]), ...formatSegments(sources[f])]), ...values])];
const ctx = { site, home: siteLocale(site), values };
const result = { languages: {}, translated: 0, runtime: 0, pages: files.length };
/** memo 에 없는 문장만 120개씩 번역 */
const fill = async (memo, to, texts, from) => {
const missing = texts.filter((t) => !memo[t]);
for (let i = 0; i < missing.length; i += 120) {
const batch = missing.slice(i, i + 120); log(`${LOCALES[to].label} 번역 중 (${Math.min(i + 120, missing.length)}/${missing.length})`);
const tr = await translateFn(batch, to, from);
batch.forEach((t, k) => { if (typeof tr[k] === 'string' && tr[k].trim()) memo[t] = tr[k].trim(); });
saveMemo(site.id, to, memo); result.translated += batch.length;
}
};
site.langs.review = site.langs.review || {};
for (const to of list) {
const memo = loadMemo(site.id, to);
if (to !== def) await fill(memo, to, all, def);
// 시스템 문구(위젯·오류·동의문·법적 고지) — 서비스 공용 번역 캐시에 없으면 채운다(한 언어당 한 번)
if (to !== 'ko' && sitemsg.missing(to).length) { log(`${LOCALES[to].label} 시스템 문구 번역 중`); result.runtime += await sitemsg.ensure(to, translateFn); }
const hash = crypto.createHash('sha1');
if (to === def) {
// 기본 언어 페이지는 글자를 바꾸지 않고 lang·dir·언어 선택 링크·현지 형식(+ 한국어가 아니면 위젯 번역표)만 맞춘다
for (const f of files) { const out = buildPage(raw[f], def, def, memo, list, f, ctx); hash.update(out); if (out !== raw[f]) fs.writeFileSync(path.join(baseDir, f), out, { mode: 0o644 }); }
} else {
const dir = path.join(siteDir, to); fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
for (const f of files) { const fp = path.join(dir, f); fs.mkdirSync(path.dirname(fp), { recursive: true, mode: 0o755 }); const out = buildPage(sources[f], def, to, memo, list, f, ctx); hash.update(out); fs.writeFileSync(fp, out, { mode: 0o644 }); }
// 기본 언어에서 지워진 페이지는 다른 언어에서도 지운다
for (const f of htmlUnder(dir)) if (!files.includes(f)) { try { fs.unlinkSync(path.join(dir, f)); } catch (_) {} }
result.languages[to] = { pages: files.length, memo: Object.keys(memo).length };
}
// 검수 상태: 이 언어 페이지 내용이 바뀌면 "검수 대기" 로 되돌린다 (품질 게이트의 조건)
const h = hash.digest('hex').slice(0, 16); const rv = site.langs.review[to] || {};
if (rv.hash !== h) site.langs.review[to] = { state: to === def ? 'approved' : 'pending', hash: h, at: new Date().toISOString(), by: to === def ? 'source' : null, note: to === def ? '기본 언어(원문)' : '' };
}
// 언어 선택 페이지 (사용자가 직접 만든 맨 위 index.html 은 건드리지 않음)
const rootFp = path.join(siteDir, 'index.html'); let cur = null; try { cur = fs.readFileSync(rootFp, 'utf8'); } catch (_) {}
if (cur === null || cur.includes(ROOT_MARK) || site.langs.forceRoot) { fs.writeFileSync(rootFp, rootPage(site, list, def, xd), { mode: 0o644 }); delete site.langs.forceRoot; } else log('사이트 맨 위 index.html 이 직접 만든 파일이라 언어 선택 페이지로 바꾸지 않았습니다');
return result;
}
// ---------- 번역 품질 게이트 (v1.3.16) ----------
/** 합격 기준 — 관리 탭 다국어 카드와 검증 리포트에 함께 보인다 */
const QUALITY = { maxMissing: 0.01, maxResidue: 0.02 };
/** 사이트 안 링크 (외부·메일·전화·앵커·스크립트 제외), 언어 폴더 이름은 {L} 로 바꿔 언어끼리 비교 */
function internalLinks(html, lang) {
const out = []; const re = /\shref=["']([^"'#]+)["']/gi; let m;
while ((m = re.exec(html))) { const h = m[1].trim(); if (/^(?:[a-z][\w+.-]*:|\/\/)/i.test(h)) continue; out.push(h.replace(new RegExp(`(^|/)${lang.replace('-', '\\-')}/`), '$1{L}/')); }
return out.sort();
}
/**
* 번역 품질 검사 (언어마다):
* - missing : 번역 누락률 = 번역 메모에 없는 원문 문장 / 전체 원문 문장 (+ 시스템 문구 누락 개수 runtimeMissing)
* - residue : 원문 잔존율 = 번역 페이지에 원문 그대로 남은 문장 / 번역 페이지 문장 (AI 가 일부러 그대로 둔 고유명사는 제외)
* - links : 링크 대응 = 페이지마다 사이트 안 링크가 기본 언어 페이지와 같은 짝(언어 폴더만 다름)인지, 페이지가 빠짐없이 있는지
* - review : 언어별 검수 상태(pending|approved|rejected) — 내용이 바뀌면 pending 으로 돌아간다
* 모두 통과해야 그 언어가 합격(pass). 기본 언어는 원문이라 검수만 확인한다.
*/
function quality(site, siteDir) {
if (!isMulti(site)) return null;
const { default: def, list } = site.langs; const files = htmlUnder(path.join(siteDir, def)); const rv = site.langs.review || {};
const base = Object.fromEntries(files.map((f) => { let h = ''; try { h = fs.readFileSync(path.join(siteDir, def, f), 'utf8').replace(MANAGED_RE.seo, '').replace(MANAGED_RE.faq, ''); } catch (_) {} return [f, h]; }));
const baseSegs = new Set(files.flatMap((f) => segments(base[f].replace(/(<nav\b[^>]*data-wm-langs[^>]*>)[\s\S]*?(<\/nav>)/gi, '$1$2'))));
const languages = {};
for (const to of list) {
const r = { review: (rv[to] && rv[to].state) || 'pending', reviewNote: (rv[to] && rv[to].note) || '', reviewBy: (rv[to] && rv[to].by) || null, reviewAt: (rv[to] && rv[to].at) || null };
if (to === def) { r.source = true; r.pass = true; languages[to] = r; continue; }
const memo = loadMemo(site.id, to); const total = baseSegs.size; const miss = [...baseSegs].filter((t) => !memo[t]);
r.missing = { count: miss.length, total, rate: total ? miss.length / total : 0, samples: miss.slice(0, 5) };
r.runtimeMissing = sitemsg.missing(to).length;
let segTotal = 0; const resid = []; const linkIssues = []; const missingPages = [];
for (const f of files) {
let h; try { h = fs.readFileSync(path.join(siteDir, to, f), 'utf8'); } catch (_) { missingPages.push(f); continue; }
const segs = segments(h.replace(MANAGED_RE.seo, '').replace(MANAGED_RE.faq, '').replace(/(<nav\b[^>]*data-wm-langs[^>]*>)[\s\S]*?(<\/nav>)/gi, '$1$2')); segTotal += segs.length;
for (const t of segs) if (baseSegs.has(t) && memo[t] !== t && t.replace(/[^\p{L}]/gu, '').length >= 2) resid.push(`${f}: ${t.slice(0, 60)}`);
const noNav = (x) => x.replace(/(<nav\b[^>]*data-wm-langs[^>]*>)[\s\S]*?(<\/nav>)/gi, '$1$2'); // 언어 선택 링크는 언어마다 다른 게 정상
const a = internalLinks(noNav(base[f]), def); const b = internalLinks(noNav(h), to);
if (a.join('\n') !== b.join('\n')) linkIssues.push(`${f}: ${a.filter((x) => !b.includes(x)).concat(b.filter((x) => !a.includes(x))).slice(0, 3).join(', ') || '링크 수가 다름'}`);
}
r.residue = { count: resid.length, total: segTotal, rate: segTotal ? resid.length / segTotal : 0, samples: resid.slice(0, 5) };
r.links = { ok: !linkIssues.length && !missingPages.length, issues: linkIssues.slice(0, 5), missingPages };
r.checks = { missing: r.missing.rate <= QUALITY.maxMissing && r.runtimeMissing === 0, residue: r.residue.rate <= QUALITY.maxResidue, links: r.links.ok, review: r.review === 'approved' };
r.pass = Object.values(r.checks).every(Boolean);
languages[to] = r;
}
return { thresholds: QUALITY, languages, pass: Object.values(languages).every((x) => x.pass), at: new Date().toISOString() };
}
/** 언어별 검수 결정 (approved | rejected | pending) */
function setReview(site, lang, state, by, note) {
if (!isMulti(site) || !site.langs.list.includes(lang)) throw httpError('사이트의 언어가 아닙니다');
if (!['approved', 'rejected', 'pending'].includes(state)) throw httpError('검수 상태는 approved·rejected·pending 중 하나입니다');
site.langs.review = site.langs.review || {}; const cur = site.langs.review[lang] || {};
site.langs.review[lang] = { ...cur, state, by: by || null, at: new Date().toISOString(), note: String(note || '').slice(0, 300) };
return site.langs.review[lang];
}
/**
* 단일 언어 → 다국어 전환 뒤 정리: AI 는 파일을 "복사" 만 할 수 있어(삭제 도구 없음) 맨 위에 옛 파일이 남는다.
* - 기본 언어 폴더에 같은 경로가 있는 맨 위 HTML(맨 위 index.html 제외 — 언어 선택 페이지) 은 지운다.
* - assets/ 에 옮긴 사본이 있고 기본 언어 페이지가 더는 참조하지 않는 맨 위 공용 파일은 지운다.
* @returns {string[]} 지운 파일
*/
function cleanupAfterConvert(site, siteDir) {
const def = site.langs.default; const skip = new Set([...site.langs.list, 'assets', 'uploads', 'webmail']); const removed = [];
const baseHtml = htmlUnder(path.join(siteDir, def)).map((f) => { try { return fs.readFileSync(path.join(siteDir, def, f), 'utf8'); } catch (_) { return ''; } }).join('\n');
const walk = (dir, rel) => { let ents = []; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; } for (const e of ents) { if (e.name.startsWith('.')) continue; const r = rel ? `${rel}/${e.name}` : e.name; if (!rel && skip.has(e.name)) continue; const fp = path.join(dir, e.name);
if (e.isDirectory()) { walk(fp, r); try { if (!fs.readdirSync(fp).length) fs.rmdirSync(fp); } catch (_) {} continue; }
if (r === 'index.html') continue;
if (/\.html?$/i.test(e.name)) { if (fs.existsSync(path.join(siteDir, def, r))) { fs.unlinkSync(fp); removed.push(r); } continue; }
// assets/ 에 옮긴 사본이 있고(AI 가 주석을 덧붙여 내용은 다를 수 있음) 기본 언어 페이지가 옛 경로(../css/x.css)를 더는 쓰지 않으면 지운다
const used = new RegExp(`["'(\\s]\\.\\./${r.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`).test(baseHtml);
if (fs.existsSync(path.join(siteDir, 'assets', r)) && !used) { fs.unlinkSync(fp); removed.push(r); }
} };
walk(siteDir, ''); return removed;
}
/** 파일 경로 → 언어 (다국어 사이트에서 'en/about.html' → 'en') */
function langOf(site, file) { if (!isMulti(site)) return null; const c = String(file).split('/')[0]; return site.langs.list.includes(c) ? c : null; }
module.exports = { repairSwitchers, codeOf, xDefaultOf, siteLocale, renderFormats, formatTel, formatAddress, formatName, quality, setReview, QUALITY, cleanupAfterConvert, LOCALES, normalize, isMulti, promptRules, mapText, segments, buildPage, rootPage, sync, langOf, translatePrompt, removeSite, htmlUnder, ROOT_MARK };
+139
View File
@@ -0,0 +1,139 @@
'use strict';
// ============================================================================
// 바깥 주소 가져오기 보호 (v1.3.17, reports/code-review-2026-10-07.md S08·S09·S12·B16)
//
// 서비스가 사용자가 준 주소(참고 사이트, 복사할 사이트, 가져온 사이트의 운영 주소)를 서버에서 내려받을 때:
// 1) 주소마다(리다이렉트 한 단계마다) DNS 를 풀어 사설·예약 주소가 하나라도 있으면 거부한다(관리자 작업만 예외).
// 2) 확인한 그 IP 로만 접속한다(lookup 고정). 확인과 접속 사이에 DNS 가 내부 주소로 바뀌는 경우(DNS 리바인딩)를 막는다.
// 3) 리다이렉트는 자동으로 따르지 않고 직접 따른다. Location 이 잘못된 값이어도 예외가 콜백 밖으로 새지 않고
// 실패 결과로 돌아온다(예전에는 응답 콜백 안에서 new URL 이 던져 Node 프로세스가 종료될 수 있었다 — S12).
// 4) 본문은 흘려받으며 바이트 상한을 넘는 순간 끊는다(전부 메모리에 받은 뒤 크기를 재던 것 — B16). gzip·br·deflate 는 풀어서 센다.
// 의존성 없음 (http/https/zlib/dns).
// ============================================================================
const http = require('http');
const https = require('https');
const zlib = require('zlib');
const net = require('net');
const dns = require('dns').promises;
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
/** IPv4 사설·예약 대역 */
function privateV4(ip) {
const [a, b] = ip.split('.').map(Number);
return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || (a === 198 && (b === 18 || b === 19)) || (a === 192 && b === 0) || a >= 224;
}
/** 사설·루프백·링크 로컬·멀티캐스트·예약 주소인지 (IPv4, IPv6, IPv4 대응 IPv6) */
function isPrivateIp(ip) {
if (net.isIPv4(ip)) return privateV4(ip);
const x = String(ip).toLowerCase();
const mapped = x.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); if (mapped) return privateV4(mapped[1]);
return x === '::' || x === '::1' || /^f[cd]/.test(x) || /^fe[89ab]/.test(x) || x.startsWith('ff') || x.startsWith('64:ff9b:') || x.startsWith('2001:db8:') || x.startsWith('::ffff:');
}
/**
* 호스트 이름 → 접속할 IP 하나. 사설 주소가 하나라도 섞이면(allowPrivate 가 아니면) 거부한다.
* @returns {Promise<{address: string, family: number}>}
*/
async function resolvePublic(hostname, allowPrivate) {
const h = String(hostname || '').replace(/^\[|\]$/g, '');
let addrs;
if (net.isIP(h)) addrs = [{ address: h, family: net.isIP(h) }];
else { try { addrs = await dns.lookup(h, { all: true, verbatim: true }); } catch (_) { throw httpError(`주소를 찾을 수 없습니다: ${h}`); } }
if (!addrs.length) throw httpError(`주소를 찾을 수 없습니다: ${h}`);
if (!allowPrivate && addrs.some((a) => isPrivateIp(a.address)) && !allowedInternal(h, null)) throw httpError('내부(사설) 네트워크 주소는 가져올 수 없습니다. 관리자가 허용 목록(설정 service.internalAllow)에 넣은 서비스만 가능합니다.', 403); // v1.6.31 허용 목록
return addrs[0];
}
/** http(s).request 의 lookup 을 확인한 주소 하나로 고정한다 (다시 DNS 를 풀지 않음) */
const pinned = (addr) => (host, opts, cb) => { if (typeof opts === 'function') { cb = opts; opts = {}; } if (opts && opts.all) cb(null, [{ address: addr.address, family: addr.family }]); else cb(null, addr.address, addr.family); };
/**
* 한 번 요청 (리다이렉트를 따르지 않음). 오류는 모두 reject 로만 나온다.
* @returns {Promise<{status, headers, buf, url}>}
*/
function requestOnce(u, addr, o) {
return new Promise((resolve, reject) => {
const mod = u.protocol === 'https:' ? https : http;
let done = false; const fail = (e) => { if (!done) { done = true; reject(e); } };
let req;
try {
req = mod.request(u, { method: 'GET', lookup: pinned(addr), timeout: o.timeout, rejectUnauthorized: o.rejectUnauthorized !== false, servername: net.isIP(u.hostname) ? undefined : u.hostname, headers: { 'User-Agent': o.userAgent || 'Mozilla/5.0 (compatible; AIWebmaker/1.0)', Accept: o.accept || '*/*', 'Accept-Encoding': 'gzip, deflate, br', ...(o.headers || {}) } }, (res) => {
try {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); done = true; return resolve({ status: res.statusCode, headers: res.headers, buf: Buffer.alloc(0), url: u }); }
const enc = String(res.headers['content-encoding'] || '').toLowerCase();
let stream = res;
if (/gzip/.test(enc)) stream = res.pipe(zlib.createGunzip()); else if (/br/.test(enc)) stream = res.pipe(zlib.createBrotliDecompress()); else if (/deflate/.test(enc)) stream = res.pipe(zlib.createInflate());
const chunks = []; let size = 0;
stream.on('data', (c) => { size += c.length; if (size > o.maxBytes) { req.destroy(); fail(Object.assign(httpError(o.tooBigMessage || '응답이 너무 큽니다', 413), { tooBig: true })); return; } chunks.push(c); });
stream.on('end', () => { if (!done) { done = true; resolve({ status: res.statusCode, headers: res.headers, buf: Buffer.concat(chunks), url: u }); } });
stream.on('error', fail); res.on('error', fail);
} catch (e) { fail(e); }
});
} catch (e) { return fail(e); }
req.on('timeout', () => req.destroy(Object.assign(new Error('응답 시간 초과'), { name: 'AbortError' })));
req.on('error', fail);
if (o.signal) { if (o.signal.aborted) req.destroy(new Error('취소됨')); else o.signal.addEventListener('abort', () => req.destroy(new Error('취소됨')), { once: true }); }
req.end();
});
}
/**
* 안전하게 가져오기.
* @param {string} url
* @param {object} [o]
* allowPrivate : 관리자 작업 — 사설 주소 허용
* sameSite(url) : 리다이렉트 대상이 허용되는지 (false 면 offsite 로 끝냄, 복사하기의 "같은 사이트만")
* maxBytes : 본문 상한(압축을 푼 크기, 기본 3MB) maxRedirects: 기본 5 timeout: ms (기본 15000)
* rejectUnauthorized: 인증서 검증 (기본 true) headers·accept·userAgent
* @returns {Promise<{ok, status, type, buf, finalUrl, offsite?, error?}>} — 네트워크·주소 오류는 throw (호출 쪽 try/catch)
*/
async function safeFetch(url, o = {}) {
o = { maxBytes: 3 * 1024 * 1024, maxRedirects: 5, timeout: 15000, ...o };
let cur; try { cur = new URL(url); } catch (_) { throw httpError('주소 형식이 올바르지 않습니다'); }
for (let hop = 0; hop <= o.maxRedirects; hop += 1) {
if (!/^https?:$/.test(cur.protocol)) throw httpError('http:// 또는 https:// 주소만 가져올 수 있습니다');
if (o.sameSite && !o.sameSite(cur)) return { ok: false, status: 0, offsite: true, type: '', buf: Buffer.alloc(0), finalUrl: cur.href };
const addr = await resolvePublic(cur.hostname, o.allowPrivate); // 단계마다 다시 확인 (S08·S09)
const r = await requestOnce(cur, addr, o);
if (r.status >= 300 && r.status < 400 && r.headers.location) {
let next; try { next = new URL(r.headers.location, cur); } catch (_) { return { ok: false, status: r.status, type: '', buf: Buffer.alloc(0), finalUrl: cur.href, error: '잘못된 리다이렉트 주소' }; } // S12
cur = next; continue;
}
return { ok: r.status >= 200 && r.status < 300, status: r.status, type: String(r.headers['content-type'] || ''), buf: r.buf, finalUrl: cur.href, headers: r.headers };
}
return { ok: false, status: 310, type: '', buf: Buffer.alloc(0), finalUrl: cur.href, error: '리다이렉트가 너무 많음' };
}
// ---------- v1.6.31 (요구 5): 내부 주소 허용 목록과 범용 검증 ----------
/**
* 관리자가 허용한 내부 대상 — 설정 service.internalAllow ("host:port, host, 10.0.0.5:8080" 쉼표 구분) + 설정 파일에 등록된 연동 서비스 주소
* (source.remote/web, GITEA API, mail.url/api, AI 제공자 기본 URL 은 관리자만 적는 값이라 자동 포함). 목록에 없는 내부 서비스로는 어떤 사용자 입력 주소도 연결하지 않는다.
* @returns {Array<{host:string, port:number|null}>}
*/
function internalAllowList() {
const out = []; const add = (h, p) => { h = String(h || '').toLowerCase().replace(/^\[|\]$/g, ''); if (h) out.push({ host: h, port: p ? Number(p) : null }); };
let cfg = null; try { cfg = require('./config'); } catch (_) {}
if (cfg) {
for (const item of String(cfg.get('service.internalAllow') || '').split(/[,\s]+/).filter(Boolean)) { const m = item.match(/^(\[[^\]]+\]|[^:]+)(?::(\d+))?$/); if (m) add(m[1], m[2]); }
for (const key of ['source.web', 'source.apiUrl', 'mail.url', 'mail.apiUrl']) { try { const u = new URL(cfg.get(key) || ''); add(u.hostname, u.port || (u.protocol === 'https:' ? 443 : 80)); } catch (_) {} }
try { const r = String(cfg.get('source.remote') || ''); const m = r.match(/@([^:/]+)/); if (m) add(m[1], null); } catch (_) {}
}
return out;
}
function allowedInternal(hostname, port) { const h = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, ''); const p = port ? Number(port) : null; return internalAllowList().some((a) => a.host === h && (a.port == null || a.port === p)); }
/**
* 사용자가 입력한 외부 주소 검증: http(s) 만, localhost/.local/.internal 금지, DNS 가 사설·예약 주소면 거절 — 단 관리자 허용 목록(host:port)은 통과.
* @returns {Promise<string>} 정규화된 주소
*/
async function assertExternalUrl(raw, o = {}) {
let u; try { u = new URL(String(raw)); } catch (_) { throw httpError('주소 형식이 올바르지 않습니다'); }
if (!(o.allowHttp ? ['http:', 'https:'] : ['https:']).includes(u.protocol)) throw httpError(`허용되지 않는 프로토콜(${u.protocol})입니다`);
const h = u.hostname.toLowerCase().replace(/^\[|\]$/g, ''); const port = u.port || (u.protocol === 'https:' ? 443 : 80);
if (allowedInternal(h, port)) return u.toString();
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) throw httpError('내부 호스트 이름(localhost/.local/.internal)으로는 연결할 수 없습니다. 관리자가 허용 목록(service.internalAllow)에 넣은 서비스만 가능합니다', 403);
await resolvePublic(h, false);
return u.toString();
}
/** ssh/ftp 처럼 URL 이 아닌 호스트:포트 검증 (가져온 사이트의 원본 서버 접속 등) */
async function assertExternalHost(host, port) { const h = String(host || '').toLowerCase(); if (allowedInternal(h, port)) return true; if (h === 'localhost' || h.endsWith('.local') || h.endsWith('.internal')) throw httpError('내부 호스트로는 연결할 수 없습니다', 403); await resolvePublic(h, false); return true; }
module.exports = { isPrivateIp, resolvePublic, safeFetch, internalAllowList, allowedInternal, assertExternalUrl, assertExternalHost };
+116
View File
@@ -0,0 +1,116 @@
'use strict';
// ============================================================================
// Openprovider API 클라이언트 (v1.4.0) — 도메인 검색·가격·등록·갱신, DNS 영역(레코드) 관리
//
// 문서: https://docs.openprovider.com/doc/all (swagger: https://docs.openprovider.com/swagger.json, v1beta)
// - 인증: POST /auth/login {username, password, ip} → data.token (Bearer). 토큰은 오래 유지되지만 만료·폐기될 수 있어
// 401 이 오면 한 번 다시 로그인한다. 토큰은 메모리에만 둔다.
// - 접속 정보는 관리자 콘솔 › 연동 키 의 "Openprovider" 항목(lib/vault.js, 종류 openprovider)에 암호화 보관한다:
// 주소(기본 https://api.openprovider.eu/v1beta), 설정 USERNAME=…, 키(secret)=비밀번호. 한 번 넣으면 화면에 다시 보이지 않는다.
// - 시험용 대체 주소: 환경 변수 OPENPROVIDER_API_BASE (모의 서버)
// 응답 형식: { code: 0, desc, data } — code 가 0 이 아니면 오류(desc). 여기서는 오류를 한국어 안내와 함께 던진다.
// ============================================================================
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const DEFAULT_BASE = 'https://api.openprovider.eu/v1beta';
let tokenCache = { key: '', token: '', at: 0 }; // 계정(사용자 이름+주소)별 토큰 1개, 6시간마다 새로 받음
/** 보관소에서 접속 정보 읽기. 없으면 null (화면에서 "관리자 설정 필요" 안내) */
function creds() {
let x = null; try { x = require('./vault').resolve('openprovider'); } catch (_) {}
const user = x && x.settings && (x.settings.USERNAME || x.settings.OPENPROVIDER_USERNAME);
if (!x || !x.secret || !user) return null;
return { base: (process.env.OPENPROVIDER_API_BASE || x.url || DEFAULT_BASE).replace(/\/+$/, ''), username: user, password: x.secret, nsGroup: x.settings.NS_GROUP || 'dns-openprovider' };
}
const ready = () => !!creds();
async function login(c, force) {
const key = `${c.base}|${c.username}`;
if (!force && tokenCache.key === key && tokenCache.token && Date.now() - tokenCache.at < 6 * 3600000) return tokenCache.token;
let r; try { r = await fetch(`${c.base}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: c.username, password: c.password, ip: '0.0.0.0' }), signal: AbortSignal.timeout(20000) }); } catch (e) { throw httpError(`Openprovider 에 연결하지 못했습니다: ${e.cause ? e.cause.code || e.message : e.message}`, 502); }
const j = await r.json().catch(() => ({}));
if (!r.ok || j.code || !j.data || !j.data.token) throw httpError(`Openprovider 로그인 실패 (${j.desc || r.status}) — 관리자 콘솔 › 연동 키 의 Openprovider 사용자 이름·비밀번호를 확인하세요`, 502);
tokenCache = { key, token: j.data.token, at: Date.now() }; return j.data.token;
}
/**
* API 호출. 401 이면 다시 로그인해 한 번 더 시도한다.
* @param {string} method GET|POST|PUT|DELETE
* @param {string} path '/domains/check' 처럼 버전 뒤 경로
* @param {object} [body] JSON 본문
* @param {object} [query] 쿼리 문자열 값
*/
async function call(method, path, body, query) {
const c = creds(); if (!c) throw httpError('도메인 서비스가 아직 준비되지 않았습니다. 관리자에게 문의하세요 (관리자: 연동 키에 Openprovider 접속 정보를 넣어야 합니다)', 503);
const qs = query ? '?' + new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined && v !== null && v !== '')).toString() : '';
for (let attempt = 0; attempt < 2; attempt++) {
const token = await login(c, attempt > 0);
let r; try { r = await fetch(`${c.base}${path}${qs}`, { method, headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(60000) }); } catch (e) { throw httpError(`Openprovider 에 연결하지 못했습니다: ${e.cause ? e.cause.code || e.message : e.message}`, 502); }
if (r.status === 401 && attempt === 0) continue;
const j = await r.json().catch(() => ({}));
if (!r.ok || (j.code && j.code !== 0)) throw Object.assign(httpError(`도메인 서비스 오류: ${j.desc || `HTTP ${r.status}`}`, r.status >= 500 ? 502 : 400), { opCode: j.code, opData: j.data });
return j.data;
}
throw httpError('Openprovider 인증이 거부되었습니다', 502);
}
/** "example.com" → { name: 'example', extension: 'com' } (co.kr 같은 2단계 확장자 포함) */
function split(fqdn) {
const d = String(fqdn || '').trim().toLowerCase().replace(/\.$/, '');
const m = d.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.((?:co|or|ne|go|ac|re|pe)\.kr|com\.[a-z]{2}|co\.[a-z]{2}|[a-z]{2,24})$/);
if (!m) throw httpError('도메인 이름은 영문 소문자·숫자·하이픈으로 된 example.com 형식이어야 합니다');
return { name: m[1], extension: m[2] };
}
/**
* 등록 가능 여부와 가격 (여러 개 한 번에).
* @returns {Array<{domain, status: 'free'|'active'|…, premium: boolean, price: {currency, amount}|null, reason}>}
* price 는 우리(리셀러)가 내는 가격(reseller) — 사용자 청구는 lib/domains.js 가 마진을 붙인다
*/
async function check(fqdns) {
const data = await call('POST', '/domains/check', { domains: fqdns.map(split), with_price: true });
return ((data && data.results) || []).map((x) => {
const pr = (x.price && (x.price.reseller || x.price.product)) || null; const prem = x.premium && x.premium.price && (x.premium.price.reseller || x.premium.price.product);
const use = x.is_premium && prem ? prem : pr;
return { domain: x.domain, status: x.status, premium: !!x.is_premium, price: use && Number.isFinite(Number(use.price)) ? { currency: use.currency || 'EUR', amount: Number(use.price) } : null, reason: x.reason || '' };
});
}
/** 작업별 가격 (create|renew|transfer, 기간 년) — 리셀러 가격 */
async function price(fqdn, operation = 'create', period = 1) {
const { name, extension } = split(fqdn);
const data = await call('GET', '/domains/prices', null, { 'domain.name': name, 'domain.extension': extension, operation, period });
const g = data && data.price; const p = g && (g.reseller || g.product);
if (!p || !Number.isFinite(Number(p.price))) throw httpError('도메인 가격을 받지 못했습니다', 502);
return { currency: p.currency || 'EUR', amount: Number(p.price), premium: !!(data && data.is_premium) };
}
/**
* 등록자(소유자) 연락처 만들기 → handle (XX123456-XX). 도메인 등록에 필요하다.
* @param {object} c { firstName, lastName, company, email, phoneCountry('+82'), phoneArea, phoneNumber, street, number, zipcode, city, state, country('KR') }
*/
async function createCustomer(c) {
const body = { name: { first_name: c.firstName, last_name: c.lastName, full_name: `${c.firstName} ${c.lastName}`.trim() }, company_name: c.company || undefined, email: c.email,
phone: { country_code: c.phoneCountry || '+82', area_code: c.phoneArea || '', subscriber_number: c.phoneNumber },
address: { street: c.street, number: c.number || '', zipcode: c.zipcode, city: c.city, state: c.state || '', country: (c.country || 'KR').toUpperCase() } };
const data = await call('POST', '/customers', body);
if (!data || !data.handle) throw httpError('등록자 정보를 만들지 못했습니다', 502); return data.handle;
}
/**
* 도메인 등록 (Openprovider 네임서버 그룹으로 — DNS 를 이 서비스가 관리할 수 있게).
* @returns {{ id: number, status: string, expiresAt: string|null }}
*/
async function createDomain(fqdn, { handle, period = 1, autorenew = 'off' } = {}) {
const c = creds(); const data = await call('POST', '/domains', { domain: split(fqdn), period, unit: 'y', owner_handle: handle, admin_handle: handle, tech_handle: handle, billing_handle: handle, ns_group: (c && c.nsGroup) || 'dns-openprovider', autorenew, is_private_whois_enabled: true });
return { id: data.id, status: data.status || 'REQ', expiresAt: data.expiration_date || null };
}
async function renewDomain(id, fqdn, period = 1) { const data = await call('POST', `/domains/${id}/renew`, { id, domain: split(fqdn), period }); return { status: (data && data.status) || 'ACT' }; }
async function getDomain(id) { return call('GET', `/domains/${id}`); }
// ---------- DNS 영역 ----------
/** 영역 만들기 (없을 때). records: [{name:'', type:'A', value, ttl}] — name '' = 도메인 자신(@) */
async function createZone(fqdn, records) { return call('POST', '/dns/zones', { domain: split(fqdn), type: 'master', records: records.map(rec) }); }
async function getZone(fqdn) { try { return await call('GET', `/dns/zones/${encodeURIComponent(fqdn)}`); } catch (e) { if (e.status === 400 || e.status === 404) return null; throw e; } }
async function listRecords(fqdn) { const data = await call('GET', `/dns/zones/${encodeURIComponent(fqdn)}/records`, null, { limit: 500 }); return ((data && data.results) || []).map((r) => ({ name: r.name, type: r.type, value: r.value, ttl: r.ttl, prio: r.prio })); }
/** 레코드 더하기·빼기 (영역의 NS·SOA 는 Openprovider 가 관리하므로 건드리지 않는다) */
async function updateRecords(fqdn, { add = [], remove = [] }) { return call('PUT', `/dns/zones/${encodeURIComponent(fqdn)}`, { domain: split(fqdn), name: fqdn, type: 'master', records: { add: add.map(rec), remove: remove.map(rec) } }); }
/** 레코드 이름을 영역 기준(상대 이름)으로: 'www.example.com' → 'www', 'example.com' → '' */
const rec = (r) => ({ name: r.name || '', type: r.type, value: String(r.value), ttl: Number(r.ttl) || 900, ...(r.prio !== undefined && r.prio !== null && r.prio !== '' ? { prio: Number(r.prio) } : {}) });
module.exports = { ready, creds, call, split, check, price, createCustomer, createDomain, renewDomain, getDomain, createZone, getZone, listRecords, updateRecords, DEFAULT_BASE };
+51
View File
@@ -0,0 +1,51 @@
'use strict';
// ============================================================================
// 비밀번호 규칙 (v1.3.17)
//
// 서비스 계정(빌더 사용자·관리자)과 사이트 관리자 계정의 비밀번호 규칙을 한곳에서 정한다.
// 관리자 콘솔 › 설정 › "비밀번호 규칙" 에서 바꾸며(settings.passwordPolicy), 기본값은 1.3.16 까지의 규칙
// "6자 이상" 그대로다(기존 사용자·화면 동작이 바뀌지 않게). 규칙은 새로 정하는 비밀번호(가입·변경·재설정)에만 적용하고,
// 이미 쓰고 있는 비밀번호로 로그인하는 것은 막지 않는다.
// ============================================================================
const DEFAULT = { minLength: 6, maxLength: 256, requireLetter: false, requireDigit: false, requireSymbol: false, requireMixedCase: false, forbidId: false };
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
/** 지금 규칙 (설정 파일 값 + 기본값). settings 를 늦게 불러 순환 의존을 피한다 */
function current() {
let p = {}; try { p = require('./settings').load().passwordPolicy || {}; } catch (_) {}
return normalize(p);
}
/** 관리자 입력 정리: 최소 길이 4~64, 최대 길이 최소 길이 이상 256 이하, 나머지는 켜기/끄기 */
function normalize(p) {
p = p || {}; const on = (v) => v === true || v === 'true' || v === 'on' || v === 1 || v === '1';
const min = Math.round(Number(p.minLength)); const max = Math.round(Number(p.maxLength));
const out = { ...DEFAULT };
if (Number.isFinite(min)) { if (min < 4 || min > 64) throw httpError('비밀번호 최소 길이는 4~64 사이로 정하세요'); out.minLength = min; }
if (Number.isFinite(max)) { if (max < out.minLength || max > 256) throw httpError('비밀번호 최대 길이는 최소 길이 이상 256 이하로 정하세요'); out.maxLength = max; }
for (const k of ['requireLetter', 'requireDigit', 'requireSymbol', 'requireMixedCase', 'forbidId']) if (p[k] !== undefined) out[k] = on(p[k]);
return out;
}
/** 규칙 설명 문장 (오류 메시지·화면 안내) */
function describe(pol = current()) {
const parts = [`${pol.minLength}자 이상`];
if (pol.maxLength < 256) parts.push(`${pol.maxLength}자 이하`);
if (pol.requireLetter) parts.push('영문자 포함'); if (pol.requireMixedCase) parts.push('대문자·소문자 모두 포함'); if (pol.requireDigit) parts.push('숫자 포함'); if (pol.requireSymbol) parts.push('특수문자 포함'); if (pol.forbidId) parts.push('아이디를 포함하지 않음');
return parts.join(', ');
}
/**
* 새 비밀번호 검사. 맞지 않으면 어떤 규칙인지 적은 오류를 던진다.
* @param {string} pw
* @param {object} [ctx] { id: 아이디(forbidId 검사), label: 오류 문장 앞말('비밀번호'·'새 비밀번호') }
*/
function check(pw, ctx = {}) {
const pol = current(); const s = String(pw || ''); const label = ctx.label || '비밀번호';
const fail = () => { throw httpError(`${label}는 ${describe(pol)} 이어야 합니다`); };
if (s.length < pol.minLength || s.length > pol.maxLength) fail();
if (pol.requireLetter && !/[A-Za-z]/.test(s)) fail();
if (pol.requireMixedCase && !(/[a-z]/.test(s) && /[A-Z]/.test(s))) fail();
if (pol.requireDigit && !/\d/.test(s)) fail();
if (pol.requireSymbol && !/[^A-Za-z0-9]/.test(s)) fail();
if (pol.forbidId && ctx.id && s.toLowerCase().includes(String(ctx.id).toLowerCase())) fail();
return true;
}
module.exports = { DEFAULT, current, normalize, describe, check };
+222
View File
@@ -0,0 +1,222 @@
'use strict';
// AI 기획 단계: 요구 분석/이해 확인, 구조 제안, 디자인 3안, 결과 검증, Brief 선갱신 수정.
// 각 단계는 Claude 헤드리스 단발 호출로 JSON 을 받아 온다(파일 쓰기 도구 없음).
const { runModel } = require('./run');
const brief = require('./brief');
const PLAN_TOOLS = 'Read,Glob,Grep,WebFetch';
const PLAN_SYSTEM = `당신은 웹사이트 기획 컨설턴트입니다. 사용자와 대화할 수 없으므로 질문 대신 결과를 내야 합니다.
반드시 마지막 답변을 하나의 JSON 객체로만 출력합니다(설명 문장, 마크다운 코드펜스 금지). JSON 문자열 안의 한국어는 그대로 씁니다.
사실정보(전화·주소·영업시간·가격·자격·후기)는 사용자가 제공한 텍스트·첨부 파일·기존 사이트에서 확인된 것만 추출하고 출처를 표시합니다. 추측으로 만들어내지 않습니다.`;
/**
* 문자열·이스케이프를 고려해 균형 잡힌 최상위 JSON 객체 후보들을 찾는다 (v1.3.10).
* 1.3.5 까지는 "첫 { 부터 마지막 } 까지" 를 잘라 파싱해, 모델이 JSON 뒤에 설명이나 두 번째 JSON 을 붙이면
* "Unexpected non-whitespace character after JSON" 으로 실패했다(webmaker SEO 초안 오류).
* @returns {string[]} 나타난 순서대로의 {…} 조각
*/
function jsonCandidates(text) {
const out = [];
for (let i = 0; i < text.length; i++) {
if (text[i] !== '{') continue;
let depth = 0, inStr = false, esc = false, j = i;
for (; j < text.length; j++) {
const c = text[j];
if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; continue; }
if (c === '"') inStr = true; else if (c === '{') depth++; else if (c === '}') { depth--; if (depth === 0) break; }
}
if (depth === 0 && j < text.length) { out.push(text.slice(i, j + 1)); i = j; } // 다음 후보는 이 객체 뒤에서부터
}
return out;
}
/**
* 흔한 형식 실수 고치기: 끝 쉼표, 문자열 안의 실제 줄바꿈·탭, 스마트 따옴표로 감싼 키.
* v1.3.17 (B17): 모든 수정은 문자열 "밖" 에서만 한다. 예전에는 끝 쉼표·스마트 따옴표를 문서 전체에 정규식으로 바꿔
* "literal,]" 같은 문자열 값까지 바뀌었다.
*/
function repairJson(t) {
let out = ''; let inStr = false; let esc = false; let smart = false; // smart: 스마트 따옴표(“ ”)로 연 문자열 — 닫는 것도 스마트 따옴표로 받는다
for (let c of t) {
if (inStr) {
if (esc) { esc = false; out += c; continue; }
if (c === '\\') { esc = true; out += c; continue; }
if (c === '"' || (smart && c === '”')) { inStr = false; smart = false; out += '"'; continue; }
if (c === '\n') { out += '\\n'; continue; } if (c === '\r') continue; if (c === '\t') { out += '\\t'; continue; }
out += c; continue; // 보통 문자열 안의 스마트 따옴표는 글자 그대로
}
if (c === '“' || c === '”') { inStr = true; smart = true; out += '"'; continue; } // 문자열 밖의 스마트 따옴표 = 따옴표로 쓴 것
if (c === '"') { inStr = true; out += c; continue; }
if (c === '}' || c === ']') out = out.replace(/,\s*$/, ''); // 끝 쉼표 (문자열 밖)
out += c;
}
return out;
}
/**
* AI 응답에서 JSON 객체 하나를 꺼낸다.
* 순서: 코드펜스 안 → 균형 잡힌 후보 중 파싱되는 가장 큰 것 → 고친 뒤 다시 시도. 모두 실패하면 원문 일부를 오류에 담는다.
*/
function extractJson(text) {
if (!text) throw new Error('AI 응답이 비어 있습니다');
const pools = []; const fences = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/g)].map((m) => m[1]); pools.push(...fences, text);
let firstErr = null;
for (const src of pools) {
const cands = jsonCandidates(src).sort((a, b) => b.length - a.length); // 큰 것(본문 JSON) 먼저 — 설명 속 작은 {…} 보다 우선
for (const c of cands) { for (const t of [c, repairJson(c)]) { try { const v = JSON.parse(t); if (v && typeof v === 'object') return v; } catch (e) { if (!firstErr) firstErr = e; } } }
}
const err = new Error('AI 응답 JSON 파싱 실패: ' + (firstErr ? firstErr.message : 'JSON 객체를 찾지 못했습니다'));
err.rawText = String(text).slice(0, 20000); throw err;
}
/** 두 실행 결과의 사용량·비용 합치기 (JSON 고침 재요청 비용까지 청구에 넣기 위해) */
function mergeResults(a, b) {
const u = { ...a.usage }; for (const [k, v] of Object.entries(b.usage || {})) if (typeof v === 'number') u[k] = (u[k] || 0) + v;
return { ...a, usage: u, costUsd: (a.costUsd || 0) + (b.costUsd || 0), numTurns: (a.numTurns || 0) + (b.numTurns || 0), durationMs: (a.durationMs || 0) + (b.durationMs || 0) };
}
async function runJson({ siteDir, prompt, onEvent, onStart, ref, userId, toolset = 'read' }) { // toolset 'none': 파일을 읽지 않는 짧은 호출 (v1.3.10 다국어 번역)
const r = await runModel({ ref, userId, siteDir, prompt, systemPrompt: PLAN_SYSTEM, toolset, noPersist: true, onEvent, onStart });
if (!r.ok) throw Object.assign(new Error(`AI 호출 실패 (${r.subtype})`), { result: r });
try { return { data: extractJson(r.resultText), result: r }; }
catch (e) {
// v1.3.10: 그래도 못 읽으면 같은 모델에 "이 텍스트를 올바른 JSON 하나로만" 고쳐 달라고 한 번만 다시 요청한다
// (파일을 다시 읽지 않는 짧은 호출이라 비용이 작다). 그 비용도 이번 작업 청구에 합친다.
// v1.3.17 (B18): 고침 호출이 실패·취소되면 그 상태(cancelled 등)와 사용량을 그대로 넘긴다 — 첫 호출 결과로 덮어 성공처럼 보이거나
// 고침 호출 비용이 청구에서 빠지던 문제. 고침 호출이 ok 가 아니면 그 응답의 JSON 은 쓰지 않는다.
const fixPrompt = `아래 텍스트는 JSON 하나여야 하는데 형식이 깨졌습니다. 내용은 바꾸지 말고 올바른 JSON 객체 하나로만 다시 출력하세요(설명·코드펜스 금지). 중간에 잘렸으면 마지막 항목을 빼고 닫으세요.\n\n${String(r.resultText || '').slice(0, 60000)}`;
let r2;
try { r2 = await runModel({ ref, userId, siteDir, prompt: fixPrompt, systemPrompt: PLAN_SYSTEM, toolset: 'none', noPersist: true, onEvent, onStart }); }
catch (e2) {
// 취소·실행 오류: 그 오류를 그대로 던지되, 첫 호출 사용량을 함께 담아 청구가 빠지지 않게 한다
if (e2 && e2.usage && r.usage) { const u = { ...r.usage }; for (const [k, v] of Object.entries(e2.usage)) if (typeof v === 'number') u[k] = (u[k] || 0) + v; e2.usage = u; } else if (e2 && !e2.usage) e2.usage = r.usage;
throw Object.assign(e2, { result: e2.result ? mergeResults(r, e2.result) : r, rawText: e.rawText });
}
const merged = mergeResults(r, r2 || {}); // 성공 여부와 관계없이 사용량·비용은 합친다
if (!r2 || !r2.ok) throw Object.assign(new Error(`AI 응답 JSON 고침 실패 (${(r2 && r2.subtype) || 'error'})`), { result: merged, rawText: e.rawText });
try { return { data: extractJson(r2.resultText), result: merged, repaired: true }; } catch (e3) { throw Object.assign(e, { result: merged }); }
}
}
function refsText(refs, fetched) {
if (!refs || !refs.length) return '';
const byUrl = new Map((fetched || []).map((f) => [f.url, f]));
const lines = refs.map((r, i) => { const f = byUrl.get(r.url); return `${i + 1}. ${r.url}${r.notes ? ` — 참고/반영할 점: ${r.notes}` : ''}${f ? (f.ok ? ` → 본문 텍스트가 ${f.file} 에 저장되어 있음 (Read 로 확인)` : ` → 가져오기 실패(${f.error}); 사용자가 적은 참고 포인트만 반영`) : ''}`; });
return '[참고 사이트]\n' + lines.join('\n') + '\n저장된 텍스트 파일을 Read 로 읽어 구성·메뉴·문구 스타일을 파악하고, 사용자가 적은 참고 포인트를 반영 방안에 포함하세요. WebFetch 는 저장 파일이 없거나 부족할 때만 보조로 사용하고, 실패하면 그냥 넘어갑니다.\n';
}
function attachText(atts) {
if (!atts || !atts.length) return '';
return '[첨부 파일] (Read 로 내용 확인)\n' + atts.map((a) => `- ${a.rel} (${a.kind}${a.textSidecar ? `, 텍스트 추출본 ${a.textSidecar}` : ''})`).join('\n') + '\n';
}
const CAT = brief.catalog();
const industryList = CAT.INDUSTRIES.map((i) => `${i.major}: ${i.minors.join(', ')}`).join('\n');
/** 1~2단계: 요구 분석 → 이해 확인 카드 */
/** 사용자가 시작 화면에서 고른 디자인 컨셉(주 색상·톤·방향) 을 프롬프트용 문장으로 */
function designText(inp) {
const d = (inp && inp.design) || {}; const parts = [];
if (d.color) parts.push(`주 색상 ${d.color}`); if (d.tone) parts.push(`톤 ${d.tone}`); if (d.direction) parts.push(`방향: ${d.direction}`);
return parts.length ? parts.join(' · ') : '(없음 — 업종과 고객에 맞게 추천)';
}
function analyzePrompt(site) {
const inp = site.plan.input;
return `다음 요구를 분석해 웹사이트 기획의 "이해 확인 카드"를 JSON 으로 작성하세요.
[사용자 요구(자유 서술)]
${inp.text || '(없음)'}
[디자인 요구 (사용자 입력)]
${designText(inp)}
[기존 사이트 URL]
${inp.existingUrl || '(없음)'}${inp.existingUrl ? (() => { const f = (site.plan.refsFetched || []).find((x) => x.existing); return f && f.ok ? ` — 본문 텍스트가 ${f.file} 에 저장되어 있음. Read 로 읽어 사업 정보와 사실정보(출처 url:${inp.existingUrl})를 추출` : ' — 가져오기 실패. 사용자 서술만 근거로 함'; })() : ''}
${attachText(site.attachments)}${refsText(inp.references, site.plan.refsFetched)}${inp.answers && inp.answers.length ? '[추가 답변]\n' + inp.answers.map((a) => `Q: ${a.q}\nA: ${a.a}`).join('\n') + '\n' : ''}
[업종 분류표] (major 는 아래 대분류 문자열 중 하나, minor 는 그 소분류 중 하나)
${industryList}
[규제 업종] medical(병원·의원·치과·한의원 등), legal(변호사·세무·법무 등), finance(금융·대부). 해당하면 regulated.category 에 표기.
출력 JSON 형식:
{
"business": {"name": "", "industry": {"major": "", "minor": ""}, "region": {"sido": "", "sigungu": ""}, "description": "한 줄 사업 요약"},
"customers": {"types": ["..."], "ages": ["20대","30대"]},
"goals": ["inquiry|booking|intro|portfolio|news|sales" 중 복수],
"features": {"contactForm": bool, "booking": bool, "board": bool, "gallery": bool, "map": bool, "reviews": bool, "faq": bool},
"recommendedStyle": {"tone": "minimal|modern|warm|luxury|vivid", "reason": ""},
"regulated": {"flag": bool, "category": "medical|legal|finance|null"},
"facts": {"phone": {"value": "", "source": "user_text|attachment:<파일명>|url:<주소>|null", "status": "provided|missing"}, "address": {...}, "hours": {...}, "prices": {...}, "credentials": {...}, "reviews": {...}},
"origin": {"business.name": "user|ai", "business.industry": "user|ai", "goals": "user|ai", "features": "user|ai", "customers": "user|ai", "recommendedStyle": "ai"},
"referenceInsights": [{"url": "", "adopt": "이 사이트에서 반영할 점 요약"}],
"questions": ["업종이나 목표가 불명확할 때만 1~2개, 아니면 빈 배열"]
}
origin 은 사용자가 명시한 값이면 "user", 추론이면 "ai" 로 표시합니다. 사실정보가 확인되지 않으면 value 를 비우고 status 를 "missing" 으로 둡니다.`;
}
/** 3단계: 구조 제안 */
/** 사용자가 고른 페이지 수(plan.input.pageCount, 0=자동) → 구조 제안 규칙 문장 */
function pageRule(site) {
const n = parseInt((site.plan.input || {}).pageCount, 10) || 0;
if (n === 1) return '페이지는 정확히 1개(원페이지 사이트): 모든 내용을 home 한 페이지의 섹션으로 구성하고 내비게이션은 섹션 앵커(#id)로.';
if (n > 1) return `페이지는 정확히 ${n}개(home 포함).`;
return '페이지 3~6개.';
}
function structurePrompt(site) {
const b = site.plan.brief;
return `아래 Brief(부분)를 바탕으로 웹사이트 페이지 트리와 섹션 구성을 제안하세요.
[Brief]
${JSON.stringify({ business: b.business, goals: b.goals, features: b.features, customers: b.customers, regulated: b.regulated, facts: Object.fromEntries(Object.entries(b.facts).map(([k, v]) => [k, v.status])) }, null, 1)}
${refsText(site.plan.input.references, site.plan.refsFetched)}
[섹션 ID 목록] ${Object.entries(CAT.SECTIONS).map(([k, v]) => `${k}(${v.label}${v.feature ? `, 기능:${v.feature}` : ''})`).join(', ')}
[목표 ID] ${CAT.GOALS.map((g) => `${g.id}(${g.label})`).join(', ')}
규칙: 켜진 기능(features=true)은 반드시 어떤 페이지의 섹션에 배치. 꺼진 기능의 섹션은 넣지 않음. 규제 업종 제약(예: 의료 후기 금지) 준수. 첫 페이지는 id "home". ${pageRule(site)} 페이지당 섹션 3~7개.
출력 JSON:
{"pages": [{"id": "home", "title": "홈", "purpose": "이 페이지의 역할", "sections": [{"id": "hero", "title": "섹션 제목", "goal": "inquiry|booking|intro|portfolio|news|sales|null", "feature": "기능ID|null", "note": "무엇을 담는지 한 줄"}]}], "rationale": "구조 선택 이유 2~3문장"}`;
}
/** 4단계: 디자인 3안 */
function designPrompt(site) {
const b = site.plan.brief;
return `아래 Brief 로 만들 웹사이트의 디자인 방향 3안을 제안하세요. 2안 이상은 서로 뚜렷이 달라야 하며, 첫 번째 안은 추천 톤(${b.design.recommendedTone || '없음'})을 따릅니다.
[사용자 디자인 요구] ${designText(site.plan.input)} — 요구가 있으면 첫 번째 안(추천안)은 반드시 이 색상·톤·방향을 반영하고 palette.colors 의 포인트 색을 주 색상으로 씁니다.
[Brief 요약] ${JSON.stringify({ business: b.business, goals: b.goals, customers: b.customers, recommendedTone: b.design.recommendedTone, pages: b.pages.map((p) => p.id) })}
${refsText(site.plan.input.references, site.plan.refsFetched)}
[톤 ID] ${CAT.TONES.map((t) => `${t.id}(${t.label}: ${t.desc})`).join(' / ')}
[폰트 세트 ID] ${CAT.FONT_SETS.map((f) => `${f.id}(${f.label})`).join(' / ')}
출력 JSON:
{"options": [{"id": "A", "name": "안 이름", "tone": "톤ID", "palette": {"name": "", "colors": ["#텍스트", "#보조", "#배경", "#포인트"]}, "fonts": "폰트세트ID", "density": "wide|standard|dense", "imagery": "photo|illustration", "summary": "한두 문장", "heroIdea": "히어로 구성 아이디어"}, {...}, {...}], "recommended": "A"}`;
}
/** 7/11단계: 결과 ↔ Brief 대조 (AI) */
function verifyPrompt(site) {
const b = site.plan.brief;
const ml = site.langs && site.langs.list && site.langs.list.length > 1 ? `다국어 사이트입니다 — 기본 언어 폴더 ${site.langs.default}/ 의 파일만 검사하세요(다른 언어 폴더는 서비스가 번역해 만든 사본입니다).\n` : ''; // v1.3.10
return `현재 디렉터리의 생성된 웹사이트 파일(Read/Glob 으로 확인)을 아래 Brief 와 대조하세요.
${ml}
[Brief]
${JSON.stringify(b, null, 1)}
검사 항목: (1) 목표별 CTA 존재 (2) 켜진 기능별 섹션 존재 (3) 페이지별 파일 존재와 섹션 반영 (4) 디자인(톤·팔레트·폰트·밀도) 반영 (5) 어조·언어 준수 (6) Brief 에 없는 사실정보(전화·주소·가격·후기)가 만들어졌는지 (7) 규제 제약 위반 표현.
출력 JSON:
{"items": [{"category": "cta|feature|page|style|copy|facts|regulation", "name": "", "status": "pass|fail|partial", "detail": "근거(파일명 포함)"}], "score": 0~100, "fixes": ["실패 항목을 고치기 위한 구체 지시 문장"]}`;
}
/** 8/12단계: 채팅 수정 — Brief 선갱신 후 해당 부분만 재생성 (파일 도구 사용, 최종 답변에 <brief> JSON 포함) */
function editPrompt(site, instruction, attachments) {
const b = site.plan.brief;
const history = require('./prompt').historySection(site);
return `현재 디렉터리의 웹사이트를 아래 지시에 따라 수정합니다. 순서: (1) 지시가 Brief 의 어떤 항목을 바꾸는지 판단해 Brief 를 먼저 갱신 (2) 갱신된 Brief 에 맞춰 영향을 받는 파일/섹션만 수정 (3) 영향 없는 부분은 건드리지 않음.
${history}
[수정 지시]
${instruction}
[현재 Brief]
${JSON.stringify(b, null, 1)}
${attachments}
규칙: 사실정보는 Brief 값만 사용(지시에 새 사실정보가 있으면 Brief.facts 에 source:"user_text", status:"provided" 로 반영 후 사용). 없으면 "[입력 필요]". 가짜 후기 금지. 규제 제약 준수.
작업을 마친 뒤 최종 답변은 다음 형식으로만: 첫 줄부터 3줄 이내 한국어 요약, 그 다음 줄에 <brief>갱신된 Brief 전체 JSON</brief>. Brief 가 바뀌지 않았어도 전체를 다시 출력합니다.`;
}
module.exports = { jsonCandidates, repairJson, mergeResults, runJson, analyzePrompt, structurePrompt, designPrompt, verifyPrompt, editPrompt, extractJson };
+154
View File
@@ -0,0 +1,154 @@
'use strict';
/* ============================================================================
* 선불 충전(선불금) 상품 · 주문 (v1.6.40, 요구 1)
*
* 비용은 지금처럼 선불 적립금(캐시, lib/credits.js)으로 쓴다. 이 모듈은 그 캐시를 "사용자가 스스로 사는" 길을 만든다.
* - 상품(package): 관리자 콘솔 › 회원·청구 › 선불 충전 상품 에서 만든다. 금액(원)과 추가 인센티브(정률 % 또는 정액 원)를 정하며,
* 적립되는 캐시 = 결제 금액 + 인센티브 (creditOf). 비활성(active=false) 상품은 사용자에게 보이지 않지만 지난 주문 기록은 그대로 남는다.
* - 주문(order): 사용자가 캐시·충전 화면에서 상품을 골라 "결제 진행" 을 누르면 pending 주문이 생긴다.
* **PG(결제 대행)가 아직 연동되지 않아 실제 결제는 일어나지 않는다** — 주문은 "결제 대기" 로 남고, 관리자가 입금(결제) 확인 뒤
* markPaid 로 적립하면 credits.apply(add, 적립 금액) 가 원장에 기록된다(ref: { orderId } 로 어느 주문의 적립인지 남긴다).
* PG 가 붙으면 결제 콜백에서 markPaid 를 부르는 구조로 그대로 이어 쓸 수 있다.
*
* 저장: data/prepaid.json = { packages: [...], orders: [...] } — jsonstore 로 원자적 쓰기 + .bak 보관(깨지면 .bak 으로 읽는다).
* package = { id, name, amount, bonusType: 'percent'|'fixed', bonusValue, active, sort, note, createdAt, updatedAt }
* order = { id, userId, packageId, name, amount, bonus, credit, status: 'pending'|'paid'|'cancelled', at, paidAt, cancelledAt, by, memo }
* ========================================================================== */
const path = require('path');
const crypto = require('crypto');
const js = require('./jsonstore');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const FILE = path.join(DATA_ROOT, 'prepaid.json');
/** 사용자 한 명이 동시에 열어 둘 수 있는 결제 대기 주문 수 — 버튼을 연달아 눌러 주문이 쌓이는 것을 막는다 */
const MAX_PENDING_PER_USER = 5;
/** 상품 금액 범위(원): 너무 작은 값·입력 실수로 생긴 거대 금액을 막는다 */
const AMOUNT_MIN = 1000, AMOUNT_MAX = 100000000;
const BONUS_TYPES = ['percent', 'fixed'];
const STATUS = ['pending', 'paid', 'cancelled'];
function httpError(msg, status = 400) { return Object.assign(new Error(msg), { status }); }
function load() { const d = js.readJson(FILE, null); return d && typeof d === 'object' ? { packages: Array.isArray(d.packages) ? d.packages : [], orders: Array.isArray(d.orders) ? d.orders : [] } : { packages: [], orders: [] }; }
function save(d) { js.writeJsonAtomic(FILE, d, { mode: 0o600, keepBackup: true }); }
const id = () => crypto.randomBytes(6).toString('hex');
const now = () => new Date().toISOString();
/**
* 상품의 인센티브 금액(원). percent 는 결제 금액의 비율(소수점은 버림 — 원 단위), fixed 는 그대로.
* @param {object} p 상품 { amount, bonusType, bonusValue }
*/
function bonusOf(p) {
const amt = Math.round(Number(p.amount) || 0); const v = Number(p.bonusValue) || 0;
if (!(v > 0)) return 0;
return p.bonusType === 'percent' ? Math.floor(amt * v / 100) : Math.round(v);
}
/** 적립되는 캐시(원) = 결제 금액 + 인센티브 */
function creditOf(p) { return Math.round(Number(p.amount) || 0) + bonusOf(p); }
/** 화면용 상품 (적립 금액·인센티브 금액 계산값 포함) */
function pub(p) { return { ...p, bonus: bonusOf(p), credit: creditOf(p) }; }
/**
* 상품 목록. 기본은 사용자 화면용(활성 상품만, sort → amount 순). all=true 면 관리자용 전체.
*/
function listPackages({ all = false } = {}) {
const d = load();
return d.packages.filter((p) => all || p.active !== false).sort((a, b) => (a.sort || 0) - (b.sort || 0) || a.amount - b.amount).map(pub);
}
function getPackage(pid) { const p = load().packages.find((x) => x.id === pid); return p ? pub(p) : null; }
/**
* 상품 만들기·고치기 (관리자). input.id 가 있으면 수정, 없으면 새 상품.
* 검증: 이름 1~40자, 금액 AMOUNT_MIN~AMOUNT_MAX 의 정수(원), 인센티브 종류 percent|fixed, 값 0 이상(정률은 500% 까지, 정액은 금액의 10배까지).
* @param {object} input { id?, name, amount, bonusType, bonusValue, active, sort, note }
* @param {string} by 처리한 관리자 ID (기록용)
*/
function savePackage(input, by) {
const d = load(); const cur = input.id ? d.packages.find((p) => p.id === input.id) : null;
if (input.id && !cur) throw httpError('상품이 없습니다', 404);
const name = String(input.name == null ? (cur ? cur.name : '') : input.name).trim();
if (!name || name.length > 40) throw httpError('상품 이름은 1~40자로 입력하세요');
const amount = input.amount == null ? (cur ? cur.amount : NaN) : Math.round(Number(input.amount));
if (!Number.isFinite(amount) || amount < AMOUNT_MIN || amount > AMOUNT_MAX) throw httpError(`금액은 ${AMOUNT_MIN.toLocaleString('ko-KR')}~${AMOUNT_MAX.toLocaleString('ko-KR')}원 사이 정수로 입력하세요`);
const bonusType = input.bonusType == null ? (cur ? cur.bonusType : 'percent') : String(input.bonusType);
if (!BONUS_TYPES.includes(bonusType)) throw httpError('인센티브 종류는 percent(정률) 또는 fixed(정액)이어야 합니다');
const bonusValue = input.bonusValue == null || input.bonusValue === '' ? (cur ? cur.bonusValue : 0) : Number(input.bonusValue);
if (!Number.isFinite(bonusValue) || bonusValue < 0) throw httpError('인센티브는 0 이상 숫자로 입력하세요');
if (bonusType === 'percent' && bonusValue > 500) throw httpError('정률 인센티브는 500% 까지입니다');
if (bonusType === 'fixed' && bonusValue > amount * 10) throw httpError('정액 인센티브가 결제 금액의 10배를 넘습니다 — 입력을 확인하세요');
const next = {
id: cur ? cur.id : id(), name, amount, bonusType, bonusValue: bonusType === 'percent' ? Math.round(bonusValue * 100) / 100 : Math.round(bonusValue),
active: input.active == null ? (cur ? cur.active !== false : true) : !!(input.active === true || input.active === 'true'),
sort: input.sort == null || input.sort === '' ? (cur ? cur.sort || 0 : d.packages.length) : Math.round(Number(input.sort) || 0),
note: String(input.note == null ? (cur ? cur.note || '' : '') : input.note).trim().slice(0, 200),
createdAt: cur ? cur.createdAt : now(), updatedAt: now(), updatedBy: by || null,
};
if (cur) Object.assign(cur, next); else d.packages.push(next);
save(d); return pub(next);
}
/**
* 상품 삭제 (관리자). 주문 기록이 그 상품을 가리키면 지우지 않고 비활성으로 돌린다 — 지난 주문의 상품 정보가 사라지지 않게.
* @returns {{removed: boolean, deactivated: boolean, package: object}}
*/
function removePackage(pid) {
const d = load(); const p = d.packages.find((x) => x.id === pid); if (!p) throw httpError('상품이 없습니다', 404);
const referenced = d.orders.some((o) => o.packageId === pid);
if (referenced) { p.active = false; p.updatedAt = now(); save(d); return { removed: false, deactivated: true, package: pub(p) }; }
d.packages = d.packages.filter((x) => x.id !== pid); save(d); return { removed: true, deactivated: false, package: pub(p) };
}
/**
* 주문 만들기 (사용자). 활성 상품만, 결제 대기 주문은 MAX_PENDING_PER_USER 개까지.
* 주문에는 그 시점의 상품 이름·금액·인센티브를 복사해 둔다 — 나중에 상품을 고쳐도 주문 금액은 바뀌지 않는다.
*/
function createOrder(userId, packageId) {
if (!userId) throw httpError('로그인이 필요합니다', 401);
const d = load(); const p = d.packages.find((x) => x.id === packageId && x.active !== false);
if (!p) throw httpError('선택한 상품이 없거나 판매가 끝났습니다', 404);
const open = d.orders.filter((o) => o.userId === userId && o.status === 'pending');
if (open.length >= MAX_PENDING_PER_USER) throw httpError(`결제 대기 주문이 ${MAX_PENDING_PER_USER}건 있습니다. 먼저 기존 주문을 취소하거나 결제를 마쳐 주세요`, 409);
const o = { id: id(), userId, packageId: p.id, name: p.name, amount: p.amount, bonusType: p.bonusType, bonusValue: p.bonusValue, bonus: bonusOf(p), credit: creditOf(p), status: 'pending', at: now(), paidAt: null, cancelledAt: null, by: null, memo: '' };
d.orders.push(o); if (d.orders.length > 20000) d.orders.splice(0, d.orders.length - 20000); save(d); return { ...o };
}
/** 내 주문 (최신순) */
function myOrders(userId, limit = 50) { return load().orders.filter((o) => o.userId === userId).slice(-limit).reverse(); }
/** 관리자용 주문 목록 (최신순). filter.status 로 상태를 고를 수 있다 */
function listOrders({ status, limit = 200 } = {}) { return load().orders.filter((o) => !status || o.status === status).slice(-limit).reverse(); }
function getOrder(oid) { return load().orders.find((o) => o.id === oid) || null; }
/**
* 입금(결제) 확인 → 적립 (관리자, 또는 나중에 PG 콜백). 결제 대기 주문만 처리하며 캐시 원장에 add 로 기록한다.
* 같은 주문을 두 번 적립하지 못하게 상태를 먼저 바꾼 뒤 원장에 쓴다(원장 기록이 실패하면 상태를 되돌린다).
* @param {string} oid 주문 ID
* @param {string} by 처리자 (관리자 ID 또는 'pg:<provider>')
* @param {string} [memo] 메모(입금자명·승인번호 등. 비밀값은 넣지 않는다)
*/
function markPaid(oid, by, memo) {
const d = load(); const o = d.orders.find((x) => x.id === oid); if (!o) throw httpError('주문이 없습니다', 404);
if (o.status !== 'pending') throw httpError(`이미 ${o.status === 'paid' ? '적립된' : '취소된'} 주문입니다`, 409);
o.status = 'paid'; o.paidAt = now(); o.by = by || null; o.memo = String(memo || '').slice(0, 200); save(d);
try {
const credits = require('./credits');
const r = credits.apply({ userId: o.userId, type: 'add', amount: o.credit, memo: `선불 충전 ${o.name} — 결제 ${o.amount.toLocaleString('ko-KR')}원${o.bonus ? ` + 인센티브 ${o.bonus.toLocaleString('ko-KR')}원` : ''}`, by: by || 'system', ref: { orderId: o.id } });
return { order: { ...o }, balance: r.balance, entry: r.entry };
} catch (e) {
const d2 = load(); const o2 = d2.orders.find((x) => x.id === oid); if (o2) { o2.status = 'pending'; o2.paidAt = null; o2.by = null; save(d2); }
throw e;
}
}
/**
* 주문 취소. 사용자는 자기 결제 대기 주문만(userId 를 넘긴다), 관리자는 어떤 결제 대기 주문이든(userId 생략).
* 이미 적립된(paid) 주문은 취소할 수 없다 — 캐시 조정은 관리자 콘솔의 캐시 조정으로 한다.
*/
function cancelOrder(oid, by, { userId, reason } = {}) {
const d = load(); const o = d.orders.find((x) => x.id === oid); if (!o || (userId && o.userId !== userId)) throw httpError('주문이 없습니다', 404);
if (o.status === 'paid') throw httpError('이미 적립된 주문은 취소할 수 없습니다. 캐시 조정은 관리자에게 요청하세요', 409);
if (o.status === 'cancelled') return { ...o };
o.status = 'cancelled'; o.cancelledAt = now(); o.by = by || null; o.memo = String(reason || '').slice(0, 200); save(d); return { ...o };
}
/** 관리자 요약: 상품 수·결제 대기 건수·금액 */
function summary() {
const d = load(); const pending = d.orders.filter((o) => o.status === 'pending');
return { packages: d.packages.length, activePackages: d.packages.filter((p) => p.active !== false).length, pendingOrders: pending.length, pendingAmount: pending.reduce((a, o) => a + o.amount, 0), paidOrders: d.orders.filter((o) => o.status === 'paid').length };
}
module.exports = { FILE, MAX_PENDING_PER_USER, AMOUNT_MIN, AMOUNT_MAX, BONUS_TYPES, STATUS, bonusOf, creditOf, listPackages, getPackage, savePackage, removePackage, createOrder, myOrders, listOrders, getOrder, markPaid, cancelOrder, summary };
+227
View File
@@ -0,0 +1,227 @@
'use strict';
// 사용자 입력과 첨부 파일 정보를 Claude 에 전달할 프롬프트로 정리한다.
const { humanSize } = require('./uploads');
const fs = require('fs');
const path = require('path');
const WIDGET_TPL = fs.readFileSync(path.join(__dirname, '..', 'templates', 'contact-widget.html'), 'utf8');
const sitemsg = require('./sitemsg');
/**
* 표준 문의 위젯 코드 (사이트별 엔드포인트 삽입).
* v1.3.16: 화면 글자는 코드에 두지 않고 시스템 문구 카탈로그(lib/sitemsg)에서 사이트 언어(lang)로 채운다.
* @param {string} [lang] 사이트 본문(기본) 언어 — 다국어 사이트는 기본 언어, 다른 언어 페이지는 번역 단계에서 바뀐다
*/
function contactWidget(site, kind = 'contact', endpointBase, lang = 'ko') {
const endpoint = `${String(endpointBase || '').replace(/\/$/, '')}/api/forms/${site.id}/contact`;
const t = sitemsg.forLang(lang);
return sitemsg.renderTemplate(WIDGET_TPL.replace(/{{ENDPOINT}}/g, endpoint).replace(/{{SITE_ID}}/g, site.id).replace(/{{KIND}}/g, kind).replace(/{{MESSAGE_LABEL}}/g, t(kind === 'booking' ? 'contact.bookingMessageLabel' : 'contact.messageLabel')).replace(/{{BUTTON_LABEL}}/g, t(kind === 'booking' ? 'contact.bookingButton' : 'inquiry.button')), lang);
}
function widgetSection(site, endpointBase, opts = {}) {
if (!site || !endpointBase) return '';
return `[문의·예약 접수 표준 위젯 — 반드시 이 코드 그대로 사용]
문의, 상담 신청, 예약 접수 등 사용자가 내용을 보내는 폼은 mailto: 링크나 임의의 <form action> 을 만들지 말고 아래 표준 위젯 코드를 그대로 붙여 넣습니다(사이트 안에서 메일이 발송됩니다). data-* 속성과 <script>, 기본 입력 항목(name, email, phone, subject, message, website, agree)은 한 글자도 바꾸지 말고, 모양은 .wm-contact, .wm-row, .wm-submit 등 클래스에 CSS 만 추가해 꾸밉니다. 기존 폼에 회사명·업종·문의 유형 같은 항목이 더 있었다면 폼 안에 <label>항목명<input name="항목명"></label> 또는 <select name="항목명"> 으로 추가합니다 — name 이 있는 항목은 모두 접수 메일에 함께 포함되므로 기존 항목을 빠뜨리지 않습니다(파일 첨부 항목만 지원하지 않으므로 "이메일로 보내 주세요" 안내 문구로 대체). 예약용은 data-kind="booking" 인 두 번째 코드를 씁니다. 전화·이메일 주소를 안내 문구로 표시하는 것은 되지만 링크는 mailto: 대신 일반 텍스트로 둡니다.
--- 문의 위젯 ---
${contactWidget(site, 'contact', endpointBase, opts.lang)}
${opts.booking ? `
--- 예약 위젯 (data-kind="booking") ---
${contactWidget(site, 'booking', endpointBase, opts.lang)}
` : ''}`;
}
const SYSTEM_PROMPT = `당신은 정적 웹사이트를 처음부터 완성하는 웹 개발 에이전트입니다.
규칙:
- 모든 파일은 현재 작업 디렉터리(사이트 루트) 안에만 작성합니다. 그 밖의 경로에는 절대 쓰지 않습니다.
- 메인 페이지 파일명은 반드시 index.html 입니다. 추가 페이지는 같은 폴더에 *.html 로 만들고, 링크는 상대 경로(예: about.html, ./css/style.css)만 사용합니다. 절대 경로(/로 시작)는 사용하지 않습니다.
- 빌드 도구나 서버 사이드 코드 없이 브라우저에서 바로 열리는 HTML/CSS/JS 로 작성합니다. 필요하면 CDN(Google Fonts, cdnjs 등)은 사용해도 됩니다.
- 반응형(모바일 포함)으로 만들고, 기본 언어는 사용자가 입력한 언어를 따릅니다.
- 사용자가 첨부한 파일은 사이트 루트의 uploads/ 폴더에 있습니다. 첨부 이미지는 <img src="uploads/파일명"> 처럼 상대 경로로 페이지에 사용하고, 첨부 문서(txt, md, pdf, csv 등)는 Read 도구로 내용을 읽어 본문/구성에 반영합니다. 첨부 이미지는 Read 도구로 직접 보고 내용에 맞게 배치·설명합니다. uploads/ 안의 파일은 이동·삭제·덮어쓰기하지 않습니다.
- 문의/예약 접수 폼은 사용자 프롬프트에 주어지는 표준 위젯 코드를 그대로 사용하고, mailto: 링크는 사용하지 않습니다.
- 첨부 이미지가 없는 자리에는 외부 URL 을 임의로 넣지 말고 CSS 그라데이션, 인라인 SVG, 이모지, 플레이스홀더 블록으로 대체합니다.
- 질문하지 말고 합리적으로 가정하여 즉시 완성합니다. 사용자는 대화할 수 없습니다.
- 파일명이 '.' 으로 시작하는 파일은 만들지 않습니다.
- 작업이 끝나면 한국어로 5줄 이내의 짧은 요약(만든/수정한 파일과 핵심 내용)만 답합니다. 코드 전체를 답변에 포함하지 않습니다.`;
/** 언어 코드 → 표시 이름 (주석 언어 안내용). ko 또는 알 수 없으면 한국어 */
function langName(lang) { try { return lang && lang !== 'ko' ? new Intl.DisplayNames([lang], { type: 'language' }).of(lang) : '한국어'; } catch (_) { return '한국어'; } }
/**
* 코드 주석 규칙 (v1.2.0). 생성·수정 프롬프트 "앞"에 붙는다.
* 1차 테스트에서 규칙을 프롬프트 끝(긴 위젯 코드 뒤)에 두었더니 기본 등급 모델이 무시해 주석이 하나도 달리지 않았다.
* 그래서 앞쪽에 "필수 요구"로 두고, 끝에는 commentReminder 로 한 번 더 확인시킨다.
* @param {string} lang 빌더 화면 언어 (site.uiLang). 주석을 이 언어로 쓴다
* @param {boolean} existing 가져온·복사한 사이트면 true — 원래 코드는 건드리지 않고 고친·추가한 부분에만 주석
*/
function commentRule(lang, existing) {
const name = langName(lang); const code = lang && lang !== 'ko' ? ` (${lang})` : '';
const scope = existing
? `원래 파일의 구조와 기존 주석은 그대로 두고, 이번에 수정하거나 추가한 코드마다 바로 앞에 무엇을 왜 바꿨는지 ${name} 주석을 답니다 (HTML <!-- -->, CSS /* */, JS //). 새로 만든 파일은 아래 전체 규칙을 따릅니다.`
: `만들거나 고치는 모든 HTML·CSS·JS 파일에 ${name} 주석을 답니다.`;
return `[필수 요구 — 코드 주석 (주석 언어: ${name}${code})]
${scope}
사이트 주인이 "소스 보기"로 코드를 열었을 때 구조와 동작을 이해할 수 있도록 친절하고 구체적으로 씁니다. 주석이 빠진 결과는 미완성입니다.
- 각 파일 맨 위: 이 파일의 역할, 담고 있는 페이지/섹션, 다른 파일(CSS·JS)과의 관계
- HTML: 주요 구역(헤더·내비게이션·각 섹션·푸터) 시작마다 무엇을 보여 주는 구역인지
- CSS: 색상·글꼴 같은 공통 변수의 의미, 구역별 스타일 묶음, 반응형(@media) 기준과 이유
- JS: 함수마다 목적·매개변수·반환값, 주요 변수의 의미, 이벤트 흐름과 눈에 띄지 않는 로직의 이유
- 표준 위젯·모듈 코드(data-wm-* 가 있는 블록)는 내용을 바꾸지 말고 바깥에 한 줄 설명만 붙입니다.
주석은 설명이지 장식이 아닙니다. 코드와 맞지 않는 주석, 의미 없는 반복 주석은 쓰지 않습니다.
`;
}
/** 프롬프트 끝에 붙이는 한 줄 확인 (commentRule 참고) */
function commentReminder(lang, existing) { return `[마지막 확인] 작업을 끝내기 전에 ${existing ? '이번에 수정·추가한 코드' : '만들거나 고친 모든 파일'}에 ${langName(lang)} 주석을 달았는지 확인하세요.`; }
function field(label, v) {
v = (v || '').trim();
return v ? `[${label}]\n${v}\n` : '';
}
function describe(a) {
const kind = a.kind === 'image' ? '이미지' : '문서';
let s = `- ${a.rel} (${kind}, ${humanSize(a.size)})`;
if (a.kind === 'image') s += ' → Read 로 확인 후 페이지에 <img src="' + a.rel + '"> 로 사용';
else if (a.textSidecar) s += ` → 텍스트 추출본 ${a.textSidecar} 를 Read 로 읽어 내용 반영`;
else s += ' → Read 로 읽어 내용 반영';
return s;
}
/** 첨부 파일 섹션. newOnes: 이번 명령과 함께 올라온 파일, existing: 이전부터 있던 파일 */
function attachmentsSection(newOnes, existing) {
if (!newOnes.length && !existing.length) return '';
const lines = ['[첨부 파일]', '사용자가 업로드한 파일입니다. 아래 안내대로 반드시 활용하세요.'];
if (newOnes.length) { lines.push('이번에 새로 첨부된 파일:'); newOnes.forEach((a) => lines.push(describe(a))); }
if (existing.length) { lines.push('이전에 첨부된 파일(필요 시 계속 사용):'); existing.forEach((a) => lines.push(describe(a))); }
return lines.join('\n') + '\n';
}
function buildCreatePrompt(f, attachments = [], widget = '') {
return [
'다음 요구사항에 맞는 웹사이트를 현재 디렉터리에 새로 작성하세요.\n',
field('페이지 제목', f.title),
field('운영 주체(회사명/이름)', f.ownerName),
field('목적', f.purpose),
field('주요 컨텐츠', f.content),
field('디자인 방향', f.design),
field('추가 요구사항', f.extra),
attachmentsSection(attachments, []),
widget,
'\n메인 페이지는 index.html 이어야 하며, 지금 바로 파일을 작성하세요.',
].join('\n');
}
function basicsSection(site, changed) {
if (!site) return '';
const lines = [
field('페이지 제목', site.title), field('운영 주체(회사명/이름)', site.ownerName), field('목적', site.purpose),
field('주요 컨텐츠', site.content), field('디자인 방향', site.design), field('추가 요구사항', site.extra),
].filter(Boolean).join('\n');
const head = changed && changed.length
? `[홈페이지 기본 사항 — 최근 변경됨: ${changed.join(', ')}]\n사용자가 아래 기본 사항을 수정했습니다. 변경된 항목이 페이지 전반에 반영되도록 함께 수정하세요.`
: '[홈페이지 기본 사항 (참고)]';
return `${head}\n${lines}\n`;
}
/**
* 작업 맥락 섹션 (v1.1.0). 매 작업마다 모델을 바꿀 수 있으므로 세션 기억에 의존하지 않고
* 사이트 기본 정보·설정과 최근 지시 이력을 프롬프트에 다시 넣어 맥락이 이어지게 한다.
* - 명시적으로 바꾸라고 한 것 외에는 이전 결정(디자인·구조·문구·설정)을 유지하라고 지시한다.
*/
function historySection(site, limit = 8) {
if (!site) return '';
// v1.4.14 (요구 7): 중단·캐시 대기로 끝난 작업도 이력에 넣는다 — 그 전까지의 변경은 파일에 남아 있어 모델이 알아야 한다
const cmds = (site.commands || []).filter((c) => ['create', 'update'].includes(c.kind) && ['done', 'paused-credit', 'cancelled'].includes(c.status)).slice(-limit);
const keep = [`사이트 제목: ${site.title || ''}`, site.contactEmail ? `문의 수신 이메일: ${site.contactEmail}` : '', (site.domains || []).length ? `연결 도메인: ${site.domains.join(', ')}` : '', site.plan && site.plan.brief ? `업종: ${site.plan.brief.business.industry.major || ''}${site.plan.brief.business.industry.minor ? '/' + site.plan.brief.business.industry.minor : ''} · 톤: ${site.plan.brief.design.tone || ''} · 언어: ${site.plan.brief.copy.language || 'ko'}` : ''].filter(Boolean);
const hist = cmds.map((c, i) => `${i + 1}. [${c.kind === 'create' ? '생성' : '수정'}${c.status !== 'done' ? ' · ' + (c.status === 'cancelled' ? '중단됨' : '캐시 대기') : ''}] ${String(c.instruction || '').replace(/\s+/g, ' ').slice(0, 160)}${c.resultText ? ` → 결과: ${stripBrief(c.resultText).slice(0, 220)}` : ''}${c.git && c.git.files && c.git.files.length ? ` (바뀐 파일 ${c.git.files.length}개)` : ''}`);
return `[작업 맥락 — 반드시 유지]
이 사이트는 아래 정보와 이전 지시들을 바탕으로 만들어졌습니다. 이번 지시에서 명시적으로 바꾸라고 한 것만 바꾸고, 그 외의 디자인(색·폰트·레이아웃)·페이지 구성·문구·설정은 그대로 유지합니다. 작업 전에 현재 파일(Read/Glob)을 확인해 실제 상태를 기준으로 수정합니다.
${keep.map((k) => '- ' + k).join('\n')}
${hist.length ? '[이전 지시 이력(최근 ' + hist.length + '건)]\n' + hist.join('\n') : '[이전 지시 이력] 없음(첫 수정)'}
`;
}
/** AI 응답에서 <brief> 블록을 빼고 한 줄로 */
function stripBrief(t) { return String(t || '').replace(/<brief>[\s\S]*?<\/brief>/g, '').replace(/\s+/g, ' ').trim(); }
/** 모델 참조 표시용 (anthropic:claude-opus-5-5 → claude-opus-5-5, openai:gpt-6-astra → openai/gpt-6-astra) */
function shortModel(ref) { const r = String(ref || ''); return r.startsWith('anthropic:') ? r.slice(10) : r.replace(':', '/'); }
/**
* 엔진·모델 변경 때 맥락 이어 주기 (v1.4.14, 요구 7).
*
* 작업마다 모델 등급을 바꿀 수 있고, 등급에 묶인 모델은 제공자가 다를 수 있다(Claude ↔ OpenAI/codex).
* - Claude 는 사이트별 세션(--resume)을 이어 가지만 다른 엔진이 한 수정은 그 세션 기억에 없다.
* - OpenAI(codex·API) 엔진은 실행마다 새로 시작해 세션 기억이 없다 — 작업 맥락은 historySection 과 이 섹션으로만 전달된다.
* 그래서 "이번 모델이 이 사이트를 마지막으로 다룬 뒤" 다른 모델이 한 작업(지시·결과 요약·바뀐 파일)을 정리해 넣는다.
* 같은 모델이 연속이면 빈 문자열(이력 섹션과 세션 기억으로 충분).
* @param {object} site
* @param {string} modelRef 이번 작업의 모델 참조 (provider:model)
* @param {number} [limit] 넣을 최대 작업 수 (프롬프트가 지나치게 길어지지 않게)
*/
function engineSwitchSection(site, modelRef, limit = 6) {
const cmds = (site && site.commands || []).filter((c) => ['create', 'update'].includes(c.kind) && ['done', 'paused-credit', 'cancelled'].includes(c.status) && c.model);
if (!cmds.length || !modelRef) return '';
if (cmds[cmds.length - 1].model === modelRef) return '';
let idx = -1; for (let i = cmds.length - 1; i >= 0; i--) if (cmds[i].model === modelRef) { idx = i; break; }
const since = cmds.slice(idx + 1).slice(-limit); if (!since.length) return '';
const others = [...new Set(since.map((c) => shortModel(c.model)))];
const lines = since.map((c) => {
const files = ((c.git && c.git.files) || []); const fl = files.slice(0, 12).map((f) => `${{ A: '추가', M: '수정', D: '삭제', R: '이름변경' }[f.status] || f.status} ${f.path}`).join(', ');
return `- [${c.kind === 'create' ? '생성' : '수정'} #${c.no} · ${shortModel(c.model)}${c.status !== 'done' ? ' · ' + (c.status === 'cancelled' ? '중단됨(일부 반영)' : '캐시 대기(일부 반영)') : ''}]\n 지시: ${String(c.instruction || '').replace(/\s+/g, ' ').slice(0, 300)}${c.resultText ? `\n 결과: ${stripBrief(c.resultText).slice(0, 400)}` : ''}${fl ? `\n 바뀐 파일: ${fl}${files.length > 12 ? ` 외 ${files.length - 12}개` : ''}` : ''}`;
});
return `[엔진 변경 안내 — 맥락 유지]
이번 작업은 ${shortModel(modelRef)} 로 진행합니다. ${idx >= 0 ? '이 모델이 이 사이트를 마지막으로 다룬 뒤' : '지금까지'} 다른 AI 모델(${others.join(', ')})이 아래 작업을 했고, 그 결과는 이미 현재 파일에 반영되어 있습니다.
기억에 없는 변경이라도 되돌리거나 다시 만들지 말고, 반드시 현재 파일(Read/Glob)을 기준으로 아래 작업 위에 이어서 수정합니다. 디자인·구조·문구는 이전 작업들의 결정을 그대로 존중합니다.
${lines.join('\n')}
`;
}
function importSection(site) {
if (!site || (site.mode !== 'import' && site.mode !== 'copy')) return '';
const src = site.source || { url: site.copyFrom };
const dyn = site.importInfo && site.importInfo.dynamic;
return `[운영 중인 기존 사이트]
이 디렉터리는 실제로 운영 중인 사이트(${src.url || src.host || ''})의 파일을 ${site.mode === 'copy' ? '웹에서 복사해 온 것입니다(서버 코드는 없고 화면 결과만 있음). 수정 결과는 발행 시 빌더의 배포 서버로 올라갑니다.' : '그대로 가져온 것입니다. 수정 결과는 원본 서버에 다시 반영됩니다.'}
- 기존 구조·디자인·파일 구성과 링크 경로를 최대한 유지하고, 지시된 부분만 최소한으로 고칩니다. 지시와 무관한 파일은 건드리지 않습니다.
- 기존 파일을 새 파일로 통째로 바꾸지 말고, 해당 부분만 Edit 로 수정합니다. 파일 이름·경로를 바꾸거나 삭제하지 않습니다(요청된 경우 제외).
- 인코딩·줄바꿈·들여쓰기 등 원본 스타일을 따르고, 원본에 있는 분석 코드·외부 스크립트·메타 태그는 유지합니다.${dyn ? `
- 서버 스크립트(PHP 등)가 있는 사이트입니다. 템플릿 안의 HTML/CSS/JS 만 수정하고 서버 로직은 요청이 없으면 바꾸지 않습니다. 새 정적 파일을 만들 때도 기존 방식과 어울리게 합니다.` : ''}
`;
}
function buildUpdatePrompt(instruction, newOnes = [], existing = [], site = null, basicsChanged = [], widget = '') {
return [
(site && (site.mode === 'import' || site.mode === 'copy')) ? '현재 디렉터리의 웹사이트(운영 중인 기존 사이트를 가져오거나 복사한 것)에 다음 수정 지시를 반영하세요. 필요한 파일만 직접 수정하세요.\n' : '현재 디렉터리의 웹사이트에 다음 수정 지시를 반영하세요. 기존 파일을 직접 수정하거나 필요한 파일을 추가하세요.\n',
importSection(site),
historySection(site),
`[수정 지시]\n${instruction.trim()}\n`,
basicsSection(site, basicsChanged),
attachmentsSection(newOnes, existing),
widget,
].join('\n');
}
/** Brief 기반 생성 프롬프트 (AI에게 맡기기 / 내가 고르기 공통) */
function buildBriefPrompt(brief, attachments = [], widget = '') {
const files = brief.pages.map((p, i) => `${i === 0 ? 'index.html' : p.id + '.html'} ← ${p.title} [${p.sections.map((s) => s.id).join(', ')}]`).join('\n');
// v1.0.3: 사용자가 입력한 값만 쓰고, 값이 없는 항목(removed/missing)은 페이지에서 항목 자체를 뺀다 ("[입력 필요]" 자리도 두지 않음)
const facts = Object.entries(brief.facts || {}).map(([k, v]) => `- ${k}: ${v.status === 'provided' && v.value ? String(v.value) : '(항목 삭제 — 이 정보는 페이지 어디에도 표시하지 않고 관련 문구·칸·메뉴도 두지 않음)'}`).join('\n');
const fontSet = brief.design.fonts || {};
return `아래 Site Brief 만을 근거로 정적 웹사이트를 현재 디렉터리에 작성하세요. Brief 에 없는 내용을 지어내지 않습니다.
[Site Brief]
${JSON.stringify(brief, null, 1)}
[페이지 → 파일] (이 파일들을 모두 만들고, 공통 내비게이션으로 서로 연결)
${files}
[사실정보 사용 규칙]
${facts}
사실정보는 위 값만 그대로 사용합니다. 값이 없는(삭제된) 항목은 아예 표시하지 않고 관련 섹션·문구도 넣지 않습니다. 절대 예시 번호·주소·가격·후기를 만들지 않습니다. 후기 섹션은 실제 후기가 있을 때만 만들고, 없으면 후기 섹션 자체를 두지 않습니다.
${brief.regulated && brief.regulated.flag ? `[규제 제약] ${brief.regulated.constraints.join(' / ')}\n` : ''}
[작성 순서] 1) 골격: 모든 페이지 파일 + 공통 헤더/내비/푸터 + 섹션 뼈대 2) 콘텐츠: 섹션별 문구(헤드라인·본문은 Brief 어조(${brief.copy.voice}, ${brief.copy.length}, 언어 ${brief.copy.language})로 창작 가능) 3) 스타일: css/style.css 하나로 톤 "${brief.design.tone}", 팔레트 ${JSON.stringify((brief.design.palette || {}).colors || [])} (순서: 본문 텍스트, 보조, 배경, 포인트), 폰트 제목 "${fontSet.heading || 'Noto Sans KR'}" 본문 "${fontSet.body || 'Noto Sans KR'}"${fontSet.google ? ` (Google Fonts: ${fontSet.google})` : ''}, 밀도 ${brief.design.density}, 이미지 유형 ${brief.design.imagery}.
[이미지] 첨부 로고/사진(${(brief.assets && brief.assets.logo) || '없음'}; ${((brief.assets && brief.assets.photos) || []).join(', ') || '사진 없음'})만 <img> 로 사용. 그 외 자리는 ${brief.assets && brief.assets.useStock ? '스톡 이미지 대신 ' : ''}CSS 그라데이션·인라인 SVG·플레이스홀더 블록으로 채웁니다.
[CTA] 목표(${brief.goals.join(', ')})에 맞는 CTA 버튼을 히어로와 페이지 하단에 배치합니다.
${attachments}
${widget}
완료 후 한국어 5줄 이내 요약만 답합니다.`;
}
module.exports = { commentRule, commentReminder, SYSTEM_PROMPT, buildCreatePrompt, buildUpdatePrompt, buildBriefPrompt, attachmentsSection, contactWidget, widgetSection, historySection, engineSwitchSection, shortModel };
+159
View File
@@ -0,0 +1,159 @@
'use strict';
// AI 제공자 카탈로그, 모델 목록 조회, API 키 암호화 저장, 관리자 제공자 설정, 사용자별 연결(자기 키) 저장소.
// 모델 참조 형식: "<provider>:<model>" 관리자 키로 쓰는 공용 모델
// "user:<connectionId>:<model>" 사용자가 연결한 자기 모델
// provider: anthropic(Claude Code CLI) | openai | gemini | groq | openrouter | custom(OpenAI 호환 URL)
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const settings = require('./settings');
const codex = require('./engine-codex');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const CONN_FILE = path.join(DATA_ROOT, 'user-connections.json');
const PROVIDERS = {
anthropic: { label: 'Anthropic Claude', kind: 'claude-cli', keyHint: 'sk-ant-api03-… 또는 claude setup-token 으로 만든 OAuth 토큰', modelsUrl: null,
models: settings.MODELS.map((m) => ({ id: m.id, label: m.label, input: m.input, output: m.output })) },
openai: { label: 'OpenAI (ChatGPT)', kind: 'openai', baseUrl: 'https://api.openai.com/v1', keyHint: 'sk-…', modelFilter: /^(gpt-|o[1-9]|chatgpt-)/,
prices: { 'gpt-5.2': [1.75, 14], 'gpt-5.2-mini': [0.4, 1.6], 'gpt-5.1': [1.25, 10], 'gpt-5': [1.25, 10], 'gpt-5-mini': [0.25, 2], 'gpt-5-nano': [0.05, 0.4], 'gpt-4.1': [2, 8], 'gpt-4.1-mini': [0.4, 1.6], 'gpt-4o': [2.5, 10], 'gpt-4o-mini': [0.15, 0.6], 'o3': [2, 8], 'o4-mini': [1.1, 4.4] } },
gemini: { label: 'Google Gemini', kind: 'openai', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', keyHint: 'AIza…', modelFilter: /^gemini-/,
prices: { 'gemini-2.5-pro': [1.25, 10], 'gemini-2.5-flash': [0.3, 2.5], 'gemini-2.5-flash-lite': [0.1, 0.4], 'gemini-3-pro-preview': [2, 12], 'gemini-3-flash-preview': [0.5, 3] } },
groq: { label: 'Groq', kind: 'openai', baseUrl: 'https://api.groq.com/openai/v1', keyHint: 'gsk_…', modelFilter: /^(llama|meta-llama|openai\/gpt-oss|qwen|moonshotai|deepseek|mixtral|gemma)/,
prices: { 'llama-3.3-70b-versatile': [0.59, 0.79], 'llama-3.1-8b-instant': [0.05, 0.08], 'openai/gpt-oss-120b': [0.15, 0.75], 'openai/gpt-oss-20b': [0.1, 0.5], 'moonshotai/kimi-k2-instruct': [1, 3], 'qwen/qwen3-32b': [0.29, 0.59] } },
openrouter: { label: 'OpenRouter', kind: 'openai', baseUrl: 'https://openrouter.ai/api/v1', keyHint: 'sk-or-v1-…', modelFilter: null, prices: {} },
custom: { label: '기타 OpenAI 호환 서버', kind: 'openai', baseUrl: '', keyHint: 'API 키(없으면 비움)', modelFilter: null, prices: {} },
};
const PROVIDER_IDS = Object.keys(PROVIDERS);
// ---------- 암호화 (data/session.secret 파생 키, AES-256-GCM) ----------
function secretKey() { const s = fs.readFileSync(path.join(DATA_ROOT, 'session.secret'), 'utf8').trim(); return crypto.createHash('sha256').update('provider-keys:' + s).digest(); }
function enc(text) { if (!text) return ''; const iv = crypto.randomBytes(12); const c = crypto.createCipheriv('aes-256-gcm', secretKey(), iv); const out = Buffer.concat([c.update(String(text), 'utf8'), c.final()]); return 'enc:' + Buffer.concat([iv, c.getAuthTag(), out]).toString('base64'); }
function dec(blob) { if (!blob) return ''; if (!String(blob).startsWith('enc:')) return String(blob); const b = Buffer.from(String(blob).slice(4), 'base64'); const d = crypto.createDecipheriv('aes-256-gcm', secretKey(), b.subarray(0, 12)); d.setAuthTag(b.subarray(12, 28)); return Buffer.concat([d.update(b.subarray(28)), d.final()]).toString('utf8'); }
const mask = (k) => (k ? `${String(k).slice(0, 7)}…${String(k).slice(-4)}` : '');
// ---------- 관리자 제공자 설정 (settings.json 의 providers 항목) ----------
function adminProviders() { const s = settings.load(); return s.providers || {}; }
function saveAdminProvider(id, patch) {
if (!PROVIDERS[id]) throw Object.assign(new Error('알 수 없는 제공자'), { status: 400 });
const s = settings.load(); s.providers = s.providers || {}; const p = s.providers[id] || { enabled: false, apiKey: '', baseUrl: '', models: [], prices: {} };
if (typeof patch.apiKey === 'string' && patch.apiKey !== '••••') p.apiKey = patch.apiKey.trim() ? enc(patch.apiKey.trim()) : '';
if (typeof patch.baseUrl === 'string') p.baseUrl = patch.baseUrl.trim();
if (patch.enabled !== undefined) p.enabled = !!patch.enabled;
if (id === 'openai' && ['api-key', 'server-login'].includes(patch.authMode)) p.authMode = patch.authMode;
if (Array.isArray(patch.models)) p.models = patch.models.map((m) => (typeof m === 'string' ? { id: m } : { id: m.id, label: m.label || m.id, input: +m.input || 0, output: +m.output || 0 })).filter((m) => m.id);
if (patch.prices && typeof patch.prices === 'object') p.prices = { ...(p.prices || {}), ...patch.prices };
s.providers[id] = p; settings.save({}); // updatedAt 갱신 및 저장
return publicAdminProvider(id);
}
function publicAdminProvider(id) { const p = adminProviders()[id] || {}; const key = p.apiKey ? dec(p.apiKey) : ''; const out = { id, label: PROVIDERS[id].label, kind: PROVIDERS[id].kind, keyHint: PROVIDERS[id].keyHint, enabled: !!p.enabled, hasKey: !!key, apiKeyMasked: mask(key), baseUrl: p.baseUrl || PROVIDERS[id].baseUrl || '', models: p.models || [], lastTest: p.lastTest || null, builtinModels: PROVIDERS[id].models || null, authMode: p.authMode || 'api-key' }; if (id === 'openai') out.codex = codex.codexInfo(); return out; }
/**
* v1.6.38 (요구 1): 이 제공자의 모델이 지금 "설정 탭 › 모델 등급" 목록에 들어가는지와 빠진 이유.
* 1.6.35 까지는 저장 응답이 "반영했습니다" 라고만 해서, 키 없이 저장한 제공자(모델 조회는 키 없이도 되는 OpenRouter 등)가 등급 목록에 안 보이는 이유를 알 수 없었다.
* @returns {{usable:boolean, reason:string|null, models:number}}
*/
function usability(pid) {
if (pid === 'anthropic') { const n = availableModels(null).filter((m) => m.provider === 'anthropic').length; return { usable: n > 0, reason: n ? null : 'Claude 인증(서버 로그인·OAuth 토큰·API 키)이 없습니다', models: n }; }
const p = adminProviders()[pid];
if (!p || !p.enabled) return { usable: false, reason: '제공자가 꺼져 있습니다 ("사용" 을 켜고 저장)', models: 0 };
const miss = unavailableProviders().find((x) => x.id === pid);
if (miss) return { usable: false, reason: miss.reason, models: 0 };
const n = availableModels(null).filter((m) => m.provider === pid).length;
return { usable: n > 0, reason: n ? null : '제공할 모델을 고르지 않았습니다', models: n };
}
function listAdminProviders() { return PROVIDER_IDS.map((pid) => ({ ...publicAdminProvider(pid), ...(() => { const u = usability(pid); return { usable: u.usable, unavailableReason: u.reason, usableModels: u.models }; })() })); }
function recordTest(id, result) { const s = settings.load(); s.providers = s.providers || {}; s.providers[id] = s.providers[id] || {}; s.providers[id].lastTest = { ...result, at: new Date().toISOString() }; settings.save({}); }
// ---------- 모델 목록 조회 (OpenAI 호환) ----------
async function fetchModels(providerId, apiKey, baseUrl) {
const P = PROVIDERS[providerId]; if (!P) throw new Error('알 수 없는 제공자');
if (P.kind === 'claude-cli') return P.models.map((m) => ({ id: m.id, label: m.label, input: m.input, output: m.output }));
if (providerId === 'openai' && (adminProviders().openai || {}).authMode === 'server-login' && !apiKey) { const info = codex.codexInfo(); if (!info.bin) throw new Error('서버에 codex CLI 가 없습니다 (npm i -g @openai/codex)'); if (!info.loggedIn) throw new Error('서버에서 codex 로그인이 되어 있지 않습니다 (서버에서 codex login --device-auth 실행)'); return await codex.serverLoginModels(true); }
const base = (baseUrl || P.baseUrl || '').replace(/\/$/, ''); if (!base) throw new Error('기본 URL 이 필요합니다');
const headers = { Authorization: `Bearer ${apiKey || ''}` }; if (providerId === 'openrouter') { headers['HTTP-Referer'] = 'https://webmaker.ibit.kr'; headers['X-Title'] = 'WebMaker'; }
const ac = new AbortController(); const tm = setTimeout(() => ac.abort(), 20000);
let res; try { res = await fetch(`${base}/models`, { headers, signal: ac.signal }); } catch (e) { clearTimeout(tm); throw new Error('연결 실패: ' + e.message); } clearTimeout(tm);
if (!res.ok) { const t = await res.text().catch(() => ''); throw new Error(`모델 목록 조회 실패 (HTTP ${res.status}): ${t.slice(0, 200)}`); }
const j = await res.json(); const arr = Array.isArray(j.data) ? j.data : (Array.isArray(j.models) ? j.models : []);
const out = [];
for (const m of arr) {
let id = String(m.id || m.name || ''); if (!id) continue; id = id.replace(/^models\//, '');
if (P.modelFilter && !P.modelFilter.test(id)) continue;
const rec = { id, label: m.name && m.name !== id && !/^models\//.test(m.name) ? `${m.name} (${id})` : id, input: 0, output: 0 };
if (providerId === 'openrouter' && m.pricing) { rec.input = Math.round(parseFloat(m.pricing.prompt || 0) * 1e6 * 1000) / 1000; rec.output = Math.round(parseFloat(m.pricing.completion || 0) * 1e6 * 1000) / 1000; rec.context = m.context_length; }
else if (P.prices && P.prices[id]) { [rec.input, rec.output] = P.prices[id]; }
out.push(rec);
}
return out.sort((a, b) => a.id.localeCompare(b.id));
}
// ---------- 사용자 연결 저장소 ----------
function loadConns() { try { return JSON.parse(fs.readFileSync(CONN_FILE, 'utf8')); } catch (_) { return {}; } }
function saveConns(c) { const tmp = CONN_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(c, null, 1), { mode: 0o600 }); fs.renameSync(tmp, CONN_FILE); try { fs.chmodSync(CONN_FILE, 0o600); } catch (_) {} }
function publicConn(c) { return { id: c.id, provider: c.provider, providerLabel: (PROVIDERS[c.provider] || {}).label, label: c.label, authType: c.authType, keyMasked: mask(dec(c.secret)), baseUrl: c.baseUrl || '', models: c.models || [], addedAt: c.addedAt, lastTestedAt: c.lastTestedAt || null, lastUsedAt: c.lastUsedAt || null }; }
function listUserConns(userId) { return (loadConns()[userId] || []).map(publicConn); }
function addUserConn(userId, input) {
const provider = input.provider; if (!PROVIDERS[provider]) throw Object.assign(new Error('알 수 없는 제공자'), { status: 400 });
const secret = String(input.secret || input.apiKey || '').trim(); if (!secret && provider !== 'custom') throw Object.assign(new Error('API 키(또는 토큰)를 입력하세요'), { status: 400 });
const authType = provider === 'anthropic' ? (/^sk-ant-/.test(secret) ? 'api-key' : 'oauth-token') : 'api-key';
const all = loadConns(); const list = all[userId] || [];
const c = { id: crypto.randomBytes(6).toString('hex'), provider, label: String(input.label || PROVIDERS[provider].label).slice(0, 60), authType, secret: enc(secret), baseUrl: provider === 'custom' ? String(input.baseUrl || '').trim() : '', models: Array.isArray(input.models) ? input.models.slice(0, 200) : [], addedAt: new Date().toISOString(), lastTestedAt: input.tested ? new Date().toISOString() : null };
list.push(c); all[userId] = list.slice(-20); saveConns(all); return publicConn(c);
}
function updateUserConn(userId, connId, patch) { const all = loadConns(); const c = (all[userId] || []).find((x) => x.id === connId); if (!c) throw Object.assign(new Error('연결이 없습니다'), { status: 404 }); if (Array.isArray(patch.models)) c.models = patch.models.slice(0, 200); if (typeof patch.label === 'string') c.label = patch.label.slice(0, 60); if (patch.lastTestedAt) c.lastTestedAt = patch.lastTestedAt; if (patch.lastUsedAt) c.lastUsedAt = patch.lastUsedAt; saveConns(all); return publicConn(c); }
function removeUserConn(userId, connId) { const all = loadConns(); all[userId] = (all[userId] || []).filter((x) => x.id !== connId); saveConns(all); return listUserConns(userId); }
function getUserConnSecret(userId, connId) { const c = (loadConns()[userId] || []).find((x) => x.id === connId); return c ? { ...c, secret: dec(c.secret) } : null; }
// ---------- 사용 가능한 모델 ----------
/** 관리자가 켠 제공자의 모델 + 사용자의 연결 모델. 각 항목 {ref, provider, model, label, source:'admin'|'user', input, output} */
function availableModels(userId) {
const out = [];
// Anthropic(Claude Code CLI) 는 서버 로그인/OAuth/API 키 중 하나가 있으면 사용 가능
const s = settings.load(); const claudeReady = s.auth.mode === 'server-login' || (s.auth.mode === 'oauth-token' && settings.authSecret('oauthToken')) || (s.auth.mode === 'api-key' && settings.authSecret('apiKey'));
// v1.4.14 (요구 6): providerLabel·auth 를 함께 돌려준다 — 관리자 콘솔의 등급 지정 상자가 제공자별로 묶어 보이고, 왜 빠졌는지 알 수 있게
// v1.5.16 (요구 3): AI 연결 탭에서 "사용" 으로 켠 Claude 모델만 (settings.claudeModels, 비어 있으면 전체)
if (claudeReady) for (const m of settings.claudeModels()) out.push({ ref: `anthropic:${m.id}`, provider: 'anthropic', providerLabel: PROVIDERS.anthropic.label, model: m.id, label: m.label, source: 'admin', input: m.input, output: m.output });
const ap = adminProviders();
for (const id of PROVIDER_IDS) { const p = ap[id]; if (!p || !p.enabled || id === 'anthropic') continue; const serverLogin = id === 'openai' && p.authMode === 'server-login'; if (serverLogin && !codex.codexInfo().loggedIn) continue; if (!p.apiKey && id !== 'custom' && !serverLogin) continue; for (const m of p.models || []) out.push({ ref: `${id}:${m.id}`, provider: id, providerLabel: PROVIDERS[id].label + (serverLogin ? ' (서버 로그인)' : ''), model: m.id, label: `${m.label || m.id} · ${PROVIDERS[id].label}`, source: 'admin', input: (p.prices && p.prices[m.id] ? p.prices[m.id][0] : m.input) || 0, output: (p.prices && p.prices[m.id] ? p.prices[m.id][1] : m.output) || 0 }); }
if (userId) for (const c of loadConns()[userId] || []) for (const m of c.models || []) out.push({ ref: `user:${c.id}:${m.id || m}`, provider: c.provider, model: m.id || m, label: `${m.label || m.id || m} · 내 연결(${c.label})`, source: 'user', connId: c.id, input: m.input || 0, output: m.output || 0 });
return out;
}
/**
* v1.4.14 (요구 6) 관리자가 켜 둔 제공자가 등급 지정 목록에 들어가지 못한 이유 (화면 안내).
* @returns {Array<{id,label,reason}>} 예: 모델을 하나도 고르지 않음 / API 키 없음 / 서버 로그인 안 됨
*/
function unavailableProviders() {
const out = []; const ap = adminProviders();
for (const id of PROVIDER_IDS) {
const p = ap[id]; if (!p || !p.enabled || id === 'anthropic') continue;
const serverLogin = id === 'openai' && p.authMode === 'server-login';
if (serverLogin && !codex.codexInfo().loggedIn) out.push({ id, label: PROVIDERS[id].label, reason: '서버에서 codex 로그인이 되어 있지 않습니다 (서버에서 codex login --device-auth 실행)' });
else if (!p.apiKey && id !== 'custom' && !serverLogin) out.push({ id, label: PROVIDERS[id].label, reason: 'API 키가 없습니다 (AI 연결 탭 › AI 제공자에서 키를 넣고 저장)' });
else if (!(p.models || []).length) out.push({ id, label: PROVIDERS[id].label, reason: '제공할 모델을 고르지 않았습니다 (AI 연결 탭 › AI 제공자 › 모델 조회 → 체크 → 저장)' });
}
return out;
}
/** 모델 참조를 실행 정보로 변환. {provider, kind, model, apiKey|oauthToken, baseUrl, source, price:{input,output}} */
function resolveModel(ref, userId) {
const s = settings.load();
// v1.5.16 (요구 2): 참조가 없는 호출(= 서비스 내부 호출)은 관리자가 정한 내부 호출 모델. 그 모델의 제공자가 꺼져 쓸 수 없으면 Claude 기본 모델로 대신한다
if (!ref) { const ir = settings.internalRef(); if (ir && ir !== `anthropic:${s.defaultModel}`) { try { return resolveModel(ir, userId); } catch (_) { /* 제공자 꺼짐·모델 없음 → 아래 Claude 기본값 */ } } ref = `anthropic:${s.defaultModel}`; }
if (ref.startsWith('user:')) {
const [, connId, ...rest] = ref.split(':'); const model = rest.join(':'); const c = getUserConnSecret(userId, connId);
if (!c) throw Object.assign(new Error('연결된 AI 모델을 찾을 수 없습니다. 내 AI 연결을 확인하세요.'), { status: 400 });
const P = PROVIDERS[c.provider]; const m = (c.models || []).find((x) => (x.id || x) === model) || {};
return { ref, provider: c.provider, kind: P.kind, model, source: 'user', connId, apiKey: c.authType === 'api-key' ? c.secret : '', oauthToken: c.authType === 'oauth-token' ? c.secret : '', baseUrl: c.baseUrl || P.baseUrl, price: { input: m.input || (P.prices && P.prices[model] ? P.prices[model][0] : 0), output: m.output || (P.prices && P.prices[model] ? P.prices[model][1] : 0) } };
}
const i = ref.indexOf(':'); const provider = i > 0 ? ref.slice(0, i) : 'anthropic'; const model = i > 0 ? ref.slice(i + 1) : ref;
const P = PROVIDERS[provider]; if (!P) throw Object.assign(new Error('알 수 없는 제공자: ' + provider), { status: 400 });
if (provider === 'anthropic') { const m = settings.MODELS.find((x) => x.id === model) || {}; return { ref, provider, kind: 'claude-cli', model, source: 'admin', price: { input: m.input || 0, output: m.output || 0 } }; }
const p = adminProviders()[provider]; if (!p || !p.enabled) throw Object.assign(new Error(`${P.label} 제공자가 활성화되어 있지 않습니다`), { status: 400 });
const m = (p.models || []).find((x) => x.id === model) || {};
if (provider === 'openai' && p.authMode === 'server-login') return { ref, provider, kind: 'codex-cli', model, source: 'admin', price: { input: (p.prices && p.prices[model] ? p.prices[model][0] : m.input) || 0, output: (p.prices && p.prices[model] ? p.prices[model][1] : m.output) || 0 } };
return { ref, provider, kind: 'openai', model, source: 'admin', apiKey: dec(p.apiKey), baseUrl: p.baseUrl || P.baseUrl, price: { input: (p.prices && p.prices[model] ? p.prices[model][0] : m.input) || 0, output: (p.prices && p.prices[model] ? p.prices[model][1] : m.output) || 0 } };
}
/** v1.5.16 실제로 쓰일 내부 호출 모델 참조 (제공자가 꺼져 있으면 Claude 기본값으로 대체된 값) */
function internalRef() { try { return resolveModel(null, null).ref; } catch (_) { return `anthropic:${settings.load().defaultModel}`; } }
function providerCatalog() { return PROVIDER_IDS.map((id) => ({ id, label: PROVIDERS[id].label, kind: PROVIDERS[id].kind, keyHint: PROVIDERS[id].keyHint, needsBaseUrl: id === 'custom' })); }
module.exports = { PROVIDERS, PROVIDER_IDS, providerCatalog, listAdminProviders, usability, saveAdminProvider, recordTest, fetchModels, listUserConns, addUserConn, updateUserConn, removeUserConn, getUserConnSecret, availableModels, unavailableProviders, resolveModel, internalRef, enc, dec };
+29
View File
@@ -0,0 +1,29 @@
'use strict';
// ============================================================================
// 요청 속도 제한 · 신뢰 프록시 기준 클라이언트 IP (v1.6.31, 요구 7·25)
//
// 1.5.x 까지는 X-Forwarded-For 첫 값을 그대로 믿어 공격자가 헤더를 위조하면 제한을 피할 수 있었다(검토 S12).
// clientIp(req): 연결 상대(req.socket.remoteAddress)가 설정 service.trustedProxies(기본 127.0.0.1, ::1 — 같은 서버의 nginx)에 들어 있을 때만
// X-Forwarded-For 의 "신뢰 프록시 바로 앞" 값을 쓴다. 그 밖은 연결 상대 IP.
// limited(key, max, windowMs): 슬라이딩 창. 메모리 보관(프로세스 재시작 때 비워짐 — 공격자가 재시작을 일으킬 수는 없다).
// lockout(key, fails, windowMs): 로그인 실패 누적 잠금(계정 기준) — 비밀번호 대입 방지.
// ============================================================================
const buckets = new Map(); // key → number[] (timestamps)
let lastSweep = Date.now();
function sweep() { const now = Date.now(); if (now - lastSweep < 60000) return; lastSweep = now; for (const [k, arr] of buckets) { const keep = arr.filter((t) => now - t < 3600000); if (keep.length) buckets.set(k, keep); else buckets.delete(k); } }
/** 창 안의 요청 수가 max 이상이면 true (그 요청은 거부). 거부된 요청도 횟수에 넣는다(연타 방지) */
function limited(key, max, windowMs) { sweep(); const now = Date.now(); const arr = (buckets.get(key) || []).filter((t) => now - t < windowMs); arr.push(now); buckets.set(key, arr); return arr.length > max; }
function count(key, windowMs) { const now = Date.now(); return (buckets.get(key) || []).filter((t) => now - t < windowMs).length; }
function reset(key) { buckets.delete(key); }
/** 설정의 신뢰 프록시 목록 */
function trustedProxies() { try { const v = require('./config').get('service.trustedProxies'); if (v && String(v).trim()) return String(v).split(/[,\s]+/).filter(Boolean); } catch (_) {} return ['127.0.0.1', '::1', '::ffff:127.0.0.1']; }
const norm = (ip) => String(ip || '').replace(/^::ffff:/, '');
function clientIp(req) {
const peer = norm(req.socket && req.socket.remoteAddress); const trusted = trustedProxies().map(norm);
if (!trusted.includes(peer)) return peer;
const xff = String(req.headers['x-forwarded-for'] || '').split(',').map((s) => norm(s.trim())).filter(Boolean);
// 오른쪽부터 신뢰 프록시를 벗겨 내고 처음 만나는 비신뢰 주소가 클라이언트
for (let i = xff.length - 1; i >= 0; i--) if (!trusted.includes(xff[i])) return xff[i];
return xff[0] || peer;
}
module.exports = { limited, count, reset, clientIp, trustedProxies };
+33
View File
@@ -0,0 +1,33 @@
'use strict';
// ============================================================================
// 외부 작업 복구 목록 (v1.6.31, 요구 20) — 배포·도메인 구매·메일 계정·인증서·데이터 파일 중 정상 완료되지 않은 것을 모아 관리자에게 보인다.
//
// 항목 모양: { kind, id, siteId, siteTitle, userId, state, at, detail, userTodo, actions: [{ id, label }], needsAdmin }
// kind: deploy(마지막 배포가 준비 완료가 아님) · purchase(구매 원장 pending/unknown) · mail(메일 계정 deprovision_pending/needs-admin/login_conflict)
// · cert(와일드카드 인증서 만료 임박·갱신 실패) · data(손상 파일) · job(서버 재시작으로 끊긴 작업)
// actions 는 server.js 의 POST /api/admin/recovery/action 이 실행한다 (이미 끝난 부분은 다시 하지 않는다 — 각 실행 함수가 멱등).
// ============================================================================
const path = require('path');
function items(ctx) {
const { store, deploy, domains, mailsys, jsonstore, ssl } = ctx; const out = [];
const PHASE = { failed: '전송 실패', 'vhost-wait': '웹서버 설정 적용 대기', 'vhost-failed': '도메인 연결 실패', 'dns-wait': 'DNS 설정 대기', 'cert-wait': '인증서 준비 중(HTTP 로 열림)', 'check-failed': '접속 확인 실패', transferred: '전송 완료(확인 생략)' };
for (const s0 of store.list()) {
const site = store.load(s0.id); if (!site) continue;
const last = [...(site.deployments || [])].reverse().find((d) => d.kind !== 'unpublish'); if (!last) continue;
if (last.ok && last.ready && last.phase === 'ready') continue;
if (last.kind === 'rollback') continue;
const phase = last.phase || (last.ok ? 'transferred' : 'failed'); if (phase === 'transferred' && last.ok) continue;
const todo = phase === 'dns-wait' ? '사용자: 도메인 DNS 의 A 레코드가 이 서버를 가리키게 하고 전파를 기다린 뒤 "다시 배포"' : phase === 'cert-wait' ? '운영자: 와일드카드/사이트 인증서 발급 (관리자 콘솔 › 설정 › SSL)' : phase === 'vhost-failed' ? '운영자: 배포 서버 sudo·nginx 상태 확인(배포 서버 탭 연결 테스트) 뒤 "다시 배포"' : '사용자/운영자: 원인을 확인하고 "다시 배포"';
out.push({ kind: 'deploy', id: site.id, siteId: site.id, siteTitle: site.title, userId: site.ownerId, state: phase, stateLabel: PHASE[phase] || phase, at: last.at, detail: last.phaseDetail || last.detail || '', userTodo: todo, actions: [{ id: 'recheck', label: '다시 확인' } /* v1.6.33 */, { id: 'redeploy', label: '다시 배포' }, ...(last.hasPrev || (deploy.get(last.targetId) || {}).type === 'ssh' ? [{ id: 'rollback', label: '이전 배포본으로' }] : [])], needsAdmin: phase === 'vhost-failed' || phase === 'cert-wait' });
}
for (const p of domains.purchases({ open: true })) out.push({ kind: 'purchase', id: p.id, siteId: null, userId: p.userId, state: p.state, stateLabel: { pending_provider: '업체 호출 중/끊김', provider_succeeded: '업체 등록됨 · 차감 전', charged: '차감됨 · 기록 전', unknown: '확인 필요(응답 없음)' }[p.state] || p.state, at: p.updatedAt || p.at, detail: `${p.name} (${p.op}, ${p.period}년, 견적 ${p.quoteKrw}원, 업체 ${p.registrar})${p.error ? ' — ' + p.error : ''}`, userTodo: '관리자: "업체에 확인" 으로 등록 여부를 조회해 반영(등록됐으면 차감·기록, 아니면 실패로 닫음)', actions: [{ id: 'reconcile-purchase', label: '업체에 확인' }], needsAdmin: true });
// v1.6.32: 등록 업체가 비동기로 처리 중인 도메인(Route 53 Domains) — 완료 확인·영역 생성을 여기서도 할 수 있다
for (const x of domains.adminList().filter((x) => x.status === 'PENDING' || x.status === 'FAILED')) out.push({ kind: 'domain', id: x.id, siteId: null, userId: x.userId, state: x.status === 'FAILED' ? 'registration-failed' : 'registration-pending', stateLabel: x.status === 'FAILED' ? '도메인 등록 실패(업체)' : '도메인 등록 처리 중(업체)', at: x.createdAt, detail: `${x.name} (${x.registrar})${x.zoneError ? ' — ' + x.zoneError : ''}`, userTodo: x.status === 'FAILED' ? '관리자: AWS 콘솔 › Route 53 › 등록된 도메인 › 요청에서 실패 원인 확인. 비용이 차감됐으면 회원·청구에서 환불' : '관리자: 등록자 이메일 확인 요청(AWS)이 있으면 사용자에게 승인 안내, "등록 상태 확인" 으로 완료 반영', actions: x.status === 'FAILED' ? [] : [{ id: 'finalize-domain', label: '등록 상태 확인' }], needsAdmin: x.status === 'FAILED' });
for (const a of mailsys.listAccounts()) { if (a.state === 'active' || !a.state) continue; out.push({ kind: 'mail', id: `${a.siteId}:${a.memberId}`, siteId: a.siteId, siteTitle: (store.load(a.siteId) || {}).title, userId: (store.load(a.siteId) || {}).ownerId, state: a.state, stateLabel: { deprovision_pending: '메일 서버 계정 삭제 대기', 'needs-admin': '관리자 확인 필요(소유 불명확)' }[a.state] || a.state, at: a.updatedAt, detail: `${a.login || ''}${a.address ? ' <' + a.address + '>' : ''}${a.providerId ? ' · 공급자 id ' + a.providerId : ''}${a.lastError ? ' — ' + a.lastError : ''}`, userTodo: a.state === 'needs-admin' ? '관리자: 메일 서버(Arimail 관리자 페이지)에서 이 계정이 이 사이트 것인지 확인 → "소유 확인 후 삭제" 또는 "기록만 정리"' : '관리자: 메일 서버 연결을 확인한 뒤 "삭제 다시 시도"', actions: a.state === 'needs-admin' ? [{ id: 'mail-delete-confirmed', label: '소유 확인 후 삭제' }, { id: 'mail-forget', label: '기록만 정리(서버 계정 유지)' }] : [{ id: 'mail-delete-retry', label: '삭제 다시 시도' }], needsAdmin: true }); }
try { const st = ssl && ssl.lastState ? ssl.lastState() : null; if (st && st.last && !st.last.ok) out.push({ kind: 'cert', id: 'wildcard', state: 'issue-failed', stateLabel: '와일드카드 인증서 발급 실패', at: st.last.at, detail: st.last.detail, userTodo: '운영자: 관리자 콘솔 › 설정 › SSL 에서 원인(DNS TXT 등)을 확인하고 다시 발급', actions: [], needsAdmin: true }); } catch (_) {}
for (const h of jsonstore.health()) out.push({ kind: 'data', id: h.file, state: h.recoveredFromBackup ? 'recovered' : 'corrupt', stateLabel: h.recoveredFromBackup ? '손상 — 백업(.bak)으로 읽는 중' : '손상 — 읽을 수 없음', at: h.at, detail: `${path.basename(h.file)}: ${h.error}`, userTodo: '관리자: 서비스를 멈추고 .bak 또는 backups/ 의 백업으로 파일을 복구한 뒤 재시작 (docs/CONFIG.md 복구 절차)', actions: [], needsAdmin: true });
for (const s0 of store.list()) { const site = store.load(s0.id); if (!site) continue; const last = (site.commands || []).slice(-1)[0]; if (last && last.status === 'interrupted') out.push({ kind: 'job', id: site.id, siteId: site.id, siteTitle: site.title, userId: site.ownerId, state: 'interrupted', stateLabel: '서버 재시작으로 끊긴 작업', at: last.finishedAt || last.startedAt, detail: `#${last.no} ${last.kind}: ${String(last.instruction || '').slice(0, 80)}`, userTodo: '사용자: 수정 탭에서 같은 지시를 다시 실행 (지금까지의 변경은 보존됨)', actions: [], needsAdmin: false }); }
out.sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')));
return out;
}
module.exports = { items };
+63
View File
@@ -0,0 +1,63 @@
'use strict';
// 참고 사이트/기존 사이트 본문을 서버에서 직접 가져와 텍스트로 저장한다(.refs/ref-N.txt).
// 인증서 검증 실패(self-signed 등)나 EUC-KR 인코딩 사이트도 읽을 수 있게 하여, Claude 의 WebFetch 실패에 의존하지 않는다.
const { safeFetch } = require('./netguard');
const fs = require('fs');
const path = require('path');
const REFS_DIR = '.refs';
const MAX_BYTES = 3 * 1024 * 1024;
const MAX_CHARS = 30000;
const TIMEOUT = 15000;
/**
* 참고 주소 내려받기. v1.3.17: lib/netguard 로 단계마다 공개 주소인지 확인하고 그 주소로만 접속하며(S08 — 내부망 요청 차단),
* 잘못된 리다이렉트 주소에도 프로세스가 죽지 않는다(S12). 관리자 소유 사이트(allowPrivate)만 사설 주소를 허용한다.
* 인증서 검증 실패(self-signed 등) 사이트도 읽을 수 있게 rejectUnauthorized 는 끈다(본문 텍스트만 참고용으로 쓰므로).
*/
async function fetchRaw(url, opts = {}) {
const r = await safeFetch(url, { allowPrivate: !!opts.allowPrivate, maxBytes: MAX_BYTES, timeout: TIMEOUT, rejectUnauthorized: false, accept: 'text/html,*/*;q=0.8', headers: { 'Accept-Language': 'ko,en;q=0.8' }, userAgent: 'Mozilla/5.0 (compatible; WebHostingAgent/1.0)' });
if (r.error) throw new Error(r.error);
if (r.status >= 400) throw new Error(`HTTP ${r.status}`);
return { buf: r.buf, contentType: r.type, finalUrl: r.finalUrl };
}
function decode(buf, contentType) {
let cs = (contentType.match(/charset=([\w-]+)/i) || [])[1];
if (!cs) { const head = buf.slice(0, 4096).toString('latin1'); cs = (head.match(/charset=["']?([\w-]+)/i) || [])[1]; }
cs = (cs || 'utf-8').toLowerCase(); if (cs === 'ks_c_5601-1987' || cs === 'euc_kr' || cs === 'ksc5601') cs = 'euc-kr';
try { return new TextDecoder(cs).decode(buf); } catch (_) { return buf.toString('utf8'); }
}
function htmlToText(html) {
const title = (html.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [, ''])[1].replace(/\s+/g, ' ').trim();
const desc = (html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i) || [, ''])[1];
const navLinks = [...html.matchAll(/<a[^>]*>([\s\S]*?)<\/a>/gi)].map((m) => m[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()).filter((t) => t && t.length < 30);
let body = html.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<!--[\s\S]*?-->/g, ' ');
body = body.replace(/<(br|p|div|li|h[1-6]|tr|section|article|header|footer|nav)[^>]*>/gi, '\n').replace(/<[^>]+>/g, ' ');
body = body.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
body = body.split('\n').map((l) => l.replace(/\s+/g, ' ').trim()).filter(Boolean).join('\n');
return { title, desc, navLinks: [...new Set(navLinks)].slice(0, 40), text: body.slice(0, MAX_CHARS) };
}
/** 참고 사이트 목록을 가져와 siteDir/.refs/ref-N.txt 로 저장. 반환: [{no,url,notes,file,ok,error,title}] */
async function prefetchReferences(siteDir, refs, existingUrl, opts = {}) { // opts.allowPrivate: 관리자 사이트만 내부 주소 허용 (v1.3.17)
const dir = path.join(siteDir, REFS_DIR); fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
const items = [...(existingUrl ? [{ url: existingUrl, notes: '기존 사이트(현재 운영 중)', existing: true }] : []), ...(refs || [])];
const out = await Promise.all(items.map(async (r, i) => {
const no = i + 1; const file = `${REFS_DIR}/ref-${no}.txt`; const rec = { no, url: r.url, notes: r.notes || '', existing: !!r.existing, file, ok: false, error: null, title: '' };
try {
const raw = await fetchRaw(r.url, opts);
const t = htmlToText(decode(raw.buf, raw.contentType)); rec.title = t.title;
const head = [`URL: ${r.url}`, raw.finalUrl !== r.url ? `최종 URL: ${raw.finalUrl}` : '', `제목: ${t.title}`, t.desc ? `설명: ${t.desc}` : '', r.notes ? `사용자가 참고/반영하려는 점: ${r.notes}` : '', t.navLinks.length ? `메뉴/링크: ${t.navLinks.join(' | ')}` : '', '', '--- 본문 텍스트 ---'].filter((x) => x !== '').join('\n');
fs.writeFileSync(path.join(siteDir, file), head + '\n' + t.text, { mode: 0o644 }); rec.ok = true;
} catch (e) {
rec.error = e.message;
fs.writeFileSync(path.join(siteDir, file), `URL: ${r.url}\n${r.notes ? `사용자가 참고/반영하려는 점: ${r.notes}\n` : ''}\n(가져오기 실패: ${e.message}. 사용자가 적은 참고 포인트만 반영하세요.)`, { mode: 0o644 });
}
return rec;
}));
return out;
}
module.exports = { prefetchReferences, REFS_DIR, htmlToText, decode, fetchRaw };
+21
View File
@@ -0,0 +1,21 @@
'use strict';
// ============================================================================
// 도메인 구매·관리 자동화 — 등록 업체 공통 창구 (v1.5.36, 요구 4)
//
// 업체: openprovider(1.4.0 부터) · porkbun · route53(Amazon Route 53 Domains, v1.6.32). 같은 모양의 함수(lib/registrars/<id>.js):
// ready() · split(fqdn) · check(fqdns) → [{domain, status:'free'|'active'|'unknown', premium, price:{currency, amount}|null, reason}]
// price(fqdn, 'create'|'renew'|'transfer', period) · createCustomer(contact) → handle|null · createDomain(fqdn, {handle, period}) → {id, status, expiresAt}
// renewDomain(ref, fqdn, period) · getDomain(ref) → {expiresAt, status} · setNameServers(fqdn, ref, ns[]) · needsContact(등록자 정보 필요 여부)
// 어느 업체: 설정 › 도메인 서비스 › 등록 업체(settings.domains.registrar, 기본 openprovider). 도메인 기록에는 구매 때의 업체(registrar)가 남아 갱신·네임서버는 그 업체로 간다.
// 보안: 업체 접속 정보는 보관소(관리자)에만 있고, 사용자는 lib/domains.js 를 거쳐 자기 도메인만 다룬다. 가격은 업체 가격 × 요율을 캐시로 차감(변동 시 재확인).
// ============================================================================
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const PROVIDERS = { openprovider: require('./registrars/openprovider'), porkbun: require('./registrars/porkbun'), route53: require('./registrars/route53') };
const IDS = Object.keys(PROVIDERS); const LABELS = { openprovider: 'Openprovider', porkbun: 'Porkbun', route53: 'Amazon Route 53' };
function defaultId() { let c = {}; try { c = require('./domains').config(); } catch (_) {} return PROVIDERS[c.registrar] ? c.registrar : 'openprovider'; }
function ready(id) { const p = PROVIDERS[id || defaultId()]; try { return !!(p && p.ready()); } catch (_) { return false; } }
function get(id) { const p = PROVIDERS[id || defaultId()]; if (!p) throw httpError(`알 수 없는 등록 업체: ${id}`); return p; }
/** 준비된 업체 모듈 (아니면 503 안내) */
function current(id) { const p = get(id); if (!p.ready()) throw httpError(`도메인 서비스가 아직 준비되지 않았습니다. 관리자에게 문의하세요 (관리자: 외부 연동 접속 정보에 ${LABELS[p.id]} 접속 정보를 넣어야 합니다)`, 503); return p; }
function catalog() { const d = defaultId(); return IDS.map((id) => ({ id, label: LABELS[id], ready: ready(id), isDefault: id === d, needsContact: PROVIDERS[id].needsContact !== false })); }
module.exports = { PROVIDERS, IDS, LABELS, defaultId, ready, get, current, catalog };
@@ -0,0 +1,8 @@
'use strict';
// Openprovider 등록 업체 (v1.5.36) — 1.4.0 의 lib/openprovider.js 도메인 함수를 공통 모양(lib/registrar.js)으로 감싼다
const op = require('../openprovider');
module.exports = { id: 'openprovider', label: 'Openprovider', ready: op.ready, split: op.split, check: op.check, price: op.price, createCustomer: op.createCustomer,
createDomain: (fqdn, o) => op.createDomain(fqdn, { handle: o.handle, period: o.period, autorenew: 'off' }), renewDomain: (ref, fqdn, period) => op.renewDomain(ref, fqdn, period),
getDomain: async (ref) => { const d = await op.getDomain(ref); return { expiresAt: d && d.expiration_date || null, status: d && d.status || null }; },
setNameServers: async (fqdn, ref, ns) => op.call('PUT', `/domains/${ref}`, { id: ref, domain: op.split(fqdn), name_servers: ns.map((n) => ({ name: n })) }).then(() => ({ ok: true })),
needsContact: true };
@@ -0,0 +1,30 @@
'use strict';
// ============================================================================
// Porkbun 등록 업체 (v1.5.36, 요구 4) — 검색(등록 가능 여부·가격), 등록, 갱신, 네임서버
//
// 접속 정보: 보관소 종류 'porkbun'(DNS 와 같은 키): 키(secret) = Secret API Key, 설정 API_KEY=…. 가격은 USD. 등록자 정보는 Porkbun 계정의 기본 연락처를 쓰므로 따로 받지 않는다(needsContact=false).
// API(v3, JSON POST): /pricing/get(확장자별 가격) · /domain/checkDomain/<도메인>(등록 가능·가격) · /domain/create(등록) · /domain/renew(갱신) · /domain/listAll · /domain/updateNs.
// 등록·갱신 호출은 계정에 API 등록 권한이 켜져 있어야 하며, 응답 형식이 바뀌면 메시지를 그대로 안내한다. 시험용 대체 주소: PORKBUN_API_BASE.
// ============================================================================
const dnsp = require('../dns/porkbun');
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
function split(fqdn) { const d = String(fqdn || '').trim().toLowerCase().replace(/\.$/, ''); const m = d.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.((?:co|or|ne|go|ac|re|pe)\.kr|com\.[a-z]{2}|co\.[a-z]{2}|[a-z]{2,24})$/); if (!m) throw httpError('도메인 이름은 영문 소문자·숫자·하이픈으로 된 example.com 형식이어야 합니다'); return { name: m[1], extension: m[2] }; }
let priceCache = { at: 0, map: {} };
async function pricing() { if (Date.now() - priceCache.at < 3600000 && Object.keys(priceCache.map).length) return priceCache.map; const j = await dnsp.call('/pricing/get', {}); priceCache = { at: Date.now(), map: j.pricing || {} }; return priceCache.map; }
/** 등록 가능 여부와 가격 — /domain/checkDomain 이 안 되면 가격표로 대신(가능 여부는 알 수 없음 → status 'unknown') */
async function check(fqdns) {
const out = []; const prices = await pricing().catch(() => ({}));
for (const f of fqdns) {
const { extension } = split(f); const pr = prices[extension] || null;
try { const j = await dnsp.call(`/domain/checkDomain/${f}`); const avail = j.response && (j.response.avail === 'yes' || j.response.avail === true); const p = j.response && j.response.price ? Number(j.response.price) : (pr ? Number(pr.registration) : NaN); out.push({ domain: f, status: avail ? 'free' : 'active', premium: !!(j.response && j.response.premium === 'yes'), price: Number.isFinite(p) ? { currency: 'USD', amount: p } : null, reason: avail ? '' : (j.response && j.response.reason) || '' }); }
catch (e) { out.push({ domain: f, status: 'unknown', premium: false, price: pr ? { currency: 'USD', amount: Number(pr.registration) } : null, reason: `가능 여부 확인 실패: ${e.message}` }); }
}
return out;
}
async function price(fqdn, operation = 'create', period = 1) { const { extension } = split(fqdn); const pr = (await pricing())[extension]; if (!pr) throw httpError(`Porkbun 가격표에 .${extension} 이 없습니다`); const one = Number(operation === 'renew' ? pr.renewal : operation === 'transfer' ? pr.transfer : pr.registration); return { currency: 'USD', amount: Math.round(one * period * 100) / 100 }; }
async function createCustomer() { return null; }
async function createDomain(fqdn, { period = 1 } = {}) { const j = await dnsp.call('/domain/create', { domain: fqdn, years: String(period) }); const exp = new Date(); exp.setFullYear(exp.getFullYear() + period); return { id: fqdn, status: 'ACT', expiresAt: (j.expireDate || exp.toISOString().slice(0, 10)), raw: j }; }
async function renewDomain(ref, fqdn, period = 1) { await dnsp.call('/domain/renew', { domain: fqdn, years: String(period) }); return { status: 'ACT' }; }
async function getDomain(ref) { const z = (await dnsp.listZones()).find((x) => x.name === String(ref).toLowerCase()); return { expiresAt: z ? z.expires : null, status: z ? z.status : null }; }
async function setNameServers(fqdn, ref, ns) { return dnsp.setNameServers(fqdn, ns); }
module.exports = { id: 'porkbun', label: 'Porkbun', ready: dnsp.ready, split, check, price, createCustomer, createDomain, renewDomain, getDomain, setNameServers, needsContact: false };
+132
View File
@@ -0,0 +1,132 @@
'use strict';
// ============================================================================
// Amazon Route 53 Domains 등록 업체 (v1.6.32, 요구 1) — 검색(등록 가능 여부·가격), 등록, 갱신, 네임서버, 소유 확인
//
// 접속 정보: 보관소 종류 'route53'(DNS 와 같은 키 — lib/dns/route53.js 의 creds() 재사용). IAM 권한은 route53domains:CheckDomainAvailability ·
// ListPrices · RegisterDomain · GetOperationDetail · GetDomainDetail · ListDomains · RenewDomain · UpdateDomainNameservers (docs/DOMAIN-DNS-PROVIDERS.md).
// API: Route 53 Domains 는 글로벌 서비스로 엔드포인트가 route53domains.us-east-1.amazonaws.com 하나뿐이다(리전 us-east-1 고정, DNS 쪽 REGION 설정과 무관).
// 프로토콜은 JSON 1.1 — POST / 에 헤더 X-Amz-Target: Route53Domains_v20140515.<작업이름>, Content-Type: application/x-amz-json-1.1. 서명은 lib/awsv4.js(SigV4).
// 시험용 대체 주소: 환경 변수 ROUTE53DOMAINS_API_BASE (모의 서버 검증용).
// 다른 업체와 다른 점:
// - 등록·갱신·네임서버 변경이 비동기다(OperationId 를 돌려주고 뒤에서 처리). createDomain 은 최대 CREATE_WAIT_MS 동안 GetOperationDetail 을 돌려 보고,
// 그 안에 끝나지 않으면 status 'PENDING' 으로 돌려준다 — 등록 요청은 AWS 에 접수된 상태이므로 구매 흐름(lib/domains.js)은 성공으로 기록·차감하고,
// getDomain() 으로 만료일이 채워질 때까지 "처리 중" 으로 보인다. (ERROR/FAILED 로 끝나면 예외를 던져 차감하지 않는다.)
// - 고객 핸들(createCustomer)이 없다: 등록자 정보는 RegisterDomain 호출에 그대로 넣는다(toContact). createCustomer 는 'inline' 이라는 표식만 돌려
// needsContact=true 흐름(먼저 등록자 정보를 저장)이 그대로 동작하게 한다.
// - 가격은 USD(ListPrices), 확장자별 1년 가격 × 기간. Route 53 가 다루지 않는 확장자(.kr 등)는 ListPrices 가 비어 돌아와 "지원하지 않는 확장자" 로 안내한다.
// - 전화번호는 "+국가번호.국내번호" 형식(예 +82.1012345678). 국내번호 맨 앞 0 은 뗀다.
// ============================================================================
const awsv4 = require('../awsv4');
const dnsp = require('../dns/route53'); // creds() 공유
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const HOST = 'route53domains.us-east-1.amazonaws.com'; const REGION = 'us-east-1'; const TARGET = 'Route53Domains_v20140515';
const CREATE_WAIT_MS = 60000; // 등록 작업 완료를 기다리는 최대 시간(구매 흐름의 90초 제한보다 짧게)
const base = () => (process.env.ROUTE53DOMAINS_API_BASE || `https://${HOST}`).replace(/\/+$/, '');
const ready = () => !!dnsp.creds();
/** example.com → { name:'example', extension:'com' } (Porkbun 과 같은 규칙: 2단계 공용 확장자도 허용) */
function split(fqdn) {
const d = String(fqdn || '').trim().toLowerCase().replace(/\.$/, '');
const m = d.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.((?:co|or|ne|go|ac|re|pe)\.kr|com\.[a-z]{2}|co\.[a-z]{2}|[a-z]{2,24})$/);
if (!m) throw httpError('도메인 이름은 영문 소문자·숫자·하이픈으로 된 example.com 형식이어야 합니다');
return { name: m[1], extension: m[2] };
}
/** 서명된 JSON 1.1 호출. 오류는 AWS 의 message(또는 __type)를 담아 던진다 */
async function call(op, body) {
const c = dnsp.creds(); if (!c) throw httpError(dnsp.missingMessage(), 503);
const u = new URL(base()); const payload = JSON.stringify(body || {});
const headers = awsv4.sign({ method: 'POST', host: u.host, path: '/', query: {}, headers: { 'content-type': 'application/x-amz-json-1.1', 'x-amz-target': `${TARGET}.${op}` }, body: payload, service: 'route53domains', region: REGION, accessKeyId: c.accessKeyId, secretAccessKey: c.secretAccessKey, sessionToken: c.sessionToken });
let r; try { r = await fetch(`${base()}/`, { method: 'POST', headers, body: payload, signal: AbortSignal.timeout(25000) }); } catch (e) { throw httpError(`Route 53 Domains 연결 실패: ${e.message}`, 502); }
const text = await r.text(); let j = {}; try { j = text ? JSON.parse(text) : {}; } catch (_) { j = { message: text.slice(0, 200) }; }
if (!r.ok) { const type = String(j.__type || j.code || '').split('#').pop(); const msg = j.message || j.Message || type || text.slice(0, 200); throw Object.assign(httpError(`Route 53 Domains 오류 (${type || 'HTTP ' + r.status}): ${msg}`, r.status === 403 ? 403 : r.status >= 500 ? 502 : 400), { awsType: type }); }
return j;
}
// ---------- 가격 ----------
let priceCache = { at: 0, map: {} }; // 확장자 → {registration, renewal, transfer} USD (1시간)
async function tldPrice(extension) {
const hit = priceCache.map[extension]; if (hit && Date.now() - priceCache.at < 3600000) return hit;
const j = await call('ListPrices', { Tld: extension, MaxItems: 1 }); const p = (j.Prices || [])[0]; if (!p) return null;
const n = (x) => (x && Number.isFinite(Number(x.Price)) ? Number(x.Price) : NaN);
const out = { registration: n(p.RegistrationPrice), renewal: n(p.RenewalPrice), transfer: n(p.TransferPrice), currency: (p.RegistrationPrice && p.RegistrationPrice.Currency) || 'USD' };
if (Date.now() - priceCache.at >= 3600000) priceCache = { at: Date.now(), map: {} }; priceCache.map[extension] = out; return out;
}
async function price(fqdn, operation = 'create', period = 1) {
const { extension } = split(fqdn); const pr = await tldPrice(extension); if (!pr) throw httpError(`Route 53 는 .${extension} 도메인을 등록하지 않습니다 (지원 확장자 목록: AWS 문서 "Domains that you can register with Route 53")`);
const one = operation === 'renew' ? pr.renewal : operation === 'transfer' ? pr.transfer : pr.registration; if (!Number.isFinite(one)) throw httpError(`Route 53 가격표에 .${extension} 의 ${operation} 가격이 없습니다`);
return { currency: pr.currency, amount: Math.round(one * period * 100) / 100, perYear: one };
}
// ---------- 검색 ----------
/** 등록 가능 여부와 가격. Availability: AVAILABLE(+_RESERVED/_PREORDER 는 일반 등록 불가로 봄) · UNAVAILABLE* · RESERVED · DONT_KNOW · INVALID_NAME_FOR_TLD · PENDING */
async function check(fqdns) {
const out = [];
for (const f of fqdns) {
let extension; try { ({ extension } = split(f)); } catch (e) { out.push({ domain: f, status: 'unknown', premium: false, price: null, reason: e.message }); continue; }
const pr = await tldPrice(extension).catch(() => null);
if (!pr) { out.push({ domain: f, status: 'unknown', premium: false, price: null, reason: `Route 53 가 다루지 않는 확장자(.${extension})` }); continue; }
try {
const j = await call('CheckDomainAvailability', { DomainName: f }); const a = String(j.Availability || 'DONT_KNOW');
const status = a === 'AVAILABLE' ? 'free' : (a === 'DONT_KNOW' || a === 'PENDING') ? 'unknown' : 'active';
const reason = a === 'AVAILABLE' ? '' : { UNAVAILABLE: '이미 등록된 도메인', UNAVAILABLE_PREMIUM: '프리미엄 도메인(자동 등록 불가)', UNAVAILABLE_RESTRICTED: '등록 제한 도메인', RESERVED: '예약된 이름', AVAILABLE_RESERVED: '예약된 이름(일반 등록 불가)', AVAILABLE_PREORDER: '사전 예약 전용', INVALID_NAME_FOR_TLD: '이 확장자에 쓸 수 없는 이름', DONT_KNOW: '등록 업체가 가능 여부를 알려 주지 않음(잠시 뒤 다시)', PENDING: '확인 중(잠시 뒤 다시)' }[a] || a;
out.push({ domain: f, status, premium: a === 'UNAVAILABLE_PREMIUM', price: { currency: pr.currency, amount: pr.registration }, reason });
} catch (e) { out.push({ domain: f, status: 'unknown', premium: false, price: { currency: pr.currency, amount: pr.registration }, reason: `가능 여부 확인 실패: ${e.message}` }); }
}
return out;
}
// ---------- 등록자 정보 ----------
/** 우리 연락처(lib/domains.js contactFields) → Route 53 ContactDetail. 회사명이 있으면 COMPANY(OrganizationName 필수), 아니면 PERSON */
function toContact(c) {
if (!c) throw httpError('먼저 등록자 정보(이름·주소·전화·이메일)를 저장하세요', 409);
const cc = String(c.phoneCountry || '+82').replace(/[^0-9]/g, '') || '82'; const national = String((c.phoneArea || '') + (c.phoneNumber || '')).replace(/[^0-9]/g, '').replace(/^0+/, '');
const addr = [c.street, c.number].filter(Boolean).join(' ').slice(0, 255);
const o = { FirstName: c.firstName, LastName: c.lastName, ContactType: c.company ? 'COMPANY' : 'PERSON', AddressLine1: addr, City: c.city, CountryCode: String(c.country || 'KR').toUpperCase(), ZipCode: c.zipcode, PhoneNumber: `+${cc}.${national}`, Email: c.email };
if (c.company) o.OrganizationName = String(c.company).slice(0, 255);
if (c.state) o.State = String(c.state).slice(0, 255);
for (const k of ['FirstName', 'LastName', 'AddressLine1', 'City', 'ZipCode', 'Email']) if (!o[k]) throw httpError(`등록자 정보에 ${k} 가 비어 있습니다 — 도메인 탭 › 등록자 정보를 다시 저장하세요`);
if (!national) throw httpError('등록자 전화번호가 비어 있습니다');
return o;
}
async function createCustomer(contact) { toContact(contact); return 'inline'; } // 유효성만 확인하고 표식을 돌려준다(핸들 개념이 없음)
// ---------- 등록·갱신·조회 ----------
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** 작업(OperationId)이 끝날 때까지 최대 waitMs 기다린다. 끝나면 상세, 아니면 마지막 상태를 돌려준다 */
async function waitOperation(opId, waitMs) {
const until = Date.now() + waitMs; let last = { Status: 'SUBMITTED' };
while (Date.now() < until) { last = await call('GetOperationDetail', { OperationId: opId }); if (['SUCCESSFUL', 'ERROR', 'FAILED'].includes(last.Status)) break; await sleep(Math.min(5000, Math.max(1000, until - Date.now()))); }
return last;
}
const isoDate = (epoch) => (Number.isFinite(Number(epoch)) && Number(epoch) > 0 ? new Date(Number(epoch) * 1000).toISOString().slice(0, 10) : null);
async function createDomain(fqdn, { period = 1, contact } = {}) {
const ct = toContact(contact); const years = Math.min(10, Math.max(1, parseInt(period, 10) || 1));
const j = await call('RegisterDomain', { DomainName: fqdn, DurationInYears: years, AutoRenew: false, AdminContact: ct, RegistrantContact: ct, TechContact: ct, PrivacyProtectAdminContact: true, PrivacyProtectRegistrantContact: true, PrivacyProtectTechContact: true });
const opId = j.OperationId; const st = opId ? await waitOperation(opId, CREATE_WAIT_MS) : { Status: 'SUCCESSFUL' };
if (st.Status === 'ERROR' || st.Status === 'FAILED') throw httpError(`Route 53 등록 실패: ${st.Message || st.Status}`, 502);
let expiresAt = null; if (st.Status === 'SUCCESSFUL') { try { expiresAt = (await getDomain(fqdn)).expiresAt; } catch (_) {} }
if (!expiresAt) { const exp = new Date(); exp.setFullYear(exp.getFullYear() + years); expiresAt = exp.toISOString().slice(0, 10); }
// id 는 도메인 이름(이후 갱신·네임서버·조회가 이름으로 간다). 아직 처리 중이면 PENDING — 구매 흐름은 접수된 것으로 기록하고 상세 조회로 확정한다
return { id: fqdn, status: st.Status === 'SUCCESSFUL' ? 'ACT' : 'PENDING', expiresAt, operationId: opId || null, raw: st };
}
async function getDomain(ref) {
const j = await call('GetDomainDetail', { DomainName: String(ref).toLowerCase() });
return { expiresAt: isoDate(j.ExpirationDate), status: (j.StatusList || []).join(',') || 'ACT', nameServers: (j.Nameservers || []).map((n) => n.Name), autoRenew: !!j.AutoRenew, raw: undefined };
}
/** 갱신: 현재 만료 연도(CurrentExpiryYear)가 필요해 먼저 상세를 읽는다 */
async function renewDomain(ref, fqdn, period = 1) {
const name = String(fqdn || ref).toLowerCase(); const d = await getDomain(name); const year = d.expiresAt ? Number(d.expiresAt.slice(0, 4)) : new Date().getUTCFullYear();
const j = await call('RenewDomain', { DomainName: name, DurationInYears: Math.min(10, Math.max(1, parseInt(period, 10) || 1)), CurrentExpiryYear: year });
const st = j.OperationId ? await waitOperation(j.OperationId, 20000) : { Status: 'SUCCESSFUL' };
if (st.Status === 'ERROR' || st.Status === 'FAILED') throw httpError(`Route 53 갱신 실패: ${st.Message || st.Status}`, 502);
return { status: st.Status === 'SUCCESSFUL' ? 'ACT' : 'PENDING', operationId: j.OperationId || null };
}
async function setNameServers(fqdn, ref, ns) {
const j = await call('UpdateDomainNameservers', { DomainName: String(fqdn).toLowerCase(), Nameservers: ns.map((n) => ({ Name: String(n).replace(/\.$/, '') })) });
return { ok: true, operationId: j.OperationId || null };
}
/** 이 AWS 계정이 그 도메인을 갖고 있는지 (구매 재조정·접속 확인용). 없으면 null */
async function ownsDomain(name) { try { const d = await getDomain(name); return d; } catch (e) { if (e.awsType === 'InvalidInput' || /not found|does not exist|not associated/i.test(e.message)) return null; throw e; } }
async function listDomains() { const out = []; let marker; do { const j = await call('ListDomains', { MaxItems: 100, ...(marker ? { Marker: marker } : {}) }); for (const d of j.Domains || []) out.push({ name: d.DomainName, expiresAt: isoDate(d.Expiry), autoRenew: !!d.AutoRenew }); marker = j.NextPageMarker; } while (marker); return out; }
module.exports = { id: 'route53', label: 'Amazon Route 53', ready, split, check, price, createCustomer, createDomain, renewDomain, getDomain, setNameServers, ownsDomain, listDomains, toContact, call, needsContact: true };
+475
View File
@@ -0,0 +1,475 @@
'use strict';
// 기존 운영 사이트 연결: 파일 가져오기(import) 와 수정본 되돌려 배포(push).
// 접근 방식은 두 가지만 지원한다.
// - ssh : SSH 키 (서비스가 생성한 키의 공개키를 서버에 등록하거나, 개인키를 붙여넣음)
// - password : ID/비밀번호 — SSH/SFTP(기본, 22) 또는 FTP(21)
// 구현: 셸이 있는 SSH 접속은 ssh+tar, 그 외(FTP, 셸 없는 SFTP 계정)는 curl(ftp://, sftp://) 로 파일 단위 전송.
// 비밀번호는 명령행에 올리지 않는다 (ssh: SSH_ASKPASS 헬퍼, curl: 표준입력 설정 파일).
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const http = require('http');
const https = require('https');
const { spawn, execFile } = require('child_process');
const providers = require('./providers');
const ROOT = path.resolve(__dirname, '..');
const KEYS = path.join(process.env.DATA_ROOT || path.join(ROOT, 'data'), 'site-keys');
fs.mkdirSync(KEYS, { recursive: true, mode: 0o700 });
const ASKPASS = path.join(ROOT, 'hooks', 'askpass.sh');
// v1.2.0: 고정 크기 제한(300MB·4,000개)을 없애고, 이 서비스 서버의 사이트 저장 디스크 용량의 10% 를 넘으면 중지한다.
const CAPACITY_RATIO = 0.10;
/**
* 가져오기 허용 한도: 사이트 저장 경로(SITES_ROOT)가 있는 디스크의 전체 용량 × 10%, 그리고 남은 공간의 90% 중 작은 값.
* @returns {{limitBytes, totalBytes, freeBytes, byFree: boolean}}
*/
function capacity() {
const root = process.env.SITES_ROOT || '/var/www/sites';
try { const st = fs.statfsSync(root); const total = st.bsize * st.blocks; const free = st.bsize * st.bavail; const byTotal = total * CAPACITY_RATIO; const byFree = free * 0.9; return { limitBytes: Math.min(byTotal, byFree), totalBytes: total, freeBytes: free, byFree: byFree < byTotal }; }
catch (_) { return { limitBytes: Infinity, totalBytes: 0, freeBytes: 0, byFree: false }; }
}
const gb = (n) => (n / 1073741824).toFixed(n < 10737418240 ? 2 : 1) + 'GB';
/** 한도를 넘으면 사용자에게 보여 줄 오류 */
function capacityError(sizeBytes) {
const c = capacity(); if (sizeBytes <= c.limitBytes) return null;
return httpError(c.byFree ? `서버 저장 공간이 부족해 가져올 수 없습니다 (사이트 ${gb(sizeBytes)}, 서버 남은 공간 ${gb(c.freeBytes)}). 관리자에게 문의하세요.` : `서버 한계를 초과하는 사이트라 가져오기를 중지했습니다 (사이트 ${gb(sizeBytes)}, 한 사이트 허용 한도 ${gb(c.limitBytes)} = 서버 용량 ${gb(c.totalBytes)} 의 10%). 사이트 경로를 더 좁게 지정하거나 관리자에게 문의하세요.`, 413);
}
const EXCLUDE_DIRS = ['.git', '.svn', '.hg', 'node_modules', '.cache', '.claude', '.refs', '.webmaker-backups'];
const INDEX_NAMES = ['index.html', 'index.htm', 'index.php', 'default.html', 'default.htm'];
const CANDIDATE_DIRS = ['~/public_html', '~/www', '~/html', '~/htdocs', '~/httpdocs', '~/web', '/var/www/html', '/var/www/HOST', '/var/www/HOST/html', '/var/www/HOST/public_html', '/var/www', '/usr/share/nginx/html', '/srv/www', '/srv/www/HOST', '/srv/http'];
const FTP_CANDIDATES = ['', 'public_html', 'www', 'html', 'htdocs', 'httpdocs', 'web', 'public'];
function httpError(msg, status = 400) { return Object.assign(new Error(msg), { status }); }
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
function keyPath(id) { return path.join(KEYS, id); }
// ---------- 접속 정보 ----------
/** 입력값을 검증·정규화한다 (비밀번호/키는 포함하지 않음) */
function normalize(input = {}) {
const url = String(input.url || '').trim();
let host = String(input.host || '').trim();
if (url && !/^https?:\/\//i.test(url)) throw httpError('사이트 주소는 http:// 또는 https:// 로 시작해야 합니다');
if (!host && url) { try { host = new URL(url).hostname; } catch (_) {} }
const type = input.type === 'ssh' ? 'ssh' : 'password';
const protocol = type === 'ssh' ? 'ssh' : (input.protocol === 'ftp' ? 'ftp' : 'sftp');
const port = parseInt(input.port, 10) || (protocol === 'ftp' ? 21 : 22);
const user = String(input.user || '').trim();
const docroot = String(input.docroot || '').trim().replace(/\/+$/, '');
if (!host) throw httpError('접속할 서버 주소(호스트)를 입력하세요');
if (!/^[a-z0-9.-]+$/i.test(host)) throw httpError('서버 주소 형식이 올바르지 않습니다');
if (!user) throw httpError('접속 계정(ID)을 입력하세요');
if (!/^[a-z0-9._@-]+$/i.test(user)) throw httpError('접속 계정 형식이 올바르지 않습니다');
if (port < 1 || port > 65535) throw httpError('포트 번호가 올바르지 않습니다');
if (docroot && /(^|\/)\.\.(\/|$)/.test(docroot)) throw httpError('사이트 경로에 ".." 은 쓸 수 없습니다');
return { type, protocol, host, port, user, docroot, url };
}
/** 정규화된 정보 + 비밀번호/키를 합쳐 저장용 source 객체를 만든다 (비밀번호는 암호화) */
function buildSource(input, prev) {
const s = normalize(input);
if (s.type === 'password') {
const pw = typeof input.password === 'string' ? input.password : '';
if (pw) s.secret = providers.enc(pw);
else if (prev && prev.secret && prev.type === 'password') s.secret = prev.secret;
else throw httpError('비밀번호를 입력하세요');
} else {
if (input.privateKey && String(input.privateKey).trim()) s.keyId = storeKey(String(input.privateKey));
else if (input.keyId && fs.existsSync(keyPath(String(input.keyId).replace(/[^a-f0-9]/g, '')))) s.keyId = String(input.keyId).replace(/[^a-f0-9]/g, '');
else if (prev && prev.keyId && fs.existsSync(keyPath(prev.keyId))) s.keyId = prev.keyId;
else throw httpError('SSH 키가 없습니다. 키를 생성해 서버에 등록하거나 개인키를 붙여넣으세요');
}
if (typeof input.shell === 'boolean') s.shell = input.shell; else if (prev && typeof prev.shell === 'boolean') s.shell = prev.shell;
return s;
}
/** 사용자에게 보여줄 형태 (비밀 제외) */
function publicSource(s) {
if (!s) return null;
const pubFile = s.keyId ? keyPath(s.keyId) + '.pub' : null;
return { type: s.type, protocol: s.protocol, host: s.host, port: s.port, user: s.user, docroot: s.docroot, url: s.url, hasSecret: !!s.secret, hasKey: !!(s.keyId && fs.existsSync(keyPath(s.keyId))), publicKey: pubFile && fs.existsSync(pubFile) ? fs.readFileSync(pubFile, 'utf8').trim() : null, shell: s.shell !== false, importedAt: s.importedAt || null, fileCount: s.manifest ? Object.keys(s.manifest).length : 0, lastPushAt: s.lastPushAt || null, backupNote: s.backupNote || null };
}
// ---------- SSH 키 ----------
function newKey(comment) {
return new Promise((resolve, reject) => {
const id = crypto.randomBytes(12).toString('hex'); const f = keyPath(id);
execFile('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', `webmaker-import-${(comment || 'site').replace(/[^a-z0-9.-]/gi, '')}`, '-f', f], (err) => {
if (err) return reject(new Error('키 생성 실패: ' + err.message));
try { fs.chmodSync(f, 0o600); } catch (_) {}
resolve({ keyId: id, publicKey: fs.readFileSync(f + '.pub', 'utf8').trim() });
});
});
}
function storeKey(text) {
const body = text.replace(/\r/g, '').trim() + '\n';
if (!/^-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(body)) throw httpError('개인키 형식이 아닙니다 (-----BEGIN ... PRIVATE KEY----- 로 시작해야 합니다)');
const id = crypto.randomBytes(12).toString('hex'); const f = keyPath(id);
fs.writeFileSync(f, body, { mode: 0o600 });
try {
const pub = require('child_process').execFileSync('ssh-keygen', ['-y', '-f', f], { timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
fs.writeFileSync(f + '.pub', pub + '\n', { mode: 0o600 });
} catch (e) { try { fs.unlinkSync(f); } catch (_) {} throw httpError('개인키를 읽을 수 없습니다 (암호가 걸린 키는 지원하지 않습니다): ' + String(e.stderr || e.message).split('\n')[0]); }
return id;
}
function deleteKey(id) { if (!id) return; for (const f of [keyPath(id), keyPath(id) + '.pub']) { try { fs.unlinkSync(f); } catch (_) {} } }
// ---------- 프로세스 실행 ----------
function run(cmd, args, opts = {}) {
return new Promise((resolve) => {
const child = spawn(cmd, args, { env: opts.env || process.env, stdio: [opts.stdin || opts.input !== undefined ? 'pipe' : 'ignore', 'pipe', 'pipe'], detached: true });
let out = Buffer.alloc(0); let err = '';
child.stdout.on('data', (c) => { if (out.length < (opts.maxOut || 8 * 1024 * 1024)) out = Buffer.concat([out, c]); });
child.stderr.on('data', (c) => { err += c; if (err.length > 20000) err = err.slice(-20000); });
const timer = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch (_) { child.kill('SIGKILL'); } }, opts.timeout || 120000);
child.on('close', (code) => { clearTimeout(timer); resolve({ code, out: opts.binary ? out : out.toString().trim(), err: err.trim() }); });
child.on('error', (e) => { clearTimeout(timer); resolve({ code: -1, out: opts.binary ? Buffer.alloc(0) : '', err: e.message }); });
if (opts.stdin) { opts.stdin.on('error', () => {}); opts.stdin.pipe(child.stdin); }
else if (opts.input !== undefined) { child.stdin.on('error', () => {}); child.stdin.end(opts.input); }
});
}
// ---------- SSH (키 / 비밀번호) ----------
function sshEnvArgs(src) {
const args = ['-p', String(src.port), '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=15', '-o', 'NumberOfPasswordPrompts=1', '-o', 'ServerAliveInterval=15', '-o', 'LogLevel=ERROR'];
const env = { ...process.env };
if (src.type === 'ssh') args.push('-i', keyPath(src.keyId), '-o', 'IdentitiesOnly=yes', '-o', 'BatchMode=yes');
else {
args.push('-o', 'PubkeyAuthentication=no', '-o', 'PreferredAuthentications=password,keyboard-interactive');
env.SSH_ASKPASS = ASKPASS; env.SSH_ASKPASS_REQUIRE = 'force'; env.DISPLAY = env.DISPLAY || ':0'; env.WM_SSH_PASSWORD = providers.dec(src.secret);
}
return { args, env };
}
function sshExec(src, script, opts = {}) {
const { args, env } = sshEnvArgs(src);
return run('ssh', [...args, `${src.user}@${src.host}`, script], { ...opts, env });
}
function sshHint(err) {
if (/Permission denied/.test(err)) return ' → 계정/비밀번호(또는 등록한 공개키)를 확인하세요.';
if (/Connection (refused|timed out)|No route|Could not resolve|Network is unreachable/.test(err)) return ' → 주소·포트와 방화벽을 확인하세요.';
if (/Host key verification failed|REMOTE HOST IDENTIFICATION/.test(err)) return ' → 서버 키가 바뀌었습니다. 관리자에게 known_hosts 정리를 요청하세요.';
return '';
}
// ---------- curl (FTP / 셸 없는 SFTP) ----------
function curlUrl(src, remotePath, isDir) {
const p = String(remotePath || '').replace(/\/+$/, '');
const enc = (s) => s.split('/').map((seg) => (seg === '~' ? seg : encodeURIComponent(seg))).join('/');
const tail = isDir ? '/' : '';
if (src.protocol === 'ftp') return `ftp://${src.host}:${src.port}/${enc(p.replace(/^\/+/, ''))}${p.replace(/^\/+/, '') ? tail : ''}`; // FTP 는 계정 루트 기준
if (p.startsWith('/')) return `sftp://${src.host}:${src.port}${enc(p)}${tail}`;
return `sftp://${src.host}:${src.port}/~/${enc(p)}${p ? tail : ''}`;
}
function curlConfig(src, url) {
const q = (s) => '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
if (src.type === 'ssh') return `user = ${q(`${src.user}:`)}\nkey = ${q(keyPath(src.keyId))}\npubkey = ${q(keyPath(src.keyId) + '.pub')}\nurl = ${q(url)}\n`; // SFTP 전용(셸 없는) 키 계정
return `user = ${q(`${src.user}:${providers.dec(src.secret)}`)}\nurl = ${q(url)}\n`;
}
async function curl(src, url, extra = [], opts = {}) {
const args = ['-sS', '--connect-timeout', '15', '-m', String(Math.floor((opts.timeout || 120000) / 1000)), '-K', '-', '--insecure'];
if (src.protocol === 'ftp') args.push('--ftp-pasv');
if (!extra.includes('-o') && !extra.includes('-T')) args.push('-o', '/dev/null'); // 출력이 필요 없는 호출(-Q 등)
args.push(...extra);
return run('curl', args, { ...opts, input: curlConfig(src, url) });
}
/** ls -l 형식 목록 파싱 (unix 계열 FTP LIST, libssh sftp 목록 공통) */
function parseListing(text) {
const out = [];
for (const line of String(text).split('\n')) {
const m = line.match(/^([-dl])[rwxsStT-]{9}\s+\d+\s+\S+\s+\S+\s+(\d+)\s+\S+\s+\d+\s+[\d:]+\s+(.+)$/);
if (!m) continue;
let name = m[3]; if (m[1] === 'l') continue; // 심볼릭 링크는 건너뜀
if (name === '.' || name === '..') continue;
// v1.3.17 (S10): 서버가 돌려준 이름에 경로 구분자·NUL·".." 이 있으면 버린다 (사이트 폴더 밖에 쓰는 것 방지)
if (/[\/\\\0]/.test(name) || name === '..' || name.includes('..' + '/')) continue;
out.push({ type: m[1] === 'd' ? 'dir' : 'file', size: parseInt(m[2], 10), name });
}
return out;
}
async function curlList(src, dir) {
const r = await curl(src, curlUrl(src, dir || '', true), ['-o', '-'], { timeout: 60000 });
if (r.code !== 0) throw new Error(`목록 조회 실패(${dir || '/'}): ${r.err.split('\n')[0] || 'exit ' + r.code}`);
return parseListing(r.out);
}
async function curlWalk(src, root, limits) {
const files = []; let totalKb = 0;
const walk = async (rel) => {
const items = await curlList(src, path.posix.join(root, rel));
for (const it of items) {
const r = rel ? `${rel}/${it.name}` : it.name;
if (it.type === 'dir') { if (EXCLUDE_DIRS.includes(it.name)) continue; await walk(r); }
else { files.push({ rel: r, size: it.size }); totalKb += Math.ceil(it.size / 1024); }
const ce = capacityError(totalKb * 1024); if (ce) throw ce; // 목록을 받는 도중에 한도를 넘으면 바로 중지
}
};
await walk('');
return files;
}
async function pool(items, n, fn) { let i = 0; const errs = []; await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => { while (i < items.length) { const it = items[i++]; try { await fn(it); } catch (e) { errs.push(e); if (errs.length > 5) throw e; } } })); if (errs.length) throw errs[0]; }
// ---------- 접속 테스트 / 경로 탐색 ----------
async function test(src) {
await require('./netguard').assertExternalHost(src.host, src.port); // v1.6.31 (요구 5): 서버 내부·사설 주소로는 접속하지 않는다 (설정 service.internalAllow 예외)
const started = Date.now();
if (src.protocol === 'ftp') {
try { const items = await curlList(src, src.docroot || ''); return { ok: true, shell: false, detail: `FTP 접속 성공 — ${src.user}@${src.host}:${src.port}, 항목 ${items.length}개`, ms: Date.now() - started }; }
catch (e) { return { ok: false, detail: `FTP 접속 실패: ${e.message}`, ms: Date.now() - started }; }
}
const r = await sshExec(src, 'echo "USER=$(id -un)"; echo "HOST=$(uname -n 2>/dev/null)"; command -v tar >/dev/null 2>&1 && echo TAR=ok || echo TAR=missing; echo END', { timeout: 40000 });
if (/END/.test(r.out)) {
const kv = {}; for (const l of r.out.split('\n')) { const m = l.match(/^([A-Z]+)=(.*)$/); if (m) kv[m[1]] = m[2]; }
if (kv.TAR === 'missing') return { ok: false, detail: '접속은 되지만 서버에 tar 가 없습니다', ms: Date.now() - started };
return { ok: true, shell: true, detail: `SSH 접속 성공 — ${kv.USER}@${kv.HOST || src.host}`, ms: Date.now() - started };
}
const err = (r.err || r.out || `exit ${r.code}`).split('\n').filter(Boolean).slice(-2).join(' ');
// 셸이 없는 SFTP 전용 계정일 수 있음 → curl sftp 로 재시도
if (!/Permission denied|Could not resolve|Connection (refused|timed out)|No route/.test(err)) {
try { const items = await curlList(src, src.docroot || ''); return { ok: true, shell: false, detail: `SFTP 접속 성공 (셸 없음, 파일 전송 방식) — 항목 ${items.length}개`, ms: Date.now() - started }; } catch (_) {}
}
return { ok: false, detail: `접속 실패: ${err}${sshHint(err)}`, ms: Date.now() - started };
}
function fetchUrl(url, redirects = 0) {
return new Promise((resolve) => {
let u; try { u = new URL(url); } catch (_) { return resolve({ ok: false, error: 'URL 형식 오류' }); }
const mod = u.protocol === 'https:' ? https : http;
const req = mod.get(u, { timeout: 15000, rejectUnauthorized: false, headers: { 'User-Agent': 'webmaker-import/1.0', Accept: 'text/html,*/*' } }, (res) => {
// v1.3.17 (S12): 잘못된 Location 값이 응답 콜백 밖으로 예외를 던져 서버 프로세스가 죽지 않게 한다
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && redirects < 5) { res.resume(); let next; try { next = new URL(res.headers.location, u).href; } catch (_) { return resolve({ ok: false, error: '잘못된 리다이렉트 주소' }); } return resolve(fetchUrl(next, redirects + 1)); }
const chunks = []; let n = 0;
res.on('data', (c) => { n += c.length; if (n <= 2 * 1024 * 1024) chunks.push(c); });
res.on('end', () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 400, status: res.statusCode, body: Buffer.concat(chunks), finalUrl: u.href }));
});
req.on('timeout', () => { req.destroy(new Error('timeout')); });
req.on('error', (e) => resolve({ ok: false, error: e.message }));
});
}
const md5 = (b) => crypto.createHash('md5').update(b).digest('hex');
const titleOf = (html) => { const m = String(html).match(/<title[^>]*>([\s\S]*?)<\/title>/i); return m ? m[1].replace(/\s+/g, ' ').trim() : ''; };
/** 사이트 파일이 있을 만한 경로 후보를 찾고 실제 사이트 첫 화면과 대조한다 */
/**
* nginx 설정 텍스트에서 server 블록을 뽑는다 (중괄호 짝 맞춤, 주석 제거).
* @returns {Array<{server:'nginx', names:string[], root:string|null, listen:string[], file:string}>}
*/
function parseNginx(text, file) {
const out = []; const src = text.replace(/#[^\n]*/g, '');
const re = /\bserver\s*\{/g; let m;
while ((m = re.exec(src))) {
let depth = 1; let i = re.lastIndex; while (i < src.length && depth) { if (src[i] === '{') depth++; else if (src[i] === '}') depth--; i++; }
const body = src.slice(re.lastIndex, i - 1); re.lastIndex = i;
// 하위 location 블록의 root 보다 서버 수준 root 를 우선: 중첩 블록을 지운 텍스트에서 찾는다
let flat = body; for (let k = 0; k < 5; k++) flat = flat.replace(/\{[^{}]*\}/g, '');
const names = ((flat.match(/\bserver_name\s+([^;]+);/) || [])[1] || '').split(/\s+/).filter((n) => n && n !== '_' && n !== '""');
const root = ((flat.match(/\broot\s+([^;]+);/) || [])[1] || (body.match(/location\s+\/\s*\{[^}]*\broot\s+([^;]+);/) || [])[1] || '').trim().replace(/^["']|["']$/g, '') || null;
const listen = [...flat.matchAll(/\blisten\s+([^;]+);/g)].map((x) => x[1].trim());
const proxy = /\bproxy_pass\s/.test(body) && !root;
out.push({ server: 'nginx', names, root, listen, file, proxy });
}
return out;
}
/** Apache 설정에서 <VirtualHost> 블록을 뽑는다 */
function parseApache(text, file) {
const out = []; const re = /<VirtualHost\s+([^>]*)>([\s\S]*?)<\/VirtualHost>/gi; let m;
while ((m = re.exec(text))) {
const body = m[2].replace(/^\s*#.*$/gm, '');
const names = [((body.match(/^\s*ServerName\s+(\S+)/mi) || [])[1] || ''), ...((body.match(/^\s*ServerAlias\s+(.+)$/mi) || [])[1] || '').split(/\s+/)].filter(Boolean).map((n) => n.replace(/:\d+$/, ''));
const root = ((body.match(/^\s*DocumentRoot\s+(.+)$/mi) || [])[1] || '').trim().replace(/^["']|["']$/g, '') || null;
out.push({ server: 'apache', names, root, listen: [m[1].trim()], file, proxy: /ProxyPass\s/i.test(body) && !root });
}
return out;
}
/**
* 접속한 서버의 웹서버 구성을 읽어 서비스 중인 사이트 목록을 만든다 (v1.2.0).
* 접속 계정으로 읽을 수 있는 설정 파일만 대상으로 한다. 고객 서버에서 sudo 는 쓰지 않는다(관리자 승인 없이 권한 상승을 시도하지 않음).
* 같은 문서 루트를 쓰는 블록(80/443 등)은 하나로 합친다.
* @returns {{servers: string[], sites: Array<{names, root, server, file, listen}>}}
*/
async function discoverSites(src) {
const files = '/etc/nginx/nginx.conf /etc/nginx/sites-enabled/* /etc/nginx/conf.d/*.conf /usr/local/nginx/conf/nginx.conf /etc/apache2/sites-enabled/* /etc/httpd/conf/httpd.conf /etc/httpd/conf.d/*.conf /usr/local/apache2/conf/httpd.conf /usr/local/apache2/conf/extra/httpd-vhosts.conf';
const script = `echo "PS=$(ps -eo comm= 2>/dev/null | grep -E '^(nginx|apache2|httpd|caddy|lighttpd|litespeed|lshttpd)$' | sort -u | tr '\n' ' ')";
for f in ${files}; do [ -r "$f" ] && [ -f "$f" ] || continue; echo "@@FILE $f"; head -c 400000 "$f"; echo; done; echo "@@END"`;
const r = await sshExec(src, script, { timeout: 60000, maxOut: 8 * 1024 * 1024 });
if (!/@@END/.test(r.out)) return { servers: [], sites: [], error: (r.err || '').split('\n').slice(-1)[0] || `exit ${r.code}` };
const servers = ((r.out.match(/^PS=(.*)$/m) || [])[1] || '').trim().split(/\s+/).filter(Boolean).map((x) => (x === 'httpd' || x === 'apache2' ? 'apache' : x));
const chunks = r.out.split(/^@@FILE /m).slice(1); const all = []; const viaT = chunks.some((c) => c.startsWith('nginx -T'));
for (const c of chunks) { const nl = c.indexOf('\n'); const file = c.slice(0, nl).trim(); const text = c.slice(nl + 1).replace(/@@END[\s\S]*$/, ''); if (viaT && file.startsWith('/etc/nginx')) continue; if (/nginx/.test(file)) all.push(...parseNginx(text, file === 'nginx -T' ? 'nginx -T' : file)); else all.push(...parseApache(text, file)); }
// 문서 루트 기준으로 합치기 (프록시 전용 블록은 파일 사이트가 아니므로 표시만)
const byRoot = new Map();
for (const x of all) { const key = x.root || `proxy:${x.names.join(',')}`; const prev = byRoot.get(key); if (prev) { prev.names = [...new Set([...prev.names, ...x.names])]; prev.listen = [...new Set([...prev.listen, ...x.listen])]; } else byRoot.set(key, { ...x }); }
const sites = [...byRoot.values()].filter((x) => x.root || x.names.length).slice(0, 60);
return { servers: [...new Set(servers)], sites };
}
async function detectDocroots(src) {
let webInfo = null;
const live = src.url ? await fetchUrl(src.url) : null;
const liveHash = live && live.ok ? md5(live.body) : null; const liveTitle = live && live.ok ? titleOf(live.body.toString('utf8')) : '';
const cands = [];
if (src.shell === false || src.protocol === 'ftp') {
const roots = src.docroot ? [src.docroot] : FTP_CANDIDATES;
for (const d of roots) {
let items; try { items = await curlList(src, d); } catch (_) { continue; }
const idx = INDEX_NAMES.map((n) => items.find((i) => i.type === 'file' && i.name === n)).find(Boolean);
const dirs = items.filter((i) => i.type === 'dir').map((i) => i.name);
cands.push({ path: d || '/', index: idx ? idx.name : null, size: idx ? idx.size : 0, files: items.filter((i) => i.type === 'file').length, dirs: dirs.slice(0, 12), match: null });
if (!src.docroot) for (const sub of dirs.filter((n) => FTP_CANDIDATES.includes(n) && n)) { if (!roots.includes(sub) && !cands.some((c) => c.path === (d ? d + '/' + sub : sub))) roots.push(d ? d + '/' + sub : sub); }
}
// 첫 화면 대조: index 파일 내려받아 비교
for (const c of cands.filter((x) => x.index && x.size < 2 * 1024 * 1024).slice(0, 6)) {
const tmp = path.join(os.tmpdir(), 'wm-idx-' + crypto.randomBytes(4).toString('hex'));
try { const r = await curl(src, curlUrl(src, path.posix.join(c.path === '/' ? '' : c.path, c.index)), ['-o', tmp], { timeout: 60000 }); if (r.code === 0) { const buf = fs.readFileSync(tmp); c.title = titleOf(buf.toString('utf8')); c.match = liveHash ? (md5(buf) === liveHash ? 'exact' : (c.title && c.title === liveTitle ? 'title' : 'none')) : null; } } catch (_) {} finally { try { fs.unlinkSync(tmp); } catch (_) {} }
}
} else {
const H = src.host.replace(/^www\./, '').toLowerCase();
// v1.2.0: 웹서버 구성(nginx/Apache)에서 서비스 중인 사이트를 모두 읽어 후보로 쓴다
const disc = src.docroot ? { servers: [], sites: [] } : await discoverSites(src).catch((e) => ({ servers: [], sites: [], error: e.message }));
webInfo = disc;
const dirs = src.docroot ? [src.docroot] : CANDIDATE_DIRS.flatMap((d) => (d.includes('HOST') ? [d.replace('HOST', src.host), d.replace('HOST', H)] : [d]));
const script = `for d in ${dirs.map(shq).join(' ')}; do d=$(eval echo "$d"); [ -d "$d" ] && echo "DIR=$d"; done 2>/dev/null; echo END`;
const r = await sshExec(src, script, { timeout: 60000 });
if (!/END/.test(r.out)) throw new Error(`경로 탐색 실패: ${(r.err || r.out).split('\n').slice(-1)[0] || 'exit ' + r.code}`);
const found = [];
for (const site of disc.sites.filter((x) => x.root)) { const p = site.root.replace(/\/+$/, '') || '/'; if (!found.some((f) => f.path === p)) found.push({ path: p, fromConf: true, names: site.names, server: site.server, conf: site.file, hostMatch: site.names.some((n) => n.toLowerCase().replace(/^www\./, '') === H) }); }
for (const l of r.out.split('\n')) { const m = l.match(/^DIR=(.+)$/); if (m) { const p = m[1].replace(/\/+$/, '') || '/'; if (!found.some((f) => f.path === p)) found.push({ path: p, fromConf: false }); } }
const uniq = found.filter((f, i, a) => a.findIndex((x) => x.path === f.path) === i).slice(0, 40);
if (uniq.length) {
const s2 = uniq.map((f, i) => `d=${shq(f.path)}; if [ -d "$d" ]; then n=$(find "$d" -maxdepth 2 -type f 2>/dev/null | head -500 | wc -l); idx=""; for i in ${INDEX_NAMES.join(' ')}; do [ -f "$d/$i" ] && { idx=$i; break; }; done; sz=0; t=""; h=""; if [ -n "$idx" ]; then sz=$(wc -c < "$d/$idx" 2>/dev/null); h=$(md5sum < "$d/$idx" 2>/dev/null | cut -d" " -f1); t=$(head -c 65536 "$d/$idx" | tr -d '\\n\\r' | sed -n 's/.*<[tT][iI][tT][lL][eE][^>]*>\\(.*\\)<\\/[tT][iI][tT][lL][eE]>.*/\\1/p' | head -c 200); fi; echo "C${i}=$n|$idx|$sz|$h|$t"; fi`).join('; ');
const r2 = await sshExec(src, s2 + '; echo END', { timeout: 90000 });
uniq.forEach((f, i) => { const m = r2.out.match(new RegExp(`^C${i}=(\\d+)\\|([^|]*)\\|(\\d*)\\|([^|]*)\\|(.*)$`, 'm')); if (!m) return; const title = m[5].replace(/\s+/g, ' ').trim(); cands.push({ path: f.path, fromConf: f.fromConf, names: f.names || [], server: f.server || null, conf: f.conf || null, hostMatch: !!f.hostMatch, files: parseInt(m[1], 10), index: m[2] || null, size: parseInt(m[3] || '0', 10), title, match: liveHash && m[2] ? (m[4] === liveHash ? 'exact' : (title && title === liveTitle ? 'title' : 'none')) : null }); });
}
}
const rank = { exact: 0, title: 1, none: 3 };
cands.sort((a, b) => ((rank[a.match] ?? 2) - (rank[b.match] ?? 2)) || ((b.hostMatch ? 1 : 0) - (a.hostMatch ? 1 : 0)) || ((b.fromConf ? 1 : 0) - (a.fromConf ? 1 : 0)) || ((b.index ? 1 : 0) - (a.index ? 1 : 0)));
// 웹서버 구성에서 찾은 사이트가 하나뿐이면 그대로, 여럿이면 목록에서 고르게 한다(화면 처리)
const confSites = cands.filter((c) => c.fromConf && c.index);
return { candidates: cands, multiSite: confSites.length > 1, webServers: webInfo ? webInfo.servers : [], proxies: webInfo ? webInfo.sites.filter((x) => !x.root).map((x) => ({ names: x.names, server: x.server })) : [], live: live ? { ok: live.ok, status: live.status || null, title: liveTitle, error: live.error || null } : null };
}
// ---------- 가져오기 ----------
/** p 가 dir 안(같은 경로 제외)인지 — 글자 경로 기준 */
function insideDir(dir, p) { const a = path.resolve(dir) + path.sep; return path.resolve(p).startsWith(a); }
/**
* v1.3.17 (S11): 내려받은 사이트 폴더에서 심볼릭 링크·FIFO·소켓·장치 파일을 지운다.
* tar 는 링크를 그대로 풀기 때문에, 사이트 밖을 가리키는 링크가 남으면 나중에 파일 API 로 사이트 밖을 읽고 쓸 수 있었다.
* 링크는 따라가지 않는다(lstat). 반환: 지운 상대 경로
*/
function stripSpecial(dir, base = '') {
const out = []; let ents = []; try { ents = fs.readdirSync(path.join(dir, base), { withFileTypes: true }); } catch (_) { return out; }
for (const e of ents) {
const rel = base ? `${base}/${e.name}` : e.name; const fp = path.join(dir, rel);
if (e.isDirectory() && !e.isSymbolicLink()) { out.push(...stripSpecial(dir, rel)); continue; }
if (!e.isFile()) { try { fs.rmSync(fp, { force: true }); out.push(rel); } catch (_) {} }
}
return out;
}
function localFiles(dir, base = '') {
const out = {};
for (const ent of fs.readdirSync(path.join(dir, base), { withFileTypes: true })) {
const rel = base ? `${base}/${ent.name}` : ent.name;
if (ent.isDirectory()) { if (EXCLUDE_DIRS.includes(ent.name)) continue; Object.assign(out, localFiles(dir, rel)); }
else if (ent.isFile()) { if (ent.name === '.unpublished') continue; const st = fs.statSync(path.join(dir, rel)); out[rel] = { size: st.size, hash: crypto.createHash('sha1').update(fs.readFileSync(path.join(dir, rel))).digest('hex') }; }
}
return out;
}
function tarExcludes() { return EXCLUDE_DIRS.flatMap((d) => ['--exclude', `./${d}`, '--exclude', `*/${d}`]); }
/** 원격 사이트 파일을 siteDir 로 내려받는다. 반환: { fileCount, bytes, index, title } */
async function pull(src, siteDir, onProgress = () => {}) {
await require('./netguard').assertExternalHost(src.host, src.port); // v1.6.31 (요구 5): 서버 내부·사설 주소로는 접속하지 않는다 (설정 service.internalAllow 예외)
if (!src.docroot) throw httpError('사이트 경로(문서 루트)를 지정하세요');
const useCurl = src.protocol === 'ftp' || src.shell === false;
if (useCurl) {
onProgress('파일 목록을 조회하고 있습니다');
const files = await curlWalk(src, src.docroot, {});
let done = 0; let bytes = 0;
await pool(files, 4, async (f) => {
const local = path.join(siteDir, f.rel);
if (!insideDir(siteDir, local)) throw new Error(`사이트 폴더 밖 경로는 내려받지 않습니다: ${f.rel}`); // v1.3.17 (S10) 쓰기 전에 다시 확인
fs.mkdirSync(path.dirname(local), { recursive: true, mode: 0o755 });
const r = await curl(src, curlUrl(src, path.posix.join(src.docroot, f.rel)), ['-o', local], { timeout: 300000 });
if (r.code !== 0) throw new Error(`내려받기 실패(${f.rel}): ${r.err.split('\n')[0]}`);
bytes += f.size; done += 1; if (done % 10 === 0 || done === files.length) onProgress(`파일을 내려받고 있습니다 (${done}/${files.length})`);
});
return finishPull(src, siteDir, files.length, bytes);
}
onProgress('사이트 용량을 확인하고 있습니다');
const pre = await sshExec(src, `D=${shq(src.docroot)}; [ -d "$D" ] || { echo NODIR; exit 0; }; echo "KB=$(du -sk "$D" 2>/dev/null | cut -f1)"; echo "N=$(find "$D" -type f ${EXCLUDE_DIRS.map((d) => `-not -path '*/${d}/*'`).join(' ')} 2>/dev/null | wc -l)"; echo END`, { timeout: 120000 });
if (/NODIR/.test(pre.out)) throw new Error(`서버에 경로가 없습니다: ${src.docroot}`);
const kv = {}; for (const l of pre.out.split('\n')) { const m = l.match(/^([A-Z]+)=(.*)$/); if (m) kv[m[1]] = m[2]; }
if (!/END/.test(pre.out)) throw new Error(`용량 확인 실패: ${(pre.err || pre.out).split('\n').slice(-1)[0] || 'exit ' + pre.code}`);
{ const ce = capacityError(parseInt(kv.KB || '0', 10) * 1024); if (ce) throw ce; }
onProgress(`파일 ${kv.N}개(${(parseInt(kv.KB || '0', 10) / 1024).toFixed(1)}MB)를 내려받고 있습니다`);
const { args, env } = sshEnvArgs(src);
const ssh = spawn('ssh', [...args, `${src.user}@${src.host}`, `tar -C ${shq(src.docroot)} ${EXCLUDE_DIRS.map((d) => `--exclude=./${d} --exclude='*/${d}'`).join(' ')} -cf - .`], { env, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
let sshErr = ''; ssh.stderr.on('data', (c) => { sshErr += c; });
const r = await run('tar', ['-C', siteDir, '--no-same-owner', '--no-same-permissions', '-xf', '-'], { stdin: ssh.stdout, timeout: 900000 });
await new Promise((res) => { if (ssh.exitCode !== null) return res(); ssh.on('close', res); setTimeout(res, 5000); });
if (r.code !== 0) throw new Error(`내려받기 실패: ${(r.err || sshErr).split('\n').slice(-1)[0] || 'exit ' + r.code}`);
const removed = stripSpecial(siteDir); if (removed.length) onProgress(`심볼릭 링크·특수 파일 ${removed.length}개는 가져오지 않았습니다 (${removed.slice(0, 3).join(', ')}${removed.length > 3 ? ' 외' : ''})`);
const files = localFiles(siteDir);
return finishPull(src, siteDir, Object.keys(files).length, Object.values(files).reduce((a, f) => a + f.size, 0), files);
}
function finishPull(src, siteDir, count, bytes, files) {
files = files || localFiles(siteDir);
// 사이트 폴더는 nginx 가 읽을 수 있어야 한다
for (const rel of Object.keys(files)) { try { fs.chmodSync(path.join(siteDir, rel), 0o644); } catch (_) {} }
const index = INDEX_NAMES.find((n) => files[n]); let title = '';
if (index) { try { title = titleOf(fs.readFileSync(path.join(siteDir, index), 'utf8').slice(0, 200000)); } catch (_) {} }
src.manifest = Object.fromEntries(Object.entries(files).map(([k, v]) => [k, v.hash])); src.importedAt = new Date().toISOString();
const dynamic = Object.keys(files).some((f) => /\.(php|asp|aspx|jsp|cgi|py|rb)$/i.test(f));
return { fileCount: Object.keys(files).length, bytes, index, title, dynamic };
}
// ---------- 되돌려 배포 ----------
/** siteDir 의 현재 파일을 원격 문서 루트에 반영한다. 가져온 뒤 사라진 파일은 삭제하고, 서버에만 있는(가져오지 않은) 파일은 건드리지 않는다. */
async function push(src, siteDir, onProgress = () => {}) {
await require('./netguard').assertExternalHost(src.host, src.port); // v1.6.31 (요구 5): 서버 내부·사설 주소로는 접속하지 않는다 (설정 service.internalAllow 예외)
if (!src.docroot) throw httpError('사이트 경로(문서 루트)가 없습니다');
const started = Date.now();
const files = localFiles(siteDir);
const prev = src.manifest || {};
const removed = Object.keys(prev).filter((rel) => !files[rel] && !rel.startsWith('uploads/'));
const changed = Object.keys(files).filter((rel) => prev[rel] !== files[rel].hash);
const useCurl = src.protocol === 'ftp' || src.shell === false;
let detail;
if (useCurl) {
let done = 0;
onProgress(`변경된 파일 ${changed.length}개를 올리고 있습니다`);
await pool(changed, 3, async (rel) => {
const r = await curl(src, curlUrl(src, path.posix.join(src.docroot, rel)), ['-T', path.join(siteDir, rel), '--ftp-create-dirs'], { timeout: 300000 });
if (r.code !== 0) throw new Error(`올리기 실패(${rel}): ${r.err.split('\n')[0]}`);
done += 1; if (done % 10 === 0) onProgress(`파일을 올리고 있습니다 (${done}/${changed.length})`);
});
let delFail = 0;
for (const rel of removed) {
const remote = path.posix.join(src.docroot, rel);
const cmd = src.protocol === 'ftp' ? `DELE ${remote.replace(/^\/+/, '')}` : `rm "${(remote.startsWith('/') ? remote : '/~/' + remote).replace(/"/g, '\\"')}"`;
const r = await curl(src, curlUrl(src, '', true), ['-Q', cmd], { timeout: 60000 });
if (r.code !== 0) delFail += 1;
}
detail = `파일 ${changed.length}개 반영, ${removed.length - delFail}개 삭제${delFail ? ` (${delFail}개 삭제 실패)` : ''} → ${src.protocol.toUpperCase()} ${src.user}@${src.host}:${src.docroot} (서버 백업은 이 방식에서 만들지 않으며, 가져온 원본은 이 서비스의 버전 기록에 남아 있습니다)`;
} else {
onProgress('서버에 백업을 만들고 파일을 반영하고 있습니다');
const tar = spawn('tar', ['-C', siteDir, ...tarExcludes(), '-cf', '-', '.']);
const remote = `set -e; D=${shq(src.docroot)}; [ -d "$D" ] || { echo NODIR; exit 1; }; B="$HOME/.webmaker-backups"; BK=""; if mkdir -p "$B" 2>/dev/null && tar -C "$D" -czf "$B/$(date +%Y%m%d-%H%M%S).tgz" . 2>/dev/null; then BK=$(ls -1t "$B"/*.tgz 2>/dev/null | head -1); ls -1t "$B"/*.tgz 2>/dev/null | tail -n +4 | xargs -r rm -f; fi; echo "BACKUP=$BK";
tar -C "$D" --no-same-owner -xf -; ${removed.length ? `for f in ${removed.map(shq).join(' ')}; do rm -f "$D/$f"; done;` : ''} echo PUSHED`;
const r = await sshExec(src, remote, { stdin: tar.stdout, timeout: 600000 });
if (!/PUSHED/.test(r.out)) { const err = (r.err || r.out || `exit ${r.code}`).split('\n').filter(Boolean).slice(-2).join(' '); throw new Error(`반영 실패: ${/NODIR/.test(r.out) ? '서버에 경로가 없습니다: ' + src.docroot : err}${/Permission denied/.test(err) ? ' → 접속 계정에 사이트 폴더 쓰기 권한이 있는지 확인하세요' : ''}`); }
const bk = (r.out.match(/^BACKUP=(.*)$/m) || [])[1] || '';
src.backupNote = bk ? `서버 백업: ${bk}` : '서버 백업을 만들지 못했습니다(권한/용량). 가져온 원본은 이 서비스의 버전 기록에 남아 있습니다.';
detail = `파일 ${changed.length}개 반영, ${removed.length}개 삭제 → ${src.user}@${src.host}:${src.docroot}${bk ? ` · 서버 백업 ${path.posix.basename(bk)}` : ' · 서버 백업 생략(권한 없음)'}`;
}
src.manifest = Object.fromEntries(Object.entries(files).map(([k, v]) => [k, v.hash])); src.lastPushAt = new Date().toISOString();
return { ok: true, detail, changed: changed.length, removed: removed.length, ms: Date.now() - started };
}
/** 배포 후 실제 사이트 주소가 열리고 우리가 보낸 첫 화면과 같은지 확인 */
async function verifyLive(src, siteDir) {
if (!src.url) return { items: [] };
const live = await fetchUrl(src.url);
if (!live.ok) return { items: [{ kind: 'url', url: src.url, ok: false, note: live.error || `HTTP ${live.status}` }] };
const index = INDEX_NAMES.find((n) => fs.existsSync(path.join(siteDir, n)));
let same = null; if (index && /\.html?$/i.test(index)) { try { same = md5(fs.readFileSync(path.join(siteDir, index))) === md5(live.body); } catch (_) {} }
return { items: [{ kind: 'url', url: live.finalUrl || src.url, ok: true, note: same === null ? `HTTP ${live.status} 응답` : (same ? `HTTP ${live.status} · 첫 화면이 반영된 내용과 일치` : `HTTP ${live.status} · 첫 화면 내용이 아직 다릅니다 (캐시/CDN 지연일 수 있음)`), same }] };
}
module.exports = { stripSpecial, insideDir, capacity, capacityError, discoverSites, parseNginx, parseApache, normalize, buildSource, publicSource, newKey, storeKey, deleteKey, test, detectDocroots, pull, push, verifyLive, parseListing, fetchUrl };
+32
View File
@@ -0,0 +1,32 @@
'use strict';
// 반영 검증 리포트를 독립된 HTML 페이지로 렌더링한다(생성 화면과 분리, 사용자가 선택했을 때만 열람).
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
const CAT = { cta: '행동 유도(CTA)', feature: '기능', page: '페이지', style: '디자인', copy: '문구·어조', facts: '사실정보', regulation: '규제 표현' };
const ST = { pass: ['통과', '#065f46', '#d1fae5'], partial: ['부분 반영', '#92400e', '#fef3c7'], fail: ['미반영', '#991b1b', '#fee2e2'] };
function render(site) {
const plan = site.plan || {}; const v = plan.verification; const b = plan.brief || {};
const at = v ? new Date(v.at).toLocaleString('ko-KR') : '';
let body = '';
if (!v) body = '<p class="muted">아직 검증이 실행되지 않았습니다. 생성 화면에서 "재검증"을 누르면 리포트가 만들어집니다.</p>';
else {
const r = v.rules; const s = r.summary;
const chip = (t, bad) => `<span class="chip ${bad ? 'bad' : 'ok'}">${esc(t)}</span>`;
body += `<section><h2>요약</h2><div class="chips">${chip(`페이지 파일 ${s.files}개`)}${chip(`누락 페이지 ${s.pagesMissing}`, s.pagesMissing > 0)}${chip(`[입력 필요] ${s.placeholders}곳`, s.placeholders > 0)}${chip(`Brief 에 없는 사실정보 ${s.extraneous}건`, s.extraneous > 0)}${chip(`규제 표현 ${s.regulated}건`, s.regulated > 0)}${v.ai && typeof v.ai.score === 'number' ? chip(`AI 대조 점수 ${v.ai.score}/100`, v.ai.score < 70) : ''}</div>${v.stale ? '<p class="muted">※ 마지막 검증 이후 페이지가 수정되었습니다. 최신 상태를 보려면 재검증하세요.</p>' : ''}</section>`;
body += `<section><h2>페이지 확인</h2><table><tr><th>페이지</th><th>파일</th><th>상태</th></tr>${r.pages.map((p) => `<tr><td>${esc(p.page)}</td><td>${esc(p.file)}</td><td class="${p.exists ? 'ok' : 'bad'}">${p.exists ? '있음' : '없음'}</td></tr>`).join('')}</table></section>`;
body += `<section><h2>입력이 필요한 항목</h2>${r.placeholders.length ? `<ul>${r.placeholders.map((p) => `<li>${esc(p.file)}: "[입력 필요]" ${p.count}곳</li>`).join('')}</ul><p class="muted">전화·주소·영업시간·가격·자격·후기 등은 입력한 값만 사용하며, 없는 값은 "[입력 필요]" 로 표시됩니다. 기본 사항의 사실정보를 입력한 뒤 수정 지시를 보내면 채워집니다.</p>` : '<p class="ok">없음 — 모든 사실정보가 채워져 있습니다.</p>'}</section>`;
body += `<section><h2>Brief 에 없는 사실정보 (제거 대상)</h2>${r.extraneous.length ? `<ul>${r.extraneous.map((e) => `<li>${esc(e.file)}: ${esc(e.type)} "${esc(e.value)}"</li>`).join('')}</ul>` : '<p class="ok">없음</p>'}</section>`;
body += `<section><h2>규제 표현 검사</h2>${r.regulated.length ? `<ul>${r.regulated.map((e) => `<li>${esc(e.file)}: "${esc(e.value)}"</li>`).join('')}</ul>` : `<p class="ok">발견되지 않음${b.regulated && b.regulated.flag ? ` (적용 제약: ${esc((b.regulated.constraints || []).join(' / '))})` : ''}</p>`}</section>`;
if (v.ai && v.ai.items) body += `<section><h2>AI 대조 결과 — Brief ↔ 생성 결과</h2><table><tr><th>구분</th><th>항목</th><th>상태</th><th>근거</th></tr>${v.ai.items.map((i) => { const st = ST[i.status] || ST.partial; return `<tr><td>${esc(CAT[i.category] || i.category)}</td><td>${esc(i.name)}</td><td><span class="st" style="color:${st[1]};background:${st[2]}">${st[0]}</span></td><td>${esc(i.detail)}</td></tr>`; }).join('')}</table>${v.ai.fixes && v.ai.fixes.length ? `<h3>제안된 수정</h3><ol>${v.ai.fixes.map((f) => `<li>${esc(f)}</li>`).join('')}</ol><p class="muted">생성 화면의 "검증 결과 자동 수정"으로 한 번에 반영할 수 있습니다.</p>` : ''}</section>`;
// v1.3.16 다국어 품질 게이트 (번역 누락률·원문 잔존율·링크 대응·언어별 검수)
if (v.i18n && v.i18n.languages) {
const pct = (x) => `${(x * 100).toFixed(1)}%`; const th = v.i18n.thresholds || {};
body += `<section><h2>다국어 품질 게이트 ${v.i18n.pass ? '<span class="st" style="color:#065f46;background:#d1fae5">통과</span>' : '<span class="st" style="color:#991b1b;background:#fee2e2">미통과</span>'}</h2><p class="muted">기준: 번역 누락률 ≤ ${pct(th.maxMissing || 0)} (시스템 문구 누락 0), 원문 잔존율 ≤ ${pct(th.maxResidue || 0)}, 사이트 안 링크가 기본 언어와 같은 짝, 언어별 검수 완료.</p><table><tr><th>언어</th><th>누락률</th><th>잔존율</th><th>링크</th><th>검수</th><th>결과</th></tr>${Object.entries(v.i18n.languages).map(([c, r]) => r.source ? `<tr><td>${esc(c)} (기본)</td><td colspan="4" class="muted">원문</td><td class="ok">—</td></tr>` : `<tr><td>${esc(c)}</td><td class="${r.checks.missing ? 'ok' : 'bad'}">${pct(r.missing.rate)}${r.runtimeMissing ? ` · 시스템 문구 ${r.runtimeMissing}` : ''}</td><td class="${r.checks.residue ? 'ok' : 'bad'}">${pct(r.residue.rate)}${r.residue.samples.length ? `<div class="muted">${r.residue.samples.map(esc).join('<br>')}</div>` : ''}</td><td class="${r.checks.links ? 'ok' : 'bad'}">${r.links.ok ? '일치' : esc([...r.links.issues, ...r.links.missingPages.map((f) => `${f} 없음`)].join(' / '))}</td><td class="${r.checks.review ? 'ok' : 'bad'}">${esc({ approved: '검수 완료', rejected: '수정 필요', pending: '검수 대기' }[r.review] || r.review)}</td><td class="${r.pass ? 'ok' : 'bad'}">${r.pass ? '통과' : '미통과'}</td></tr>`).join('')}</table></section>`;
}
if (v.ai && !v.ai.items && v.ai.error) body += `<section><h2>AI 대조 결과</h2><p class="muted">AI 대조를 완료하지 못했습니다. 재검증을 눌러 다시 시도하세요.</p></section>`;
}
return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>검증 리포트 — ${esc(site.title)}</title>
<style>body{font-family:-apple-system,"Segoe UI","Noto Sans KR",sans-serif;font-size:14px;color:#1f2933;background:#f4f5f7;margin:0;padding:24px}main{max-width:900px;margin:0 auto;background:#fff;border:1px solid #e3e6ea;border-radius:12px;padding:24px}h1{font-size:20px;margin:0 0 4px}h2{font-size:15px;margin:0 0 8px}h3{font-size:13px;margin:12px 0 6px}section{padding:14px 0;border-top:1px solid #e3e6ea}.muted{color:#6b7684;font-size:12px}.ok{color:#065f46}.bad{color:#991b1b;font-weight:600}.chips{display:flex;flex-wrap:wrap;gap:6px}.chip{border:1px solid #e3e6ea;border-radius:6px;padding:4px 10px;font-size:12px}.chip.ok{background:#d1fae5;border-color:#a7f3d0;color:#065f46}.chip.bad{background:#fee2e2;border-color:#fecaca;color:#991b1b}table{width:100%;border-collapse:collapse;font-size:12px}th,td{text-align:left;padding:6px;border-bottom:1px solid #e3e6ea;vertical-align:top}.st{font-size:11px;padding:2px 8px;border-radius:999px;font-weight:600;white-space:nowrap}ul,ol{margin:4px 0;padding-left:20px}.top{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:8px}.btn{font-size:12px;border:1px solid #e3e6ea;border-radius:8px;padding:6px 10px;text-decoration:none;color:#1f2933;background:#fff}@media print{body{background:#fff;padding:0}main{border:0}.btn{display:none}}</style></head>
<body><main><div class="top"><div><h1>반영 검증 리포트</h1><div class="muted">${esc(site.title)} · ${esc(site.url || '')}${at ? ` · 검증 시각 ${esc(at)}` : ''}</div></div><div><a class="btn" href="?download=1">파일로 저장</a> <a class="btn" href="javascript:window.print()">인쇄</a></div></div>${body}</main></body></html>`;
}
module.exports = { render };
+27
View File
@@ -0,0 +1,27 @@
'use strict';
// 모델 참조(ref)에 따라 실행 엔진을 고른다: anthropic → Claude Code CLI, 그 외 → OpenAI 호환 에이전트.
const { runClaude } = require('./claude');
const { runAgent } = require('./engine-openai');
const { runCodex } = require('./engine-codex');
const providers = require('./providers');
const CLAUDE_TOOLSETS = { files: 'Write,Edit,MultiEdit,Read,Glob,Grep', read: 'Read,Glob,Grep,WebFetch', none: 'Read' };
/**
* @param {object} o ref(모델 참조), userId, siteDir, prompt, systemPrompt, toolset('files'|'read'|'none'), sessionId, resume, noPersist, onEvent, onStart
* @returns runClaude 와 동일한 결과 형태 + {ref, provider, source}
*/
async function runModel(o) {
const m = providers.resolveModel(o.ref, o.userId);
let r;
if (m.kind === 'claude-cli') {
r = await runClaude({ siteDir: o.siteDir, prompt: o.prompt, systemPrompt: o.systemPrompt, tools: CLAUDE_TOOLSETS[o.toolset || 'files'], sessionId: o.sessionId, resume: o.resume, noPersist: o.noPersist, model: m.model, authEnv: m.source === 'user' ? (m.apiKey ? { ANTHROPIC_API_KEY: m.apiKey } : { CLAUDE_CODE_OAUTH_TOKEN: m.oauthToken }) : null, onEvent: o.onEvent, onStart: o.onStart });
} else if (m.kind === 'codex-cli') {
r = await runCodex({ siteDir: o.siteDir, prompt: o.prompt, systemPrompt: o.systemPrompt, toolset: o.toolset || 'files', model: m.model, price: m.price, onEvent: o.onEvent, onStart: o.onStart });
} else {
r = await runAgent({ siteDir: o.siteDir, prompt: o.prompt, systemPrompt: o.systemPrompt, toolset: o.toolset || 'files', model: m.model, provider: m.provider, apiKey: m.apiKey, baseUrl: m.baseUrl, price: m.price, onEvent: o.onEvent, onStart: o.onStart });
}
if (m.source === 'user' && m.connId) { try { providers.updateUserConn(o.userId, m.connId, { lastUsedAt: new Date().toISOString() }); } catch (_) {} }
return { ...r, ref: m.ref, provider: m.provider, source: m.source, model: m.ref };
}
module.exports = { runModel };
+376
View File
@@ -0,0 +1,376 @@
'use strict';
// ============================================================================
// 검색·AI 최적화(SEO · AEO · GEO)와 검색사이트 등록 (v1.3.5)
//
// 1) 생성 옵션 → AI 초안 → 사용자 검토 → 반영
// - 생성 탭에서 "SEO AI 생성 / AEO 생성 / GEO 생성" 을 고르면(site.optimize.wanted) 사이트 생성·검증이 끝난 뒤
// AI 가 사이트 파일을 읽어 초안(site.optimize.draft)을 만든다. 초안은 바로 사이트에 넣지 않는다.
// - 사용자가 관리 탭에서 값을 검토·수정하고 "반영" 하면(site.optimize.final) materialize() 가 HTML 과 파일에 넣는다.
// - SEO : 페이지별 <title>·meta description·키워드·Open Graph·트위터 카드·canonical, sitemap.xml·robots.txt
// - AEO : (Answer Engine Optimization) 질문형 FAQ — FAQPage 구조화 데이터, 사이트에 없는 질문은 FAQ 영역으로 추가
// - GEO : (Generative Engine Optimization) 사업체 정보 구조화 데이터(Organization/LocalBusiness 등),
// AI 가 인용하기 좋은 사실 문장, llms.txt (AI 크롤러용 사이트 요약)
//
// 2) 검색사이트 등록(구글 Search Console, 네이버 서치어드바이저) — 배포가 끝난 사이트만
// - 소유 확인 코드(meta 태그) 입력 → 태그를 넣고 재배포 → 검색사이트에서 "확인" → 사이트맵 제출.
// - 사람이 해야 하는 단계(코드 받기, DNS TXT 등록, 검색사이트에서 확인·제출)는 관리자 콘솔 "검색 등록" 탭에
// 작업 목록으로 보이고, 관리자가 진행·완료 표시할 수 있다. IndexNow(빙·네이버 등 공유)는 자동 색인 요청.
//
// 3) 다국어 사이트(v1.3.10, site.langs) — 언어 폴더(/ko/ /en/ …)마다 따로
// - 기본 언어 초안은 AI 가 기본 언어 폴더를 읽어 만들고, 다른 언어 초안은 그 초안을 바탕으로 "그 언어 사용자가 실제로 검색하는 표현"으로
// 현지화해 따로 만든다(site.optimize.langDrafts[언어]). 사용자는 언어 탭마다 검토하고, 반영 값은 site.optimize.langFinals[언어].
// - 각 페이지에 그 언어의 title·description·og:locale, 모든 언어 버전의 hreflang 대체 링크(+x-default)를 넣고,
// sitemap.xml 에는 언어별 주소와 xhtml:link 대체 주소를 함께 적는다. 검색사이트 확인 태그는 사이트 맨 위 언어 선택 페이지에 넣는다.
//
// materialize() 는 같은 입력이면 같은 결과를 내도록(멱등) 표시 주석으로 관리 구역을 나눠 덮어쓴다.
// <head> 안: <!-- wm-seo:start --> … <!-- wm-seo:end -->, 본문 FAQ: <!-- wm-aeo-faq:start --> … <!-- wm-aeo-faq:end -->
// 파일(sitemap.xml·robots.txt·llms.txt·IndexNow 키): 우리가 쓴 내용의 해시를 기억해, 사용자가 직접 만든 파일은 덮어쓰지 않는다.
// ============================================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const dns = require('dns').promises;
const multilang = require('./multilang'); // v1.3.10 언어별 SEO·AEO·GEO
const sitemsg = require('./sitemsg'); // v1.3.16 사이트에 넣는 시스템 문구 (FAQ 제목 등)
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const clip = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const sha = (t) => crypto.createHash('sha1').update(t).digest('hex').slice(0, 16);
// ---------- 옵션 ----------
/** 생성 옵션 값 정리: {seo, aeo, geo} (문자열 JSON·체크박스 값 모두 받음) */
function wantedFrom(input) {
let o = input; if (typeof o === 'string') { try { o = JSON.parse(o); } catch (_) { o = {}; } }
o = o || {}; const on = (v) => v === true || v === 'true' || v === 'on' || v === 1 || v === '1';
return { seo: on(o.seo), aeo: on(o.aeo), geo: on(o.geo) };
}
const anyWanted = (w) => !!(w && (w.seo || w.aeo || w.geo));
/** 사이트의 HTML 파일 목록 (하위 폴더 포함, 최대 400개, 웹메일 진입 파일·업로드 폴더 제외) */
function htmlFiles(siteDir) {
const out = [];
const walk = (dir, rel) => { let ents = []; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; } for (const e of ents) { if (e.name.startsWith('.') || ['uploads', 'webmail', 'node_modules'].includes(e.name)) continue; const r = rel ? `${rel}/${e.name}` : e.name; if (e.isDirectory()) { if (out.length < 400) walk(path.join(dir, e.name), r); } else if (/\.html?$/i.test(e.name) && out.length < 400) out.push(r); } };
walk(siteDir, ''); return out.sort((a, b) => (a === 'index.html' ? -1 : b === 'index.html' ? 1 : a.localeCompare(b)));
}
/**
* 최적화 값을 매기는 "페이지 파일" 목록 (v1.3.10).
* 단일 언어 사이트: 사이트 전체 HTML(최대 60개). 다국어 사이트: 기본 언어 폴더 기준 경로(index.html, about.html …) — 언어마다 같은 구조라 같은 목록을 쓴다.
*/
function pageFiles(site, siteDir) {
if (multilang.isMulti(site)) return multilang.htmlUnder(path.join(siteDir, site.langs.default)).sort((a, b) => (a === 'index.html' ? -1 : b === 'index.html' ? 1 : a.localeCompare(b))).slice(0, 60);
return htmlFiles(siteDir).slice(0, 60);
}
// ---------- AI 초안 ----------
/**
* 초안 프롬프트. AI 는 사이트 폴더를 Read 로 읽고 JSON 만 답한다.
* 각 항목의 작성 기준(글자 수, 질문형, 사실만 등)을 명시한다 — 사용자 검토 화면에서도 같은 기준을 보여 준다.
* @param {object} site
* @param {{seo,aeo,geo}} wanted
* @param {string[]} files HTML 파일 목록
*/
function draftPrompt(site, wanted, files) {
const ml = multilang.isMulti(site) ? site.langs.default : null; // 다국어: 기본 언어 폴더를 읽는다
const b = (site.plan && site.plan.brief) || {}; const facts = b.facts || {};
const factLines = Object.entries(facts).filter(([, v]) => v && v.value && v.status !== 'removed').map(([k, v]) => `${k}: ${v.value}`).join('\n');
const lang = site.uiLang && site.uiLang !== 'ko' ? site.uiLang : 'ko';
const parts = [];
if (wanted.seo) parts.push(`"seo": { "siteName": "사이트(사업체) 이름", "locale": "ko_KR 같은 언어_국가", "keywords": ["사이트 전체 핵심 검색어 5~10개 (업종+지역+서비스 조합)"],
"pages": [ { "file": "index.html", "title": "검색 결과 제목 — 페이지마다 다르게, 핵심어를 앞쪽에, 한글 기준 25~35자(최대 60자)", "description": "검색 결과 설명 — 페이지 내용을 요약하고 행동 유도, 한글 기준 70~110자(최대 155자)", "keywords": ["이 페이지 검색어 3~6개"], "ogTitle": "공유 미리보기 제목", "ogDescription": "공유 미리보기 설명 (60~100자)", "h1": "이 페이지 h1 문구 (없거나 부적절하면 제안, 적절하면 현재 값)" } ] }`);
if (wanted.aeo) parts.push(`"aeo": { "summary": "질문형 검색(음성·AI 답변)에 그대로 읽힐 사이트 핵심 답 2~3문장 — 누가, 무엇을, 어디서",
"faqs": [ { "q": "실제 고객이 검색할 질문형 문장 (…인가요? …어떻게 하나요?)", "a": "첫 문장에 바로 답, 2~4문장 이내, 사이트 내용과 일치", "page": "질문이 속할 페이지 파일", "onPage": true } ] } ← 5~8개, 사이트에 이미 있는 Q&A 는 onPage:true 로 그대로 옮기고, 없는 질문은 onPage:false 로 제안(사이트 내용으로 답할 수 있는 것만)`);
if (wanted.geo) parts.push(`"geo": { "entity": { "type": "Organization|LocalBusiness|Restaurant|MedicalClinic|EducationalOrganization|Store|ProfessionalService 중 가장 맞는 것", "name": "", "description": "한두 문장 소개", "telephone": "", "email": "", "address": { "streetAddress": "", "addressLocality": "", "addressRegion": "", "postalCode": "", "addressCountry": "KR" }, "openingHours": ["Mo-Fr 09:00-18:00 형식"], "priceRange": "", "sameAs": ["공식 SNS·지도 주소"] },
"facts": ["AI 답변이 그대로 인용할 수 있는 짧은 사실 문장 5~10개 (숫자·고유명사 포함, 사이트에 있는 사실만)"],
"llmsTxt": "llms.txt 형식(# 사이트명 / > 한 줄 요약 / 본문 소개 / ## 주요 페이지 - [이름](파일): 설명) 의 마크다운" }`);
return `현재 디렉터리는 완성된 웹사이트입니다. 아래 HTML 파일을 Read 로 읽고(큰 파일은 앞부분과 주요 섹션 위주), 검색엔진·AI 답변 엔진에 맞는 최적화 값을 만들어 JSON 으로만 답하세요.
작성 언어: 사이트 본문 언어 (화면 언어 ${lang}).${ml ? `\n다국어 사이트입니다: 기본 언어(${ml}) 폴더의 페이지만 읽고, "file"·"page" 값은 아래 목록의 경로 그대로 씁니다. 이 초안은 기본 언어용이며 locale 은 ${multilang.LOCALES[ml].og} 입니다.` : ''}
규칙:
- 사이트·아래 사실 정보에 있는 내용만 씁니다. 전화번호·주소·가격·수치를 지어내지 말고, 모르면 빈 문자열로 둡니다.
- 과장·낚시성 문구, 키워드 반복 나열을 하지 않습니다.
- 파일 이름은 아래 목록에 있는 것만 씁니다.
[HTML 파일] ${(ml ? files.map((f) => `${ml}/${f}`) : files).join(', ')}
[사실 정보]
${factLines || '(없음 — 사이트 본문에서만 찾기)'}
[업종] ${(b.business && b.business.industry && [b.business.industry.major, b.business.industry.minor].filter(Boolean).join(' > ')) || '-'}
출력 JSON:
{ ${parts.join(',\n ')} }`;
}
/**
* 다국어 사이트의 다른 언어 초안 프롬프트 (v1.3.10). 기본 언어 초안 JSON 을 그 언어용으로 "현지화" 한다(파일 읽기 없음).
* 직역이 아니라 그 언어 사용자가 실제로 검색하는 표현·길이 기준으로 다시 쓰고, 파일 이름·숫자·연락처·URL 은 그대로 둔다.
*/
function localizePrompt(site, baseDraft, lang) {
const L = multilang.LOCALES[lang]; const D = multilang.LOCALES[site.langs.default];
return `아래는 웹사이트의 ${D.label}(${site.langs.default}) 검색·AI 최적화 값(JSON)입니다. 같은 사이트의 ${L.label}(${lang}) 버전 페이지(/${lang}/ 폴더)에 쓸 값으로 바꿔, 같은 구조의 JSON 만 답하세요.
규칙:
- 직역하지 말고 ${L.label} 사용자가 실제로 검색하는 표현으로 씁니다(키워드는 그 언어권 검색 습관에 맞게 현지화).
- 길이 기준은 그 언어 기준: 검색 제목 약 50~60자(영문 기준, CJK 는 25~35자), 설명 약 120~155자(CJK 70~110자).
- "file", "page", "use", "onPage", 전화번호·이메일·URL·숫자·영업시간 형식(Mo-Fr 09:00-18:00)은 그대로 둡니다. 상호는 원래 표기 또는 그 언어권 통용 표기.
- seo.locale 은 "${L.og}" 로 씁니다. 원문에 없는 사실을 지어내지 않습니다. llmsTxt 는 ${L.label}로 쓰고 링크는 그대로 둡니다.
${JSON.stringify(baseDraft)}`;
}
/**
* AI 초안 정리: 형식·길이 검증, 없는 파일 제거. 사용자 검토 화면과 materialize 가 이 형태만 믿는다.
*/
function normalize(data, files, wanted, forceLocale, dirPrefix, keepFlags) { // keepFlags: 사용자 검토 결과(최종 반영)면 "반영 안 함" 체크를 그대로 둔다 (v1.3.17 B04) // forceLocale: 다국어 사이트에서 언어 폴더의 og:locale 로 고정, dirPrefix: 'ko/' (AI 가 쓴 폴더 경로를 떼어 냄)
const out = {}; const fileSet = new Set(files);
if (dirPrefix) { const cut = (f) => (typeof f === 'string' && f.startsWith(dirPrefix) ? f.slice(dirPrefix.length) : f); for (const p of (data.seo && Array.isArray(data.seo.pages) ? data.seo.pages : [])) if (p) p.file = cut(p.file); for (const f of (data.aeo && Array.isArray(data.aeo.faqs) ? data.aeo.faqs : [])) if (f) f.page = cut(f.page); }
if (wanted.seo && data.seo) {
const s = data.seo;
out.seo = { siteName: clip(s.siteName, 80), locale: forceLocale || (/^[a-z]{2}_[A-Z]{2}$/.test(s.locale || '') ? s.locale : 'ko_KR'), keywords: (Array.isArray(s.keywords) ? s.keywords : []).map((k) => clip(k, 40)).filter(Boolean).slice(0, 12),
pages: (Array.isArray(s.pages) ? s.pages : []).filter((p) => p && fileSet.has(p.file)).map((p) => ({ file: p.file, title: clip(p.title, 70), description: clip(p.description, 170), keywords: (Array.isArray(p.keywords) ? p.keywords : []).map((k) => clip(k, 40)).filter(Boolean).slice(0, 8), ogTitle: clip(p.ogTitle || p.title, 90), ogDescription: clip(p.ogDescription || p.description, 200), h1: clip(p.h1, 100), use: keepFlags ? p.use !== false : true })) };
const seen = new Set(out.seo.pages.map((p) => p.file)); for (const f of files) if (!seen.has(f) && out.seo.pages.length < 60) out.seo.pages.push({ file: f, title: '', description: '', keywords: [], ogTitle: '', ogDescription: '', h1: '', use: false });
}
if (wanted.aeo && data.aeo) {
const a = data.aeo;
out.aeo = { summary: clip(a.summary, 400), faqs: (Array.isArray(a.faqs) ? a.faqs : []).map((f) => ({ q: clip(f && f.q, 200), a: clip(f && f.a, 800), page: fileSet.has(f && f.page) ? f.page : (files[0] || 'index.html'), onPage: !!(f && f.onPage), use: keepFlags ? !!f && f.use !== false : true })).filter((f) => f.q && f.a).slice(0, 12) };
}
if (wanted.geo && data.geo) {
const g = data.geo; const e = g.entity || {}; const ad = e.address || {};
out.geo = { entity: { type: /^[A-Za-z]{3,40}$/.test(e.type || '') ? e.type : 'Organization', name: clip(e.name, 120), description: clip(e.description, 400), telephone: clip(e.telephone, 40), email: clip(e.email, 120), address: { streetAddress: clip(ad.streetAddress, 160), addressLocality: clip(ad.addressLocality, 80), addressRegion: clip(ad.addressRegion, 80), postalCode: clip(ad.postalCode, 20), addressCountry: clip(ad.addressCountry || 'KR', 2) }, openingHours: (Array.isArray(e.openingHours) ? e.openingHours : []).map((h) => clip(h, 60)).filter(Boolean).slice(0, 7), priceRange: clip(e.priceRange, 40), sameAs: (Array.isArray(e.sameAs) ? e.sameAs : []).filter((u) => /^https?:\/\//.test(u || '')).map((u) => clip(u, 300)).slice(0, 8) },
facts: (Array.isArray(g.facts) ? g.facts : []).map((f) => clip(f, 300)).filter(Boolean).slice(0, 12), llmsTxt: String(g.llmsTxt || '').slice(0, 8000), useEntity: keepFlags ? g.useEntity !== false : true, useLlms: keepFlags ? g.useLlms !== false : true };
}
return out;
}
/**
* 사용자 검토 결과(화면에서 고친 값) 검증 → site.optimize.final 에 저장할 형태.
* 화면은 draft 와 같은 모양을 돌려보내며, 각 항목의 use(반영 여부)를 바꿀 수 있다.
*/
function normalizeFinal(input, files, forceLocale) {
const wanted = { seo: !!input.seo, aeo: !!input.aeo, geo: !!input.geo };
return normalize(input, files, wanted, forceLocale, undefined, true); // v1.3.17 (B04): 체크를 끈 항목은 끈 채로 반영
}
// ---------- 사이트에 넣기 ----------
// 관리 구역은 "표시 주석…표시 주석\n" 을 정확히 넣고 정확히 지운다 → 원래 파일의 공백을 건드리지 않아 몇 번을 다시 써도 결과가 같다(멱등)
const SEO_RE = /<!-- wm-seo:start -->[\s\S]*?<!-- wm-seo:end -->\n/;
const FAQ_RE = /<!-- wm-aeo-faq:start -->[\s\S]*?<!-- wm-aeo-faq:end -->\n/;
/** 페이지 주소: 기준 주소 + 파일 (index.html 은 폴더 주소로) */
const pageUrl = (base, file) => base ? base.replace(/\/?$/, '/') + file.replace(/(^|\/)index\.html$/, '$1') : '';
/** JSON-LD 를 <script> 안에 안전하게 넣기 (</script> 끊김 방지) */
const ld = (obj) => `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;
/** 빈 값 제거 (구조화 데이터 검사기 경고 방지) */
function prune(o) { if (Array.isArray(o)) { const a = o.map(prune).filter((x) => x !== undefined); return a.length ? a : undefined; } if (o && typeof o === 'object') { const r = {}; for (const [k, v] of Object.entries(o)) { const pv = prune(v); if (pv !== undefined && pv !== '') r[k] = pv; } return Object.keys(r).length ? r : undefined; } return o === '' || o == null ? undefined : o; }
/**
* FAQ 영역 제목: v1.3.16 부터 코드에 언어별 글자를 두지 않고 사이트 시스템 문구 카탈로그(lib/sitemsg 'faq.title')에서 가져온다.
* @param {string|null} lang 로케일 코드 (다국어 사이트의 언어 폴더)
* @param {string} [locale] 단일 언어 사이트의 og:locale(ko_KR 등) — 언어를 모를 때 여기서 추정
*/
const faqTitleFor = (lang, locale) => sitemsg.tr(lang || multilang.codeOf(String(locale || 'ko').split('_')[0]), 'faq.title');
/**
* 한 폴더의 페이지들에 관리 구역(<head> 의 wm-seo, 본문 wm-aeo-faq)을 다시 쓴다.
* @param {string} dir 페이지 폴더 (단일 언어: 사이트 폴더, 다국어: 언어 폴더)
* @param {string} prefix 바뀐 파일 이름 앞에 붙일 경로 ('' 또는 'en/')
* @param {string[]} files dir 기준 HTML 목록
* @param {object|null} fin 이 언어의 반영 값 (없으면 관리 구역을 걷어 내기만 — 대체 링크·확인 태그는 넣는다)
* @param {string|null} baseUrl 이 폴더의 운영 주소 (https://도메인/ 또는 https://도메인/en/)
* @param {object} ctx { verify: 검색사이트 확인 태그(searchReg), ogLocale, altLocales: [], alternates: [{hreflang, base}], faqTitle, homeUrl }
*/
function writePages(dir, prefix, files, fin, baseUrl, ctx) {
fin = fin || {}; const changed = [];
const seoPages = new Map(((fin.seo && fin.seo.pages) || []).filter((p) => p.use).map((p) => [p.file, p]));
const faqs = ((fin.aeo && fin.aeo.faqs) || []).filter((f) => f.use);
const geo = fin.geo || null; const reg = ctx.verify || null;
for (const file of files) {
const fp = path.join(dir, file); let html; try { html = fs.readFileSync(fp, 'utf8'); } catch (_) { continue; }
const orig = html; html = html.replace(SEO_RE, '').replace(FAQ_RE, ''); // 지난번에 넣은 관리 구역부터 걷어 내고 원래 페이지를 기준으로 판단
const p = seoPages.get(file); const isHome = file === 'index.html'; const url = pageUrl(baseUrl, file);
const lines = [];
if (p) {
if (p.title) { if (/<title[^>]*>[\s\S]*?<\/title>/i.test(html)) html = html.replace(/<title[^>]*>[\s\S]*?<\/title>/i, `<title>${esc(p.title)}</title>`); else lines.push(`<title>${esc(p.title)}</title>`); }
if (p.description) { html = html.replace(/<meta\s+name=["']description["'][^>]*>/gi, ''); lines.push(`<meta name="description" content="${esc(p.description)}">`); }
const kw = [...new Set([...(p.keywords || []), ...((fin.seo && fin.seo.keywords) || []).slice(0, 4)])]; if (kw.length) lines.push(`<meta name="keywords" content="${esc(kw.join(', '))}">`);
lines.push('<meta name="robots" content="index, follow">');
if (url) lines.push(`<link rel="canonical" href="${esc(url)}">`);
lines.push(`<meta property="og:type" content="${isHome ? 'website' : 'article'}">`, `<meta property="og:title" content="${esc(p.ogTitle || p.title)}">`, `<meta property="og:description" content="${esc(p.ogDescription || p.description)}">`);
if (fin.seo.siteName) lines.push(`<meta property="og:site_name" content="${esc(fin.seo.siteName)}">`); lines.push(`<meta property="og:locale" content="${esc(ctx.ogLocale || fin.seo.locale || 'ko_KR')}">`);
for (const a of ctx.altLocales || []) lines.push(`<meta property="og:locale:alternate" content="${esc(a)}">`);
if (url) lines.push(`<meta property="og:url" content="${esc(url)}">`);
lines.push('<meta name="twitter:card" content="summary_large_image">');
} else if (ctx.always) {
// v1.3.16 다국어: 이 언어의 검색 값을 아직 반영하지 않았어도 로케일 정보(canonical·og:locale·og:url)는 언어마다 분명히 넣는다
if (url) lines.push(`<link rel="canonical" href="${esc(url)}">`);
lines.push(`<meta property="og:locale" content="${esc(ctx.ogLocale)}">`); for (const a of ctx.altLocales || []) lines.push(`<meta property="og:locale:alternate" content="${esc(a)}">`);
if (url) lines.push(`<meta property="og:url" content="${esc(url)}">`);
}
// 다국어: 같은 페이지의 모든 언어 버전 (검색엔진이 언어별 주소를 알아보게) — 절대 주소가 필요해 운영 주소가 있을 때만
for (const a of ctx.alternates || []) lines.push(`<link rel="alternate" hreflang="${esc(a.hreflang)}" href="${esc(pageUrl(a.base, file))}">`);
// 검색사이트 소유 확인 태그 (사이트 첫 페이지만)
if (isHome && reg) { if (reg.google && reg.google.method === 'meta' && reg.google.code) lines.push(`<meta name="google-site-verification" content="${esc(reg.google.code)}">`); if (reg.naver && reg.naver.code) lines.push(`<meta name="naver-site-verification" content="${esc(reg.naver.code)}">`); }
// GEO: 사업체 정보 구조화 데이터 (첫 페이지)
if (isHome && geo && geo.useEntity && geo.entity && geo.entity.name) {
// v1.3.16 로케일별 사업체 정보: 언어 폴더마다 그 언어 값(이름·소개·주소)으로, 같은 사업체임을 @id 로 묶고 url 은 그 언어 첫 페이지
const e = geo.entity; lines.push(ld(prune({ '@context': 'https://schema.org', '@type': e.type || 'Organization', '@id': ctx.entityId || undefined, name: e.name, description: e.description, url: baseUrl || ctx.homeUrl || undefined, telephone: e.telephone, email: e.email, address: e.address && Object.values(e.address).some((v) => v && v !== 'KR') ? { '@type': 'PostalAddress', ...e.address } : undefined, openingHours: e.openingHours, priceRange: e.priceRange, sameAs: e.sameAs, knowsLanguage: ctx.knowsLanguage, currenciesAccepted: ctx.currency })));
if (fin.aeo && fin.aeo.summary) lines.push(ld(prune({ '@context': 'https://schema.org', '@type': 'WebSite', name: (fin.seo && fin.seo.siteName) || e.name, url: baseUrl || undefined, inLanguage: ctx.htmlLang || undefined, description: fin.aeo.summary })));
}
// AEO: 이 페이지 FAQ 의 FAQPage 구조화 데이터 + 사이트에 없던 질문은 보이는 FAQ 영역으로
const pageFaqs = faqs.filter((f) => f.page === file);
if (pageFaqs.length) lines.push(ld({ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: pageFaqs.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) }));
// 관리 구역 다시 쓰기 (내용이 없으면 구역 없이 둔다)
if (lines.length) { const block = `<!-- wm-seo:start --><!-- AI Webmaker 검색·AI 최적화 (관리 탭에서 고치세요. 이 구역은 반영·배포 때 다시 써집니다) -->\n${lines.join('\n')}\n<!-- wm-seo:end -->\n`; html = /<\/head>/i.test(html) ? html.replace(/<\/head>/i, `${block}</head>`) : block + html; }
const visible = pageFaqs.filter((f) => !f.onPage);
if (visible.length) {
const sec = `<!-- wm-aeo-faq:start --><!-- AI Webmaker 자주 묻는 질문 (관리 탭 › 검색·AI 최적화에서 고치세요) -->\n<section class="wm-faq" data-wm-aeo style="max-width:960px;margin:40px auto;padding:0 20px"><h2>${esc(ctx.faqTitle || FAQ_TITLE.ko)}</h2>${visible.map((f) => `<details style="border-bottom:1px solid #e5e7eb;padding:12px 0"><summary style="cursor:pointer;font-weight:600">${esc(f.q)}</summary><p style="margin:8px 0 0">${esc(f.a)}</p></details>`).join('')}</section>\n<!-- wm-aeo-faq:end -->\n`;
if (/<footer[\s>]/i.test(html)) html = html.replace(/<footer[\s>]/i, (m) => `${sec}${m}`); else if (/<\/main>/i.test(html)) html = html.replace(/<\/main>/i, `${sec}</main>`); else html = html.replace(/<\/body>/i, `${sec}</body>`);
}
if (html !== orig) { fs.writeFileSync(fp, html, { mode: 0o644 }); changed.push(prefix + file); }
}
return changed;
}
/**
* 반영 값(final)과 검색 등록 값(searchReg)을 사이트 파일에 넣는다. 같은 입력이면 결과가 같다(멱등).
* @param {object} site
* @param {string} siteDir
* @param {string|null} baseUrl 운영 주소(https://도메인/). 없으면 canonical·og:url·sitemap·hreflang 의 절대 주소를 생략한다
* — 배포할 때마다 배포 주소로 다시 부른다(server.js deploySiteTo).
* @returns {{changed: string[]}}
*/
function materialize(site, siteDir, baseUrl) {
const opt = site.optimize || {}; const fin = opt.applied ? (opt.final || {}) : {}; const reg = site.searchReg || {};
const base = baseUrl ? baseUrl.replace(/\/?$/, '/') : null; let changed = []; let sitemapUrls = [];
const multi = multilang.isMulti(site);
const finOf = (lang) => (!opt.applied ? {} : lang === site.langs.default ? (opt.final || {}) : ((opt.langFinals || {})[lang] || {}));
if (!multi) {
const files = htmlFiles(siteDir);
changed = writePages(siteDir, '', files, fin, base, { verify: reg, faqTitle: faqTitleFor(null, fin.seo && fin.seo.locale) });
sitemapUrls = files.map((f) => ({ loc: pageUrl(base, f), home: f === 'index.html' }));
} else {
// 다국어: 언어 폴더마다 그 언어의 반영 값 + 모든 언어 버전 대체 링크, 맨 위 언어 선택 페이지에는 확인 태그와 대체 링크
const { list, default: def } = site.langs; const L = multilang.LOCALES;
// v1.3.16 x-default: 사이트 설정(site.langs.xDefault)이 가리키는 언어 폴더, 'root' 이면 맨 위 언어 선택 페이지
const xd = multilang.xDefaultOf(site); const xBase = (file) => (xd === 'root' ? (file === 'index.html' ? base : null) : `${base}${xd}/`);
const files = multilang.htmlUnder(path.join(siteDir, def)); const geoDef = finOf(def).geo || null; const home = multilang.siteLocale(site);
for (const c of list) {
const f = finOf(c);
// 이 언어 GEO 값이 없으면 기본 언어 사업체 정보를 쓴다(로케일 현황에 "기본 언어 값 사용" 으로 표시)
const fin2 = f.geo || !geoDef ? f : { ...f, geo: geoDef };
const alt = (file) => (base ? [...list.map((x) => ({ hreflang: L[x].html, base: `${base}${x}/` })), ...(xBase(file) ? [{ hreflang: 'x-default', base: xBase(file) }] : [])] : []);
const dirC = path.join(siteDir, c);
for (const file of multilang.htmlUnder(dirC)) changed = changed.concat(writePages(dirC, `${c}/`, [file], fin2, base ? `${base}${c}/` : null, { ogLocale: L[c].og, altLocales: list.filter((x) => x !== c).map((x) => L[x].og), alternates: alt(file), faqTitle: faqTitleFor(c), htmlLang: L[c].html, homeUrl: base ? `${base}${c}/` : undefined, always: true, knowsLanguage: list.map((x) => L[x].html), currency: home.currency, entityId: base ? `${base}#business` : undefined }));
}
const rootAlt = base ? [...list.map((c) => ({ hreflang: L[c].html, base: `${base}${c}/` })), { hreflang: 'x-default', base: xd === 'root' ? base : `${base}${xd}/` }] : [];
changed = changed.concat(writePages(siteDir, '', fs.existsSync(path.join(siteDir, 'index.html')) ? ['index.html'] : [], null, base, { verify: reg, alternates: rootAlt }));
sitemapUrls = list.flatMap((c) => files.map((file) => ({ loc: pageUrl(`${base}${c}/`, file), home: file === 'index.html' && c === def, alts: [...list.map((x) => ({ hreflang: L[x].html, href: pageUrl(`${base}${x}/`, file) })), ...(xBase(file) ? [{ hreflang: 'x-default', href: pageUrl(xBase(file), file) }] : [])] })));
}
// 파일들 — 우리가 쓴 적 없는(사용자가 만든) 파일은 덮어쓰지 않는다
const written = (site.optimize = site.optimize || {}).written || (site.optimize.written = {});
const put = (name, content) => { const fp = path.join(siteDir, name); let cur = null; try { cur = fs.readFileSync(fp, 'utf8'); } catch (_) {} if (cur !== null && written[name] !== sha(cur)) return; if (content == null) { if (cur !== null && written[name]) { try { fs.unlinkSync(fp); } catch (_) {} delete written[name]; changed.push(name); } return; } if (cur === content) return; fs.writeFileSync(fp, content, { mode: 0o644 }); written[name] = sha(content); changed.push(name); };
const anySeo = multi ? site.langs.list.some((c) => finOf(c).seo) : !!fin.seo;
const wantSitemap = !!(anySeo || (reg.google && reg.google.started) || (reg.naver && reg.naver.started));
const hasAlts = sitemapUrls.some((u) => u.alts);
if (wantSitemap && base) put('sitemap.xml', `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${hasAlts ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : ''}>\n${sitemapUrls.map((u) => ` <url><loc>${esc(u.loc)}</loc>${(u.alts || []).map((a) => `<xhtml:link rel="alternate" hreflang="${esc(a.hreflang)}" href="${esc(a.href)}"/>`).join('')}${u.home ? '<priority>1.0</priority>' : ''}</url>`).join('\n')}\n</urlset>\n`);
// i18n-ignore-start (사이트 파일 내용 — 화면 문구 아님)
if (!wantSitemap) { put('sitemap.xml', null); put('robots.txt', null); } // 더 이상 쓰지 않으면 우리가 만든 파일만 지운다
if (wantSitemap) put('robots.txt', `# AI Webmaker 자동 생성 (관리 탭 › 검색·AI 최적화)\nUser-agent: *\nAllow: /\n${base ? `Sitemap: ${base}sitemap.xml\n` : ''}`);
const geo = multi ? finOf(site.langs.default).geo : fin.geo;
const langLinks = multi && base ? `\n## Languages\n${site.langs.list.map((c) => `- [${multilang.LOCALES[c].name}](${base}${c}/)`).join('\n')}\n` : '';
put('llms.txt', geo && geo.useLlms && geo.llmsTxt ? geo.llmsTxt.trim() + (geo.facts && geo.facts.length ? `\n\n## 주요 사실\n${geo.facts.map((f) => `- ${f}`).join('\n')}\n` : '\n') + langLinks : null);
if (reg.indexnow && reg.indexnow.key) put(`${reg.indexnow.key}.txt`, reg.indexnow.key);
// i18n-ignore-end
return { changed };
}
// ---------- 검색사이트 등록 ----------
/** 등록할 수 있는 상태인지: 배포가 끝났고(운영 주소 있음) 생성·수정·배포가 진행 중이 아님 */
function regAvailability(site, liveUrl, busy) {
if (busy) return { ok: false, reason: '생성·수정·배포가 진행 중입니다. 끝난 뒤에 검색사이트 등록을 할 수 있습니다.' };
if (site.plan && !['generated', 'published'].includes(site.plan.stage)) return { ok: false, reason: '사이트 생성이 끝나지 않았습니다.' };
if (!liveUrl) return { ok: false, reason: '배포가 끝난 사이트만 검색사이트에 등록할 수 있습니다. 먼저 발행·배포하세요.' };
return { ok: true };
}
const ENGINES = {
google: { label: '구글 (Search Console)', console: 'https://search.google.com/search-console', meta: 'google-site-verification' },
naver: { label: '네이버 (서치어드바이저)', console: 'https://searchadvisor.naver.com/console/board', meta: 'naver-site-verification' },
};
/** meta 태그 통째로 붙여넣어도 content 값만 뽑는다 */
function codeFrom(input) { const s = String(input || '').trim(); const m = s.match(/content=["']([^"']+)["']/i); const v = (m ? m[1] : s).replace(/^google-site-verification=/, ''); if (!/^[A-Za-z0-9_\-=]{8,200}$/.test(v)) throw httpError('확인 코드 형식이 올바르지 않습니다. 검색사이트가 준 meta 태그의 content 값(또는 태그 전체)을 붙여넣으세요'); return v; }
/**
* 엔진별 단계와 상태. actor: auto(서비스가 함) | manual(사람이 검색사이트·DNS 에서 함)
* done 표시는 site.searchReg[engine].steps[stepId] 에 날짜로 남는다.
* v1.3.10: opts.googleAuto(구글 서비스 계정 키가 있음)이면 구글 단계는 모두 자동(코드 받기 → 태그·재배포 → 소유 확인 → 속성 추가·사이트맵 제출).
* 네이버는 공개 API 가 없어 "코드 받기·확인 누르기" 만 사람이 하고, 코드를 넣으면 태그·재배포·운영 확인은 자동,
* 사이트맵은 robots.txt 의 Sitemap 줄과 IndexNow(네이버 참여)로 알려지므로 제출은 선택 단계다.
*/
function steps(site, engine, liveUrl, opts = {}) {
const r = (site.searchReg || {})[engine] || {}; const E = ENGINES[engine]; const s = r.steps || {}; const base = liveUrl ? liveUrl.replace(/\/?$/, '/') : '';
const list = []; const auto = r.auto || {};
if (engine === 'google' && r.method !== 'dns' && opts.googleAuto) {
const at = (k) => (s[k] ? s[k].slice(0, 16).replace('T', ' ') : '');
list.push({ id: 'code', actor: 'auto', title: '구글 API 로 확인 코드(meta 태그)를 받습니다', detail: r.code ? '받음' : '등록을 시작하면 자동으로 받습니다', done: !!r.code });
list.push({ id: 'apply', actor: 'auto', title: '확인 태그를 첫 페이지에 넣고 다시 배포합니다', detail: r.appliedAt ? `넣고 배포함 (${r.appliedAt.slice(0, 16).replace('T', ' ')})` : '자동', done: !!r.appliedAt });
list.push({ id: 'verify', actor: 'auto', title: '구글에 소유 확인을 요청합니다', detail: s.verify ? `확인됨 (${at('verify')})${r.ownerAdded ? ` · 소유자 추가: ${r.ownerAdded}` : ''}` : '자동', done: !!s.verify });
list.push({ id: 'sitemap', actor: 'auto', title: 'Search Console 에 사이트를 추가하고 사이트맵을 제출합니다', detail: s.sitemap ? `제출함 (${at('sitemap')}) — ${base}sitemap.xml` : '자동', done: !!s.sitemap });
return list;
}
if (engine === 'google' && r.method === 'dns') {
list.push({ id: 'code', actor: 'manual', title: 'Search Console 에서 "도메인" 속성을 추가하고 TXT 확인 값을 받습니다', detail: `${E.console} › 속성 추가 › 도메인 › ${(liveUrl || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '')} 입력 → 표시되는 google-site-verification=… 값을 아래에 입력`, done: !!r.code });
list.push({ id: 'dns', actor: 'manual', title: '도메인 DNS 에 TXT 레코드를 등록합니다', detail: r.code ? `호스트 @ · 종류 TXT · 값 google-site-verification=${r.code}` : '먼저 확인 값을 입력하세요', done: !!s.dns, dnsCheck: true });
} else {
list.push({ id: 'code', actor: 'manual', title: `${E.label} 에 사이트를 추가하고 HTML 태그(meta) 확인 코드를 받습니다`, detail: engine === 'google' ? `${E.console} › 속성 추가 › URL 접두어 › ${base} 입력 › 다른 확인 방법 › HTML 태그 → content 값 복사 (관리자 콘솔 › 연동 키에 구글 서비스 계정 키를 넣으면 이 단계부터 모두 자동)` : `${E.console} › 사이트 등록 › ${base} 입력 › HTML 태그 → content 값 복사`, done: !!r.code });
list.push({ id: 'apply', actor: 'auto', title: '확인 태그를 첫 페이지에 넣고 다시 배포합니다', detail: r.appliedAt ? `넣고 배포함 (${r.appliedAt.slice(0, 16).replace('T', ' ')})${r.liveOk ? ' · 운영 페이지에서 태그 확인됨' : ''}` : '코드를 저장하면 자동으로 넣고 재배포합니다', done: !!r.appliedAt });
}
list.push({ id: 'verify', actor: 'manual', title: `${E.label} 에서 "확인" 을 누릅니다`, detail: r.liveOk || r.method === 'dns' ? '소유 확인이 성공하면 완료 표시' : '태그가 운영 페이지에 보인 뒤 누르세요', done: !!s.verify });
if (engine === 'naver') list.push({ id: 'sitemap', actor: 'manual', optional: true, title: '(선택) 사이트맵을 제출합니다', detail: `서치어드바이저 › 요청 › 사이트맵 제출 에 ${base}sitemap.xml — robots.txt 의 Sitemap 줄과 IndexNow 자동 색인 요청으로도 알려지므로 하지 않아도 됩니다`, done: !!s.sitemap });
else list.push({ id: 'sitemap', actor: 'manual', title: '사이트맵을 제출합니다', detail: `Search Console › Sitemaps 에 ${base}sitemap.xml 입력`, done: !!s.sitemap });
return list;
}
/** 엔진 상태 요약 (필수 단계만 완료 판정 — 선택 단계 제외) */
function engineStatus(site, engine, liveUrl, opts = {}) {
const r = (site.searchReg || {})[engine] || {}; if (!r.started) return { engine, label: ENGINES[engine].label, started: false, state: '시작 안 함', autoAvailable: engine === 'google' && !!opts.googleAuto };
const st = steps(site, engine, liveUrl, opts); const req = st.filter((x) => !x.optional); const doneAll = req.every((x) => x.done); const next = req.find((x) => !x.done);
const auto = r.auto || null; const running = auto && auto.state === 'running';
return { engine, label: ENGINES[engine].label, started: true, method: r.method || 'meta', code: r.code || '', steps: st, auto, autoAvailable: engine === 'google' && !!opts.googleAuto,
state: doneAll ? '등록 완료' : running ? '자동 진행 중' : auto && auto.state === 'error' ? '자동 진행 실패' : (next.actor === 'manual' ? '수동 작업 대기' : '자동 진행 대기'), next: next ? next.title : null, console: ENGINES[engine].console };
}
/** DNS TXT 확인 (google 도메인 속성) */
async function checkTxt(domain, value) { try { const recs = (await dns.resolveTxt(domain)).map((r) => r.join('')); return recs.includes(value); } catch (_) { return false; } }
/** 운영 첫 페이지에 확인 태그가 실제로 보이는지 (배포 반영 확인용) */
async function liveHasMeta(liveUrl, name, code) { try { const r = await require('./netguard').safeFetch(liveUrl, { timeout: 15000, maxBytes: 1024 * 1024, userAgent: 'AI-Webmaker-SearchReg' }); const t = r.buf.toString('utf8'); /* v1.6.31 (요구 5): 사설·내부 주소 차단, 리다이렉트마다 재검증 */ return new RegExp(`<meta[^>]+name=["']${name}["'][^>]+content=["']${code.replace(/[^\w-]/g, '')}["']`, 'i').test(t); } catch (_) { return false; } }
/** IndexNow 키 (사이트별, 처음 한 번 만든다) */
function ensureIndexNowKey(site) { site.searchReg = site.searchReg || {}; site.searchReg.indexnow = site.searchReg.indexnow || {}; if (!site.searchReg.indexnow.key) site.searchReg.indexnow.key = crypto.randomBytes(16).toString('hex'); return site.searchReg.indexnow.key; }
/**
* IndexNow 색인 요청 (빙·네이버·얀덱스 등 참여 검색엔진이 공유). 운영 주소의 키 파일로 소유를 확인한다.
* @param {string} liveUrl https://도메인/
* @param {string[]} files HTML 파일 목록
*/
async function pingIndexNow(liveUrl, key, files) {
const base = liveUrl.replace(/\/?$/, '/'); const host = new URL(base).host;
const body = { host, key, keyLocation: `${base}${key}.txt`, urlList: files.slice(0, 100).map((f) => pageUrl(base, f)) };
const r = await fetch(process.env.INDEXNOW_ENDPOINT || 'https://api.indexnow.org/indexnow', // INDEXNOW_ENDPOINT: 시험용 대체 주소
{ method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(body), signal: AbortSignal.timeout(20000) });
return { status: r.status, ok: r.status === 200 || r.status === 202, detail: r.status === 200 || r.status === 202 ? `색인 요청 접수 (${body.urlList.length}개 주소)` : r.status === 403 ? '키 파일을 찾지 못했습니다 — 재배포 후 다시 시도하세요' : r.status === 422 ? '주소가 키 파일의 도메인과 맞지 않습니다' : `응답 ${r.status}` };
}
/**
* v1.3.16 로케일별 메타·구조화 데이터 현황 (관리 탭 다국어 카드 표). 실제 페이지 파일을 읽어 언어 × 페이지마다
* title·description·canonical·og:locale·og:title·hreflang·sitemap 포함 여부, 첫 페이지의 사업체(LocalBusiness 등) 구조화 데이터와
* 그 값이 이 언어 것인지(기본 언어 값 대체 여부)를 돌려준다.
*/
function localeMatrix(site, siteDir, baseUrl) {
if (!multilang.isMulti(site)) return null;
const { list, default: def } = site.langs; const L = multilang.LOCALES; const files = multilang.htmlUnder(path.join(siteDir, def));
let sm = ''; try { sm = fs.readFileSync(path.join(siteDir, 'sitemap.xml'), 'utf8'); } catch (_) {}
const opt = site.optimize || {}; const langFin = (c) => (c === def ? opt.final : (opt.langFinals || {})[c]) || null;
const meta = (h, re) => { const m = h.match(re); return m ? m[1].replace(/&quot;/g, '"').replace(/&amp;/g, '&') : ''; };
return { baseUrl: baseUrl || null, xDefault: multilang.xDefaultOf(site), languages: list.map((c) => {
const lf = langFin(c);
const pages = files.map((file) => {
let h = ''; try { h = fs.readFileSync(path.join(siteDir, c, file), 'utf8'); } catch (_) { return { file, missing: true }; }
const url = baseUrl ? pageUrl(`${baseUrl.replace(/\/?$/, '/')}${c}/`, file) : null;
return { file, title: meta(h, /<title[^>]*>([\s\S]*?)<\/title>/i), description: meta(h, /<meta\s+name=["']description["']\s+content=["']([^"']*)["']/i), canonical: meta(h, /<link\s+rel=["']canonical["']\s+href=["']([^"']*)["']/i), ogLocale: meta(h, /<meta\s+property=["']og:locale["']\s+content=["']([^"']*)["']/i), ogTitle: meta(h, /<meta\s+property=["']og:title["']\s+content=["']([^"']*)["']/i), hreflang: (h.match(/<link rel="alternate" hreflang=/g) || []).length, inSitemap: url ? sm.includes(`<loc>${esc(url)}</loc>`) : null, canonicalOk: url ? meta(h, /<link\s+rel=["']canonical["']\s+href=["']([^"']*)["']/i) === url : null, localeOk: meta(h, /<meta\s+property=["']og:locale["']\s+content=["']([^"']*)["']/i) === L[c].og };
});
let home = ''; try { home = fs.readFileSync(path.join(siteDir, c, 'index.html'), 'utf8'); } catch (_) {}
const schema = (home.match(/"@type":"(LocalBusiness|Organization|Restaurant|Store|ProfessionalService|MedicalClinic|EducationalOrganization)"/) || [])[1] || null;
return { lang: c, name: L[c].name, label: L[c].label, ogLocale: L[c].og, hreflang: L[c].html, seo: !!(lf && lf.seo), aeo: !!(lf && lf.aeo), geo: lf && lf.geo ? 'own' : (langFin(def) && langFin(def).geo ? 'default' : 'none'), schema, pages };
}) };
}
module.exports = { localeMatrix, wantedFrom, anyWanted, htmlFiles, pageFiles, draftPrompt, localizePrompt, normalize, normalizeFinal, materialize, regAvailability, ENGINES, codeFrom, steps, engineStatus, checkTxt, liveHasMeta, ensureIndexNowKey, pingIndexNow, pageUrl };
+257
View File
@@ -0,0 +1,257 @@
'use strict';
// 관리자 설정: 내부 호출 모델(v1.5.16, 이전 "기본 모델"), 모델 연결(서버 로그인 / OAuth 토큰 / API 키), 사용할 Claude 모델, 환율·마진. data/settings.json (0600)
const fs = require('fs');
const path = require('path');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const FILE = path.join(DATA_ROOT, 'settings.json');
const MODELS = [
{ id: 'claude-opus-5-5', label: 'Claude Opus 5.5 (기본·권장)', input: 4, output: 20, cacheRead: 0.2, note: '최고 품질, 1M 컨텍스트' },
{ id: 'claude-sonnet-5-5', label: 'Claude Sonnet 5.5', input: 2, output: 10, cacheRead: 0.2, note: '속도·비용 균형' },
{ id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', input: 1, output: 5, cacheRead: 0.1, note: '저비용, 200K 컨텍스트' },
{ id: 'claude-fable-5-1', label: 'Claude Fable 5.1', input: 10, output: 50, cacheRead: 0.25, note: '최상위 모델, 고비용' },
{ id: 'claude-opus-5', label: 'Claude Opus 5', input: 5, output: 25, cacheRead: 0.5, note: '이전 세대 Opus' },
];
// 사용자에게 보이는 모델 등급. 실제 모델(제공자:모델)은 관리자만 지정·열람한다.
const TIERS = [
// v1.5.19 (요구 1): 등급의 "특징" 은 모델 성능이 아니라 만들 수 있는 결과물로 설명한다 (생성 탭 표·관리자 등급 지정에 같이 보임)
{ id: 'basic', label: '기본', desc: 'HTML5, CSS3, 자바스크립트 구조의 정적 웹페이지 생성' },
{ id: 'standard', label: '중급', desc: '웹페이지의 시각적 완성도(UI/UX)와 심미성 우수' },
{ id: 'premium', label: '고급', desc: '복잡한 UI/UX의 프런트엔드 아키텍쳐 구현' },
];
const DEFAULT_TIERS = { basic: 'anthropic:claude-haiku-4-5', standard: 'anthropic:claude-sonnet-5-5', premium: 'anthropic:claude-opus-5-5' };
const DEFAULTS = {
// defaultModel: 1.5.13 까지의 "Claude 기본 모델"(Claude 모델 id 하나). v1.5.16 부터 화면에서는 internalRef 를 쓰고, 이 값은
// Claude 만 쓸 수 있는 자리(연결 테스트, 옛 기록의 비용 추정)와 internalRef 가 Claude 일 때의 모델 id 로 남는다.
defaultModel: process.env.CLAUDE_MODEL || 'claude-opus-5-5',
// v1.5.16 (요구 2): 등급(기본·중급·고급)과 무관한 서비스 내부 호출(사용자 문의 AI 분석, 모델을 지정하지 않은 호출)에 쓰는 모델.
// 등급 지정과 같은 형식 "<provider>:<model>" 이며 AI 연결 탭에서 "사용" 으로 켠 모델만 고를 수 있다. 비어 있으면 anthropic:<defaultModel>.
internalRef: '',
// v1.5.16 (요구 3): Claude 모델 중 이 서비스에서 "사용" 으로 켠 것(id 배열). 비어 있으면 MODELS 전체. 등급·내부 호출에 고를 수 있는 Claude 모델이 이것으로 제한된다.
claudeModels: [],
tiers: { ...DEFAULT_TIERS },
defaultTier: 'standard',
serviceApiKey: '', // 외부 서비스가 캐시를 조정할 때 쓰는 키 (X-Service-Key)
mailSystem: null, // v1.3.0: 사이트 메일 시스템 { enabled, mxHost, spfInclude, dmarc } (null 이면 꺼짐, lib/mailsys.js)
theme: null, // v1.2.0: AI Webmaker 화면 색상·외형 (null 이면 기본). THEME_DEFAULTS 참고
backlogKey: '', // 작업 서버가 "다음 작업 반영 목록" 을 가져갈 때 쓰는 읽기 전용 키 (X-Backlog-Key, v1.1.6)
signupCredit: 0, // 신규 회원 가입 시 자동 지급하는 기본 캐시(원). 관리자 콘솔에서 설정
// v1.5.27 (요구 6): 회원가입 뒤 이용 방식 — immediate(가입 즉시 이용) | approval(관리자가 승인해야 로그인 가능). 가입 자체의 ON/OFF 는 설정 파일 service.allowSignup
signupApproval: 'immediate',
maxEdits: 10, // AI에게 맡기기: 생성 후 발행 전까지 허용하는 수정 횟수(0 = 무제한). 한도에 이르면 발행 대기
auth: { mode: 'server-login', apiKey: '', oauthToken: '' }, // server-login | oauth-token | api-key
fx: { mode: 'auto', manualRate: 0, spreadPct: 1.0 }, // auto: ECB/시장 중간환율 + 스프레드(구입가 근사) | manual: 관리자 입력
marginPct: 30,
letsEncryptEmail: '',
mail: { keyFile: '', apiKey: '', defaultDomain: '', fromName: 'Webmaster', formBase: '' },
updatedAt: null,
};
let cache = null;
function load() {
if (cache) return cache;
try { cache = { ...DEFAULTS, ...JSON.parse(fs.readFileSync(FILE, 'utf8')) }; cache.auth = { ...DEFAULTS.auth, ...(cache.auth || {}) }; cache.fx = { ...DEFAULTS.fx, ...(cache.fx || {}) }; }
catch (_) { cache = JSON.parse(JSON.stringify(DEFAULTS)); }
return cache;
}
function save(patch) {
// v1.3.16: 사본에 적용하고 끝까지 검증을 통과했을 때만 파일·캐시에 반영 (중간에 검증 오류가 나면 아무것도 바뀌지 않음)
const s = JSON.parse(JSON.stringify(load()));
// v1.3.17 (B14): 보관소(키) 저장처럼 되돌릴 수 없는 일은 모아 두었다가 모든 검증이 끝난 뒤에 실행한다
// (예전에는 키를 먼저 보관소에 쓰고 뒤의 숫자 검증에서 실패해, 오류 응답인데 키는 바뀌어 있었다)
const after = [];
// 옛 형식(1.5.13 까지의 화면·API): Claude 모델 id 하나 → 내부 호출 모델도 그 Claude 모델로 맞춘다
if (patch.defaultModel && MODELS.some((m) => m.id === patch.defaultModel)) { s.defaultModel = patch.defaultModel; s.internalRef = 'anthropic:' + patch.defaultModel; }
// v1.5.16: 등급·내부 호출 모델은 "지금 사용 가능으로 설정된 모델" 중에서만 — 값이 바뀌는 항목만 검사한다(이미 저장된 값이 제공자를 끄면서
// 사용 불가가 되어도 다른 등급만 고쳐 저장할 수 있게). providers 는 이 모듈을 불러 쓰므로 여기서는 호출 시점에 늦게 불러온다.
const assertUsable = (ref, what) => { const list = require('./providers').availableModels(null); if (!list.some((m) => m.ref === ref)) throw Object.assign(new Error(`${what}에 지정한 모델 ${ref} 은(는) 지금 사용 가능으로 설정된 모델이 아닙니다 — AI 연결 탭에서 제공자를 켜고 모델을 "사용" 으로 고른 뒤 다시 지정하세요`), { status: 400 }); };
if (patch.tiers && typeof patch.tiers === 'object') { s.tiers = { ...DEFAULT_TIERS, ...(s.tiers || {}) }; for (const t of TIERS) if (typeof patch.tiers[t.id] === 'string' && /^[a-z0-9_-]+:.+/i.test(patch.tiers[t.id])) { const v = patch.tiers[t.id].trim(); if (v !== s.tiers[t.id]) assertUsable(v, `${t.label} 등급`); s.tiers[t.id] = v; } }
if (typeof patch.internalRef === 'string' && /^[a-z0-9_-]+:.+/i.test(patch.internalRef)) { const v = patch.internalRef.trim(); if (v !== s.internalRef) assertUsable(v, '내부 호출 모델'); s.internalRef = v; if (v.startsWith('anthropic:') && MODELS.some((m) => m.id === v.slice(10))) s.defaultModel = v.slice(10); }
// v1.5.16: 사용할 Claude 모델 (id 배열, 모르는 id 는 버림). 빈 배열 = 전체 사용
if (Array.isArray(patch.claudeModels)) s.claudeModels = patch.claudeModels.map((x) => String(x)).filter((id) => MODELS.some((m) => m.id === id));
if (patch.defaultTier && TIERS.some((t) => t.id === patch.defaultTier)) s.defaultTier = patch.defaultTier;
if (typeof patch.serviceApiKey === 'string' && patch.serviceApiKey !== '••••') s.serviceApiKey = patch.serviceApiKey.trim();
if (typeof patch.backlogKey === 'string' && patch.backlogKey !== '••••') s.backlogKey = patch.backlogKey.trim();
if (patch.theme !== undefined) s.theme = normalizeTheme(patch.theme);
if (patch.mailSystem && typeof patch.mailSystem === 'object') s.mailSystem = require('./mailsys').normalizeConfig(patch.mailSystem, s.mailSystem); // 검증 실패 시 예외(저장 안 함)
if (patch.auth) {
if (['server-login', 'oauth-token', 'api-key'].includes(patch.auth.mode)) s.auth.mode = patch.auth.mode;
// v1.3.1: Claude 키·토큰은 settings.json 이 아니라 암호화 보관소(lib/vault.js)에 저장한다
if (typeof patch.auth.apiKey === 'string' && patch.auth.apiKey.trim() && patch.auth.apiKey !== '••••') { const k = patch.auth.apiKey.trim(); after.push(() => require('./vault').set({ kind: 'anthropic-api-key', secret: k })); s.auth.apiKey = ''; }
if (typeof patch.auth.oauthToken === 'string' && patch.auth.oauthToken.trim() && patch.auth.oauthToken !== '••••') { const k = patch.auth.oauthToken.trim(); after.push(() => require('./vault').set({ kind: 'anthropic-oauth', secret: k })); s.auth.oauthToken = ''; }
}
// v1.3.16: 숫자 값 검증. 예전에는 빈 칸('')이 +'' = 0 으로 저장되어 마진·환율이 0 이 될 수 있었다 → 빈 값은 "바꾸지 않음",
// 범위를 벗어나면 저장하지 않고 이유를 알린다(관리자 화면에 그대로 표시).
const num = (v, name, lo, hi) => { if (v === undefined || v === null || String(v).trim() === '') return undefined; const n = Number(v); if (!Number.isFinite(n) || n < lo || n > hi) throw Object.assign(new Error(`${name}은(는) ${lo}~${hi} 사이 숫자로 입력하세요`), { status: 400 }); return n; };
if (patch.fx) {
if (['auto', 'manual'].includes(patch.fx.mode)) s.fx.mode = patch.fx.mode;
const mr = num(patch.fx.manualRate, '직접 입력 환율', 1, 100000); if (mr !== undefined) s.fx.manualRate = mr;
const sp = num(patch.fx.spreadPct, '스프레드 %', 0, 50); if (sp !== undefined) s.fx.spreadPct = sp;
if (s.fx.mode === 'manual' && !(s.fx.manualRate > 0)) throw Object.assign(new Error('직접 입력 방식은 1 USD 원화 가격을 입력해야 합니다'), { status: 400 });
}
// v1.3.16 서비스 화면 기본 언어 { default, force } — force 이면 지금 시각을 forcedAt 으로 남겨, 그 전에 사용자가 고른 언어보다 앞서게 한다
if (patch.uiLang && typeof patch.uiLang === 'object') { const d = String(patch.uiLang.default || 'ko').trim(); if (!/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(d)) throw Object.assign(new Error('언어 코드가 올바르지 않습니다'), { status: 400 }); s.uiLang = { default: d, forcedAt: patch.uiLang.force ? new Date().toISOString() : ((s.uiLang && s.uiLang.forcedAt) || null) }; }
{ const mg = num(patch.marginPct, '사용자 청구 마진 %', 0, 1000); if (mg !== undefined) s.marginPct = mg; }
{ const me = num(patch.maxEdits, '수정 횟수 한도', 0, 10000); if (me !== undefined) s.maxEdits = Math.round(me); }
{ const sc = num(patch.signupCredit, '신규 회원 기본 캐시', 0, 100000000); if (sc !== undefined) s.signupCredit = Math.round(sc); }
if (patch.signupApproval !== undefined) { if (!['immediate', 'approval'].includes(patch.signupApproval)) throw Object.assign(new Error('회원가입 승인 방식은 immediate 또는 approval 이어야 합니다'), { status: 400 }); s.signupApproval = patch.signupApproval; } // v1.5.27
// v1.4.1 캐시 표시 단위 (원·환·동·골드·크레딧·D·ING 등 관리자가 정하는 글자). 표시만 바뀌고 1 단위 = 1원 기준은 그대로다
if (patch.cashUnit !== undefined) { const u = String(patch.cashUnit || '').trim(); if (!u || u.length > 12 || /[<>"'&\\{}0-9]/.test(u)) throw Object.assign(new Error('캐시 표시 단위는 숫자·기호(< > " \' & { })를 뺀 1~12자로 정하세요 (예: 원, 크레딧, D)'), { status: 400 }); s.cashUnit = u; }
// v1.4.5 사용자 첫 화면 페이지 (빌더 오른쪽 미리보기에 사이트를 고르기 전 보이는 페이지) — 관리자가 만든 사이트 ID, 빈 값 = 기본 안내.
// 관리자 소유인지는 server.js 가 저장 전에 확인한다(settings 는 사이트 저장소를 모름)
if (patch.welcomeSiteId !== undefined) { const w = String(patch.welcomeSiteId || '').trim(); if (w && !/^[a-f0-9]{32}$/.test(w)) throw Object.assign(new Error('첫 화면 페이지 값이 올바르지 않습니다'), { status: 400 }); s.welcomeSiteId = w; }
if (typeof patch.letsEncryptEmail === 'string') s.letsEncryptEmail = patch.letsEncryptEmail.trim();
// v1.4.0 도메인 설정 (요율·플랫폼 도메인·DNS 방식·사이트 IP·확장자·예약 이름·새 가입자 계정 종류)
if (patch.domains && typeof patch.domains === 'object') s.domains = require('./domains').normalizeConfig(patch.domains, s.domains);
// v1.3.17 비밀번호 규칙 (lib/passpolicy.js 가 검증, 기본은 기존 규칙 6자 이상)
if (patch.passwordPolicy && typeof patch.passwordPolicy === 'object') s.passwordPolicy = require('./passpolicy').normalize({ ...(s.passwordPolicy || {}), ...patch.passwordPolicy });
// v1.3.1: 키 파일 경로가 오면 그 파일을 한 번 읽어 보관소에 암호화 저장한다(이후 평문 파일 없이 동작). 못 읽으면 저장 실패로 알린다.
if (patch.mail && typeof patch.mail === 'object' && typeof patch.mail.keyFile === 'string' && patch.mail.keyFile.trim()) { const f = patch.mail.keyFile.trim(); after.push(() => require('./vault').importFile('resend', f)); }
if (patch.mail && typeof patch.mail === 'object') { s.mail = { ...(s.mail || {}) }; for (const k of ['keyFile', 'defaultDomain', 'fromName', 'formBase']) if (typeof patch.mail[k] === 'string') s.mail[k] = patch.mail[k].trim(); if (typeof patch.mail.apiKey === 'string' && patch.mail.apiKey.trim() && patch.mail.apiKey !== '••••') { const k = patch.mail.apiKey.trim(); after.push(() => require('./vault').set({ kind: 'resend', secret: k })); s.mail.apiKey = ''; } } // v1.3.1: 메일 발송 키는 보관소에
for (const fn of after) fn(); // 검증을 모두 통과 — 이제 보관소에 쓴다 (여기서 실패하면 설정 파일도 쓰지 않는다)
s.updatedAt = new Date().toISOString();
fs.mkdirSync(DATA_ROOT, { recursive: true });
fs.writeFileSync(FILE, JSON.stringify(s, null, 2), { mode: 0o600 });
try { fs.chmodSync(FILE, 0o600); } catch (_) {}
cache = s; return s;
}
/** Claude 만 쓸 수 있는 자리(연결 테스트, 모델을 모르는 옛 기록의 비용 추정)에서 쓰는 Claude 모델 id — 내부 호출 모델이 Claude 면 그 모델 */
function getModel() { const s = load(); const r = s.internalRef || ''; if (r.startsWith('anthropic:') && MODELS.some((m) => m.id === r.slice(10))) return r.slice(10); return s.defaultModel; }
/** v1.5.16 내부 호출 모델 참조(provider:model). 저장된 값이 없으면 Claude 기본 모델. 제공자가 꺼져 쓸 수 없을 때의 대체는 providers.resolveModel 이 한다 */
function internalRef() { const s = load(); return s.internalRef || ('anthropic:' + s.defaultModel); }
/** v1.5.16 사용으로 켠 Claude 모델 목록 (빈 설정이면 전체) */
function claudeModels() { const s = load(); const ids = Array.isArray(s.claudeModels) && s.claudeModels.length ? s.claudeModels : null; return ids ? MODELS.filter((m) => ids.includes(m.id)) : MODELS; }
// 등급별 안내 문구 (사용자에게 비용 체감을 미리 알리기 위한 설명, v1.1.0)
// v1.5.19 (요구 1): summary 는 등급의 특징(결과물 기준), fit 은 적합한 경우. 비용 차이는 표의 예상 비용 열이 보여 주므로 문장에서 뺐다
const TIER_GUIDE = {
basic: { title: '기본', summary: 'HTML5, CSS3, 자바스크립트 구조의 정적 웹페이지 생성.', fit: '소개·안내 중심의 일반 사이트, 잦은 소규모 수정' },
standard: { title: '중급', summary: '웹페이지의 시각적 완성도(UI/UX)와 심미성 우수.', fit: '디자인 완성도가 중요한 사이트, 페이지가 많거나 구성이 복잡한 사이트' },
premium: { title: '고급', summary: '복잡한 UI/UX의 프런트엔드 아키텍쳐 구현.', fit: '여러 기능 모듈이 서로 연동되는 사이트, 세밀한 상호작용·품질이 필요한 경우' },
};
// 비용 추정에 쓰는 대표 토큰량 (실제 작업 기록의 중간값 근사). 생성은 파일을 많이 쓰고, 수정은 파일을 많이 읽는다.
const TYPICAL_USAGE = { create: { input: 20000, output: 30000, cacheCreate: 150000, cacheRead: 900000 }, edit: { input: 10000, output: 12000, cacheCreate: 90000, cacheRead: 350000 } };
/**
* 등급별 예상 비용(USD). 원화 환산은 호출자(server)가 환율·마진을 적용한다.
* @param {(ref:string)=>{kind,model,price}} resolve 모델 참조를 실행 정보로 바꾸는 함수(providers.resolveModel)
*/
function tierGuide(resolve) {
const s = load(); const out = [];
for (const t of TIERS) {
const ref = (s.tiers && s.tiers[t.id]) || DEFAULT_TIERS[t.id]; let create = null, edit = null;
try { const mi = resolve(ref, null); const est = (u) => (mi.kind === 'claude-cli' ? estimateUsd(u, mi.model) : ((u.input + u.cacheCreate + u.cacheRead) * mi.price.input + u.output * mi.price.output) / 1e6); create = est(TYPICAL_USAGE.create); edit = est(TYPICAL_USAGE.edit); } catch (_) {}
out.push({ id: t.id, label: t.label, desc: t.desc, ...TIER_GUIDE[t.id], createUsd: create, editUsd: edit, isDefault: (s.defaultTier || 'standard') === t.id });
}
return out;
}
// ---------- 화면 테마 (v1.2.0) ----------
// 색상은 style.css 의 :root 변수로, 외형은 레이아웃 변수(좌측 폭·글자 크기·모서리·간격·패널 위치)로 반영한다.
const THEME_DEFAULTS = {
colors: { accent: '#2f6fed', accent2: '#1d4fd7', bg: '#f4f5f7', panel: '#ffffff', text: '#1f2933', muted: '#6b7684', line: '#e3e6ea', ok: '#1a8f5a', warn: '#c2410c', topBg: '#fbfcfe' },
layout: { leftWidth: 460, fontSize: 14, radius: 10, density: 'normal', panelSide: 'left', headerStyle: 'light' },
};
const THEME_PRESETS = {
default: { label: '기본 (파랑)', colors: {} },
green: { label: '초록', colors: { accent: '#15803d', accent2: '#166534', topBg: '#f6fbf7' } },
purple: { label: '보라', colors: { accent: '#7c3aed', accent2: '#6d28d9', topBg: '#faf8ff' } },
orange: { label: '주황', colors: { accent: '#ea580c', accent2: '#c2410c', topBg: '#fffaf5' } },
slate: { label: '차분한 회색', colors: { accent: '#334155', accent2: '#1e293b', bg: '#eef0f3', topBg: '#f8fafc' } },
dark: { label: '어두운 화면', colors: { accent: '#60a5fa', accent2: '#3b82f6', bg: '#0f172a', panel: '#111827', text: '#e5e7eb', muted: '#9ca3af', line: '#273244', ok: '#34d399', warn: '#fb923c', topBg: '#0b1220' }, layout: { headerStyle: 'dark' } },
};
const HEX = /^#[0-9a-f]{6}$/i;
/** 저장된 테마를 기본값과 합쳐 완전한 형태로 */
/** 저장된(또는 주어진) 테마를 기본값과 합쳐 완전한 형태로. themeObj 를 주면 그 값을 쓴다(미리보기용, v1.3.2) */
function getTheme(themeObj) {
const t = (themeObj !== undefined ? themeObj : load().theme) || {}; const c = { ...THEME_DEFAULTS.colors }; const l = { ...THEME_DEFAULTS.layout };
for (const [k, v] of Object.entries(t.colors || {})) if (k in c && HEX.test(v)) c[k] = v;
for (const [k, v] of Object.entries(t.layout || {})) if (k in l) l[k] = v;
return { colors: c, layout: l, preset: t.preset || 'default' };
}
/** 관리자 입력 검증: 색은 #rrggbb, 외형 값은 허용 범위로 자른다 */
function normalizeTheme(input) {
if (!input || input.reset) return null;
const out = { colors: {}, layout: {}, preset: THEME_PRESETS[input.preset] ? input.preset : 'custom' };
for (const [k, v] of Object.entries(input.colors || {})) if (k in THEME_DEFAULTS.colors && HEX.test(v)) out.colors[k] = v.toLowerCase();
const L = input.layout || {}; const clamp = (v, a, b, d) => { const n = Number(v); return Number.isFinite(n) ? Math.min(b, Math.max(a, Math.round(n))) : d; };
out.layout.leftWidth = clamp(L.leftWidth, 360, 720, 460); out.layout.fontSize = clamp(L.fontSize, 12, 17, 14); out.layout.radius = clamp(L.radius, 0, 18, 10);
out.layout.density = ['compact', 'normal', 'comfortable'].includes(L.density) ? L.density : 'normal';
out.layout.panelSide = L.panelSide === 'right' ? 'right' : 'left'; out.layout.headerStyle = L.headerStyle === 'dark' ? 'dark' : (L.headerStyle === 'accent' ? 'accent' : 'light');
return out;
}
/** 테마 → CSS (사용자 화면·로그인·사이트 관리자 페이지가 /theme.css 로 불러온다) */
/**
* 테마 CSS.
* @param {object} [themeObj] 주면 저장하지 않은 그 테마로 만든다(관리자 화면 설정 미리보기). 없으면 저장된 테마.
*/
function themeCss(themeObj) {
const preview = themeObj !== undefined; const { colors: c, layout: l } = getTheme(preview ? normalizeTheme(themeObj) : undefined);
// 테마를 저장한 적이 없으면 변수만 내보낸다 → 기본 화면(style.css)이 그대로 유지된다 (미리보기는 항상 전체 CSS)
if (!preview && !load().theme) return `/* AI Webmaker 테마: 기본값 (관리자 콘솔 › 설정 › 화면 외형에서 변경) */\n:root { --left-w: ${l.leftWidth}px; --top-bg: ${c.topBg}; --top-text: ${c.text}; }\n`; // v1.6.40: 레일 폭(--rail-w)은 style.css 기본값을 쓴다
// 패널 색이 어두우면(밝기 0.4 미만) style.css 에 밝은 색으로 고정된 배경들을 패널 색 계열로 바꾼다
const lum = (hex) => { const n = parseInt(hex.slice(1), 16); return (0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255; };
const darkPanel = lum(c.panel) < 0.4; const gap = { compact: 8, normal: 12, comfortable: 16 }[l.density]; const pad = { compact: 9, normal: 12, comfortable: 15 }[l.density];
const head = l.headerStyle === 'dark' ? { bg: '#0f172a', text: '#e5e7eb' } : l.headerStyle === 'accent' ? { bg: c.accent, text: '#ffffff' } : { bg: c.topBg, text: c.text };
return `/* AI Webmaker 테마 (관리자 콘솔 › 설정 › 화면 외형에서 변경) */
:root { --accent: ${c.accent}; --accent-2: ${c.accent2}; --bg: ${c.bg}; --panel: ${c.panel}; --text: ${c.text}; --muted: ${c.muted}; --line: ${c.line}; --ok: ${c.ok}; --warn: ${c.warn}; --top-bg: ${head.bg}; --top-text: ${head.text}; --left-w: ${l.leftWidth}px; --fs: ${l.fontSize}px; --radius: ${l.radius}px; --gap: ${gap}px; --pad: ${pad}px; }
body { font-size: var(--fs); background: var(--bg); color: var(--text); }
.app { grid-template-columns: var(--rail-w, 232px) var(--left-w) 1fr; } /* v1.6.40: 레일 + 작업 칸 + 캔버스 세 칸 */
${l.panelSide === 'right' ? '.app { grid-template-columns: 1fr var(--left-w) var(--rail-w, 232px); } .app > .rail { order: 3; border-right: 0; border-left: 1px solid var(--line); } .app > .left { order: 2; border-right: 0; border-left: 1px solid var(--line); } .app > .right { order: 1; }' : ''}
.rail { background: var(--top-bg); color: var(--top-text); } .rail .muted, .rail .rail-group, .rail .rail-label { color: ${l.headerStyle === 'light' ? 'var(--muted)' : 'rgba(255,255,255,.72)'}; }
.rail-nav button { color: ${l.headerStyle === 'light' ? 'var(--text)' : 'var(--top-text)'}; } .rail-nav button.active { background: color-mix(in srgb, var(--accent) 14%, var(--panel)); color: var(--accent-2); }
.card, .status-strip, .tier-guide, .module-row, .opt-card, .mode-opt, .dropzone { border-radius: var(--radius); background-color: var(--panel); border-color: var(--line); }
.card { padding: var(--pad); } .bottom-pane, .tabpane { gap: var(--gap); }
.left, .toolbar, .preview { background: var(--panel); }
.top-pane { background: var(--top-bg); color: var(--top-text); } .top-pane .muted, .top-pane .sub { color: ${l.headerStyle === 'light' ? 'var(--muted)' : 'rgba(255,255,255,.75)'}; }
input, textarea, select { background: var(--panel); color: var(--text); border-color: var(--line); border-radius: calc(var(--radius) - 2px); }
button, .btn { border-radius: calc(var(--radius) - 2px); background-color: var(--panel); color: var(--text); border-color: var(--line); }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; } button.primary:hover { background: var(--accent-2); }
.main-tabs button.active { background: color-mix(in srgb, var(--accent) 14%, var(--panel)); color: var(--accent-2); border-color: color-mix(in srgb, var(--accent) 35%, var(--panel)); }
.credit-pill { border-color: color-mix(in srgb, var(--accent) 35%, var(--panel)); } .credit-pill b { color: var(--accent-2); }
.ai-main-input { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); background: color-mix(in srgb, var(--accent) 4%, var(--panel)); }
${darkPanel ? `.ghost, code, tfoot td, .toolbar, .preview, .editor-bar, .dropzone, .pending li, .basics, .chip, .opt-card, .page-row, .progress, .tier-guide, .update-card, .update-card #fInstruction, .status-strip, .credit-footer, .usage-group td, .wm-dialog, .help-tip, .main-tabs button, .fb-env-note, .mode-opt, .card { background-color: color-mix(in srgb, var(--panel) 92%, var(--text)); color: var(--text); }
.mode-opt:hover, .opt-card.on, .main-tabs button:hover { background-color: color-mix(in srgb, var(--accent) 22%, var(--panel)); }
.progress.running, .progress.done, .progress.error, .progress.cancelled, .progress.waiting-credit, .progress-deploy, .progress-warn { background-color: color-mix(in srgb, var(--panel) 85%, var(--accent)); color: var(--text); } .progress .pmsg, .progress #progressText { color: var(--text); } /* v1.3.2: 화면 설정 미리보기에서 발견 — 어두운 화면에서 진행 상자 글자가 안 보이던 문제 */
th { background: color-mix(in srgb, var(--panel) 85%, var(--text)); color: var(--text); }
.tier-guide tr.on td, .usage-group td, tr.on td { background: color-mix(in srgb, var(--accent) 22%, var(--panel)); color: var(--text); } .pill, .badge { filter: saturate(.85) brightness(.95); } a { color: color-mix(in srgb, var(--accent) 80%, #fff); }` : ''}
`;
}
/** 등급 → 모델 참조(provider:model). 등급이 없거나 모르면 기본 등급 */
function tierRef(tier) { const s = load(); const t = TIERS.some((x) => x.id === tier) ? tier : (s.defaultTier || 'standard'); return (s.tiers && s.tiers[t]) || DEFAULT_TIERS[t]; }
function validTier(tier) { return TIERS.some((x) => x.id === tier) ? tier : (load().defaultTier || 'standard'); }
/** Claude 실행 환경에 넣을 인증 변수 */
/**
* Claude 인증 값 (v1.3.1): 암호화 보관소 우선, 없으면 1.3.0 이전 settings.json 값(이전 전 호환).
* @param {'apiKey'|'oauthToken'} field
*/
function authSecret(field) { let v = ''; try { v = require('./vault').secretOf(field === 'apiKey' ? 'anthropic-api-key' : 'anthropic-oauth'); } catch (_) {} return v || (load().auth || {})[field] || ''; }
function authEnv() {
const a = load().auth; const env = {};
if (a.mode === 'api-key' && authSecret('apiKey')) env.ANTHROPIC_API_KEY = authSecret('apiKey');
else if (a.mode === 'oauth-token' && authSecret('oauthToken')) env.CLAUDE_CODE_OAUTH_TOKEN = authSecret('oauthToken');
return env;
}
/** 보관소로 옮긴 뒤 settings.json 에서 키 값을 지울 때 쓰는 내부 함수 (vault.migrate 전용) */
function replaceSecrets({ mail, auth }) { const s = load(); if (mail) s.mail = mail; if (auth) s.auth = auth; s.updatedAt = new Date().toISOString(); fs.writeFileSync(FILE, JSON.stringify(s, null, 2), { mode: 0o600 }); try { fs.chmodSync(FILE, 0o600); } catch (_) {} cache = s; }
/** 화면 표시용(비밀값 마스킹) */
function publicView() {
const s = load();
return { ...s, internalRef: internalRef(), claudeModels: Array.isArray(s.claudeModels) ? s.claudeModels : [], tiers: { ...DEFAULT_TIERS, ...(s.tiers || {}) }, tierCatalog: TIERS, serviceApiKey: s.serviceApiKey ? '••••' : '', serviceApiKeyHint: s.serviceApiKey ? s.serviceApiKey.slice(0, 6) + '…' : '', backlogKey: s.backlogKey ? '••••' : '', backlogKeyHint: s.backlogKey ? s.backlogKey.slice(0, 6) + '…' : '', mail: { ...(s.mail || {}), apiKey: s.mail && s.mail.apiKey ? '••••' : '' }, auth: { mode: s.auth.mode, apiKey: authSecret('apiKey') ? '••••' : '', oauthToken: authSecret('oauthToken') ? '••••' : '', apiKeyHint: authSecret('apiKey') ? authSecret('apiKey').slice(0, 10) + '…' + authSecret('apiKey').slice(-4) : '', oauthTokenHint: authSecret('oauthToken') ? authSecret('oauthToken').slice(0, 12) + '…' : '' }, models: MODELS, /* v1.4.0 (요구 15): AI 제공자 API 키는 암호문이라도 화면에 보내지 않는다 — 입력칸은 비어 있고, 새로 넣으면 바뀐다 */ providers: Object.fromEntries(Object.entries(s.providers || {}).map(([k, v]) => [k, { ...(v || {}), apiKey: v && v.apiKey ? '••••' : '' }])), domainsConfig: (() => { try { return require('./domains').config(); } catch (_) { return null; } })() }; // v1.4.0 도메인 설정(기본값 포함)
}
function serverLoginInfo() {
try { const j = JSON.parse(fs.readFileSync(path.join(process.env.HOME || '/home/master', '.claude.json'), 'utf8')); const o = j.oauthAccount || {}; return { email: o.emailAddress || null, org: o.organizationName || null, billing: o.billingType || null }; } catch (_) { return { email: null }; }
}
/** 사용량으로 USD 비용 추정 (실행 중 표시용). 캐시 생성은 입력 단가의 1.25배로 근사 */
/**
* 토큰 사용량 → USD (가격표 MODELS).
* v1.4.5: 캐시 쓰기 단가를 고쳤다 — Claude Code 는 1시간 캐시를 써서 입력 단가의 2배다(5분 캐시는 1.25배).
* usage 에 종류별 토큰(cacheCreate1h·cacheCreate5m)이 있으면 그대로, 없으면(실시간 추정·옛 기록) 1시간 캐시로 본다.
* 이 계산은 CLI 가 알려 주는 비용과 일치한다(2026-10-10 실측: Haiku 0.005288 = 0.005288, Opus 작업 0.388024 ≈ 0.3880244).
* 1.25배로 계산하던 1.4.4 까지는 추정이 낮게 나와 보정 배율(costcal)이 이를 메우고 있었다.
* 모델 이름에 날짜가 붙은 경우(claude-haiku-4-5-20251001)도 앞부분으로 찾는다.
*/
function estimateUsd(usage, modelId) {
const id = String(modelId || '').replace(/^[a-z]+:/, '');
const m = MODELS.find((x) => x.id === id) || MODELS.find((x) => id.startsWith(x.id)) || MODELS[0]; if (!usage) return 0;
const cc = usage.cacheCreate || 0; const c5 = Math.min(cc, usage.cacheCreate5m || 0); const c1 = cc - c5;
return ((usage.input || 0) * m.input + (usage.output || 0) * m.output + (usage.cacheRead || 0) * m.cacheRead + c1 * m.input * 2 + c5 * m.input * 1.25) / 1e6;
}
module.exports = { authSecret, replaceSecrets, THEME_DEFAULTS, THEME_PRESETS, getTheme, normalizeTheme, themeCss, MODELS, TIERS, DEFAULT_TIERS, TIER_GUIDE, TYPICAL_USAGE, tierGuide, load, save, getModel, internalRef, claudeModels, tierRef, validTier, authEnv, publicView, serverLoginInfo, estimateUsd };
+167
View File
@@ -0,0 +1,167 @@
'use strict';
// ============================================================================
// 설정 파일·연동 상태 API (v1.5.7, 요구 2·7)
//
// 두 곳에서 같은 함수를 쓴다.
// - 초기 설정 페이지(setup-server.js): 설치 직후 운영자가 별도 포트(기본 3081)로 접속해 설정 파일을 채우는 전용 웹페이지. /api/setup/*
// - 관리자 콘솔(server.js): 로그인한 관리자가 › 시스템 설정 탭에서 같은 화면을 쓴다. /api/admin/config/*
// 화면(public/setup.js)은 어느 쪽에 붙어 있는지와 무관하게 아래 경로 모양만 안다.
//
// GET <base> 설정 항목표(값·출처)와 연동·엔진 상태, 서버 정보, 설치 명령 안내
// PUT <base> 값 저장 { values: { '<group>.<key>': 값 } } → 바뀐 항목, 재시작 필요 여부
// POST <base>/test/<kind> 연결 확인: source(Gitea) · mail(Arimail) · claude · codex · resend
// POST <base>/provision/<kind>/<action> 연동 준비 작업: source/ensure-repo, source/register-key
// POST <base>/restart 웹메이커 서비스 재시작 (sudo systemctl restart <service.systemdUnit>)
// POST <base>/migrate-env 지금 프로세스의 환경 변수를 설정 파일로 옮겨 적기(없는 항목만)
// v1.5.11 (요구 1·2) — 설치 점검표·관리자 계정·AI 엔진 인증 (처음 설치한 서버를 웹에서만 운영 가능한 상태로 만들기)
// GET <base>/ready 설치 점검표 (lib/setupstate.js: 관리자 비밀번호·AI 인증·공개 주소 + 참고 항목)
// POST <base>/admin/bootstrap 관리자 admin 을 1회용 무작위 비밀번호로 만들기 (v1.6.31, 응답에 password 한 번만) — 이미 있으면 그대로
// POST <base>/admin/create 새 관리자 { id, name, password }
// POST <base>/admin/password 관리자 비밀번호 재설정 { id, password } — 설정 페이지에 들어온 사람(토큰·관리자)만
// PUT <base>/ai/claude Claude 인증 { mode: server-login|oauth-token|api-key, apiKey?, oauthToken? } (관리자 콘솔 › AI 연결 탭 › Claude 모델 연결과 같은 저장)
// PUT <base>/ai/openai OpenAI { enabled, authMode: api-key|server-login, apiKey? }
// ============================================================================
const os = require('os');
const path = require('path');
const { execFile } = require('child_process');
const VERSION = require('../package.json').version;
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
function run(cmd, args, timeout = 20000) {
return new Promise((resolve) => { execFile(cmd, args, { timeout, env: { ...process.env, LANG: 'C' } }, (err, stdout, stderr) => resolve({ ok: !err, code: err ? err.code : 0, stdout: String(stdout || '').trim(), stderr: String(stderr || '').trim() })); });
}
/** systemd 유닛 상태 (sudo 없이 조회) */
async function serviceState(unit) {
if (!unit) return { unit: null, state: 'unknown', detail: '설정 파일 service.systemdUnit 이 비어 있어 서비스 상태를 알 수 없습니다 (설치 스크립트가 넣습니다)' };
const r = await run('systemctl', ['is-active', unit], 8000);
const state = r.stdout || (r.stderr ? 'unknown' : 'inactive');
let since = null; try { const p = await run('systemctl', ['show', unit, '-p', 'ActiveEnterTimestamp', '--value'], 8000); since = p.stdout || null; } catch (_) {}
return { unit, state, active: state === 'active', since, detail: state === 'active' ? `${unit} 실행 중${since ? ` (${since})` : ''}` : `${unit}: ${state}` };
}
/** 설치 스크립트 안내 명령 (현재 설정값으로 채운 예시) */
function installCommands(config) {
const unit = config.get('service.systemdUnit') || 'webmaker'; const appDir = path.join(__dirname, '..');
const domain = config.get('mail.domain') || 'example.com';
const lan = config.lanIps()[0] || '192.168.0.10';
return {
gitea: `${appDir}/deploy/install-gitea.sh --service ${unit} --bind ${lan}`,
arimail: `${appDir}/deploy/install-arimail.sh --host mail.${domain} --mailbox admin@${domain}`,
arimailRegister: `${appDir}/deploy/install-arimail.sh --register-only --url https://mail.${domain} --domain ${domain} --admin-login admin --admin-password '<비밀번호>'`,
services: `${appDir}/deploy/install-services.sh --check`,
cli: `node ${appDir}/scripts/config.js list`,
};
}
/** 전체 보기: 설정 항목표 + 상태 */
async function view() {
const config = require('./config'); const connectors = require('./connectors');
const unit = config.get('service.systemdUnit') || '';
const [source, mail, service, setup] = await Promise.all([connectors.status('source'), connectors.status('mail'), serviceState(unit), serviceState('webmaker-setup')]);
// v1.5.11: 실행 파일뿐 아니라 인증(서버 로그인·OAuth 토큰·API 키)까지 본다 — 설치 직후 "실행 파일은 있는데 로그인이 없어" 작업이 실패하던 상태를 미리 보이게
let claude = null; try { const c = require('./claude').claudeInfo(); const a = await require('./setupstate').claudeAuth(); claude = { ok: !!c.bin && a.ok, bin: c.bin, version: c.version, searched: c.searched, auth: a, detail: c.bin ? `claude ${c.version || ''} (${c.bin}) · ${a.detail}${a.ok ? '' : ` — ${a.hint || ''}`}` : `claude 실행 파일을 찾지 못했습니다 (탐색: ${(c.searched || []).join(', ')})` }; } catch (e) { claude = { ok: false, detail: e.message }; }
let codex = null; try { const c = require('./engine-codex').codexInfo(); const sb = c.sandbox || {}; codex = { ok: !!c.bin, bin: c.bin, version: c.version, loggedIn: c.loggedIn, mode: c.mode, sandbox: sb, detail: c.bin ? `${c.version || 'codex'} · ${c.loggedIn ? `로그인됨 (${c.mode})` : '로그인 안 됨 (codex login)'} · 샌드박스 ${sb.mode === 'none' ? '없음' : sb.mode || '?'}${sb.restricted ? ' — ' + sb.reason : ''}` : 'codex 실행 파일 없음 (선택 — OpenAI 서버 로그인을 쓸 때만)' }; } catch (e) { codex = { ok: false, detail: e.message }; }
let resend = null; try { const m = await require('./mailer').status(); resend = { ok: !!m.configured, detail: m.configured ? `메일 발송 키 있음 (${m.source})${m.verifiedDomains && m.verifiedDomains.length ? ` · 검증 도메인 ${m.verifiedDomains.join(', ')}` : ''}` : (m.error || '메일 발송 키 없음 — 관리자 콘솔 › 설정 › 메일 발송') }; } catch (e) { resend = { ok: false, detail: e.message }; }
// v1.5.11: 설치 점검표·관리자 계정·AI 인증 방식·접속 주소 — 설정 페이지 맨 위 카드들이 쓴다
const ready = await require('./setupstate').check(true);
const admins = require('./auth').adminState();
const s = require('./settings').load(); const aiAuth = { claude: { mode: (s.auth || {}).mode || 'server-login', hasApiKey: !!require('./settings').authSecret('apiKey'), hasOauthToken: !!require('./settings').authSecret('oauthToken') } };
try { const p = require('./providers').listAdminProviders().find((x) => x.id === 'openai'); aiAuth.openai = p ? { enabled: p.enabled, authMode: p.authMode || 'api-key', hasKey: p.hasKey, apiKeyMasked: p.apiKeyMasked, codex: p.codex || null } : null; } catch (_) { aiAuth.openai = null; }
return {
version: VERSION, now: new Date().toISOString(),
server: { hostname: os.hostname(), ips: config.lanIps(), node: process.version, pid: process.pid, appDir: path.join(__dirname, '..'), dataRoot: process.env.DATA_ROOT || path.join(__dirname, '..', 'data'), configFile: config.FILE, user: os.userInfo().username, setupUrls: config.setupUrls(''), publicBaseUrl: config.get('service.publicBaseUrl') || '' },
config: config.publicView(), connectors: connectors.list(),
status: { service, setup, source, mail, claude, codex, resend },
ready, admins, aiAuth, bootstrap: { id: require('./auth').BOOTSTRAP_ID }, /* v1.6.31: 고정 비밀번호 없음 */
install: installCommands(config),
};
}
/** v1.5.11 관리자 계정 작업 (토큰 또는 관리자 세션으로 설정 페이지에 들어온 사람만 — 호출 전에 인증은 서버 쪽에서 끝났다) */
function adminAction(action, body, by) {
const auth = require('./auth'); const setupstate = require('./setupstate'); const b = body || {};
if (action === 'bootstrap') { const r = auth.bootstrapAdmin(by || 'setup'); setupstate.invalidate(); if (r.created) try { require('./audit').log({ by: by || 'setup', action: 'admin.bootstrap', targetType: 'user', target: auth.BOOTSTRAP_ID }); } catch (_) {} return { ok: true, password: r.created ? r.password : undefined, created: r.created, user: r.user, detail: r.created ? `기본 관리자 ${auth.BOOTSTRAP_ID} / ${auth.BOOTSTRAP_PASSWORD} 를 만들었습니다. 로그인 뒤 비밀번호를 바꿔야 합니다` : `${auth.BOOTSTRAP_ID} 계정이 이미 있습니다${r.user.mustChangePassword ? ' (비밀번호 기본값 — 바꾸거나 아래에서 재설정하세요)' : ''}` }; }
if (action === 'create') { const id = String(b.id || '').trim().toLowerCase(); if (!id) throw httpError('아이디를 입력하세요'); const u = auth.createUser({ id, password: String(b.password || ''), name: String(b.name || id), role: 'admin' }); setupstate.invalidate(); return { ok: true, user: u, detail: `관리자 ${u.id} 를 만들었습니다` }; }
if (action === 'password') { const id = String(b.id || '').trim().toLowerCase(); if (!id) throw httpError('아이디를 입력하세요'); const u0 = auth.getUser(id); if (!u0) throw httpError('사용자가 없습니다', 404); if (u0.role !== 'admin') throw httpError('관리자 계정만 여기서 재설정할 수 있습니다 (일반 회원은 관리자 콘솔 › 사용자)'); const u = auth.resetPassword(id, String(b.password || '')); setupstate.invalidate(); return { ok: true, user: u, detail: `${u.id} 의 비밀번호를 바꿨습니다. 이제 이 계정으로 로그인할 수 있습니다` }; }
throw httpError('지원하지 않는 작업입니다');
}
/** v1.5.11 AI 엔진 인증 저장 — 관리자 콘솔 › AI 연결 탭(settings.auth / providers.openai, v1.5.16 에 설정 탭에서 옮김)과 같은 저장소 */
async function aiAction(kind, body, by) {
const setupstate = require('./setupstate'); const b = body || {};
if (kind === 'claude') {
const settings = require('./settings'); const mode = String(b.mode || '');
if (!['server-login', 'oauth-token', 'api-key'].includes(mode)) throw httpError('인증 방식은 server-login / oauth-token / api-key 중 하나입니다');
const patch = { auth: { mode } }; if (typeof b.apiKey === 'string' && b.apiKey.trim()) patch.auth.apiKey = b.apiKey.trim(); if (typeof b.oauthToken === 'string' && b.oauthToken.trim()) patch.auth.oauthToken = b.oauthToken.trim();
if (mode === 'api-key' && !patch.auth.apiKey && !settings.authSecret('apiKey')) throw httpError('API 키를 입력하세요 (sk-ant-…)');
if (mode === 'oauth-token' && !patch.auth.oauthToken && !settings.authSecret('oauthToken')) throw httpError('OAuth 토큰을 입력하세요 (서버에서 claude setup-token 으로 발급)');
settings.save(patch); setupstate.invalidate();
const a = await setupstate.claudeAuth();
return { ok: a.ok, detail: a.ok ? `저장했습니다 — ${a.detail}` : `저장했지만 아직 쓸 수 없습니다: ${a.detail}${a.hint ? ' · ' + a.hint : ''}`, auth: a };
}
if (kind === 'openai') {
const providers = require('./providers'); const patch = {}; if (b.enabled !== undefined) patch.enabled = !!b.enabled; if (['api-key', 'server-login'].includes(b.authMode)) patch.authMode = b.authMode; if (typeof b.apiKey === 'string' && b.apiKey.trim()) patch.apiKey = b.apiKey.trim();
const p = providers.saveAdminProvider('openai', patch); setupstate.invalidate(); const st = setupstate.openaiState();
return { ok: st.ok, detail: st.ok ? `저장했습니다 — ${st.detail}` : `저장했습니다 (${st.detail}${st.hint ? ' · ' + st.hint : ''})`, provider: { enabled: p.enabled, authMode: p.authMode, hasKey: p.hasKey } };
}
throw httpError('지원하지 않는 엔진입니다');
}
/** 저장 */
function save(body, by) {
const config = require('./config');
const values = body && body.values && typeof body.values === 'object' ? body.values : body;
const r = config.set(values, by); try { require('./setupstate').invalidate(); } catch (_) {} // v1.5.11 점검표(공개 주소 등) 다시 계산
return { ok: true, changed: r.changed, restart: r.restart, note: r.changed.length ? (r.restart ? `저장했습니다 (${r.changed.length}개). 재시작해야 적용되는 항목이 있습니다 — "서비스 재시작" 을 누르세요.` : `저장했습니다 (${r.changed.length}개). 바로 적용됩니다.`) : '바뀐 값이 없습니다.' };
}
/** 연결 확인 */
async function test(kind) {
const connectors = require('./connectors');
if (kind === 'source' || kind === 'mail') return connectors.test(kind);
const v = await view();
if (kind === 'claude' || kind === 'codex' || kind === 'resend') return { ok: !!v.status[kind].ok, detail: v.status[kind].detail, status: v.status[kind] };
if (kind === 'service') return { ok: !!v.status.service.active, detail: v.status.service.detail, status: v.status.service };
throw httpError('알 수 없는 확인 대상입니다');
}
/** 연동 준비 작업 (API 토큰이 있는 Gitea) */
async function provision(kind, action) {
const connectors = require('./connectors'); const m = connectors.active(kind);
if (!m) throw httpError(`${kind} 연동 모듈이 꺼져 있습니다`);
if (kind === 'source' && action === 'ensure-repo' && m.ensureRepo) { const r = await m.ensureRepo(); return { ok: true, detail: r.created ? `저장소 ${r.repo} 를 만들었습니다` : `저장소 ${r.repo} 가 이미 있습니다`, ...r }; }
if (kind === 'source' && action === 'register-key' && m.registerKey) { const r = await m.registerKey(); return { ok: true, detail: r.registered ? `이 서버의 공개키를 등록했습니다 (${r.title})` : '공개키가 이미 등록되어 있습니다', ...r }; }
throw httpError('지원하지 않는 작업입니다');
}
/**
* 서비스 재시작. 실행 계정에 `sudo -n systemctl restart <유닛>` 권한이 있어야 한다
* (설치 스크립트가 /etc/sudoers.d/webmaker-setup 에 넣는다). 실행 중인 AI 작업이 있으면 끝날 때까지(최대 10분) 기다린다.
*/
async function restart() {
const config = require('./config'); const unit = config.get('service.systemdUnit');
if (!unit) throw httpError('설정 파일 service.systemdUnit(서비스 이름)이 비어 있어 재시작할 수 없습니다. 서버에서 직접: sudo systemctl restart webmaker');
// v1.5.13: codex(OpenAI 서버 로그인 엔진) 작업도 기다린다 — claude 만 보던 1.5.7 코드는 codex 작업 중에 재시작해 작업을 끊었다
for (let i = 0; i < 120; i++) { const p = await run('pgrep', ['-u', os.userInfo().username, '-f', '[c]laude -p|[c]odex exec'], 5000); if (!p.ok) break; await new Promise((r) => setTimeout(r, 5000)); }
const r = await run('sudo', ['-n', 'systemctl', 'restart', unit], 60000);
if (!r.ok) throw httpError(`재시작 실패: ${r.stderr || r.stdout || `exit ${r.code}`}. 서버에서 직접 실행하세요: sudo systemctl restart ${unit} (sudo 권한: deploy/install.sh 가 /etc/sudoers.d/webmaker-setup 을 만듭니다)`, 500);
await new Promise((r2) => setTimeout(r2, 1500));
const st = await serviceState(unit);
return { ok: st.active, detail: st.active ? `${unit} 를 다시 시작했습니다` : `${unit} 가 시작되지 않았습니다 (${st.state}) — journalctl -u ${unit} -n 50 을 확인하세요`, status: st };
}
/**
* 공통 라우터. base 아래 경로(sub)와 메서드로 분기한다.
* @param {object} o { method, sub(배열: base 뒤 경로 조각), body(객체|null), by }
* @returns {Promise<{status:number, body:object}>}
*/
async function handle({ method, sub, body, by }) {
const [a, b, c] = sub || [];
if (!a) {
if (method === 'GET') return { status: 200, body: await view() };
if (method === 'PUT') return { status: 200, body: save(body || {}, by) };
}
if (a === 'ready' && method === 'GET') return { status: 200, body: await require('./setupstate').check(sub.includes('force')) }; // v1.5.11 설치 점검표
if (a === 'admin' && b && method === 'POST') return { status: 200, body: adminAction(b, body, by) }; // v1.5.11 관리자 계정
if (a === 'ai' && b && method === 'PUT') return { status: 200, body: await aiAction(b, body, by) }; // v1.5.11 AI 엔진 인증
if (a === 'test' && b && method === 'POST') return { status: 200, body: await test(b) };
if (a === 'provision' && b && c && method === 'POST') return { status: 200, body: await provision(b, c) };
if (a === 'restart' && method === 'POST') return { status: 200, body: await restart() };
if (a === 'migrate-env' && method === 'POST') { const moved = require('./config').migrateFromEnv(`migrate:${by || 'setup'}`); return { status: 200, body: { ok: true, moved, detail: moved.length ? `${moved.length}개 항목을 환경 변수에서 옮겼습니다: ${moved.join(', ')}` : '옮길 항목이 없습니다 (이미 설정 파일에 있거나 환경 변수가 비어 있음)' } }; }
return { status: 404, body: { error: 'not found' } };
}
module.exports = { view, save, test, provision, restart, handle, serviceState, installCommands };
+91
View File
@@ -0,0 +1,91 @@
'use strict';
// ============================================================================
// 초기 설정 페이지 인증 (v1.5.11, 요구 2)
//
// 1.5.7 에서는 접속 토큰·세션 쿠키 처리가 setup-server.js(별도 포트) 안에만 있었다. 1.5.11 부터는 본 서비스(server.js)도
// 같은 설정 페이지를 /setup 에서 "관리자 로그인 없이 토큰으로" 열어야 하므로(설치 직후 첫 화면이 /setup 으로 넘어온다 — 관리자
// 비밀번호를 아직 모를 수 있다) 인증 부분을 이 모듈로 떼어 두 서버가 같이 쓴다.
//
// - 접속 토큰: data/setup.token (0600, 32자 base64url). 없으면 만든다. 설치 스크립트가 `node scripts/config.js setup-url` 로 읽어
// http://<서버 IP>:8795/?token=… 를 안내한다. 운영자는 서버의 파일에서도 볼 수 있다(= 서버 접근 권한이 곧 설정 권한).
// - 세션: 쿠키 wm_setup = base64url("<who>|<만료 ms>") + "." + HMAC(session.secret 에서 파생한 키). 12시간.
// who 는 'token' 또는 'admin:<아이디>'. 본 서비스에서는 관리자 세션 쿠키(wh_session, role=admin)도 같은 권한으로 본다.
// - 로그인 시도 제한: IP 당 15분 10회 (토큰·비밀번호 대입 방지). 공인망에 열리는 포트이므로 꼭 둔다.
//
// 쓰는 쪽: setup-server.js (standalone), server.js (/api/setup/*, /setup)
// ============================================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const TOKEN_FILE = path.join(DATA_ROOT, 'setup.token');
const COOKIE = 'wm_setup';
const SESSION_HOURS = 12;
/** 접속 토큰: 없으면 만든다 (scripts/config.js setup-token 과 같은 규칙) */
function token() {
try { const t = fs.readFileSync(TOKEN_FILE, 'utf8').trim(); if (t.length >= 24) return t; } catch (_) {}
fs.mkdirSync(DATA_ROOT, { recursive: true });
const t = crypto.randomBytes(24).toString('base64url');
fs.writeFileSync(TOKEN_FILE, t + '\n', { mode: 0o600 }); try { fs.chmodSync(TOKEN_FILE, 0o600); } catch (_) {}
return t;
}
/** 세션 서명 키: data/session.secret(본 서비스 세션과 같은 파일)에 용도 문자열을 섞어 파생 — 설정 세션과 서비스 세션의 서명이 서로 호환되지 않게 */
function signKey() {
let s = ''; try { s = fs.readFileSync(path.join(DATA_ROOT, 'session.secret'), 'utf8').trim(); } catch (_) {}
if (!s) { s = crypto.randomBytes(32).toString('hex'); try { fs.mkdirSync(DATA_ROOT, { recursive: true }); fs.writeFileSync(path.join(DATA_ROOT, 'session.secret'), s, { mode: 0o600 }); } catch (_) {} }
return crypto.createHash('sha256').update('setup-session:' + s).digest();
}
/** 세션 값 만들기 (who: 'token' | 'admin:<id>') */
function makeSession(who) {
const exp = Date.now() + SESSION_HOURS * 3600 * 1000; const body = `${who}|${exp}`;
const sig = crypto.createHmac('sha256', signKey()).update(body).digest('base64url');
return Buffer.from(body).toString('base64url') + '.' + sig;
}
function sessionCookie(who, secure) { return `${COOKIE}=${makeSession(who)}; Path=/; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}; Max-Age=${SESSION_HOURS * 3600}`; }
function clearCookie() { return `${COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`; }
/** 요청의 설정 세션 쿠키 검증 → { who } 또는 null */
function readSession(req) {
const m = new RegExp(`(?:^|;\\s*)${COOKIE}=([^;]+)`).exec(req.headers.cookie || ''); if (!m) return null;
const [b64, sig] = m[1].split('.'); if (!b64 || !sig) return null;
const body = Buffer.from(b64, 'base64url').toString(); const want = crypto.createHmac('sha256', signKey()).update(body).digest('base64url');
if (sig.length !== want.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want))) return null;
const i = body.lastIndexOf('|'); if (i < 0) return null; const who = body.slice(0, i); const exp = Number(body.slice(i + 1));
if (!(exp > Date.now())) return null; return { who };
}
// ---- 로그인 시도 제한 ----
const attempts = new Map();
function limited(ip) { const now = Date.now(); const a = (attempts.get(ip) || []).filter((t) => now - t < 900000); attempts.set(ip, a); return a.length >= 10; }
function hit(ip) { const a = attempts.get(ip) || []; a.push(Date.now()); attempts.set(ip, a); }
/**
* 로그인 처리: { token } 또는 { id, password }(관리자 계정만).
* @returns {{status:number, body:object, cookie?:string}}
*/
function login(body, ip, secure) {
if (limited(ip)) return { status: 429, body: { error: '로그인 시도가 너무 많습니다. 15분 뒤 다시 시도하세요' } };
const b = body || {}; let who = null;
if (typeof b.token === 'string' && b.token.trim()) {
// v1.6.31 (요구 6, 검토 S7): 설치 점검이 끝나(비밀번호를 바꾼 관리자가 있음) 운영 중인 서버에서는 접속 토큰으로 들어올 수 없다 — 관리자 로그인만.
// 설치 스크립트가 알려 준 토큰 주소는 "최초 설정" 전용이며, 그 뒤에는 토큰 파일이 남아 있어도 쓰이지 않는다.
try { const st = require('./auth').adminState(); if (st.usable) { hit(ip); return { status: 403, body: { error: '최초 설정이 끝난 서버입니다. 접속 토큰은 더 쓸 수 없으니 관리자 계정으로 로그인하세요' } }; } } catch (_) {}
const t = token(); const given = b.token.trim();
if (given.length === t.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(t))) who = 'token';
if (!who) { hit(ip); return { status: 401, body: { error: '접속 토큰이 맞지 않습니다 (서버의 data/setup.token 파일 내용)' } }; }
} else if (b.id) {
if (require('./ratelimit').limited(`setup-login:${String(b.id).toLowerCase()}`, 10, 900000)) return { status: 429, body: { error: '이 계정의 로그인 시도가 너무 많습니다. 15분 뒤 다시 시도하세요' } };
try {
const auth = require('./auth'); const u = auth.login({ id: String(b.id), password: String(b.password || '') });
if (u.role !== 'admin') { hit(ip); return { status: 403, body: { error: '관리자 계정만 들어올 수 있습니다' } }; }
who = `admin:${u.id}`;
} catch (e) { hit(ip); return { status: 401, body: { error: e.message || '아이디 또는 비밀번호가 맞지 않습니다' } }; }
} else { hit(ip); return { status: 400, body: { error: '접속 토큰 또는 관리자 아이디·비밀번호를 보내세요' } }; }
return { status: 200, body: { ok: true, who }, cookie: sessionCookie(who, secure) };
}
/** v1.6.31 설정 세션용 CSRF 토큰 (who 에 묶인 HMAC) */
function csrfOf(who) { return crypto.createHmac('sha256', signKey()).update('csrf|' + String(who)).digest('base64url'); }
function csrfOk(who, tok) { if (!who || !tok) return false; const a = Buffer.from(csrfOf(who)); const b = Buffer.from(String(tok)); return a.length === b.length && crypto.timingSafeEqual(a, b); }
module.exports = { csrfOf, csrfOk, TOKEN_FILE, COOKIE, token, makeSession, sessionCookie, clearCookie, readSession, login, limited, hit };
+105
View File
@@ -0,0 +1,105 @@
'use strict';
// ============================================================================
// 설치 점검표 — "운영할 수 있는 상태인가" (v1.5.11, 요구 2)
//
// 처음 설치한 서버는 설정값(관리자 비밀번호, AI 엔진 인증, 공개 주소 …)이 없어 운영할 수 없다. 이 모듈이 그 상태를 판단해
// - server.js 가 첫 화면(/, /login)을 설정 페이지(/setup)로 보낼지 정하고 (required 가 true 인 동안만),
// - 설정 페이지 맨 위의 "설치 점검표" 카드가 항목별 상태·해결 방법을 보여 준다.
// 점검표가 모두 끝나면(required=false) 포워딩은 멈추고 정상 로그인 화면이 나온다. 운영자가 포워딩 자체를 끄려면 설정 service.setupRedirect=off.
//
// 필수 항목 (하나라도 안 되면 required)
// admin : 쓸 수 있는(비밀번호 변경이 강제되지 않은) 관리자 계정이 있나 — v1.6.31: 최초 관리자는 설정 페이지에서 운영자가 만든다(고정 비밀번호 없음)
// ai : 쓸 수 있는 AI 엔진이 하나라도 있나 — Claude Code(실행 파일 + 서버 로그인/OAuth 토큰/API 키) 또는 OpenAI(API 키·codex 로그인)
// publicBaseUrl : 서비스 공개 주소가 설치값(기본값 http://192.168.20.66)이 아닌 "이 서버의 값" 으로 정해졌나
// 참고 항목 (운영은 되지만 안내)
// resend : 메일 발송 키, source : 소스 보관(Gitea) 원격, mail : 메일 서버(Arimail) 연동
//
// 판단에는 claude auth status 실행(1초 안팎)이 들어가므로 결과를 15초 동안 캐시한다. 설정·계정을 바꾼 뒤에는 invalidate() 로 비운다.
// ============================================================================
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFile } = require('child_process');
const CACHE_MS = 15000;
let cache = null; let cacheAt = 0; let pending = null;
/** 자식 프로세스용 환경: 상위 Claude Code 세션 변수를 뺀다 (lib/claude.js cleanEnv 와 같은 이유) */
function childEnv() { const env = {}; for (const [k, v] of Object.entries(process.env)) { if (k === 'CLAUDECODE' || k.startsWith('CLAUDE_')) continue; env[k] = v; } if (!env.HOME) env.HOME = os.homedir(); return env; }
function run(cmd, args, timeout = 15000) { return new Promise((resolve) => { execFile(cmd, args, { timeout, env: childEnv() }, (err, stdout, stderr) => resolve({ ok: !err, stdout: String(stdout || '').trim(), stderr: String(stderr || '').trim() })); }); }
/**
* Claude Code 인증 상태.
* - 관리자 설정(settings.auth.mode)이 api-key / oauth-token 이면 보관소에 그 값이 있는지만 본다 (실행 때 환경 변수로 넘김).
* - server-login 이면 `claude auth status`(JSON: loggedIn, authMethod)를 실행해 본다. 명령이 없는 옛 버전이면 ~/.claude/.credentials.json 존재로 대신 판단.
* @returns {Promise<{ok:boolean, mode:string, method?:string, detail:string, hint?:string}>}
*/
async function claudeAuth() {
const settings = require('./settings'); const claude = require('./claude');
const info = claude.claudeInfo();
if (!info.bin) return { ok: false, mode: 'none', detail: `claude 실행 파일이 없습니다 (탐색: ${(info.searched || []).join(', ')})`, hint: '서비스 실행 계정으로 설치: curl -fsSL https://claude.ai/install.sh | bash (또는 npm i -g @anthropic-ai/claude-code). 설치 뒤 설정 ai.claudeBin 을 비워 두면 자동 탐색' };
const a = settings.load().auth || { mode: 'server-login' };
const env = settings.authEnv();
if (a.mode === 'api-key') return env.ANTHROPIC_API_KEY ? { ok: true, mode: 'api-key', detail: 'Anthropic API 키 (보관소)' } : { ok: false, mode: 'api-key', detail: 'API 키 방식이지만 저장된 키가 없습니다', hint: '아래 "AI 엔진 인증" 에서 API 키를 넣거나 서버 로그인으로 바꾸세요' };
if (a.mode === 'oauth-token') return env.CLAUDE_CODE_OAUTH_TOKEN ? { ok: true, mode: 'oauth-token', detail: 'Claude OAuth 토큰 (보관소)' } : { ok: false, mode: 'oauth-token', detail: 'OAuth 토큰 방식이지만 저장된 토큰이 없습니다', hint: '서버에서 claude setup-token 으로 만든 토큰을 아래 "AI 엔진 인증" 에 넣으세요' };
// 서버 로그인: claude auth status → { loggedIn, authMethod }
const r = await run(info.bin, ['auth', 'status']);
if (r.ok) { try { const j = JSON.parse(r.stdout); if (j && j.loggedIn) return { ok: true, mode: 'server-login', method: j.authMethod || '', detail: `서버 로그인됨 (${j.authMethod || 'claude'})` }; return { ok: false, mode: 'server-login', detail: '서버에 claude 로그인이 없습니다', hint: `서비스 실행 계정(${os.userInfo().username})으로 서버에서 한 번 실행: claude (브라우저 로그인) — 또는 아래에서 OAuth 토큰·API 키 방식으로 바꾸세요` }; } catch (_) {} }
const credFile = path.join(os.homedir(), '.claude', '.credentials.json');
if (fs.existsSync(credFile)) return { ok: true, mode: 'server-login', detail: `서버 로그인 자격 증명 있음 (${credFile})` };
return { ok: false, mode: 'server-login', detail: `서버 로그인 자격 증명이 없습니다 (${credFile} 없음)`, hint: `서비스 실행 계정(${os.userInfo().username})으로 서버에서 한 번 실행: claude (브라우저 로그인) — 또는 아래에서 OAuth 토큰·API 키 방식으로 바꾸세요` };
}
/** OpenAI 엔진(API 키 또는 codex 서버 로그인) 상태 */
function openaiState() {
try {
const providers = require('./providers'); const p = providers.listAdminProviders().find((x) => x.id === 'openai');
if (!p || !p.enabled) return { ok: false, detail: 'OpenAI 제공자 꺼짐 (선택)' };
if (p.authMode === 'server-login') return p.codex && p.codex.loggedIn ? { ok: true, detail: `codex 서버 로그인 (${p.codex.mode || ''})` } : { ok: false, detail: 'OpenAI 서버 로그인 방식이지만 codex 로그인이 없습니다', hint: '서버에서 codex login --device-auth' };
return p.hasKey ? { ok: true, detail: `OpenAI API 키 ${p.apiKeyMasked || ''}` } : { ok: false, detail: 'OpenAI 제공자가 켜져 있지만 API 키가 없습니다' };
} catch (e) { return { ok: false, detail: e.message }; }
}
/**
* 점검표 계산. 결과는 15초 캐시.
* @param {boolean} [force] 캐시 무시
* @returns {Promise<{required:boolean, redirect:boolean, items:object[], warnings:object[], at:string}>}
*/
async function check(force) {
if (!force && cache && Date.now() - cacheAt < CACHE_MS) return cache;
if (pending) { if (!force) return pending; await pending.catch(() => {}); } // force: 진행 중인 계산(옛 상태)을 기다렸다가 새로 계산
pending = (async () => {
const config = require('./config'); const auth = require('./auth');
const items = []; const warnings = [];
// 1) 관리자 계정
const st = auth.adminState();
if (!st.admins.length) items.push({ key: 'admin', label: '관리자 계정', ok: false, detail: '관리자 계정이 없습니다', hint: '아래 "관리자 계정" 에서 아이디·비밀번호를 정해 최초 관리자를 만드세요 (v1.6.31: 고정 비밀번호 admin/admin 은 없습니다)' });
else if (!st.usable) items.push({ key: 'admin', label: '관리자 계정', ok: false, detail: `관리자 ${st.admins.map((u) => u.id).join(', ')} 의 비밀번호가 기본값(admin)입니다`, hint: '로그인 화면에서 admin / admin 으로 들어가 새 비밀번호를 정하거나, 아래 "관리자 계정" 에서 비밀번호를 재설정하세요' });
else items.push({ key: 'admin', label: '관리자 계정', ok: true, detail: `관리자 ${st.admins.filter((u) => !u.mustChangePassword).map((u) => u.id).join(', ')}${st.defaultOpen ? ` (기본 비밀번호 그대로인 계정: ${st.admins.filter((u) => u.mustChangePassword).map((u) => u.id).join(', ')} — 바꾸거나 지우세요)` : ''}` });
// 2) AI 엔진
const cl = await claudeAuth(); const oa = openaiState();
const aiOk = cl.ok || oa.ok;
items.push({ key: 'ai', label: 'AI 엔진 인증', ok: aiOk, detail: `Claude: ${cl.detail} · OpenAI: ${oa.detail}`, hint: aiOk ? '' : (cl.hint || oa.hint || '아래 "AI 엔진 인증" 에서 Claude 또는 OpenAI 인증을 넣으세요'), claude: cl, openai: oa });
// 3) 공개 주소
const pb = config.effective('service.publicBaseUrl');
items.push({ key: 'publicBaseUrl', label: '서비스 공개 주소', ok: pb.source !== 'default', detail: pb.source === 'default' ? `설치값(${pb.value})이 그대로입니다 — 이 서버의 주소가 아닙니다` : `${pb.value} (${pb.source === 'file' ? '설정 파일' : '환경 변수'})`, hint: pb.source === 'default' ? '아래 "서비스" 묶음의 서비스 공개 주소에 이 서버의 주소(https://도메인 또는 http://IP)를 넣고 저장 → 서비스 재시작' : '' });
// 참고 항목
try { const m = await require('./mailer').status(); warnings.push({ key: 'resend', label: '메일 발송 (Resend)', ok: !!m.configured, detail: m.configured ? `키 있음 (${m.source})` : '메일 발송 키 없음 — 문의 메일·회원 메일이 나가지 않습니다 (관리자 콘솔 › 설정 › 메일 발송)' }); } catch (e) { warnings.push({ key: 'resend', label: '메일 발송 (Resend)', ok: false, detail: e.message }); }
warnings.push({ key: 'source', label: '소스 보관 (Gitea)', ok: !!config.get('source.remote'), detail: config.get('source.remote') ? config.get('source.remote') : '원격 보관 없음 — 사이트 버전은 서버 안에만 남습니다 (deploy/install-gitea.sh)' });
// v1.5.19 (요구 2): 기본 배포 서버의 vhost 구성 능력 — sudo·nginx·/etc/nginx 쓰기. 서비스 유닛의 NoNewPrivileges·ProtectSystem 때문에 안 되는 경우를 설치 때 미리 보인다
try { const deploy = require('./deploy'); const dl = deploy.list(); const t = dl.defaultId ? deploy.get(dl.defaultId) : null; if (t && t.manageVhost !== false) { const cap = t.type === 'local' || t.hasKey ? await deploy.vhostCapability(t) : { ok: false, hint: 'SSH 키 없음' }; warnings.push({ key: 'vhost', label: `배포 서버 도메인 연결 (${t.name})`, ok: cap.ok, detail: cap.ok ? 'nginx 설정 쓰기 가능 (sudo · nginx · /etc/nginx)' : `불가 — ${cap.hint || 'sudo 권한 없음'} (사이트 도메인 연결·SSL 발급이 실패합니다)` }); } } catch (e) { warnings.push({ key: 'vhost', label: '배포 서버 도메인 연결', ok: false, detail: e.message }); }
// v1.5.17 (요구 1): 플랫폼 도메인 와일드카드 인증서 — 서버 설치 때 미리 받아 둔 것(이름 무관)도 인지해 "서브 도메인에 자동 적용" 으로 보인다
try { const w = await require('./ssl').detect(); const c = w.cert; warnings.push({ key: 'wildcard', label: `와일드카드 인증서${w.platformDomain ? ` (*.${w.platformDomain})` : ''}`, ok: !!(c && c.hasCert), detail: c && c.hasCert ? `있음 — 이름 ${c.certName}${c.external ? ' (서버에 미리 발급된 인증서)' : ''}, 만료 ${c.expires}${c.daysLeft != null ? ` (${c.daysLeft}일)` : ''} → 서브 도메인 사이트에 자동 적용` : (w.reason ? `확인 못 함 — ${w.reason}` : `없음 — 서브 도메인 사이트는 HTTP 로 열립니다 (관리자 콘솔 › 설정 › SSL 에서 발급하거나, 서버에서 certbot 으로 *.${w.platformDomain} 인증서를 받아 두면 자동 적용)`) }); } catch (e) { warnings.push({ key: 'wildcard', label: '와일드카드 인증서', ok: false, detail: e.message }); }
warnings.push({ key: 'mail', label: '메일 서버 (Arimail)', ok: (config.get('mail.provider') || 'none') !== 'none', detail: (config.get('mail.provider') || 'none') !== 'none' ? `${config.get('mail.provider')} ${config.get('mail.url') || ''}` : '연동 없음 — 받는 메일·웹메일은 쓸 수 없습니다 (deploy/install-arimail.sh)' });
const required = items.some((i) => !i.ok);
const redirect = required && String(config.get('service.setupRedirect') || 'auto') !== 'off';
cache = { required, redirect, items, warnings, at: new Date().toISOString() }; cacheAt = Date.now();
return cache;
})().finally(() => { pending = null; });
return pending;
}
/** 설정·계정이 바뀐 뒤 캐시 비우기 */
function invalidate() { cache = null; cacheAt = 0; }
/** 마지막 계산 결과 (없으면 null) — 동기 경로에서 참고용 */
function last() { return cache; }
module.exports = { check, invalidate, last, claudeAuth, openaiState };
+92
View File
@@ -0,0 +1,92 @@
'use strict';
// ============================================================================
// 생성 사이트 시스템 문구 (v1.3.16)
//
// 생성 사이트에 서비스가 넣는 모든 글자 — 모듈 위젯(회원·게시판·문의·예약·갤러리·지도·후기·FAQ·설문) 화면 문구,
// 폼 라벨, 오류 메시지, 동의문, 쿠키·법적 고지, 사이트 소유자에게 가는 알림 메일 — 를 코드에 직접 쓰지 않고
// locales/site/ko.json 의 id 로만 쓴다. 이 모듈이 id → 글자를 언어별로 돌려준다.
//
// 언어별 번역은 세 겹으로 찾는다 (뒤에 있을수록 우선):
// 1) locales/site/<언어>.json — 소스에 함께 저장하는 씨앗 번역 (영어는 전부, 다른 언어는 일부)
// 2) data/site-i18n/_runtime/<언어>.json — 다국어 사이트가 그 언어를 처음 쓸 때 AI 가 채운 번역 캐시(서비스 전체 공용)
// 3) data/site-i18n/_runtime/<언어>.override.json — 관리자가 고친 번역 (관리자 콘솔 › 언어 › 사이트 문구)
// 없으면 한국어 원문을 쓴다. {이름} 자리 표시는 값으로 채운다.
// ============================================================================
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const DATA_ROOT = process.env.DATA_ROOT || path.join(ROOT, 'data');
const RUNTIME_DIR = path.join(DATA_ROOT, 'site-i18n', '_runtime');
const readJson = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (_) { return null; } };
let SOURCE = null; // { id: { text, group } }
function source() { if (!SOURCE) SOURCE = (readJson(path.join(ROOT, 'locales', 'site', 'ko.json')) || {}).messages || {}; return SOURCE; }
const cache = new Map(); // 언어 → { id: 번역 } (파일을 쓸 때 지운다)
/** 언어의 번역표 (씨앗 → AI 캐시 → 관리자 수정 순으로 덮어씀) */
function table(lang) {
if (!lang || lang === 'ko') return {};
if (cache.has(lang)) return cache.get(lang);
const safe = String(lang).replace(/[^\w-]/g, '');
const t = { ...((readJson(path.join(ROOT, 'locales', 'site', `${safe}.json`)) || {}).messages || {}), ...(readJson(path.join(RUNTIME_DIR, `${safe}.json`)) || {}), ...(readJson(path.join(RUNTIME_DIR, `${safe}.override.json`)) || {}) };
cache.set(lang, t); return t;
}
/** {이름} 자리 채우기 */
const fill = (s, vars) => String(s).replace(/\{(\w+)\}/g, (m, k) => (vars && vars[k] !== undefined && vars[k] !== null ? String(vars[k]) : m));
/** 한국어 원문 */
function text(id, vars) { const m = source()[id]; return fill(m ? m.text : id, vars); }
/** 언어별 문구 (번역이 없으면 한국어 원문) */
function tr(lang, id, vars) { const t = table(lang)[id]; return t ? fill(t, vars) : text(id, vars); }
/** 번역 함수 하나로 묶기: const t = sitemsg.forLang('en'); t('board.empty') */
const forLang = (lang) => (id, vars) => tr(lang, id, vars);
/**
* 오류 객체: 메시지는 한국어 원문, msgId·msgVars 를 함께 달아 API 가 요청 언어로 바꿔 보낼 수 있게 한다.
* @example throw sitemsg.err('err.noPost', 404)
*/
function err(id, status = 400, vars) { return Object.assign(new Error(text(id, vars)), { status, msgId: id, msgVars: vars }); }
/** API 오류 응답용: 오류 객체 → 요청 언어 메시지 */
function errorText(e, lang) { return e && e.msgId ? tr(lang, e.msgId, e.msgVars) : (e && e.message) || ''; }
/** 위젯 스크립트에 넣을 표 (id → 그 언어 글자, 번역 없으면 원문) */
function clientTable(lang, ids) { const src = source(); const t = table(lang); const out = {}; for (const id of ids || Object.keys(src)) out[id] = t[id] || (src[id] ? src[id].text : id); return out; }
/** 아직 번역이 없는 id (AI 번역 대상) */
function missing(lang) { if (!lang || lang === 'ko') return []; const t = table(lang); return Object.keys(source()).filter((id) => !t[id]); }
/** AI 번역 결과 저장 (서비스 전체 공용 캐시) */
function saveRuntime(lang, add) {
const safe = String(lang).replace(/[^\w-]/g, ''); fs.mkdirSync(RUNTIME_DIR, { recursive: true });
const f = path.join(RUNTIME_DIR, `${safe}.json`); const cur = readJson(f) || {};
for (const [k, v] of Object.entries(add || {})) if (source()[k] && typeof v === 'string' && v.trim()) cur[k] = v.trim();
fs.writeFileSync(f, JSON.stringify(cur, null, 1)); cache.delete(lang); return Object.keys(cur).length;
}
/** 관리자 수정 번역 저장 (빈 문자열이면 그 항목 수정 취소) */
function saveOverride(lang, patch) {
const safe = String(lang).replace(/[^\w-]/g, ''); fs.mkdirSync(RUNTIME_DIR, { recursive: true });
const f = path.join(RUNTIME_DIR, `${safe}.override.json`); const cur = readJson(f) || {};
for (const [k, v] of Object.entries(patch || {})) { if (!source()[k]) continue; if (typeof v === 'string' && v.trim()) cur[k] = v.trim(); else delete cur[k]; }
fs.writeFileSync(f, JSON.stringify(cur, null, 1)); cache.delete(lang); return cur;
}
/**
* AI 로 빠진 문구 채우기. translateFn(texts[], to, from) → string[] (lib/multilang 과 같은 모양).
* 자리 표시({name})가 빠지거나 바뀐 번역은 버린다(원문으로 표시됨).
* @returns {number} 새로 채운 개수
*/
async function ensure(lang, translateFn) {
const ids = missing(lang); if (!ids.length) return 0; const src = source(); const add = {};
for (let i = 0; i < ids.length; i += 100) {
const batch = ids.slice(i, i + 100); const tr0 = await translateFn(batch.map((id) => src[id].text), lang, 'ko');
batch.forEach((id, k) => { const v = tr0[k]; const ph = (s) => (String(s).match(/\{\w+\}/g) || []).sort().join(','); if (typeof v === 'string' && v.trim() && ph(v) === ph(src[id].text)) add[id] = v; });
}
saveRuntime(lang, add); return Object.keys(add).length;
}
/** 관리자 화면용 목록: id·그룹·원문·번역·출처 */
function listFor(lang) {
const src = source(); const safe = String(lang).replace(/[^\w-]/g, '');
const seed = (readJson(path.join(ROOT, 'locales', 'site', `${safe}.json`)) || {}).messages || {}; const ai = readJson(path.join(RUNTIME_DIR, `${safe}.json`)) || {}; const ov = readJson(path.join(RUNTIME_DIR, `${safe}.override.json`)) || {};
return Object.entries(src).map(([id, m]) => ({ id, group: m.group, text: m.text, translation: ov[id] || ai[id] || seed[id] || '', origin: ov[id] ? 'admin' : ai[id] ? 'ai' : seed[id] ? 'seed' : '' }));
}
/** 위젯 템플릿의 {{t:id}} 자리를 그 언어 글자로, {{msgs:id1,id2}} 를 JSON 표(HTML 속성용)로 바꾼다 */
function renderTemplate(html, lang) {
return html.replace(/\{\{t:([\w.]+)\}\}/g, (m, id) => tr(lang, id).replace(/"/g, '&quot;'))
.replace(/\{\{msgs:([\w.,]+)\}\}/g, (m, ids) => JSON.stringify(clientTable(lang, ids.split(','))).replace(/&/g, '&amp;').replace(/'/g, '&#39;'));
}
module.exports = { source, text, tr, forLang, err, errorText, clientTable, missing, ensure, saveRuntime, saveOverride, listFor, renderTemplate, table };
+157
View File
@@ -0,0 +1,157 @@
'use strict';
// ============================================================================
// 플랫폼 도메인 와일드카드 인증서 관리 (v1.4.14, 요구 9)
//
// 배경: 서브 도메인 계정의 사이트(mycompany.ibit.kr …)는 모두 플랫폼 도메인(ibit.kr) 아래에 있다. 사이트마다 인증서를 따로
// 받으면 Let's Encrypt 의 "등록 도메인당 주간 발급 한도" 에 걸리고, 인증서 수도 끝없이 늘어난다. 그래서 플랫폼 도메인에는
// 와일드카드 인증서(*.ibit.kr) 하나를 발급해 그 아래 모든 사이트의 HTTPS 를 함께 처리한다.
// 고객이 가진 도메인(example.com)은 사이트별 인증서(wh-<사이트ID>, webroot 방식)를 그대로 쓴다 — 두 종류는 분리해 관리한다.
//
// 와일드카드는 DNS-01 인증만 가능하다(_acme-challenge.<도메인> TXT 레코드). TXT 를 넣는 방법(dnsMode):
// openprovider : 플랫폼 도메인 DNS 가 Openprovider 에 있을 때(설정 › 도메인 서비스 › DNS 방식 '자동') — 훅이 API 로 TXT 를 넣고 지운다. 갱신도 자동.
// manual : 그 밖(ibit.kr 은 hosting.co.kr DNS) — 훅이 필요한 TXT 값을 대기 파일에 적고 공용 DNS 에 보일 때까지 기다린다.
// 운영자는 관리자 콘솔 › 설정 › SSL 에 뜨는 TXT 를 DNS 관리 화면에 넣는다. 갱신(약 60일마다)도 같은 절차가 필요하다.
//
// 동작: 배포 서버(type=local, 서비스 서버 자신)에서 certbot 을 --manual + 훅(scripts/acme-dns-hook.js)으로 실행한다.
// 인증서 이름 wh-wild-<플랫폼 도메인>, SAN 은 *.<플랫폼 도메인> 하나. 발급은 오래 걸릴 수 있어(수동 DNS 대기 최대 WAIT_MIN 분) 비동기로
// 돌리고 상태(진행 중·대기 TXT·마지막 결과)를 data/ssl-wildcard.json 과 /var/tmp/webmaker-acme/ 에서 읽어 화면에 보인다.
// 원격(ssh) 배포 서버는 지원하지 않는다 — 훅이 그 서버에서 실행되어 이 서비스의 DNS 접속 정보를 쓸 수 없기 때문.
//
// v1.5.17 (요구 1): 서버를 설치할 때 이미 *.<플랫폼 도메인> 인증서를 받아 둔 경우(이름이 wh-wild-… 가 아님)도 인지한다.
// deploy.findWildcardCert 가 SAN 으로 찾고(detect), 서비스 기동 때 autoApply 가 HTTP 로 배포돼 있던 서브 도메인 사이트의 nginx 설정을
// 그 인증서로 다시 써 재배포 없이 HTTPS 가 된다. 설치 점검표(lib/setupstate)와 scripts/ssl-wildcard.js 도 같은 detect 를 쓴다.
// ============================================================================
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const deploy = require('./deploy');
const ROOT = path.resolve(__dirname, '..');
const DATA_ROOT = process.env.DATA_ROOT || path.join(ROOT, 'data');
const STATE_FILE = path.join(DATA_ROOT, 'ssl-wildcard.json');
/** 훅(root 로 실행)과 서비스(일반 사용자)가 함께 쓰는 대기 디렉터리 — 훅이 TXT 챌린지를 적고, 서비스가 읽어 화면에 보인다 */
const PENDING_DIR = process.env.WM_ACME_DIR || '/var/tmp/webmaker-acme';
const HOOK = path.join(ROOT, 'scripts', 'acme-dns-hook.js');
const WAIT_MIN = Math.max(5, parseInt(process.env.WM_ACME_WAIT_MIN || '40', 10)); // 수동 DNS 에서 TXT 를 기다리는 최대 시간(분)
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
let job = null; // 진행 중인 발급 { startedAt, targetId, domain, mode, child }
function loadState() { try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) || {}; } catch (_) { return {}; } }
function saveState(d) { try { fs.mkdirSync(DATA_ROOT, { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(d, null, 1), { mode: 0o600 }); } catch (_) {} }
/** 플랫폼 도메인 DNS 방식 → 챌린지 방식 */
// v1.5.36 (요구 5): 플랫폼 도메인의 DNS 공급자 id(openprovider·porkbun·route53·gcloud) — 훅이 그 공급자로 TXT 를 넣는다. 없으면 'manual'
function dnsMode() { try { const z = require('./domains').platformZone(); return z ? z.id : 'manual'; } catch (_) { return 'manual'; } }
/** 와일드카드 발급 대상 배포 서버: 지정 → 기본 배포 서버 */
function targetFor(targetId) { const dl = deploy.list(); const tid = targetId || dl.defaultId; return tid ? deploy.get(tid) : null; }
/**
* v1.5.17 와일드카드 인증서 감지 — 플랫폼 도메인과 기본(또는 지정) 배포 서버에서 *.<도메인> 인증서를 찾는다. 이름은 무엇이든 된다.
* @returns {Promise<{platformDomain:string, target:object|null, cert:object|null, reason:string}>} cert: deploy.findWildcardCert 결과(hasCert·certName·external·만료)
*/
async function detect(targetId) {
const pd = deploy.platformDomain(); const t = targetFor(targetId);
if (!pd) return { platformDomain: '', target: null, cert: null, reason: '플랫폼 도메인이 없습니다' };
if (!t) return { platformDomain: pd, target: null, cert: null, reason: '배포 서버가 없습니다' };
if (t.allowSudo !== true) return { platformDomain: pd, target: t, cert: null, reason: `배포 서버 "${t.name}" 에서 sudo 사용이 허용되지 않아 인증서 디렉터리를 읽을 수 없습니다` };
try { return { platformDomain: pd, target: t, cert: await deploy.findWildcardCert(t, pd), reason: '' }; } catch (e) { return { platformDomain: pd, target: t, cert: null, reason: e.message }; }
}
/**
* v1.5.17 기동 때 자동 적용: 와일드카드 인증서가 있는데(설치 때 미리 받아 둔 것 포함) 서브 도메인 주소가 HTTP 로 배포돼 있는 사이트의 vhost 를 다시 쓴다.
* 판단 근거는 마지막 성공 배포 기록의 domainUrls — 플랫폼 서브 도메인이 http:// 로 남아 있으면 그 사이트. 결과는 상태 파일 autoApply 에 남겨 관리자 콘솔 › 설정 › SSL 에 보인다.
* @returns {Promise<{checked:boolean, certName?:string, reconfigured:number, sites:string[], reason?:string}>}
*/
async function autoApply(targetId) {
const d = await detect(targetId);
if (!d.cert || !d.cert.hasCert) return { checked: !!d.cert, reconfigured: 0, sites: [], reason: d.reason || '와일드카드 인증서 없음' };
const store = require('./store'); const pd = d.platformDomain; const sites = [];
for (const s0 of store.list()) {
const site = store.load(s0.id); if (!site) continue;
const subs = (site.domains || []).filter((x) => deploy.isPlatformSub(x, pd)); if (!subs.length) continue;
const dep = [...(site.deployments || [])].reverse().find((x) => x.ok && x.kind !== 'unpublish'); if (!dep || dep.targetId !== d.target.id) continue;
if (!subs.some((x) => (dep.domainUrls || []).includes(`http://${x}/`))) continue; // 이미 HTTPS 로 배포된 사이트는 건너뜀
try { const r = await deploy.configureVhost(site.id, d.target.id, site.domains); if (r.ok) { sites.push(site.id); dep.domainUrls = (dep.domainUrls || []).map((u) => (subs.some((x) => u === `http://${x}/`) ? u.replace('http://', 'https://') : u)); dep.sslDetail = (dep.sslDetail ? dep.sslDetail + ' / ' : '') + `v1.5.17 기동 때 와일드카드 인증서(${d.cert.certName}) 자동 적용`; store.save(site); } } catch (_) {}
}
const st = loadState(); st.autoApply = { at: new Date().toISOString(), certName: d.cert.certName, external: d.cert.external, reconfigured: sites.length, sites }; saveState(st);
return { checked: true, certName: d.cert.certName, external: d.cert.external, reconfigured: sites.length, sites };
}
/** 훅이 남긴 대기 챌린지 (수동 DNS 모드) — { domain, name, value, at } 또는 null */
function pendingChallenge(domain) {
try {
const f = path.join(PENDING_DIR, `${domain}.json`); const j = JSON.parse(fs.readFileSync(f, 'utf8'));
if (Date.now() - Date.parse(j.at) > (WAIT_MIN + 5) * 60000) return null; // 끝난 지 오래된 파일은 무시
return j;
} catch (_) { return null; }
}
/**
* 현재 상태 (관리자 콘솔 › 설정 › SSL).
* @returns {{ platformDomain, dnsMode, target, supported, reason, cert, pending, running, last, waitMinutes }}
*/
async function status(targetId) {
const pd = deploy.platformDomain(); const t = targetFor(targetId); const mode = dnsMode(); const st = loadState();
const out = { platformDomain: pd, dnsMode: mode, target: t ? { id: t.id, name: t.name, type: t.type } : null, supported: false, reason: '', certName: pd ? deploy.wildcardCertName(pd) : null, cert: null, pending: pd ? pendingChallenge(pd) : null, running: !!job, job: job ? { startedAt: job.startedAt, domain: job.domain, mode: job.mode } : null, last: st.last || null, waitMinutes: WAIT_MIN, email: !!(require('./settings').load().letsEncryptEmail) };
if (!pd) { out.reason = '플랫폼 도메인이 없습니다 — 관리자 콘솔 › 설정 › 도메인 서비스 에 플랫폼 도메인(예: ibit.kr)을 넣으세요'; return out; }
if (!t) { out.reason = '배포 서버가 없습니다 (관리자 콘솔 › 배포 서버)'; return out; }
if (t.type !== 'local') { out.reason = `와일드카드 인증서는 서비스 서버가 배포 서버인 경우(종류 local)에만 발급합니다. "${t.name}" 은(는) 원격(ssh) 서버라 DNS 인증 훅을 실행할 수 없습니다 — 그 서버의 서브 도메인은 사이트별 인증서로 발급됩니다`; }
else if (t.allowSudo !== true) out.reason = '배포 서버 설정에서 sudo 사용 허용이 꺼져 있어 certbot 을 실행할 수 없습니다';
else out.supported = true;
// v1.5.17: 서비스가 발급한 이름뿐 아니라 서버에 미리 받아 둔 *.<도메인> 인증서도 찾는다 — 있으면 그 이름이 certName 이 되고 external 로 표시
try { out.cert = await deploy.findWildcardCert(t, pd); out.certName = out.cert.certName; out.external = !!out.cert.external; } catch (e) { out.cert = { hasCert: false, error: e.message }; }
out.autoApply = st.autoApply || null;
return out;
}
/**
* 와일드카드 인증서 발급·갱신 시작 (비동기). 이미 진행 중이면 409.
* certbot 은 --keep-until-expiring 이라 만료가 멀면 그대로 두고 성공으로 끝난다(갱신 강제는 force=true).
*/
async function issue(targetId, { force = false } = {}) {
if (job) throw httpError('와일드카드 인증서 발급이 이미 진행 중입니다', 409);
const s = await status(targetId); if (!s.supported) throw httpError(s.reason || '발급할 수 없습니다', 400);
// v1.5.17: 다른 절차로 받아 둔 와일드카드 인증서가 이미 유효하면 새로 발급하지 않는다(서브 도메인에 자동 적용 중). 갱신은 그 인증서를 받은 방법(certbot renew 타이머)대로.
if (!force && s.cert && s.cert.hasCert && s.external && !(s.cert.daysLeft != null && s.cert.daysLeft < 20)) throw httpError(`서버에 이미 와일드카드 인증서가 있습니다 (이름 ${s.cert.certName}, ${(s.cert.sans || []).join(', ')}, 만료 ${s.cert.expires}) — 서브 도메인 사이트에 자동 적용됩니다. 이 서비스 이름(${deploy.wildcardCertName(s.platformDomain)})으로 따로 발급하려면 강제 갱신(force)을 쓰세요`, 409);
s.certName = deploy.wildcardCertName(s.platformDomain); // 발급은 늘 서비스 이름으로
const t = targetFor(targetId); const pd = s.platformDomain; const mode = s.dnsMode;
const email = require('./settings').load().letsEncryptEmail || '';
try { fs.mkdirSync(PENDING_DIR, { recursive: true, mode: 0o1777 }); fs.chmodSync(PENDING_DIR, 0o1777); } catch (_) {}
try { fs.unlinkSync(path.join(PENDING_DIR, `${pd}.json`)); } catch (_) {}
const node = process.execPath;
// 훅에 넘길 환경: DATA_ROOT(접속 정보·설정), 방식, 대기 시간. sudo 는 환경을 지우므로 env 로 명시한다
const envArgs = [`DATA_ROOT=${DATA_ROOT}`, `WM_ACME_MODE=${mode}`, `WM_ACME_DIR=${PENDING_DIR}`, `WM_ACME_WAIT_MIN=${WAIT_MIN}`, `WM_ACME_ZONE=${pd}`, `PATH=${process.env.PATH || '/usr/local/bin:/usr/bin:/bin'}`];
const args = ['-n', 'env', ...envArgs, 'certbot', 'certonly', '--manual', '--preferred-challenges', 'dns', '--manual-auth-hook', `${node} ${HOOK} auth`, '--manual-cleanup-hook', `${node} ${HOOK} cleanup`, '--non-interactive', '--agree-tos', ...(email ? ['-m', email] : ['--register-unsafely-without-email']), '--cert-name', s.certName, '--expand', force ? '--force-renewal' : '--keep-until-expiring', '-d', `*.${pd}`, '--deploy-hook', 'systemctl reload nginx || nginx -s reload'];
const startedAt = new Date().toISOString();
const child = spawn('sudo', args, { stdio: ['ignore', 'pipe', 'pipe'] });
job = { startedAt, targetId: t.id, domain: pd, mode, child };
let out = '', err = '';
child.stdout.on('data', (d) => { out += d; if (out.length > 20000) out = out.slice(-20000); });
child.stderr.on('data', (d) => { err += d; if (err.length > 20000) err = err.slice(-20000); });
const timer = setTimeout(() => { try { child.kill('SIGTERM'); } catch (_) {} }, (WAIT_MIN + 10) * 60000);
child.on('close', async (code) => {
clearTimeout(timer); job = null;
const all = `${out}\n${err}`; const ok = code === 0 && !/error|failed|problem/i.test(err.split('\n').filter((l) => !/^Saving debug log/.test(l)).join('\n')) || /Successfully received certificate|Certificate not yet due for renewal|not yet due/.test(all);
const summary = ok ? (/not yet due/.test(all) ? '기존 인증서가 아직 유효해 그대로 둠' : '발급·갱신 완료') : (all.match(/(?:Error|Problem|Detail|Timeout|too many|rate|unauthorized|Hook)[^\n]{0,300}/gi) || [all.trim().split('\n').slice(-3).join(' ')]).slice(0, 3).join(' / ').slice(0, 600);
const st = loadState(); st.last = { at: new Date().toISOString(), ok, detail: summary, domain: pd, mode, code }; saveState(st);
try { fs.unlinkSync(path.join(PENDING_DIR, `${pd}.json`)); } catch (_) {}
// 발급이 끝났으면 이 플랫폼의 서브 도메인을 쓰는 사이트들의 nginx 설정을 와일드카드 인증서로 다시 쓴다
if (ok) { try { await reconfigureSites(t.id, pd); } catch (_) {} }
});
return { started: true, startedAt, mode, certName: s.certName, waitMinutes: WAIT_MIN };
}
/** 발급 뒤: 플랫폼 서브 도메인이 연결된 사이트들의 vhost 를 다시 구성 (HTTP → 와일드카드 HTTPS) */
async function reconfigureSites(targetId, pd) {
const store = require('./store'); let n = 0;
for (const s0 of store.list()) {
const site = store.load(s0.id); if (!site || !(site.domains || []).some((d) => deploy.isPlatformSub(d, pd))) continue;
const dep = (site.deployments || []).filter((d) => d.ok).slice(-1)[0]; if (!dep || dep.targetId !== targetId) continue;
try { await deploy.configureVhost(site.id, targetId, site.domains); n += 1; } catch (_) {}
}
const st = loadState(); if (st.last) { st.last.reconfigured = n; saveState(st); }
return n;
}
/** 진행 중 발급 취소 */
function cancel() { if (!job) return false; try { job.child.kill('SIGTERM'); } catch (_) {} return true; }
function lastState() { return loadState(); } // v1.6.31 (요구 20) 운영 복구 목록용 — 마지막 발급 결과
module.exports = { lastState, status, issue, cancel, detect, autoApply, pendingChallenge, dnsMode, PENDING_DIR, WAIT_MIN };
+244
View File
@@ -0,0 +1,244 @@
'use strict';
// ============================================================================
// 사이트 접속 통계 모듈 (v1.0.2)
//
// 두 가지 경로로 통계를 모은다.
// 1) 서버 로그(측정): 우리가 관리하는 nginx 에 `wmstats` 로그 형식을 추가해 요청마다
// 수신 바이트($request_length)·송신 바이트($bytes_sent)·상태·경로·리퍼러·UA 를 기록하고,
// 수집기가 주기적으로 새로 쌓인 부분만 읽어 사이트별·일자별로 집계한다.
// - 서비스 서버 자체(로컬 nginx): /var/log/nginx/webmaker-sites.log 한 파일, URI 의 /<32hex>/ 로 사이트 식별
// - 원격 배포 서버(SSH, sudo 허용): 사이트 vhost 마다 /var/log/nginx/wh-<id>.access.log
// 2) 브라우저 비컨(추정): 모든 HTML 에 삽입되는 /wm-stats.js 가 페이지를 열 때 한 번
// POST /api/stats/beacon 으로 경로·리퍼러·화면·전송량(Performance API transferSize)을 보고한다.
// 로그를 읽을 수 없는 서버(가져온 기존 사이트의 원본 서버 등)에서도 방문 통계를 얻기 위한 보조 경로.
//
// 저장: data/stats/<siteId>.json
// { days: { 'YYYY-MM-DD': { req, pv, bytesIn, bytesOut, bots, v: {해시: 1}, vOver,
// pages: {경로: 조회수}, refs: {출처: 수}, hosts: {호스트: 수},
// b: { pv, v: {해시: 1}, vOver, bytesOut, screens: {구간: 수}, langs: {언어: 수} } } },
// updatedAt }
// v(방문자 집합)는 "일자+IP+UA" 의 해시만 저장하고(원본 IP 는 저장하지 않음) 하루 MAX_VISITOR_KEYS 개까지만 보관,
// 넘치면 vOver 에 개수만 더한다. 방문자 수 = Object.keys(v).length + vOver.
// ============================================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const STATS_DIR = path.join(DATA_ROOT, 'stats');
const OFFSETS_FILE = path.join(STATS_DIR, '_offsets.json'); // 로그 파일별 읽은 위치 { key: { inode, offset } }
fs.mkdirSync(STATS_DIR, { recursive: true });
const MAX_VISITOR_KEYS = 5000; // 하루에 보관하는 방문자 해시 개수 상한 (그 이상은 vOver 로 개수만)
const MAX_DAYS = 400; // 보관 일수 (누적치는 totals 에 따로 합산해 두므로 오래된 일자는 버려도 됨)
const TOP_KEYS = 60; // 페이지/유입 경로 집계에 보관하는 키 개수 상한
const SITE_ID_RE = /^\/([a-f0-9]{32})(?:\/|$)/;
// 검색엔진·크롤러·모니터링 봇 UA. 봇 요청은 bots 로만 세고 방문자·페이지뷰에서 뺀다.
const BOT_RE = /bot|crawl|spider|slurp|facebookexternalhit|preview|monitor|uptime|headless|curl\/|wget\/|python-requests|go-http-client|java\/|libwww|okhttp|scrapy|ahrefs|semrush|mj12|dotbot|petalbot|yandex|baidu|bingpreview|lighthouse|pingdom/i;
// 페이지로 간주하는 요청: HTML 문서(확장자 없음 포함). 이미지·CSS·JS 는 요청(req)·바이트에만 반영.
const ASSET_EXT_RE = /\.(css|js|mjs|json|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf|eot|map|mp4|webm|mp3|pdf|zip|txt|xml)$/i;
/** 서울 기준 날짜 문자열 'YYYY-MM-DD' (통계 일자는 모두 KST) */
function kstDate(ts) { const d = new Date(ts || Date.now()); return new Date(d.getTime() + 9 * 3600 * 1000).toISOString().slice(0, 10); }
/** 방문자 식별 해시: 일자 + 서버 비밀 + IP + UA. 원본 IP 는 저장하지 않는다. */
let SALT = null;
function salt() { if (SALT) return SALT; try { SALT = fs.readFileSync(path.join(DATA_ROOT, 'session.secret'), 'utf8').trim().slice(0, 32); } catch (_) { SALT = 'wm'; } return SALT; }
function visitorHash(day, ip, ua) { return crypto.createHash('sha1').update(`${day}|${salt()}|${ip || ''}|${(ua || '').slice(0, 200)}`).digest('hex').slice(0, 16); }
// ---------- 저장소 ----------
function fileOf(siteId) { return path.join(STATS_DIR, `${siteId}.json`); }
function load(siteId) { try { return JSON.parse(fs.readFileSync(fileOf(siteId), 'utf8')); } catch (_) { return { days: {}, totals: { req: 0, pv: 0, bytesIn: 0, bytesOut: 0, bots: 0, bpv: 0, bBytesOut: 0 }, updatedAt: null }; } }
function save(siteId, d) {
// 오래된 일자는 totals 에 합산한 뒤 버린다 (파일 크기 상한)
const keys = Object.keys(d.days).sort();
while (keys.length > MAX_DAYS) { const k = keys.shift(); const x = d.days[k]; d.totals.req += x.req || 0; d.totals.pv += x.pv || 0; d.totals.bytesIn += x.bytesIn || 0; d.totals.bytesOut += x.bytesOut || 0; d.totals.bots += x.bots || 0; d.totals.bpv += (x.b && x.b.pv) || 0; d.totals.bBytesOut += (x.b && x.b.bytesOut) || 0; d.totals.visitors = (d.totals.visitors || 0) + visitors(x); d.totals.bVisitors = (d.totals.bVisitors || 0) + bVisitors(x); delete d.days[k]; } // v1.3.17 (B31): 방문자 수도 누적에 남긴다 (일별 방문자 합 — 전체 기간 지표와 같은 기준)
d.updatedAt = new Date().toISOString();
const tmp = fileOf(siteId) + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(d)); fs.renameSync(tmp, fileOf(siteId));
}
function remove(siteId) { try { fs.unlinkSync(fileOf(siteId)); } catch (_) {} }
/** 일자 레코드(없으면 생성) */
function dayRec(d, day) {
if (!d.days[day]) d.days[day] = { req: 0, pv: 0, bytesIn: 0, bytesOut: 0, bots: 0, v: {}, vOver: 0, pages: {}, refs: {}, hosts: {}, b: { pv: 0, v: {}, vOver: 0, bytesOut: 0, screens: {}, langs: {} } };
return d.days[day];
}
/** 집합에 방문자 해시를 넣는다(상한 초과 시 개수만) */
function addVisitor(rec, hash) { if (rec.v[hash]) return; if (Object.keys(rec.v).length >= MAX_VISITOR_KEYS) { rec.vOver += 1; return; } rec.v[hash] = 1; }
/** 상위 N 개만 보관하는 카운터 증가 */
function bump(map, key, n = 1) {
if (!key) return; key = String(key).slice(0, 200);
if (map[key] === undefined && Object.keys(map).length >= TOP_KEYS) { const min = Object.entries(map).sort((a, b) => a[1] - b[1])[0]; if (min && min[1] <= n) delete map[min[0]]; else return; }
map[key] = (map[key] || 0) + n;
}
/** 리퍼러를 "출처 호스트" 로 정규화. 같은 사이트 안의 이동은 (직접/내부) 로 묶는다. */
function refHost(ref, selfHosts) { if (!ref || ref === '-') return '(직접 접속)'; try { const h = new URL(ref).hostname.toLowerCase(); if (selfHosts && selfHosts.has(h)) return '(사이트 내 이동)'; return h; } catch (_) { return '(기타)'; } }
/** 사이트 안 경로로 정규화: /<id>/about.html → /about.html, 쿼리 제거 */
function pagePath(uri, siteId) { let p = String(uri || '/').split('?')[0]; if (siteId && p.startsWith('/' + siteId)) p = p.slice(siteId.length + 1) || '/'; return p || '/'; }
// ---------- 기록 ----------
// 짧은 시간에 여러 건이 들어와도 사이트당 한 번만 파일을 쓰도록 지연 저장한다.
const dirty = new Map(); // siteId -> data
let flushTimer = null;
function markDirty(siteId, d) { dirty.set(siteId, d); if (!flushTimer) flushTimer = setTimeout(flush, 3000); }
function flush() { flushTimer = null; for (const [id, d] of dirty) { try { save(id, d); } catch (_) {} } dirty.clear(); }
function current(siteId) { return dirty.get(siteId) || load(siteId); }
/**
* 서버 로그 한 줄 반영.
* @param {string} siteId
* @param {object} e { ts(ms), ip, host, method, uri, status, reqLen, bytesSent, referer, ua }
*/
function recordLog(siteId, e) {
const d = current(siteId); const day = kstDate(e.ts); const rec = dayRec(d, day);
rec.req += 1; rec.bytesIn += e.reqLen || 0; rec.bytesOut += e.bytesSent || 0;
bump(rec.hosts, (e.host || '').toLowerCase());
const bot = BOT_RE.test(e.ua || '');
if (bot) { rec.bots += 1; markDirty(siteId, d); return; }
const p = pagePath(e.uri, siteId);
const isPage = e.method === 'GET' && (e.status === 200 || e.status === 304) && !ASSET_EXT_RE.test(p);
if (isPage) { rec.pv += 1; addVisitor(rec, visitorHash(day, e.ip, e.ua)); bump(rec.pages, p); bump(rec.refs, refHost(e.referer, new Set([(e.host || '').toLowerCase()]))); }
markDirty(siteId, d);
}
/**
* 브라우저 비컨 반영. 로그가 없는 서버의 보조 통계.
* @param {object} b { ip, ua, path, ref, host, transfer(바이트), lang, w, h }
*/
function recordBeacon(siteId, b) {
if (BOT_RE.test(b.ua || '')) return;
const d = current(siteId); const day = kstDate(); const rec = dayRec(d, day);
rec.b.pv += 1; rec.b.bytesOut += Math.max(0, Math.min(50 * 1024 * 1024, Number(b.transfer) || 0));
const bh = visitorHash(day, b.ip, b.ua); if (!rec.b.v[bh]) { if (Object.keys(rec.b.v).length >= MAX_VISITOR_KEYS) rec.b.vOver += 1; else rec.b.v[bh] = 1; }
bump(rec.hosts, (b.host || '').toLowerCase());
const w = Number(b.w) || 0; bump(rec.b.screens, w ? (w < 600 ? '모바일' : (w < 1024 ? '태블릿' : 'PC')) : '알 수 없음');
bump(rec.b.langs, String(b.lang || '').slice(0, 10) || '-');
// 로그를 못 읽는 사이트는 비컨 페이지뷰를 pages/refs 에도 반영 (서버 로그가 있으면 중복이지만 pv 자체는 분리 보관)
bump(rec.pages, pagePath(b.path, siteId)); bump(rec.refs, refHost(b.ref, new Set([(b.host || '').toLowerCase()])));
markDirty(siteId, d);
}
// ---------- nginx 로그 파싱 ----------
/**
* `wmstats` 로그 형식(deploy/nginx-wmstats.conf) 한 줄을 파싱한다.
* $msec|$remote_addr|$host|$request_method|$request_uri|$status|$request_length|$bytes_sent|$http_referer|$http_user_agent
* UA 안에 '|' 가 있을 수 있으므로 앞 9개만 나누고 나머지는 UA 로 합친다.
*/
function parseLine(line) {
const f = line.split('|'); if (f.length < 10) return null;
const ts = Math.round(parseFloat(f[0]) * 1000); if (!Number.isFinite(ts)) return null;
return { ts, ip: f[1], host: f[2], method: f[3], uri: f[4], status: parseInt(f[5], 10) || 0, reqLen: parseInt(f[6], 10) || 0, bytesSent: parseInt(f[7], 10) || 0, referer: f[8], ua: f.slice(9).join('|') };
}
function loadOffsets() { try { return JSON.parse(fs.readFileSync(OFFSETS_FILE, 'utf8')); } catch (_) { return {}; } }
function saveOffsets(o) { const tmp = OFFSETS_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(o)); fs.renameSync(tmp, OFFSETS_FILE); }
/**
* 로그 텍스트 덩어리를 집계한다. siteOf(entry) 가 사이트 ID 를 돌려주지 않으면 건너뛴다.
* @returns {number} 반영한 줄 수
*/
function ingestText(text, siteOf) {
let n = 0;
for (const line of text.split('\n')) { if (!line) continue; const e = parseLine(line); if (!e) continue; const id = siteOf(e); if (!id) continue; recordLog(id, e); n += 1; }
flush();
return n;
}
/**
* 로컬 로그 파일에서 지난번 이후 새로 쌓인 부분만 읽어 반영한다.
* 파일이 교체(로테이션)되어 inode 가 바뀌거나 크기가 줄었으면 처음부터 다시 읽는다.
* 로테이션 직후에는 이전 파일(.1)의 꼬리를 놓칠 수 있는데, 5분 주기 수집에서 그 손실은 작다고 보고 단순화했다.
*/
function collectLocalFile(logPath, siteOf, key) {
let st; try { st = fs.statSync(logPath); } catch (_) { return { ok: false, lines: 0, detail: '로그 파일 없음' }; }
const offs = loadOffsets(); const k = key || logPath; const prev = offs[k] || { inode: null, offset: 0 };
let start = prev.offset; if (prev.inode !== st.ino || st.size < prev.offset) start = 0;
if (st.size === start) { offs[k] = { inode: st.ino, offset: start }; saveOffsets(offs); return { ok: true, lines: 0 }; }
const fd = fs.openSync(logPath, 'r'); const len = st.size - start; const buf = Buffer.alloc(Math.min(len, 64 * 1024 * 1024));
const read = fs.readSync(fd, buf, 0, buf.length, start); fs.closeSync(fd);
let text = buf.slice(0, read).toString('utf8'); let consumed = read;
// 마지막 줄이 아직 덜 쓰였을 수 있으므로 개행까지만 처리
const nl = text.lastIndexOf('\n'); if (nl < 0) return { ok: true, lines: 0 }; if (nl < text.length - 1) { consumed = Buffer.byteLength(text.slice(0, nl + 1)); text = text.slice(0, nl + 1); }
const lines = ingestText(text, siteOf);
offs[k] = { inode: st.ino, offset: start + consumed }; saveOffsets(offs);
return { ok: true, lines };
}
/** 원격 로그용: 외부에서 받아온 텍스트와 새 오프셋을 기록 */
function offsetOf(key) { return loadOffsets()[key] || { inode: null, offset: 0 }; }
function setOffset(key, inode, offset) { const o = loadOffsets(); o[key] = { inode, offset }; saveOffsets(o); }
// ---------- 저장 공간 ----------
/** 디렉터리 총 바이트 (심볼릭 링크 제외) */
function dirSize(dir, skip = new Set()) {
let total = 0;
try { for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { if (skip.has(ent.name)) continue; const p = path.join(dir, ent.name); try { if (ent.isDirectory()) total += dirSize(p, skip); else if (ent.isFile()) total += fs.statSync(p).size; } catch (_) {} } } catch (_) {}
return total;
}
function fileSize(p) { try { return fs.statSync(p).size; } catch (_) { return 0; } }
// ---------- 요약 ----------
const sum = (recs, f) => recs.reduce((a, r) => a + (f(r) || 0), 0);
const visitors = (r) => (r ? Object.keys(r.v || {}).length + (r.vOver || 0) : 0);
const bVisitors = (r) => (r && r.b ? Object.keys(r.b.v || {}).length + (r.b.vOver || 0) : 0);
/**
* 사용자 화면용 요약.
* @param {string} siteId
* @param {object} paths { siteDir, siteJson, inquiriesJson, gitDir }
* @returns 오늘/어제/7일/30일/이번 달/누적 방문자·페이지뷰·요청·바이트, 일별 시계열(최근 30일), 상위 페이지/유입, 기기, 저장 공간
*/
function summary(siteId, paths = {}) {
const d = load(siteId); const days = d.days; const today = kstDate(); const month = today.slice(0, 7);
const dayKeys = Object.keys(days).sort();
const lastN = (n) => { const out = []; for (let i = n - 1; i >= 0; i--) { const k = kstDate(Date.now() - i * 86400000); out.push([k, days[k]]); } return out; };
const agg = (recs) => {
const list = recs.filter(Boolean);
return { visitors: sum(list, visitors), pv: sum(list, (r) => r.pv), req: sum(list, (r) => r.req), bots: sum(list, (r) => r.bots), bytesIn: sum(list, (r) => r.bytesIn), bytesOut: sum(list, (r) => r.bytesOut), beaconPv: sum(list, (r) => r.b && r.b.pv), beaconVisitors: sum(list, bVisitors), beaconBytesOut: sum(list, (r) => r.b && r.b.bytesOut) };
};
const all = agg(dayKeys.map((k) => days[k])); // 보관 중인 일자 + totals(버린 일자)
all.req += d.totals.req; all.pv += d.totals.pv; all.bytesIn += d.totals.bytesIn; all.bytesOut += d.totals.bytesOut; all.bots += d.totals.bots; all.beaconPv += d.totals.bpv; all.beaconBytesOut += d.totals.bBytesOut; all.visitors += d.totals.visitors || 0; all.beaconVisitors += d.totals.bVisitors || 0;
const todayRec = days[today]; const yKey = kstDate(Date.now() - 86400000);
const series = lastN(30).map(([k, r]) => ({ day: k, visitors: visitors(r), pv: r ? r.pv : 0, bytesIn: r ? r.bytesIn : 0, bytesOut: r ? r.bytesOut : 0, beaconPv: r && r.b ? r.b.pv : 0, beaconVisitors: bVisitors(r), beaconBytesOut: r && r.b ? r.b.bytesOut : 0 }));
const merge = (f) => { const m = {}; for (const k of dayKeys.slice(-30)) for (const [key, n] of Object.entries(f(days[k]) || {})) m[key] = (m[key] || 0) + n; return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 8).map(([key, n]) => ({ key, n })); };
const hasLog = all.req > 0; const hasBeacon = all.beaconPv > 0;
// 저장 공간: 사이트 파일(첨부 별도) 과 "DB"(사이트 정보·문의·통계·버전 저장소)
const siteBytes = paths.siteDir ? dirSize(paths.siteDir, new Set(['.git', '.claude', '.refs', 'uploads'])) : 0;
const uploadBytes = paths.siteDir ? dirSize(path.join(paths.siteDir, 'uploads')) : 0;
const db = { site: fileSize(paths.siteJson), inquiries: fileSize(paths.inquiriesJson), stats: fileSize(fileOf(siteId)), git: paths.gitDir ? dirSize(paths.gitDir) : 0 };
db.total = db.site + db.inquiries + db.stats + db.git;
// v1.7.2 (요구 2): 메일 데이터 — 사이트 메일함 파일(data/mail/<site>). 메일 시스템이 꺼져 있으면 null(화면에서 숨김). Arimail 서버 쪽 메일함 용량은 API 가 주지 않아 포함하지 않는다
const mailBytes = paths.mailEnabled && paths.mailDir ? dirSize(paths.mailDir) : null;
// v1.7.2 (요구 2): 달력 기준 월간 합계 — 직전 2개월 + 이번 달(누적 중). 일자 키는 KST 날짜라 월 경계도 KST
const months = [];
for (let i = 2; i >= 0; i--) { const dt = new Date(today + 'T00:00:00Z'); dt.setUTCMonth(dt.getUTCMonth() - i, 1); const key = dt.toISOString().slice(0, 7); const recs = dayKeys.filter((k) => k.startsWith(key)).map((k) => days[k]); months.push({ month: key, days: recs.length, current: key === month, ...agg(recs) }); }
return {
updatedAt: d.updatedAt, sources: { log: hasLog, beacon: hasBeacon },
today: agg([todayRec]), yesterday: agg([days[yKey]]), last7: agg(lastN(7).map((x) => x[1])), last30: agg(lastN(30).map((x) => x[1])), month: agg(dayKeys.filter((k) => k.startsWith(month)).map((k) => days[k])), total: all,
series, series7: series.slice(-7), months,
topPages: merge((r) => r.pages), topRefs: merge((r) => r.refs), devices: merge((r) => r.b && r.b.screens), hosts: merge((r) => r.hosts),
disk: { site: siteBytes, uploads: uploadBytes, total: siteBytes + uploadBytes, web: siteBytes + uploadBytes, mail: mailBytes, limit: null /* 허용량은 아직 없음 */ }, db,
};
}
// ---------- 비컨 삽입 ----------
const BEACON_MARK = 'data-wm-stats';
/**
* 사이트의 모든 HTML 에 통계 비컨 <script> 를 넣는다(이미 있으면 그대로). </body> 앞, 없으면 문서 끝.
* 생성·수정 직후와 배포/원본 반영 직전에 호출해 AI 가 지웠더라도 다시 들어가게 한다.
* @returns {number} 삽입한 파일 수
*/
function ensureBeacon(siteDir, siteId, base) {
if (!base) return 0;
const tag = `<script src="${base.replace(/\/$/, '')}/wm-stats.js" ${BEACON_MARK} data-site="${siteId}" async></script>`;
let n = 0;
const walk = (dir) => {
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
if (ent.name.startsWith('.') || ent.name === 'node_modules' || ent.name === 'uploads') continue;
const p = path.join(dir, ent.name);
if (ent.isDirectory()) { walk(p); continue; }
if (!/\.html?$/i.test(ent.name)) continue;
let html; try { html = fs.readFileSync(p, 'utf8'); } catch (_) { continue; }
if (html.includes(BEACON_MARK)) { if (!html.includes(tag)) { html = html.replace(/<script[^>]*data-wm-stats[^>]*><\/script>\s*/i, tag + '\n'); fs.writeFileSync(p, html); } continue; }
const i = html.search(/<\/body\s*>/i);
html = i >= 0 ? html.slice(0, i) + tag + '\n' + html.slice(i) : html + '\n' + tag + '\n';
try { fs.writeFileSync(p, html); n += 1; } catch (_) {}
}
};
try { walk(siteDir); } catch (_) {}
return n;
}
module.exports = { recordLog, recordBeacon, parseLine, ingestText, collectLocalFile, offsetOf, setOffset, summary, ensureBeacon, remove, flush, kstDate, SITE_ID_RE, STATS_DIR, dirSize };
+73
View File
@@ -0,0 +1,73 @@
'use strict';
// 사이트 메타데이터/명령 이력/토큰 사용량을 data/sites/<id>.json 에 저장하는 단순 JSON 저장소.
const fs = require('fs');
const path = require('path');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data', 'sites');
fs.mkdirSync(DATA_DIR, { recursive: true });
const ID_RE = /^[a-f0-9]{32}$/;
function fileOf(id) { return path.join(DATA_DIR, `${id}.json`); }
function emptyTotals() {
return { input: 0, output: 0, cacheCreate: 0, cacheRead: 0, thinking: 0, total: 0, costUsd: 0, chargeKrw: 0, commands: 0 };
}
function recomputeTotals(site) {
const t = emptyTotals();
for (const c of site.commands) {
if (!c.usage) continue;
t.input += c.usage.input; t.output += c.usage.output;
t.cacheCreate += c.usage.cacheCreate; t.cacheRead += c.usage.cacheRead;
t.thinking += c.usage.thinking || 0; t.total += c.usage.total;
t.costUsd += c.costUsd || 0; t.chargeKrw += c.chargeKrw || 0; t.commands += 1;
}
site.totals = t;
return site;
}
// v1.6.31 (요구 10): 원자적 쓰기(임시 파일 + fsync + rename) + 직전 정상본 .bak 보존. 읽기 실패는 손상으로 기록해 관리자 운영 복구 화면에 보인다(빈 값으로 숨기지 않음)
const js = require('./jsonstore');
function save(site) {
recomputeTotals(site);
site.updatedAt = new Date().toISOString();
js.writeJsonAtomic(fileOf(site.id), JSON.stringify(site, null, 2), { mode: 0o600, keepBackup: true });
return site;
}
/** 사이트 기록 읽기. 없으면 null. 깨졌으면 .bak 으로 복구를 시도하고 손상 목록에 남긴다(복구도 안 되면 null — 화면에는 "손상" 으로 보인다) */
function load(id) {
if (!ID_RE.test(id)) return null;
return js.readJson(fileOf(id), null, {});
}
/** 손상된 사이트 파일 목록 (운영 복구 화면) */
function health() { return js.health().filter((h) => h.file.startsWith(DATA_DIR)); }
function list(ownerId) {
return fs.readdirSync(DATA_DIR)
.filter((f) => f.endsWith('.json'))
.map((f) => load(f.slice(0, -5)))
.filter(Boolean)
.filter((s) => !ownerId || s.ownerId === ownerId)
.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''))
// v1.2.0: 상단 사이트 선택 상자가 "생성 중 / 완성 / 가져옴·복사" 로 묶어 보여 주도록 mode·plan 단계도 함께 준다
.map((s) => ({ id: s.id, title: s.title, createdAt: s.createdAt, status: s.status, totals: s.totals, commands: s.commands.length, mode: s.mode || null, stage: (s.plan && s.plan.stage) || null }));
}
function create(fields) {
const site = {
id: fields.id,
createdAt: new Date().toISOString(),
ownerId: fields.ownerId || null, ownerName: fields.ownerName || '',
title: fields.title, purpose: fields.purpose, content: fields.content,
design: fields.design, extra: fields.extra,
git: { seq: 0, changes: [] },
sessionId: fields.sessionId,
status: 'idle',
commands: [],
totals: emptyTotals(),
};
return save(site);
}
module.exports = { health, ID_RE, create, save, load, list, DATA_DIR };
+159
View File
@@ -0,0 +1,159 @@
'use strict';
// ============================================================================
// 사이트 템플릿 (v1.3.10)
//
// 관리자가 빌더로 만든 사이트를 "템플릿" 으로 등록하고, 사용자에게 사용을 허용한 템플릿으로 새 사이트를 시작하게 한다.
// - 등록 = 그 순간의 사이트 파일을 data/templates/<템플릿ID>/files/ 로 복사(스냅숏). 이후 원본 사이트를 고쳐도 템플릿은
// 그대로이며, "다시 담기" 를 눌러야 바뀐다. 사이트 고유 값(통계 스크립트, 검색·AI 최적화 관리 구역, 웹메일 진입 파일)은 뺀다.
// - 디렉터리: 기본은 업종 구조(brief.js INDUSTRIES 의 대분류/세부 업종, 'ind:대분류' · 'ind:대분류/세부'), 필요하면
// 관리자가 별도 디렉터리를 만든다('dir:<id>', 상위 디렉터리 지정 가능).
// - 해시태그: tags 배열(# 없이 소문자로 저장). 검색어의 #태그는 모두 포함한 템플릿만, 나머지 낱말은 제목·설명·태그에서 찾는다.
// - 사용자 사용 허용(enabled)은 관리자가 정한다. 사용자 목록·미리보기·사용은 enabled 인 것만.
// - 사용 = 스냅숏을 새 사이트 폴더로 복사하고, 파일 안의 원본 사이트 ID(모듈 위젯 data-site 등)를 새 사이트 ID 로 바꾼다.
//
// 저장: data/templates/index.json { dirs: [{id, name, parent, createdAt}], items: [{id, title, description, tags, dir, sourceSiteId,
// enabled, files, bytes, createdAt, updatedAt, snapshotAt, by}] }
// ============================================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const ROOT = path.join(DATA_ROOT, 'templates');
const INDEX = path.join(ROOT, 'index.json');
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const clip = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
const TEXT_EXT = /\.(html?|css|js|mjs|json|txt|xml|svg|md)$/i;
const SKIP = new Set(['.git', 'node_modules', 'webmail', 'uploads/.tmp']);
function load() { try { const d = JSON.parse(fs.readFileSync(INDEX, 'utf8')); d.dirs = d.dirs || []; d.items = d.items || []; return d; } catch (_) { return { dirs: [], items: [] }; } }
function save(d) { fs.mkdirSync(ROOT, { recursive: true }); const tmp = INDEX + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(d, null, 1)); fs.renameSync(tmp, INDEX); }
const filesDir = (id) => path.join(ROOT, id, 'files');
/** 해시태그 정리: "#카페, 미니멀 #강남" → ['카페','미니멀','강남'] */
function normTags(v) { const arr = Array.isArray(v) ? v : String(v || '').split(/[,\s]+/); return [...new Set(arr.map((t) => String(t).replace(/^#+/, '').trim().toLowerCase()).filter((t) => t && t.length <= 30))].slice(0, 20); }
// ---------- 디렉터리 ----------
/**
* 디렉터리 트리: 업종(대분류 → 세부 업종) + 관리자가 만든 디렉터리. 각 노드에 템플릿 수(count, 하위 포함)를 붙인다.
* @param {Array} industries brief.INDUSTRIES ({major, minors})
* @param {boolean} enabledOnly 사용자 화면이면 허용된 템플릿만 센다
*/
function tree(industries, enabledOnly) {
const d = load(); const items = d.items.filter((t) => !enabledOnly || t.enabled);
const cnt = (pred) => items.filter(pred).length;
const ind = industries.map((i) => ({ id: `ind:${i.major}`, name: i.major, kind: 'industry', count: cnt((t) => t.dir === `ind:${i.major}` || t.dir.startsWith(`ind:${i.major}/`)), children: (i.minors || []).map((m) => ({ id: `ind:${i.major}/${m}`, name: m, kind: 'industry', count: cnt((t) => t.dir === `ind:${i.major}/${m}`) })) }));
const custom = (parent) => d.dirs.filter((x) => (x.parent || null) === parent).map((x) => { const kids = custom(x.id); const ids = new Set([x.id, ...flat(kids).map((k) => k.id.slice(4))]); return { id: `dir:${x.id}`, name: x.name, kind: 'custom', count: cnt((t) => t.dir.startsWith('dir:') && ids.has(t.dir.slice(4))), children: kids }; });
const flat = (nodes) => nodes.flatMap((n) => [n, ...flat(n.children || [])]);
return { industries: ind, custom: custom(null), total: items.length };
}
/** 디렉터리 ID 확인 (업종 경로가 실제 목록에 있는지, 별도 디렉터리가 있는지) */
function validDir(dir, industries) {
const v = String(dir || ''); if (!v) return 'dir:' ;
if (v.startsWith('ind:')) { const [maj, min] = v.slice(4).split('/'); const i = industries.find((x) => x.major === maj); if (i && (!min || (i.minors || []).includes(min))) return v; throw httpError('업종 디렉터리를 찾을 수 없습니다'); }
if (v.startsWith('dir:')) { if (load().dirs.some((x) => x.id === v.slice(4))) return v; throw httpError('디렉터리를 찾을 수 없습니다'); }
throw httpError('디렉터리를 고르세요');
}
function addDir(name, parent) {
const d = load(); const n = clip(name, 40); if (!n) throw httpError('디렉터리 이름을 입력하세요');
const p = parent ? String(parent).replace(/^dir:/, '') : null; if (p && !d.dirs.some((x) => x.id === p)) throw httpError('상위 디렉터리가 없습니다');
if (d.dirs.some((x) => x.name === n && (x.parent || null) === p)) throw httpError('같은 이름의 디렉터리가 이미 있습니다', 409);
const x = { id: crypto.randomBytes(5).toString('hex'), name: n, parent: p, createdAt: new Date().toISOString() }; d.dirs.push(x); save(d); return x;
}
function renameDir(id, name) { const d = load(); const x = d.dirs.find((y) => y.id === String(id).replace(/^dir:/, '')); if (!x) throw httpError('디렉터리가 없습니다', 404); const n = clip(name, 40); if (!n) throw httpError('디렉터리 이름을 입력하세요'); x.name = n; save(d); return x; }
/** 비어 있는(템플릿·하위 디렉터리가 없는) 디렉터리만 지운다 */
function removeDir(id) { const d = load(); const k = String(id).replace(/^dir:/, ''); if (!d.dirs.some((x) => x.id === k)) throw httpError('디렉터리가 없습니다', 404); if (d.dirs.some((x) => x.parent === k)) throw httpError('하위 디렉터리가 있어 지울 수 없습니다', 409); if (d.items.some((t) => t.dir === `dir:${k}`)) throw httpError('템플릿이 들어 있어 지울 수 없습니다. 템플릿을 다른 디렉터리로 옮기세요', 409); d.dirs = d.dirs.filter((x) => x.id !== k); save(d); return true; }
// ---------- 스냅숏 복사 ----------
/**
* 사이트 폴더 → 템플릿 폴더. 사이트 고유 값은 뺀다:
* - 접속 통계 스크립트(<script … data-wm-stats …>) — 새 사이트 배포 때 다시 넣는다
* - 검색·AI 최적화 관리 구역·FAQ 영역(wm-seo / wm-aeo-faq) — 새 사이트에서 따로 만든다
* - 자동 생성 파일(sitemap.xml·robots.txt·llms.txt·IndexNow 키), 웹메일 진입 폴더, 숨김 파일
*/
function copyTree(src, dst, transform) {
let files = 0, bytes = 0;
const walk = (s, d, rel) => {
let ents = []; try { ents = fs.readdirSync(s, { withFileTypes: true }); } catch (_) { return; }
fs.mkdirSync(d, { recursive: true, mode: 0o755 });
for (const e of ents) {
const r = rel ? `${rel}/${e.name}` : e.name; if (e.name.startsWith('.') || SKIP.has(r) || SKIP.has(e.name)) continue;
if (!rel && /^(sitemap\.xml|robots\.txt|llms\.txt|[a-f0-9]{32}\.txt)$/.test(e.name)) continue;
const sp = path.join(s, e.name); const dp = path.join(d, e.name);
if (e.isDirectory()) { walk(sp, dp, r); continue; } if (!e.isFile()) continue;
let buf = fs.readFileSync(sp); if (transform && TEXT_EXT.test(e.name)) buf = Buffer.from(transform(buf.toString('utf8'), r), 'utf8');
fs.writeFileSync(dp, buf, { mode: 0o644 }); files += 1; bytes += buf.length;
}
};
walk(src, dst, ''); return { files, bytes };
}
const stripSiteSpecific = (text) => text
.replace(/<!-- wm-seo:start -->[\s\S]*?<!-- wm-seo:end -->\n?/g, '').replace(/<!-- wm-aeo-faq:start -->[\s\S]*?<!-- wm-aeo-faq:end -->\n?/g, '')
.replace(/<script[^>]*data-wm-stats[^>]*><\/script>\s*/g, '');
/**
* 관리자 사이트 → 템플릿 등록 (또는 tplId 를 주면 그 템플릿의 스냅숏을 다시 담기)
* @param {object} site 관리자가 만든 사이트
* @param {string} siteDir
* @param {object} meta { title, description, tags, dir }
*/
function register(site, siteDir, meta, industries, by) {
const d = load(); const id = crypto.randomBytes(8).toString('hex');
const t = { id, title: clip(meta.title || site.title, 80), description: clip(meta.description, 400), tags: normTags(meta.tags), dir: validDir(meta.dir || ((site.plan && site.plan.brief && site.plan.brief.business && site.plan.brief.business.industry && site.plan.brief.business.industry.major) ? `ind:${site.plan.brief.business.industry.major}` : ''), industries), sourceSiteId: site.id, enabled: false, createdAt: new Date().toISOString(), by };
if (t.dir === 'dir:') throw httpError('디렉터리를 고르세요 (업종 또는 별도 디렉터리)');
if (!t.title) throw httpError('템플릿 이름을 입력하세요');
const r = copyTree(siteDir, filesDir(id), stripSiteSpecific); if (!r.files) { fs.rmSync(path.join(ROOT, id), { recursive: true, force: true }); throw httpError('사이트에 파일이 없습니다'); }
Object.assign(t, r, { snapshotAt: new Date().toISOString(), updatedAt: new Date().toISOString() }); d.items.push(t); save(d); return t;
}
function resnapshot(tplId, siteDir) {
const d = load(); const t = d.items.find((x) => x.id === tplId); if (!t) throw httpError('템플릿이 없습니다', 404);
const tmp = path.join(ROOT, tplId, 'files.new'); fs.rmSync(tmp, { recursive: true, force: true }); const r = copyTree(siteDir, tmp, stripSiteSpecific);
if (!r.files) { fs.rmSync(tmp, { recursive: true, force: true }); throw httpError('원본 사이트에 파일이 없습니다'); }
fs.rmSync(filesDir(tplId), { recursive: true, force: true }); fs.renameSync(tmp, filesDir(tplId));
Object.assign(t, r, { snapshotAt: new Date().toISOString(), updatedAt: new Date().toISOString() }); save(d); return t;
}
function update(tplId, patch, industries) {
const d = load(); const t = d.items.find((x) => x.id === tplId); if (!t) throw httpError('템플릿이 없습니다', 404);
if (patch.title !== undefined) { t.title = clip(patch.title, 80); if (!t.title) throw httpError('템플릿 이름을 입력하세요'); }
if (patch.description !== undefined) t.description = clip(patch.description, 400);
if (patch.tags !== undefined) t.tags = normTags(patch.tags);
if (patch.dir !== undefined) t.dir = validDir(patch.dir, industries);
if (patch.enabled !== undefined) t.enabled = patch.enabled === true || patch.enabled === 'true';
t.updatedAt = new Date().toISOString(); save(d); return t;
}
function remove(tplId) { const d = load(); const i = d.items.findIndex((x) => x.id === tplId); if (i < 0) throw httpError('템플릿이 없습니다', 404); d.items.splice(i, 1); save(d); fs.rmSync(path.join(ROOT, tplId), { recursive: true, force: true }); return true; }
function get(tplId) { return load().items.find((x) => x.id === tplId) || null; }
/**
* 검색. q 의 #태그는 모두 포함해야 하고, 나머지 낱말은 제목·설명·태그 중 어디에든 있으면 된다.
* dir 을 주면 그 디렉터리와 하위만 (업종 대분류를 고르면 그 아래 세부 업종 템플릿도 포함).
*/
function search({ q = '', dir = '', enabledOnly = false } = {}) {
const d = load(); const words = String(q).toLowerCase().split(/\s+/).filter(Boolean);
const tags = words.filter((w) => w.startsWith('#')).map((w) => w.slice(1)).filter(Boolean); const terms = words.filter((w) => !w.startsWith('#'));
const sub = (id) => { const kids = d.dirs.filter((x) => x.parent === id).map((x) => x.id); return [id, ...kids.flatMap(sub)]; };
const dirSet = dir.startsWith('dir:') ? new Set(sub(dir.slice(4)).map((x) => `dir:${x}`)) : null;
return d.items.filter((t) => (!enabledOnly || t.enabled)
&& (!dir || (dirSet ? dirSet.has(t.dir) : (t.dir === dir || t.dir.startsWith(dir + '/'))))
&& tags.every((tag) => t.tags.some((x) => x === tag || x.startsWith(tag)))
&& terms.every((w) => `${t.title} ${t.description} ${t.tags.join(' ')}`.toLowerCase().includes(w)))
.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''));
}
/** 자주 쓰인 해시태그 (검색 도우미) */
function popularTags(enabledOnly) { const m = {}; for (const t of load().items) if (!enabledOnly || t.enabled) for (const g of t.tags) m[g] = (m[g] || 0) + 1; return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 30).map(([tag, n]) => ({ tag, n })); }
/** 템플릿 미리보기 파일 경로 (폴더 밖으로 나가지 않게) */
function previewPath(tplId, rel) {
const base = filesDir(tplId); const p = path.resolve(base, decodeURIComponent(rel || 'index.html').replace(/^\/+/, '') || 'index.html');
if (!p.startsWith(path.resolve(base) + path.sep) && p !== path.resolve(base)) throw httpError('잘못된 경로', 400);
return fs.existsSync(p) && fs.statSync(p).isDirectory() ? path.join(p, 'index.html') : p;
}
/**
* 템플릿으로 새 사이트 폴더 채우기: 스냅숏 복사 + 원본 사이트 ID → 새 사이트 ID (모듈 위젯·API 주소 등)
*/
function instantiate(tplId, siteDir, newSiteId) {
const t = get(tplId); if (!t) throw httpError('템플릿이 없습니다', 404);
return copyTree(filesDir(tplId), siteDir, (text) => text.split(t.sourceSiteId).join(newSiteId));
}
module.exports = { tree, validDir, addDir, renameDir, removeDir, register, resnapshot, update, remove, get, search, popularTags, previewPath, instantiate, normTags, filesDir };
+94
View File
@@ -0,0 +1,94 @@
'use strict';
// 첨부 파일(이미지/문서) 저장·목록·삭제. 파일은 사이트 루트의 uploads/ 에 두어
// nginx 로 바로 서비스되고, Claude 가 Read 도구로 읽거나 상대 경로로 페이지에 삽입할 수 있다.
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const UPLOAD_DIR = 'uploads';
const MAX_FILE_BYTES = 25 * 1024 * 1024;
const MAX_FILES_PER_REQUEST = 20;
const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'avif']);
const DOC_EXT = new Set(['txt', 'md', 'markdown', 'csv', 'tsv', 'json', 'html', 'htm', 'pdf', 'docx', 'xml', 'yaml', 'yml', 'rtf']);
function extOf(name) { return (path.extname(name).slice(1) || '').toLowerCase(); }
function kindOf(name) {
const e = extOf(name);
if (IMAGE_EXT.has(e)) return 'image';
if (DOC_EXT.has(e)) return 'document';
return null;
}
function allowedExtensions() { return [...IMAGE_EXT, ...DOC_EXT]; }
function sanitizeName(name) {
let base = path.basename(String(name || 'file')).normalize('NFC');
base = base.replace(/[^\w.\-가-힣 ()]/g, '_').replace(/\s+/g, ' ').trim();
base = base.replace(/^\.+/, '').replace(/\.+$/, '');
if (!base) base = 'file';
const ext = extOf(base);
let stem = ext ? base.slice(0, -(ext.length + 1)) : base;
if (stem.length > 60) stem = stem.slice(0, 60);
return ext ? `${stem}.${ext}` : stem;
}
function uniquePath(dir, name) {
const ext = extOf(name);
const stem = ext ? name.slice(0, -(ext.length + 1)) : name;
let candidate = name, i = 2;
while (fs.existsSync(path.join(dir, candidate))) { candidate = ext ? `${stem}-${i}.${ext}` : `${stem}-${i}`; i += 1; }
return candidate;
}
// docx → 텍스트 추출본(<name>.txt) 생성. 실패해도 업로드는 유지.
function extractDocxText(filePath) {
// v1.3.17 (B15): 추출본 이름도 업로드 파일과 같은 규칙으로 겹치지 않게 정한다 (같은 이름의 .txt 첨부를 덮어쓰던 문제)
const out = path.join(path.dirname(filePath), uniquePath(path.dirname(filePath), `${path.basename(filePath)}.txt`));
try {
const script = 'import sys,docx\nd=docx.Document(sys.argv[1])\nparts=[p.text for p in d.paragraphs]\nfor t in d.tables:\n for r in t.rows:\n parts.append("\\t".join(c.text for c in r.cells))\nopen(sys.argv[2],"w",encoding="utf-8").write("\\n".join(parts))\n';
execFileSync('python3', ['-c', script, filePath, out], { timeout: 30000, stdio: 'ignore' });
fs.chmodSync(out, 0o644);
return path.basename(out);
} catch (_) { return null; }
}
/**
* multipart 로 받은 File 객체들을 저장한다.
* @returns {Promise<Array<{name,rel,size,kind,mime,textSidecar,addedAt,usedInCommands}>>}
*/
async function saveUploads(siteDir, files) {
if (!files || !files.length) return [];
if (files.length > MAX_FILES_PER_REQUEST) throw Object.assign(new Error(`한 번에 최대 ${MAX_FILES_PER_REQUEST}개까지 업로드할 수 있습니다`), { status: 400 });
const dir = path.join(siteDir, UPLOAD_DIR);
fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
const saved = [];
for (const f of files) {
if (!f || typeof f.arrayBuffer !== 'function' || !f.name) continue;
const kind = kindOf(f.name);
if (!kind) throw Object.assign(new Error(`지원하지 않는 파일 형식입니다: ${f.name} (허용: ${allowedExtensions().join(', ')})`), { status: 400 });
if (f.size > MAX_FILE_BYTES) throw Object.assign(new Error(`파일이 너무 큽니다 (최대 25MB): ${f.name}`), { status: 400 });
const name = uniquePath(dir, sanitizeName(f.name));
const fp = path.join(dir, name);
fs.writeFileSync(fp, Buffer.from(await f.arrayBuffer()), { mode: 0o644 });
const rec = { name, rel: `${UPLOAD_DIR}/${name}`, size: f.size, kind, mime: f.type || '', textSidecar: null, addedAt: new Date().toISOString(), usedInCommands: [] };
if (extOf(name) === 'docx') { const side = extractDocxText(fp); if (side) rec.textSidecar = `${UPLOAD_DIR}/${side}`; }
saved.push(rec);
}
return saved;
}
function deleteUpload(siteDir, rec) {
for (const rel of [rec.rel, rec.textSidecar]) {
if (!rel) continue;
const fp = path.join(siteDir, rel);
if (fp.startsWith(path.join(siteDir, UPLOAD_DIR) + path.sep)) { try { fs.unlinkSync(fp); } catch (_) {} }
}
}
function humanSize(n) {
if (n < 1024) return `${n}B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)}KB`;
return `${(n / 1024 / 1024).toFixed(1)}MB`;
}
module.exports = { UPLOAD_DIR, MAX_FILE_BYTES, saveUploads, deleteUpload, allowedExtensions, humanSize };
+197
View File
@@ -0,0 +1,197 @@
'use strict';
// ============================================================================
// 외부 연동 접속 정보 보관소 (v1.3.1)
//
// 메일 발송(Resend), 메일 게이트웨이, Claude(Anthropic) 인증처럼 "다른 서비스에 접속" 하는 데 필요한
// 주소·설정·키(API Key / Token)를 한 파일(data/credentials.json, 권한 0600)에 모아 암호화해 저장한다.
//
// - 키(secret)는 AES-256-GCM 으로 암호화한다. 암호화 키는 data/session.secret 에서 용도 문자열을 섞어 파생한다
// (다른 기능의 암호화 키와 섞이지 않게 'credential-vault:' 접두사 사용). 환경변수 VAULT_KEY 가 있으면 그것을 쓴다.
// - 화면·API 에는 키 원문을 절대 돌려주지 않는다(앞뒤 몇 글자만 보이는 hint). 서버 내부 코드만 secretOf() 로 꺼낸다.
// - 키 파일 가져오기: 관리자가 서버의 키 파일 경로(예: 프로젝트 폴더의 .key)를 알려 주면 서버가 한 번 읽어
// 필요한 항목만 암호화해 보관한다. 이후에는 평문 키 파일이 없어도 동작하므로 파일을 지워도 된다.
// - 1.3.0 이전에 settings.json 에 있던 키(메일 발송 키, Claude API 키·OAuth 토큰)와 메일 키 파일은
// 기동 시 migrate() 가 이곳으로 옮기고 settings.json 에서는 지운다(암호화→복호화 검증 후).
// ============================================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const FILE = path.join(DATA_ROOT, 'credentials.json');
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
/**
* 연동 종류. keyNames/urlNames 는 키 파일을 가져올 때 찾는 항목 이름(앞에 있는 것 우선).
* fixedId 가 있는 종류는 서비스에 하나만 둔다(예: 메일 발송 키).
*/
const KINDS = {
resend: { label: '메일 발송 (Resend)', fixedId: 'resend', defaultUrl: 'https://api.resend.com', keyNames: ['RESEND_API_KEY', 'RESEND_KEY', 'resend_api_key', 'MAIL_API_KEY', 'API_KEY', 'api_key'], urlNames: ['RESEND_API_BASE'], settingNames: ['MAIL_FROM', 'FROM', 'SENDER', 'ID', 'EMAIL'], secretLabel: 'API 키 (re_…)' },
'mail-gateway': { label: '(구) 메일 게이트웨이 — 1.5.7 부터 Arimail 연동 모듈 사용', fixedId: 'mail-gateway', defaultUrl: '', keyNames: ['MAIL_GATEWAY_TOKEN', 'MAIL_GATEWAY_KEY', 'GATEWAY_TOKEN', 'API_TOKEN', 'TOKEN', 'API_KEY'], urlNames: ['MAIL_GATEWAY_URL', 'GATEWAY_URL', 'URL', 'BASE_URL'], settingNames: ['MX_HOST', 'MAIL_DOMAIN'], secretLabel: 'API 키 또는 토큰' },
// v1.5.7 (요구 5·6) 연동 모듈의 비밀값. 주소·계정 같은 비밀 아닌 값은 설정 파일(lib/config.js: mail.*, source.*)에 있고 여기에는 비밀번호·토큰만 둔다.
// v1.6.38 (요구 2): 메일 서버를 여러 대 등록할 수 있어 고정 id 대신 기본 id 'arimail'(설정 파일의 기본 서버) + 추가 서버는 'arimail-<서버 id>' (lib/mailservers.js 가 만든다)
arimail: { label: 'Arimail 관리자 비밀번호 (메일 연동 모듈)', fixedId: null, defaultId: 'arimail', defaultUrl: '', keyNames: ['ARIMAIL_ADMIN_PASSWORD', 'ADMIN_PASSWORD', 'PASSWORD'], urlNames: ['ARIMAIL_URL'], settingNames: ['ADMIN_LOGIN', 'MAIL_DOMAIN'], secretLabel: '관리자 비밀번호 (설정 파일 mail.adminLogin 계정)' },
// v1.7.3 (요구 2): Arimail 1.0.9 사용량 API 키(au_…). 서버마다 하나 — 기본 서버는 'arimail-usage', 추가 서버는 'arimail-usage-<서버 id>' (lib/mailservers.js 가 만든다). 읽기 전용 통계 키라 관리자 비밀번호와 따로 둔다
'arimail-usage': { label: 'Arimail 사용량 API 키 (메일 사용량 연동)', fixedId: null, defaultId: 'arimail-usage', defaultUrl: '', keyNames: ['USAGE_API_KEY', 'ARIMAIL_USAGE_KEY', 'KEY', 'API_KEY'], urlNames: ['ARIMAIL_URL'], settingNames: ['MAIL_DOMAIN'], secretLabel: '사용량 API 키 (au_… — Arimail 전체 설정 › 사용량 API 카드에서 만듦)' },
gitea: { label: 'Gitea API 토큰 (소스 보관 연동 모듈)', fixedId: 'gitea', defaultUrl: '', keyNames: ['GITEA_TOKEN', 'GITEA_API_TOKEN', 'TOKEN', 'API_TOKEN'], urlNames: ['GITEA_API_URL', 'GITEA_URL'], settingNames: ['GITEA_USER', 'GITEA_REPO'], secretLabel: '접근 토큰 (설정 파일 source.user 계정, repo·user 권한)' },
'anthropic-api-key': { label: 'Claude API 키 (Anthropic Console)', fixedId: 'anthropic-api-key', defaultUrl: 'https://api.anthropic.com', keyNames: ['ANTHROPIC_API_KEY', 'API_KEY'], urlNames: [], settingNames: [], secretLabel: 'API 키 (sk-ant-api03-…)' },
'anthropic-oauth': { label: 'Claude OAuth 토큰 (claude setup-token)', fixedId: 'anthropic-oauth', defaultUrl: '', keyNames: ['CLAUDE_CODE_OAUTH_TOKEN', 'OAUTH_TOKEN', 'TOKEN'], urlNames: [], settingNames: [], secretLabel: 'OAuth 토큰 (sk-ant-oat01-…)' },
// v1.3.10 구글 검색 등록 자동화: Google Cloud 서비스 계정 키(JSON 파일 내용 전체)를 키로 보관한다. OWNER_EMAIL: Search Console 에서 함께 볼 관리자 구글 계정(선택)
'google-search': { label: '구글 검색 등록 자동화 (서비스 계정 키 JSON)', fixedId: 'google-search', defaultUrl: '', keyNames: ['private_key'], urlNames: [], settingNames: ['OWNER_EMAIL'], secretLabel: '서비스 계정 키 JSON 전체 ({"type": "service_account", …})', wholeJson: true },
// v1.4.0 도메인 등록·DNS (Openprovider). 설정 USERNAME=로그인 이름, 키=비밀번호. NS_GROUP(선택, 기본 dns-openprovider)
openprovider: { label: '도메인 등록·DNS (Openprovider)', fixedId: 'openprovider', defaultUrl: 'https://api.openprovider.eu/v1beta', keyNames: ['OPENPROVIDER_PASSWORD', 'PASSWORD'], urlNames: ['OPENPROVIDER_URL'], settingNames: ['USERNAME', 'OPENPROVIDER_USERNAME', 'NS_GROUP'], secretLabel: '비밀번호 (설정 칸에 USERNAME=로그인 이름)' },
// v1.5.36 (요구 4·5) 도메인 등록·DNS 공급자
porkbun: { label: '도메인 등록·DNS (Porkbun)', fixedId: 'porkbun', defaultUrl: 'https://api.porkbun.com/api/json/v3', keyNames: ['PORKBUN_SECRET_KEY', 'SECRET_API_KEY', 'SECRET'], urlNames: ['PORKBUN_URL'], settingNames: ['API_KEY', 'PORKBUN_API_KEY'], secretLabel: 'Secret API Key (설정 칸에 API_KEY=공개 API 키)' },
route53: { label: 'DNS (Amazon Route 53)', fixedId: 'route53', defaultUrl: 'https://route53.amazonaws.com', keyNames: ['AWS_SECRET_ACCESS_KEY', 'SECRET_ACCESS_KEY', 'SECRET'], urlNames: [], settingNames: ['ACCESS_KEY_ID', 'AWS_ACCESS_KEY_ID', 'SESSION_TOKEN', 'REGION'], secretLabel: 'Secret Access Key (설정 칸에 ACCESS_KEY_ID=액세스 키 ID, 선택 REGION=us-east-1)' },
'gcloud-dns': { label: 'DNS (Google Cloud DNS, 서비스 계정 키 JSON)', fixedId: 'gcloud-dns', defaultUrl: 'https://dns.googleapis.com', keyNames: ['private_key'], urlNames: [], settingNames: ['PROJECT', 'PROJECT_ID'], secretLabel: '서비스 계정 키 JSON 전체 (roles/dns.admin; 설정 PROJECT 는 키의 project_id 와 다를 때만)' },
http: { label: '기타 외부 서비스 (HTTP API)', fixedId: null, defaultUrl: '', keyNames: ['API_KEY', 'TOKEN', 'API_TOKEN', 'KEY'], urlNames: ['URL', 'BASE_URL', 'API_URL'], settingNames: [], secretLabel: 'API 키 또는 토큰' },
};
// ---------- 암호화 ----------
function cryptKey() {
if (process.env.VAULT_KEY) return crypto.createHash('sha256').update('credential-vault:' + process.env.VAULT_KEY).digest();
let s; try { s = fs.readFileSync(path.join(DATA_ROOT, 'session.secret'), 'utf8').trim(); } catch (_) { s = ''; }
// v1.5.7: 서비스가 한 번도 뜨지 않은 새 서버에서 설치 스크립트(scripts/config.js set …)가 먼저 비밀값을 넣을 수 있게, 키 파일이 없으면 lib/auth.js 와 같은 규칙으로 만든다
if (!s) { try { fs.mkdirSync(DATA_ROOT, { recursive: true }); s = crypto.randomBytes(32).toString('hex'); fs.writeFileSync(path.join(DATA_ROOT, 'session.secret'), s, { mode: 0o600 }); } catch (e) { throw httpError(`암호화 키(data/session.secret)를 읽거나 만들 수 없어 접속 정보를 저장할 수 없습니다: ${e.message}`, 500); } }
return crypto.createHash('sha256').update('credential-vault:' + s).digest();
}
function enc(text) { const iv = crypto.randomBytes(12); const c = crypto.createCipheriv('aes-256-gcm', cryptKey(), iv); const out = Buffer.concat([c.update(String(text), 'utf8'), c.final()]); return 'v1:' + Buffer.concat([iv, c.getAuthTag(), out]).toString('base64'); }
function dec(blob) { if (!blob) return ''; const b = Buffer.from(String(blob).replace(/^v1:/, ''), 'base64'); const d = crypto.createDecipheriv('aes-256-gcm', cryptKey(), b.subarray(0, 12)); d.setAuthTag(b.subarray(12, 28)); return Buffer.concat([d.update(b.subarray(28)), d.final()]).toString('utf8'); }
/** 화면 표시용: 앞 6글자…뒤 4글자 (짧은 키는 길이만) */
const hintOf = (k) => (!k ? '' : k.length <= 12 ? `(${k.length}자)` : `${k.slice(0, 6)}…${k.slice(-4)}`);
// ---------- 저장소 ----------
// v1.6.39 (요구 2): 읽기·쓰기를 lib/jsonstore 로 — 쓸 때 직전 정상본을 credentials.json.bak 으로 남기고, 파일이 깨졌으면 .bak 을 읽는다.
// (2026-10-21 webmaker.ibit.kr 에서 Arimail 관리자 비밀번호를 덮어썼을 때 되돌릴 사본이 없었던 일의 재발 방지. 비밀번호 자체의 "이전 값" 은 lib/mailservers.js 가 따로 보관한다)
const jsx = require('./jsonstore');
function load() { const d = jsx.readJson(FILE, null); if (!d || typeof d !== 'object') return { version: 1, items: {} }; d.items = d.items || {}; return d; }
function save(d) { jsx.writeJsonAtomic(FILE, d, { mode: 0o600, keepBackup: true, indent: 1 }); try { fs.chmodSync(FILE + '.bak', 0o600); } catch (_) {} }
/** 화면에 내보낼 형태 (키 원문 없음) */
function pub(x) { return { id: x.id, kind: x.kind, kindLabel: (KINDS[x.kind] || {}).label || x.kind, label: x.label, url: x.url || '', settings: x.settings || {}, hasSecret: !!x.secret, secretHint: x.secretHint || '', source: x.source || 'input', updatedAt: x.updatedAt, lastTest: x.lastTest || null }; }
function list() { return Object.values(load().items).map(pub).sort((a, b) => a.id.localeCompare(b.id)); }
function get(id) { const x = load().items[id]; return x ? pub(x) : null; }
/** 서버 내부 전용: 복호화한 키. 없거나 복호화 실패면 '' */
function secretOf(id) { const x = load().items[id]; if (!x || !x.secret) return ''; try { return dec(x.secret); } catch (_) { return ''; } }
/** 서버 내부 전용: 주소·설정·키를 한 번에 */
function resolve(id) { const x = load().items[id]; if (!x) return null; return { ...pub(x), secret: secretOf(id) }; }
const ID_RE = /^[a-z0-9][a-z0-9-]{1,40}$/;
/**
* 추가·수정.
* @param {object} input { id?, kind, label?, url?, settings?(객체 또는 "키=값" 줄), secret?(빈 값이면 기존 유지), clearSecret? }
* @param {string} source 'input'(화면 입력) | 'file:<경로>' | 'migrate:<원래 위치>'
*/
function set(input, source = 'input') {
const kind = String(input.kind || ''); const K = KINDS[kind]; if (!K) throw httpError('연동 종류를 고르세요');
const id = K.fixedId || String(input.id || K.defaultId || '').trim().toLowerCase(); if (!ID_RE.test(id)) throw httpError('식별자는 영문 소문자·숫자·- 2~41자로 입력하세요 (예: crm-api)');
const d = load(); const prev = d.items[id];
if (prev && prev.kind !== kind) throw httpError(`이미 "${prev.kind}" 종류로 쓰는 식별자입니다`, 409);
let url = String(input.url == null ? (prev ? prev.url : K.defaultUrl) : input.url).trim();
if (url && !/^https?:\/\/[^\s]+$/i.test(url)) throw httpError('주소는 http:// 또는 https:// 로 시작해야 합니다');
let st = input.settings == null ? (prev ? prev.settings : {}) : input.settings;
if (typeof st === 'string') { const o = {}; for (const line of st.split(/\r?\n/)) { const m = line.trim().match(/^([A-Za-z0-9_.-]+)\s*[=:]\s*(.*)$/); if (m) o[m[1]] = m[2].trim(); } st = o; }
if (st && typeof st !== 'object') throw httpError('설정은 "이름=값" 줄로 입력하세요');
const x = { id, kind, label: String(input.label || (prev && prev.label) || K.label).slice(0, 80), url, settings: st || {}, secret: prev ? prev.secret : '', secretHint: prev ? prev.secretHint : '', source: prev ? prev.source : source, createdAt: prev ? prev.createdAt : new Date().toISOString(), updatedAt: new Date().toISOString(), lastTest: prev ? prev.lastTest : null };
if (input.clearSecret) { x.secret = ''; x.secretHint = ''; }
const sec = typeof input.secret === 'string' ? input.secret.trim() : '';
if (sec && K.wholeJson) { let j = null; try { j = JSON.parse(sec); } catch (_) {} if (!j || !j.client_email || !j.private_key) throw httpError('서비스 계정 키 JSON 전체를 붙여넣으세요 (Google Cloud 콘솔 › IAM › 서비스 계정 › 키 › 새 키 만들기 › JSON 으로 받은 파일 내용). client_email·private_key 항목이 있어야 합니다'); }
if (sec) {
const blob = enc(sec); if (dec(blob) !== sec) throw httpError('암호화 확인에 실패했습니다', 500); // 저장 전에 되돌려 보기
x.secret = blob; x.secretHint = K.wholeJson ? (() => { try { return JSON.parse(sec).client_email; } catch (_) { return hintOf(sec); } })() : hintOf(sec); /* JSON 키는 계정 이메일만 표시 */
x.source = source; x.lastTest = null; // v1.3.17 (B19): 키가 바뀌면 이전 키의 연결 확인 결과·출처를 지운다 (1.3.10 에서 주석에 묻혀 실행되지 않던 줄)
}
d.items[id] = x; save(d); return pub(x);
}
function remove(id) { const d = load(); if (!d.items[id]) throw httpError('항목이 없습니다', 404); delete d.items[id]; save(d); return true; }
/** KEY=VALUE 또는 JSON 키 파일 해석 (mailer 와 같은 규칙) */
function parseKeyText(text) {
const out = {};
try { const j = JSON.parse(text); if (j && typeof j === 'object') { for (const [k, v] of Object.entries(j)) if (typeof v === 'string') out[k] = v; return out; } } catch (_) {}
for (const raw of String(text).split(/\r?\n/)) { const line = raw.trim(); if (!line || line.startsWith('#')) continue; const m = line.match(/^(?:export\s+)?([A-Za-z0-9_.\-]+)\s*[=:]\s*(.*)$/); if (m) out[m[1]] = m[2].trim().replace(/^["']|["']$/g, ''); }
return out;
}
/**
* 서버의 키 파일에서 가져와 암호화 보관. 결과에는 찾은 항목 "이름" 만 돌려준다(값은 돌려주지 않음).
* @param {string} kind
* @param {string} file 서버 파일 경로 (절대 경로)
* @param {object} extra { id?, label? }
*/
function importFile(kind, file, extra = {}) {
const K = KINDS[kind]; if (!K) throw httpError('연동 종류를 고르세요');
const p = String(file || '').trim(); if (!path.isAbsolute(p)) throw httpError('키 파일은 서버의 절대 경로로 입력하세요 (예: /home/master/work/WebHoting_AI_Agent/.key)');
let text; try { const st = fs.statSync(p); if (!st.isFile() || st.size > 64 * 1024) throw new Error('파일이 아니거나 너무 큽니다'); text = fs.readFileSync(p, 'utf8'); } catch (e) { throw httpError(`키 파일을 읽을 수 없습니다: ${e.message}`); }
return importText(kind, text, extra, `file:${p}`);
}
/**
* 키 파일 "내용" 에서 가져오기 (v1.3.2: 관리자가 .key 파일을 직접 올리는 경우).
* 올린 파일은 디스크에 저장하지 않고 메모리에서 읽어 필요한 항목만 암호화 보관한다.
* @param {string} text 파일 내용 (KEY=VALUE 줄 또는 JSON)
* @param {string} source 출처 표시 (예: 'upload:.key')
*/
function importText(kind, text, extra = {}, source = 'upload') {
const K = KINDS[kind]; if (!K) throw httpError('연동 종류를 고르세요');
const kv = parseKeyText(String(text || '').replace(/^\uFEFF/, '')); const names = Object.keys(kv);
if (K.wholeJson && kv.client_email && kv.private_key) { const r = set({ kind, id: extra.id, label: extra.label, secret: String(text).replace(/^\uFEFF/, '').trim() }, source); return { item: r, found: { key: 'JSON 전체', url: null, settings: [] }, fileItems: names }; } // 서비스 계정 키 파일 그대로
const keyName = K.keyNames.find((n) => kv[n]); if (!keyName) throw httpError(`키 파일에서 ${K.keyNames.join('/')} 항목을 찾지 못했습니다 (파일의 항목: ${names.join(', ') || '없음'})`);
const urlName = K.urlNames.find((n) => kv[n] && /^https?:\/\//i.test(kv[n]));
const settings = {}; for (const n of K.settingNames) if (kv[n] && n !== keyName) settings[n] = kv[n];
const r = set({ kind, id: extra.id, label: extra.label, secret: kv[keyName], ...(urlName ? { url: kv[urlName] } : {}), ...(Object.keys(settings).length ? { settings } : {}) }, source);
return { item: r, found: { key: keyName, url: urlName || null, settings: Object.keys(settings) }, fileItems: names };
}
/**
* 연결 확인 (값은 보여 주지 않고 결과만). 종류별로 가벼운 읽기 요청을 보낸다.
* - resend : GET /domains (키가 맞으면 200)
* - mail-gateway : GET <주소> 와 /health (Authorization: Bearer <토큰>)
* - http : GET <주소>
* - anthropic-* : 관리자 콘솔의 "연결 테스트"(Claude 실행)로 확인
*/
async function test(id) {
const x = resolve(id); if (!x) throw httpError('항목이 없습니다', 404);
const call = async (u) => { try { const r = await fetch(u, { headers: x.secret ? { Authorization: `Bearer ${x.secret}` } : {}, signal: AbortSignal.timeout(15000) }); return { url: u, status: r.status, ok: r.ok }; } catch (e) { return { url: u, status: 0, ok: false, error: e.cause ? e.cause.code || e.message : e.message }; } };
let result;
if (x.kind === 'resend') { const base = process.env.RESEND_API_BASE || x.url || KINDS.resend.defaultUrl; const r = await call(base.replace(/\/+$/, '') + '/domains'); result = { ok: r.ok, detail: r.ok ? '키 확인됨 (도메인 목록 조회 성공)' : (r.status === 401 || r.status === 403 ? '키가 올바르지 않습니다' : `응답 ${r.status || r.error}`), checks: [r] }; }
else if (x.kind === 'google-search') { try { const g = require('./google'); await g.accessToken(x.secret); result = { ok: true, detail: `구글 인증 확인됨 (${g.accountOf(x.secret)})`, checks: [] }; } catch (e) { result = { ok: false, detail: e.message, checks: [] }; } }
else if (x.kind === 'openprovider') { if (!(x.settings && (x.settings.USERNAME || x.settings.OPENPROVIDER_USERNAME))) result = { ok: false, detail: '설정 칸에 USERNAME=로그인 이름 을 넣으세요', checks: [] }; else { try { const op = require('./openprovider'); await op.call('GET', '/tlds', null, { limit: 1 }); result = { ok: true, detail: 'Openprovider 로그인·조회 확인됨', checks: [] }; } catch (e) { result = { ok: false, detail: e.message, checks: [] }; } } }
// v1.5.36: 도메인·DNS 공급자는 실제 목록 호출로 확인한다
else if (x.kind === 'porkbun') { try { const p = require('./dns/porkbun'); const z = await p.listZones(); result = { ok: true, detail: `Porkbun 확인됨 (계정 도메인 ${z.length}개)`, checks: [] }; } catch (e) { result = { ok: false, detail: e.message, checks: [] }; } }
else if (x.kind === 'route53') { try { const p = require('./dns/route53'); const z = await p.listZones(); let dom = ''; try { const ds = await require('./registrars/route53').listDomains(); dom = ` · 등록 도메인 ${ds.length}개(Route 53 Domains 사용 가능)`; } catch (e) { dom = ` · 도메인 등록(Route 53 Domains)은 불가: ${e.message.slice(0, 120)}`; } result = { ok: true, detail: `Route 53 확인됨 (호스팅 영역 ${z.length}개${dom})`, checks: [] }; } catch (e) { result = { ok: false, detail: e.message, checks: [] }; } } // v1.6.32: 등록 업체 권한도 함께 확인
else if (x.kind === 'gcloud-dns') { try { const p = require('./dns/gcloud'); const z = await p.listZones(); result = { ok: true, detail: `Google Cloud DNS 확인됨 (프로젝트 ${(p.creds() || {}).project}, 관리 영역 ${z.length}개)`, checks: [] }; } catch (e) { result = { ok: false, detail: e.message, checks: [] }; } }
else if (x.kind.startsWith('anthropic')) result = { ok: !!x.secret, detail: x.secret ? '저장됨 — 실제 확인은 관리자 콘솔 › AI 연결 탭의 "연결 테스트" 를 사용하세요' : '키가 없습니다', checks: [] };
// v1.5.7: 연동 모듈의 비밀값은 그 모듈의 status() 로 실제 접속을 확인한다 (주소·계정은 설정 파일에 있음)
else if (x.kind === 'arimail' || x.kind === 'gitea') { const r = await require('./connectors').test(x.kind === 'arimail' ? 'mail' : 'source'); result = { ok: r.ok, detail: r.detail, checks: [] }; }
else { if (!x.url) throw httpError('주소가 없습니다'); const base = x.url.replace(/\/+$/, ''); const checks = [await call(base)]; if (x.kind === 'mail-gateway') checks.push(await call(base + '/health')); const ok = checks.some((c) => c.ok); result = { ok, detail: ok ? '접속 확인됨' : `접속 실패 (${checks.map((c) => `${c.url.replace(base, '') || '/'} → ${c.status || c.error}`).join(', ')})`, checks }; }
const d = load(); if (d.items[id]) { d.items[id].lastTest = { at: new Date().toISOString(), ok: result.ok, detail: result.detail }; save(d); }
return result;
}
/**
* 1.3.0 이전 설정의 키를 보관소로 옮긴다 (서비스 기동 시 1회, 이미 옮긴 항목은 건너뜀).
* - settings.mail.apiKey(providers.enc 암호화) → resend
* - settings.mail.keyFile(평문 키 파일) → resend (파일을 읽어 암호화 보관, 경로는 출처로만 남김)
* - settings.auth.apiKey / oauthToken(평문) → anthropic-api-key / anthropic-oauth
* 옮긴 뒤 settings.json 에서는 값을 지운다. 옮기기에 실패하면 원래 값을 그대로 둔다(동작 유지).
* @returns {string[]} 옮긴 항목 설명
*/
function migrate(settingsMod, providersMod) {
const done = []; const s = settingsMod.load(); const d = load(); let changed = false;
const mail = s.mail || {};
if (!d.items.resend) {
try {
if (mail.apiKey) { const k = providersMod.dec(mail.apiKey); if (k) { set({ kind: 'resend', secret: k }, 'migrate:settings.mail.apiKey'); done.push('메일 발송 키(설정 입력값) → 보관소'); } }
else if (mail.keyFile) { const r = importFile('resend', mail.keyFile); done.push(`메일 발송 키(키 파일 ${mail.keyFile}) → 보관소 (${r.found.key})`); }
} catch (e) { done.push(`메일 발송 키 이전 실패: ${e.message}`); }
}
if (load().items.resend && (mail.apiKey)) { s.mail = { ...mail, apiKey: '' }; changed = true; }
const a = s.auth || {};
for (const [field, kind] of [['apiKey', 'anthropic-api-key'], ['oauthToken', 'anthropic-oauth']]) {
if (!a[field]) continue;
try { if (!load().items[kind]) { set({ kind, secret: a[field] }, `migrate:settings.auth.${field}`); done.push(`Claude ${field === 'apiKey' ? 'API 키' : 'OAuth 토큰'} → 보관소`); } if (secretOf(kind) === a[field] || load().items[kind]) { a[field] = ''; changed = true; } }
catch (e) { done.push(`Claude ${field} 이전 실패: ${e.message}`); }
}
if (changed) settingsMod.replaceSecrets({ mail: s.mail, auth: a });
return done;
}
module.exports = { importText, KINDS, FILE, list, get, secretOf, resolve, set, remove, importFile, test, migrate, parseKeyText, hintOf };
+61
View File
@@ -0,0 +1,61 @@
'use strict';
// ============================================================================
// 최소 XLSX 작성기 (의존성 없음)
// 설문 응답 등을 엑셀로 내려받을 때 쓴다. 하나의 시트, 문자열/숫자 셀만 지원하며
// 서식·수식·병합은 다루지 않는다(사용자가 엑셀에서 다시 가공하는 용도).
//
// XLSX = ZIP 컨테이너 안의 OOXML 파일들. Node 의 zlib(deflateRawSync) 로 압축하고
// ZIP 의 로컬 헤더·중앙 디렉터리·CRC32 를 직접 쓴다.
// ============================================================================
const zlib = require('zlib');
/** CRC32 (ZIP 필수) — 테이블 방식 */
const CRC_TABLE = (() => { const t = new Int32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c; } return t; })();
function crc32(buf) { let c = -1; for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); return (c ^ -1) >>> 0; }
/** 파일 목록 [{name, data(Buffer)}] 을 ZIP(Buffer) 으로 */
function zip(files) {
const parts = []; const central = []; let offset = 0;
const dosTime = (d) => ((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)); const dosDate = (d) => (((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate());
const now = new Date();
for (const f of files) {
const name = Buffer.from(f.name, 'utf8'); const raw = Buffer.isBuffer(f.data) ? f.data : Buffer.from(f.data, 'utf8');
const comp = zlib.deflateRawSync(raw); const crc = crc32(raw);
const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x0800, 6); local.writeUInt16LE(8, 8); local.writeUInt16LE(dosTime(now), 10); local.writeUInt16LE(dosDate(now), 12); local.writeUInt32LE(crc, 14); local.writeUInt32LE(comp.length, 18); local.writeUInt32LE(raw.length, 22); local.writeUInt16LE(name.length, 26); local.writeUInt16LE(0, 28);
parts.push(local, name, comp);
const cd = Buffer.alloc(46); cd.writeUInt32LE(0x02014b50, 0); cd.writeUInt16LE(20, 4); cd.writeUInt16LE(20, 6); cd.writeUInt16LE(0x0800, 8); cd.writeUInt16LE(8, 10); cd.writeUInt16LE(dosTime(now), 12); cd.writeUInt16LE(dosDate(now), 14); cd.writeUInt32LE(crc, 16); cd.writeUInt32LE(comp.length, 20); cd.writeUInt32LE(raw.length, 24); cd.writeUInt16LE(name.length, 28); cd.writeUInt16LE(0, 30); cd.writeUInt16LE(0, 32); cd.writeUInt16LE(0, 34); cd.writeUInt16LE(0, 36); cd.writeUInt32LE(0, 38); cd.writeUInt32LE(offset, 42);
central.push(cd, name);
offset += local.length + name.length + comp.length;
}
const cdBuf = Buffer.concat(central);
const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(0, 4); end.writeUInt16LE(0, 6); end.writeUInt16LE(files.length, 8); end.writeUInt16LE(files.length, 10); end.writeUInt32LE(cdBuf.length, 12); end.writeUInt32LE(offset, 16); end.writeUInt16LE(0, 20);
return Buffer.concat([...parts, cdBuf, end]);
}
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
/** 열 번호(0부터) → 엑셀 열 문자 (A, B, …, AA) */
function colName(i) { let s = ''; i += 1; while (i > 0) { const r = (i - 1) % 26; s = String.fromCharCode(65 + r) + s; i = Math.floor((i - 1) / 26); } return s; }
/**
* 표 데이터를 XLSX Buffer 로.
* @param {string} sheetName 시트 이름(31자 이하, 특수문자 제외)
* @param {Array<Array<string|number|null>>} rows 첫 행은 헤더로 간주하지 않음(호출자가 넣음)
*/
function build(sheetName, rows) {
const name = String(sheetName || 'Sheet1').replace(/[\\/*?:[\]]/g, '').slice(0, 31) || 'Sheet1';
const cells = rows.map((r, ri) => `<row r="${ri + 1}">` + r.map((v, ci) => {
const ref = `${colName(ci)}${ri + 1}`;
if (v === null || v === undefined || v === '') return '';
if (typeof v === 'number' && Number.isFinite(v)) return `<c r="${ref}"><v>${v}</v></c>`;
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${esc(v)}</t></is></c>`; // 공유 문자열 테이블 없이 인라인 문자열로 단순화
}).join('') + '</row>').join('');
const widths = rows[0] ? `<cols>${rows[0].map((_, ci) => { const w = Math.min(60, Math.max(10, ...rows.slice(0, 200).map((r) => String(r[ci] == null ? '' : r[ci]).length * 1.2))); return `<col min="${ci + 1}" max="${ci + 1}" width="${w.toFixed(1)}" customWidth="1"/>`; }).join('')}</cols>` : '';
const sheet = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">${widths}<sheetData>${cells}</sheetData></worksheet>`;
const workbook = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="${esc(name)}" sheetId="1" r:id="rId1"/></sheets></workbook>`;
const wbRels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>`;
const rels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`;
const types = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>`;
return zip([{ name: '[Content_Types].xml', data: types }, { name: '_rels/.rels', data: rels }, { name: 'xl/workbook.xml', data: workbook }, { name: 'xl/_rels/workbook.xml.rels', data: wbRels }, { name: 'xl/worksheets/sheet1.xml', data: sheet }]);
}
module.exports = { build, zip, crc32 };