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:
co-authored by
Claude Fable 5.1
parent
9ca4cee341
commit
c7aea8d419
@@ -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 };
|
||||
@@ -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 기본 관리자·관리자 상태
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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(/ /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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 : []; } };
|
||||
@@ -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 };
|
||||
@@ -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) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c]));
|
||||
const unesc = (s) => String(s).replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/&/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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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(/ /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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const escAttr = (v) => escText(v).replace(/"/g, '"');
|
||||
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(/ /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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 메일 시스템이 도메인 등록·확인에 사용)
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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, ''').replace(/\r?\n/g, ''); }
|
||||
|
||||
module.exports = { html, nginxString, serviceDomain, HEADLINE, LINES };
|
||||
@@ -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 일정관리
|
||||
@@ -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, '"') : 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, '"') : 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, '"') : 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const unescH = (v) => String(v).replace(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/&/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, '<'); 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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,32 @@
|
||||
'use strict';
|
||||
// 반영 검증 리포트를 독립된 HTML 페이지로 렌더링한다(생성 화면과 분리, 사용자가 선택했을 때만 열람).
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 };
|
||||
@@ -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 };
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
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(/"/g, '"').replace(/&/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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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, '"'))
|
||||
.replace(/\{\{msgs:([\w.,]+)\}\}/g, (m, ids) => JSON.stringify(clientTable(lang, ids.split(','))).replace(/&/g, '&').replace(/'/g, '''));
|
||||
}
|
||||
|
||||
module.exports = { source, text, tr, forLang, err, errorText, clientTable, missing, ensure, saveRuntime, saveOverride, listFor, renderTemplate, table };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').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 };
|
||||
Reference in New Issue
Block a user