Files
AriWeb-Suite/modules/webmaker/server.js
T

2944 lines
328 KiB
JavaScript

'use strict';
// AI 웹호스팅 에이전트 서버 (의존성 없음, Node 18+).
// - / : 생성 UI
// - /api/* : 사이트 생성/수정/조회, SSE 진행 스트림
// nginx 가 / 를 이 서버로 프록시하고, /<32hex>/... 는 /var/www/sites 에서 정적으로 서비스한다.
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
// v1.5.7 (요구 1·3): 설정 파일(data/config.json)의 값을 process.env 에 먼저 넣는다 — 아래 모듈들이 기동 때 환경 변수를 읽기 때문에 가장 먼저.
// 파일이 없으면(1.5.7 로 처음 올린 서버) 지금 systemd 유닛의 환경 변수를 그대로 파일에 옮겨 적어 운영 값이 바뀌지 않게 한다.
const config = require('./lib/config');
{ const r = config.apply(); console.log(`[config] ${r.created ? '설정 파일을 환경 변수에서 만들었습니다: ' : '설정 파일: '}${r.file}${r.applied.length ? ` (적용 ${r.applied.join(', ')})` : ''}`); }
const setupapi = require('./lib/setupapi'); // v1.5.7 (요구 2·7) 설정 파일·연동 상태 API (초기 설정 페이지와 관리자 콘솔이 같이 쓴다)
const setupauth = require('./lib/setupauth'); // v1.5.11 (요구 2) 설정 페이지 토큰·세션 — 본 서비스의 /setup 도 관리자 로그인 없이 토큰으로 열린다
const setupstate = require('./lib/setupstate');
const ratelimit = require('./lib/ratelimit'); // v1.6.31 (요구 7) 신뢰 프록시 기준 IP · 속도 제한
const audit = require('./lib/audit'); // v1.6.31 (요구 21) 관리자 작업 기록
const jsonstore = require('./lib/jsonstore'); // v1.6.31 (요구 10) 손상 파일 목록
const recovery = require('./lib/recovery'); // v1.6.31 (요구 20) 운영 복구 목록(미완료 외부 작업) // v1.5.11 (요구 2) 설치 점검표 — 끝나기 전에는 첫 화면을 /setup 으로 보낸다
const { runClaude, MODEL, currentModel, claudeInfo, resolveClaudeBin } = require('./lib/claude');
const { runModel } = require('./lib/run');
const providers = require('./lib/providers');
const settings = require('./lib/settings');
const billing = require('./lib/billing');
const credits = require('./lib/credits');
const VERSION = require('./package.json').version;
const fx = require('./lib/fx');
const deploy = require('./lib/deploy');
const sslLib = require('./lib/ssl'); // v1.4.14 플랫폼 도메인 와일드카드 인증서 (요구 9)
const store = require('./lib/store');
const { SYSTEM_PROMPT, buildCreatePrompt, buildUpdatePrompt } = require('./lib/prompt');
const uploads = require('./lib/uploads');
const auth = require('./lib/auth');
// v1.5.11 (요구 1): 사용자가 한 명도 없는 서버(처음 설치)에는 기본 관리자 admin / admin 을 만든다 — 첫 로그인 뒤 비밀번호 변경이 강제된다
{ const m = auth.ensureBootstrapAdmin(); if (m) console.log('[auth] ' + m); }
const gitstore = require('./lib/gitstore');
const briefLib = require('./lib/brief');
const planner = require('./lib/planner');
const { buildBriefPrompt, attachmentsSection, widgetSection, commentRule, commentReminder, engineSwitchSection, shortModel } = require('./lib/prompt');
const mailer = require('./lib/mailer');
const forms = require('./lib/forms');
const refsLib = require('./lib/refs');
const report = require('./lib/report');
const remote = require('./lib/remote');
const stats = require('./lib/stats');
const modules = require('./lib/modules');
const mailsys = require('./lib/mailsys'); // v1.3.0 사이트 메일 시스템
const mailServers = require('./lib/mailservers'); // v1.6.38 메일 서버(Arimail) 등록부 — 여러 대, 닉네임·IP 로 구분
const mailusage = require('./lib/mailusage'); // v1.7.3 메일 사용량(Arimail 1.0.9 사용량 API, 캐시)
const vault = require('./lib/vault'); // v1.3.1 외부 연동 접속 정보 암호화 보관소
const logview = require('./lib/logview'); // v1.3.2 관리자 로그 보기
const seo = require('./lib/seo'); // v1.3.5 검색·AI 최적화(SEO·AEO·GEO), 검색사이트 등록
const templates = require('./lib/templates'); // v1.3.10 사이트 템플릿
const multilang = require('./lib/multilang');
const google = require('./lib/google');
const sitemsg = require('./lib/sitemsg');
const passpolicy = require('./lib/passpolicy');
const domainsLib = require('./lib/domains'); // v1.4.0 도메인 탭 (Openprovider·서브 도메인·MX 자동 설정)
const openprovider = require('./lib/openprovider'); // v1.3.17 비밀번호 규칙 // v1.3.16 생성 사이트 시스템 문구(id → 언어별 글자) // v1.3.10 구글 검색 등록 자동화 (서비스 계정) // v1.3.10 다국어 사이트 (/ko/ /en/ 언어 폴더)
// 1.3.0 이전 settings.json 의 키(메일 발송 키·키 파일, Claude API 키/OAuth 토큰)를 암호화 보관소로 옮긴다 (값은 로그에 남기지 않음)
try { for (const line of vault.migrate(settings, require('./lib/providers'))) console.log('[vault] ' + line); } catch (e) { console.log('[vault] 이전 실패: ' + e.message); }
const xlsx = require('./lib/xlsx');
const i18n = require('./lib/i18n');
const feedback = require('./lib/feedback');
const costcal = require('./lib/costcal');
const crawler = require('./lib/crawler');
const ariweb = require('./lib/ariweb'); // [Ariweb Suite 변경] 포털 연동(SSO 티켓·내부 API·프로젝트↔사이트 연결)
process.umask(0o022); // 생성 파일을 nginx(www-data)가 읽을 수 있도록
const PORT = parseInt(process.env.PORT || '3080', 10);
const HOST = process.env.HOST || '127.0.0.1';
const SITES_ROOT = process.env.SITES_ROOT || '/var/www/sites';
const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || 'http://192.168.20.66').replace(/\/$/, '');
const PUBLIC_DIR = path.join(__dirname, 'public');
let wmModulesJs = null; // v1.3.16 문구 표를 넣은 wm-modules.js (서비스가 다시 시작될 때까지 재사용)
function formBase() { return (mailer.mailConfig().formBase || process.env.FORM_BASE_URL || PUBLIC_BASE_URL).replace(/\/$/, ''); }
/**
* 생성·수정 프롬프트에 넣을 표준 위젯 코드. 문의 위젯(기존) + 켜진 공통 모듈(또는 Brief 기능/지시로 요청된 모듈).
* 모듈이 지시문에 언급되면(게시판·설문·예약·갤러리·지도·후기·FAQ·회원) 켜져 있지 않아도 자동으로 켜서 코드를 넣는다.
*/
/** 지시문에서 모듈을 찾는 키워드 (v1.4.0 블로그·자료실, v1.4.18 일정관리) */
const MODULE_KEYS = { board: /게시판|공지사항|커뮤니티|board/i, survey: /설문|신청서|참가 ?신청|survey/i, booking: /예약|booking/i, gallery: /갤러리|포트폴리오|gallery|portfolio/i, map: /지도|오시는 ?길|위치|map/i, reviews: /후기|리뷰|review/i, faq: /faq|자주 묻는|질문과 답/i, members: /회원가입|로그인|회원 전용|member/i, inquiry: /문의 ?폼|첨부 ?파일|문의 유형/i, blog: /블로그|blog/i, library: /자료실|내려받기|다운로드 ?(자료|센터)|downloads?|library/i, calendar: /일정 ?관리|일정표|캘린더|달력|행사 ?일정|calendar|schedule/i };
/**
* v1.5.7 (오류 수정): 수정 지시에 표준 위젯 코드를 넣어야 하는지.
* 1.4.18 까지는 문의·예약 키워드(문의|예약|폼|form|contact|메일|mail)만 보고 결정해, "일정 관리를 켰어요. 소식란에 '주요 일정'을 삽입하세요"
* 같은 모듈 요청에는 위젯 코드가 프롬프트에 들어가지 않았다. 그래서 AI 가 표준 위젯(data-wm-module="calendar") 대신 자기 마음대로
* data-wm-module="schedule" 같은 존재하지 않는 모듈을 만들어 넣었다(2026-10-14 운영 사이트). 이제 모듈 키워드(MODULE_KEYS)가 하나라도
* 있으면 켜진 모듈 전체의 표준 코드를 함께 넣는다.
*/
function widgetWanted(instruction) { const t = instruction || ''; return /문의|예약|폼|form|contact|메일|mail|모듈|위젯|widget/i.test(t) || Object.values(MODULE_KEYS).some((re) => re.test(t)); }
function widgetFor(site, instruction) {
const b = site.plan && site.plan.brief; const booking = !!(b && (b.features.booking || (b.goals || []).includes('booking'))) || /예약|booking/i.test(instruction || '');
const wanted = [];
for (const [m, re] of Object.entries(MODULE_KEYS)) { if (re.test(instruction || '')) wanted.push(m); }
if (b) { for (const m of modules.MODULE_IDS) { const f = modules.CATALOG[m].feature; if (f && b.features[f] && m !== 'inquiry') wanted.push(m); } }
if (wanted.length) { const d = modules.load(site.id); for (const m of wanted) if (!d.enabled[m]) { try { modules.setEnabled(site.id, m, true); } catch (_) {} } }
const lang = contentLang(site); // v1.3.16 위젯 글자는 사이트 본문(기본) 언어로
const modSection = modules.promptSection(site.id, formBase(), wanted, lang);
// 문의 모듈이 켜져 있으면 기존 간단 문의 위젯 대신 문의폼 모듈만 쓴다(중복 방지)
const useInquiryModule = modules.isEnabled(modules.load(site.id), 'inquiry');
return (useInquiryModule ? '' : widgetSection(site, formBase(), { booking: booking && !wanted.includes('booking'), lang })) + (modSection ? '\n' + modSection : '');
}
/** v1.3.16 사이트 본문(기본) 언어: 다국어면 기본 언어, 아니면 사이트 현지 설정(site.locale.lang) → 한국어 */
function contentLang(site) { return (site.langs && site.langs.default) || (site.locale && site.locale.lang && multilang.LOCALES[site.locale.lang] ? site.locale.lang : 'ko'); }
fs.mkdirSync(SITES_ROOT, { recursive: true });
/** 명령 비용(USD)을 실행일 환율·마진으로 원화 청구액으로 확정 */
async function chargeFor(costUsd, at, userKey) {
const day = fx.kstDate(at || Date.now()); const m = (settings.load().marginPct || 0) / 100;
if (userKey) return { chargeKrw: 0, chargeRate: null, chargeMargin: 0, chargeDay: day, userKey: true };
try { const r = await fx.getRate(day); return { chargeKrw: Math.round((costUsd || 0) * r.rate * (1 + m)), chargeRate: r.rate, chargeMargin: settings.load().marginPct, chargeDay: day }; }
catch (_) { return { chargeKrw: null, chargeRate: null, chargeMargin: settings.load().marginPct, chargeDay: day }; }
}
// 이전 선결제 충전 내역 → 캐시 잔액 1회 이관
try { const n = credits.migrateFromBilling(billing.load().charges); if (n) console.log(`credits: migrated prepaid charges for ${n} user(s)`); } catch (e) { console.error('credits migrate failed:', e.message); }
function isAdminUser(id) { const u = id ? auth.getUser(id) : null; return !!(u && u.role === 'admin'); }
/**
* 사용자 제안·오류 글을 고급 등급 모델로 분석해 댓글로 남긴다 (사용자에게 청구하지 않고 글에 비용만 기록).
* 실패해도 글은 남고 관리자가 "다시 분석" 할 수 있다.
*/
async function analyzeFeedback(item) {
// v1.5.16 (요구 2): 사용자 문의 AI 분석은 등급과 무관한 내부 호출 → 관리자가 정한 "내부 호출 모델"(1.5.13 까지는 고급 등급 모델)
const ref = providers.internalRef(); const tmp = fs.mkdtempSync(path.join(require('os').tmpdir(), 'wm-fb-'));
try {
const site = item.siteId ? store.load(item.siteId) : null;
const context = site ? `[사이트 정보] 제작 방식: ${site.mode || 'quick'} · 단계: ${site.plan ? site.plan.stage : '-'} · 최근 작업: ${(site.commands || []).slice(-3).map((c) => `${c.kind}:${c.status}${c.error ? '(' + String(c.error).slice(0, 80) + ')' : ''}`).join(', ') || '-'}\n` : '';
const { data, result: r } = await planner.runJson({ siteDir: tmp, prompt: feedback.analysisPrompt(item, context), ref, userId: null, onEvent: () => {} });
const charge = await chargeFor(r.costUsd, new Date().toISOString(), false);
feedback.update(item.id, { analysis: data }); // 상태는 아래 AI 댓글이 '새 글 → 검토 중' 으로 바꾼다(이미 답변된 글은 그대로)
feedback.comment(item.id, { by: 'ai', text: feedback.analysisText(data), model: r.model, usd: r.costUsd || 0, krw: charge.chargeKrw || 0 });
} catch (e) {
feedback.comment(item.id, { by: 'ai', text: `[AI 분석 실패] ${e.message}` });
} finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} }
}
/** 등급 안내 + 예상 비용(원). 환율·마진을 적용해 사용자가 작업 전에 비용을 체감할 수 있게 한다 */
async function tierGuideKrw() {
let rate = 0; try { rate = (await fx.getRate()).rate; } catch (_) {}
const m = 1 + (settings.load().marginPct || 0) / 100;
return settings.tierGuide(providers.resolveModel).map((t) => ({ ...t, createKrw: rate && t.createUsd != null ? Math.round(t.createUsd * rate * m) : null, editKrw: rate && t.editUsd != null ? Math.round(t.editUsd * rate * m) : null }));
}
/** AI 엔진 준비 상태 확인: 설정된 등급의 모델이 실행 가능한지(Claude 실행 파일·제공자 활성). 실패 원인은 운영자용 메시지로 */
/**
* AI 엔진 준비 확인. v1.3.10: 매번 claude --version 을 실행하던 것을 실행 파일 탐색만으로 바꾸고,
* 못 찾으면 3초 간격으로 두 번 더 찾는다 — claude 자동 업데이트 중에는 실행 파일 링크가 잠깐 없어져
* "AI 엔진이 아직 준비되지 않았습니다" 가 일시적으로 나던 문제(ERRORS.md).
*/
async function assertEngine(ref) {
const mi = providers.resolveModel(ref, null);
if (mi.kind === 'claude-cli') for (let i = 0; i < 2 && !resolveClaudeBin(i > 0); i++) await new Promise((ok) => setTimeout(ok, 3000));
if (mi.kind === 'claude-cli' && !resolveClaudeBin(true)) throw Object.assign(new Error('AI 엔진이 아직 준비되지 않았습니다. 관리자에게 문의하세요.'), { status: 503, adminDetail: `Claude 실행 파일을 찾지 못했습니다 (탐색: ${claudeInfo().searched.join(', ')}). 서비스 서버에 Claude Code 를 설치하거나 CLAUDE_BIN 을 지정하세요.` });
return mi;
}
/** AI 작업을 시작하기 전 캐시 확인 (관리자는 한도 없음) */
function assertCredit(user) { if (user && user.role !== 'admin' && credits.balance(user.id) <= 0) throw Object.assign(new Error('캐시가 부족합니다. 관리자에게 캐시 충전을 요청하세요.'), { status: 402, code: 'no-credit' }); }
/** 사이트의 모델 등급 → 실행 모델 참조 (관리자 설정) */
function siteRef(site) { return settings.tierRef(site.tier); }
/** 사용자 응답용 명령 기록: 모델명 등 내부 정보는 제외 */
function stripCmd(c) { if (!c) return c; const { model, modelUsage, prompt, userIdForEstimate, log, costCumulativeUsd, costPricedUsd, ...rest } = c; /* v1.4.5 확인용 비용 값은 관리자 전용 */ return rest; }
// 청구액이 없는 과거 명령 보정(환율은 실행일 기준)
(async function backfillCharges() {
for (const s0 of store.list()) { const site = store.load(s0.id); if (!site) continue; let changed = false;
for (const c of site.commands || []) if (c.usage && c.chargeKrw == null) { if (!c.costUsd) c.costUsd = settings.estimateUsd(c.usage, c.model || settings.getModel()); Object.assign(c, await chargeFor(c.costUsd, c.startedAt)); changed = true; }
if (changed) store.save(site); }
})().catch((e) => console.error('backfill charges failed:', e.message));
// v1.1.6: 기존 작업 기록(실제 비용이 있는 완료 작업)으로 모델별 보정 배율을 한 번 채운다
try { const samples = []; for (const s0 of store.list()) { const st = store.load(s0.id); for (const c of (st && st.commands) || []) if (c.status === 'done' && c.usage && c.costUsd > 0 && !c.usageEstimated && c.model && c.costSource) /* v1.4.5: 이번 실행 비용(costSource)으로 기록된 작업만 — 그 전 기록은 세션 누적 비용이라 보정에 쓰지 않는다 */ samples.push({ ref: c.model, estUsd: estimateUsdFor(c.usage, c.model), actualUsd: c.costUsd }); } const n = costcal.seed(samples); if (n) console.log(`cost calibration seeded from ${n} commands`); } catch (e) { console.error('cost calibration seed failed:', e.message); }
// 서버 재시작으로 끊긴 작업 정리: running 명령 → interrupted, 사이트 상태 → idle, 생성 중이던 기획 → 다시 생성 가능
(function recoverInterrupted() {
for (const s0 of store.list()) {
const site = store.load(s0.id); if (!site) continue;
let changed = false;
for (const c of site.commands || []) if (c.status === 'running') { c.status = 'interrupted'; c.error = '서버 재시작으로 작업이 끊겼습니다'; c.finishedAt = new Date().toISOString(); changed = true; }
if (site.status === 'running') { site.status = 'idle'; changed = true; }
if (site.status === 'importing') { site.status = 'error'; site.importError = '서버 재시작으로 가져오기가 끊겼습니다. 다시 가져오세요.'; changed = true; }
if (site.plan && site.plan.mode === 'copy' && site.plan.stage === 'importing') { site.plan.stage = 'generated'; changed = true; }
if (site.plan && site.plan.stage === 'generating') { site.plan.stage = (site.commands || []).some((c) => c.kind === 'create' && c.status === 'done') ? 'generated' : 'confirmed'; changed = true; }
if (changed) { store.save(site); console.log(`recovered interrupted state for site ${site.id}`); }
}
})();
// ---------- 진행 상태 / SSE ----------
const jobs = new Map(); // siteId -> { running, log: [], subscribers: Set<res> }
function jobOf(id) {
if (!jobs.has(id)) jobs.set(id, { running: false, log: [], subscribers: new Set(), cancel: null, cancelRequested: false });
return jobs.get(id);
}
const USER_LOG_LEVELS = new Set(['warn']); // 사용자에게 보이는 로그 레벨(한 줄 안내용)
function broadcast(id, ev) {
const j = jobOf(id);
ev.ts = Date.now();
if (ev.type === 'log') {
j.log.push(ev); if (j.log.length > 500) j.log.shift();
if (j.currentCmd) { j.currentCmd.log = j.currentCmd.log || []; j.currentCmd.log.push({ ts: ev.ts, level: ev.level, text: String(ev.text).slice(0, 2000) }); if (j.currentCmd.log.length > 400) j.currentCmd.log.shift(); }
}
const data = `data: ${JSON.stringify(ev)}\n\n`;
for (const res of j.subscribers) {
if (ev.type === 'log' && !res.isAdmin && !USER_LOG_LEVELS.has(ev.level)) continue;
try { res.write(data); } catch (_) { j.subscribers.delete(res); }
}
}
/** 사용자에게는 일반 안내만, 상세 원인은 관리자 로그(debug)에 남긴다 */
/** 실행 중 usage 이벤트에 원화 추정 금액을 붙여 전달 */
function liveUsageEvent(job, cmd, ev) {
if (ev.type !== 'usage' || !ev.usage) return ev;
let usd = 0; try { const mi = providers.resolveModel(cmd.model, cmd.userIdForEstimate); if (mi.kind === 'claude-cli') usd = settings.estimateUsd(ev.usage, mi.model); else usd = ((ev.usage.input || 0) * mi.price.input + (ev.usage.output || 0) * mi.price.output) / 1e6; if (mi.source === 'user') return { ...ev, krwEstimate: 0, userKey: true }; } catch (_) { usd = settings.estimateUsd(ev.usage, cmd.model); }
usd *= costcal.factor(cmd.model); // v1.1.6: 가격표 오차를 실제 청구 비율로 보정 (보정 없으면 캐시 소진 중지가 늦어짐)
const r = job.todayRate || 0; const m = (settings.load().marginPct || 0) / 100;
return { ...ev, krwEstimate: r ? Math.round(usd * r * (1 + m)) : null };
}
/**
* 캐시 소진 중지 기준 (v1.1.6): 시작 잔액의 97% 를 쓰면 멈춘다.
* 실시간 사용량은 AI 의 응답 메시지 단위로만 들어오므로, 마지막 메시지 하나만큼 넘칠 수 있는 여유를 남긴다.
*/
const CREDIT_RESERVE = 0.03;
const creditExhausted = (startBalance, left) => left <= startBalance * CREDIT_RESERVE;
// 자동 파이프라인이 돌고 있는 사이트 (그 안의 기획 단계는 개별 대기 대신 파이프라인 단위로 대기·재개)
const pipelineSites = new Set();
async function primeRate(job) { try { job.todayRate = (await fx.getRate()).rate; } catch (_) { job.todayRate = 0; } }
function userWarn(id, text, detail) { broadcast(id, { type: 'log', level: 'warn', text }); if (detail) broadcast(id, { type: 'log', level: 'debug', text: detail }); }
// ---------- 유틸 ----------
function send(res, status, body, headers = {}) {
const buf = typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body);
res.writeHead(status, { ...SEC_HEADERS, 'Content-Type': Buffer.isBuffer(body) ? headers['Content-Type'] || 'application/octet-stream' : (typeof body === 'string' ? 'text/plain; charset=utf-8' : 'application/json; charset=utf-8'), 'Cache-Control': 'no-store', ...headers });
res.end(buf);
}
const MAX_BODY = 200 * 1024 * 1024;
// v1.6.31 (검토 10장): 공통 보안 헤더. HSTS 는 공개 주소가 https 일 때만(평문 설치에서 켜면 접속이 막힌다)
const SEC_HEADERS = { 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'X-Frame-Options': 'SAMEORIGIN', ...(/^https:/i.test(process.env.PUBLIC_BASE_URL || '') ? { 'Strict-Transport-Security': 'max-age=15552000' } : {}) };
/** JSON 또는 multipart/form-data 본문을 읽어 { fields, files } 로 돌려준다. */
async function readBody(req) {
const ct = req.headers['content-type'] || '';
const len = parseInt(req.headers['content-length'] || '0', 10);
if (len > MAX_BODY) throw Object.assign(new Error('본문이 너무 큽니다'), { status: 413 });
if (ct.startsWith('multipart/form-data')) {
const r = new Request('http://localhost' + req.url, { method: 'POST', headers: { 'content-type': ct }, body: req, duplex: 'half' });
let fd;
try { fd = await r.formData(); } catch (e) { throw Object.assign(new Error('multipart 파싱 실패: ' + e.message), { status: 400 }); }
const fields = {}; const files = [];
for (const [k, v] of fd.entries()) {
if (typeof v === 'string') fields[k] = v;
else if (v && v.size > 0 && v.name) files.push(v);
}
return { fields, files };
}
const fields = await new Promise((resolve, reject) => {
let data = '';
req.on('data', (c) => { data += c; if (data.length > 6e6) { reject(new Error('본문이 너무 큽니다')); req.destroy(); } });
req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(new Error('JSON 파싱 실패')); } });
req.on('error', reject);
});
return { fields, files: [] };
}
function chmodTree(dir) {
// 파일 644 / 디렉터리 755 로 통일 (nginx 읽기 보장)
// v1.3.17 (S11): 링크는 따라가지 않는다(lstat) — 사이트 밖을 가리키는 링크의 대상 권한을 바꾸지 않게
let st; try { st = fs.lstatSync(dir); } catch (_) { return; }
if (st.isSymbolicLink()) return;
if (st.isDirectory()) {
try { fs.chmodSync(dir, 0o755); } catch (_) {}
for (const f of fs.readdirSync(dir)) chmodTree(path.join(dir, f));
} else { try { fs.chmodSync(dir, 0o644); } catch (_) {} }
}
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('.')) continue;
if (!base && e.name === uploads.UPLOAD_DIR) 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 TEXT_EXT = new Set(['html', 'htm', 'css', 'js', 'mjs', 'json', 'txt', 'md', 'svg', 'xml', 'csv', 'yaml', 'yml']);
const MAX_TEXT_BYTES = 5 * 1024 * 1024;
/** 사이트 안의 텍스트 파일 경로를 검증해 절대 경로를 돌려준다. 실패 시 status 를 가진 Error. */
function resolveTextFile(siteDir, rel) {
rel = decodeURIComponent(rel || '').replace(/\\/g, '/').replace(/^\/+/, '');
if (!rel || rel.split('/').some((seg) => !seg || seg === '..' || seg.startsWith('.'))) throw Object.assign(new Error('잘못된 파일 경로입니다'), { status: 400 });
const ext = path.extname(rel).slice(1).toLowerCase();
if (!TEXT_EXT.has(ext)) throw Object.assign(new Error(`텍스트 파일만 편집할 수 있습니다 (${[...TEXT_EXT].join(', ')})`), { status: 400 });
const abs = path.resolve(siteDir, rel);
if (!abs.startsWith(siteDir + path.sep)) throw Object.assign(new Error('잘못된 파일 경로입니다'), { status: 400 });
// v1.3.17 (S11): 경로 중간이나 파일 자체가 심볼릭 링크면 거부 (글자 경로는 사이트 안이어도 실제로는 밖을 가리킬 수 있음)
{ let cur = siteDir; for (const seg of rel.split('/')) { cur = path.join(cur, seg); let st = null; try { st = fs.lstatSync(cur); } catch (_) { break; } if (st.isSymbolicLink()) throw Object.assign(new Error('잘못된 파일 경로입니다 (링크 파일)'), { status: 400 }); } }
return { abs, rel };
}
function siteUrl(id) { return `${PUBLIC_BASE_URL}/${id}/`; }
/**
* v1.4.5 사용자 첫 화면 페이지 후보: 관리자 계정이 만든 사이트 (관리자도 빌더(/builder)에서 사용자처럼 만들고 배포한다).
* url 은 배포된 운영 주소가 있으면 그것, 없으면 서비스의 사이트 주소(/<사이트ID>/).
*/
function welcomeCandidates() {
const admins = new Set(auth.listUsers().filter((u) => u.role === 'admin').map((u) => u.id)); const out = [];
for (const s0 of store.list()) { const s1 = store.load(s0.id); if (!s1 || !admins.has(s1.ownerId)) continue; const live = liveUrlOf(s1); const hasIndex = fs.existsSync(path.join(SITES_ROOT, s1.id, 'index.html')); out.push({ id: s1.id, title: s1.title, url: live || siteUrl(s1.id), published: !!live, hasIndex }); }
return out;
}
/** 설정된 첫 화면 페이지 (없거나 사이트가 사라졌으면 null) — /api/config 로 빌더에 알린다 */
function welcomePage() { const id = settings.load().welcomeSiteId; if (!id) return null; const c = welcomeCandidates().find((x) => x.id === id); return c && (c.published || c.hasIndex) ? { siteId: c.id, title: c.title, url: c.url } : null; }
/**
* v1.4.0 (요구 9): 서브 도메인 계정은 자기가 등록한 서브 도메인만 사이트에 붙일 수 있다. 어기면 안내 문구, 괜찮으면 null.
* 자기 도메인 계정·관리자는 제한 없음(1.4.0 이전과 같음).
*/
function subdomainOnlyViolation(user, names) {
if (!user || user.role === 'admin' || domainsLib.accountTypeOf(user) !== 'subdomain' || !names || !names.length) return null;
const mine = new Set(domainsLib.listFor(user.id).map((x) => x.name));
const bad = names.filter((n) => !mine.has(String(n).toLowerCase()));
return bad.length ? `서브 도메인 계정은 "도메인" 탭에서 등록한 서브 도메인만 쓸 수 있습니다 (${bad.join(', ')})` : null;
}
/**
* v1.4.0 사용자 "도메인" 탭 API.
* GET /api/domains 계정 종류·플랫폼 도메인·내 도메인·등록자 정보·사용 가능 여부
* POST /api/domains/subdomain/check {name} / POST /api/domains/subdomain {name} 서브 도메인 중복 확인·등록
* POST /api/domains/search {name, tlds} / POST /api/domains/quote {domain, operation, period} (자기 도메인 계정)
* PUT /api/domains/contact {…} 등록자 정보 저장 → Openprovider handle
* POST /api/domains/purchase {domain, period, expectedKrw} POST /api/domains/<id>/renew {period, expectedKrw}
* GET/POST/DELETE /api/domains/<id>/records POST /api/domains/<id>/connect {siteId} POST /api/domains/<id>/mail {siteId}
* GET /api/domains/<id>/propagation DELETE /api/domains/<id> (서브 도메인 해제)
* v1.6.35 (요구 1-2) 도메인 준비 프로세스:
* GET/POST/DELETE /api/domains/cart {domain} 장바구니(가격 다시 받기 / 담기 / 빼기)
* POST /api/domains/external {domain, mode:'ns'|'manual', subdomains, owned:true} 이미 가진 도메인 등록(네임서버 이전 / 직접 DNS 관리)
* POST /api/domains/<id>/transfer-check {confirmed} 네임서버 변경 확인("네임서버 변경했습니다" / 다시 확인)
* GET /api/domains/<id>/required?siteId=&mail=1 배포·메일에 필요한 DNS 레코드 POST /api/domains/<id>/verify {siteId, confirmed, mail} 레코드가 공용 DNS 에 보이는지 확인
* GET /api/domains/<id>/status?siteId= 단계별 상태(소유 → DNS → 사이트 레코드 → 배포 → 메일)와 막힌 이유
*/
async function domainRoutes(req, res, url, user) {
const p = url.pathname; const m = p.match(/^\/api\/domains(?:\/([a-z0-9-]+))?(?:\/([a-z-]+))?$/); if (!m) return send(res, 404, { error: 'not found' });
const [, a, b] = m; const body = async () => (req.method === 'GET' || req.method === 'DELETE' && !req.headers['content-length'] ? {} : (await readBody(req)).fields);
const own = (siteId) => { const s0 = store.ID_RE.test(String(siteId || '')) ? store.load(siteId) : null; if (!s0 || (s0.ownerId !== user.id && user.role !== 'admin')) throw Object.assign(new Error('사이트를 찾을 수 없습니다'), { status: 404 }); return s0; };
if (!a && req.method === 'GET') {
const c = domainsLib.config(); const type = domainsLib.accountTypeOf(user);
const mine = domainsLib.listFor(user.id); const sites = store.list(user.id).map((x) => ({ id: x.id, title: x.title }));
// v1.6.35: allowSubdomain(관리자 설정)·dnsReady(우리 DNS 공급자가 준비돼 네임서버 이전을 받을 수 있나)·cart(장바구니, 저장된 가격)·targetIp 를 함께 보낸다
const dnsReady = (() => { try { return !!require('./lib/dnszone').forZone(null); } catch (_) { return false; } })();
return send(res, 200, { accountType: type, platform: { domain: c.platformDomain, dns: c.platformDns, allowSubdomain: c.allowSubdomain !== false }, tlds: c.tlds, providerReady: require('./lib/registrar').ready(), dnsReady, dnsProviderLabel: dnsReady ? require('./lib/dnszone').LABELS[require('./lib/dnszone').forZone(null).id] : null, providers: domainsLib.publicCatalog(), contact: type === 'domain' ? domainsLib.getContact(user.id) : null, domains: mine, cart: type === 'domain' ? domainsLib.cart(user.id) : [], sites, credit: credits.info(user.id), mailHost: (() => { try { return mailsys.config().mxHost || null; } catch (_) { return null; } })(), targetIp: await domainsLib.targetIp().catch(() => null) });
}
// v1.6.35 (요구 1-2 ①) 장바구니: GET 은 가격·등록 가능 여부를 다시 받는다
if (a === 'cart' && req.method === 'GET') return send(res, 200, { items: await domainsLib.cartRefresh(user) });
if (a === 'cart' && req.method === 'POST') { const f = await body(); return send(res, 200, { items: await domainsLib.cartAdd(user, f.domain) }); }
if (a === 'cart' && req.method === 'DELETE') { const f = (await readBody(req)).fields; return send(res, 200, { items: domainsLib.cartRemove(user, f.domain) }); }
// v1.6.35 (요구 1-2 ②③) 이미 가진 도메인 등록 — 네임서버 이전(ns: 영역 만들고 레코드 옮김) / 직접 DNS 관리(manual)
if (a === 'external' && req.method === 'POST') {
const f = await body();
try { const r = await domainsLib.addExternal(user, f.domain, { mode: f.mode, subdomains: f.subdomains, owned: f.owned === true || f.owned === 'true' }); audit.log({ by: user.id, action: 'domain.external', targetType: 'domain', target: String(f.domain || ''), userId: user.id, detail: { mode: f.mode, imported: r.imported.length, next: r.next } }); return send(res, 201, r); }
catch (e) { audit.log({ by: user.id, action: 'domain.external', targetType: 'domain', target: String(f.domain || ''), userId: user.id, ok: false, detail: { mode: f.mode, error: e.message, status: e.status } }); return send(res, e.status || 500, { error: e.message }); }
}
if (a === 'subdomain' && b === 'check' && req.method === 'POST') { const f = await body(); return send(res, 200, await domainsLib.checkSubdomain(f.name)); }
if (a === 'subdomain' && !b && req.method === 'POST') { const f = await body(); return send(res, 201, await domainsLib.registerSubdomain(user, f.name)); }
if (a === 'search' && req.method === 'POST') { const f = await body(); return send(res, 200, { results: await domainsLib.search(user, f.name, Array.isArray(f.tlds) ? f.tlds : String(f.tlds || '').split(',').filter(Boolean)) }); }
if (a === 'quote' && req.method === 'POST') { const f = await body(); return send(res, 200, await domainsLib.quote(user, String(f.domain || ''), ['create', 'renew', 'transfer'].includes(f.operation) ? f.operation : 'create', Math.min(10, Math.max(1, parseInt(f.period, 10) || 1)))); }
if (a === 'contact' && req.method === 'PUT') { const f = await body(); return send(res, 200, await domainsLib.saveContact(user, f)); }
if (a === 'purchase' && req.method === 'POST') { const f = await body(); try { const r = await domainsLib.purchase(user, String(f.domain || ''), { period: Math.min(10, Math.max(1, parseInt(f.period, 10) || 1)), expectedKrw: f.expectedKrw }); audit.log({ by: user.id, action: 'domain.purchase', targetType: 'domain', target: String(f.domain || ''), userId: user.id, detail: { period: f.period, krw: f.expectedKrw } }); return send(res, 201, r); } catch (e) { audit.log({ by: user.id, action: 'domain.purchase', targetType: 'domain', target: String(f.domain || ''), userId: user.id, ok: false, detail: { error: e.message, status: e.status } }); return send(res, e.status || 500, { error: e.message, quote: e.quote || undefined }); } }
if (a && /^[a-f0-9]{16}$/.test(a)) {
const id = a;
if (b === 'renew' && req.method === 'POST') { const f = await body(); try { const r = await domainsLib.renew(user, id, { period: Math.min(10, Math.max(1, parseInt(f.period, 10) || 1)), expectedKrw: f.expectedKrw }); audit.log({ by: user.id, action: 'domain.renew', targetType: 'domain', target: id, userId: user.id, detail: { period: f.period, krw: f.expectedKrw } }); return send(res, 200, r); } catch (e) { audit.log({ by: user.id, action: 'domain.renew', targetType: 'domain', target: id, userId: user.id, ok: false, detail: { error: e.message } }); return send(res, e.status || 500, { error: e.message, quote: e.quote || undefined }); } }
if (b === 'records' && req.method === 'GET') return send(res, 200, { records: await domainsLib.records(user, id) });
if (b === 'records' && req.method === 'POST') { const f = await body(); return send(res, 200, { records: await domainsLib.addRecord(user, id, f) }); }
if (b === 'records' && req.method === 'DELETE') { const f = (await readBody(req)).fields; return send(res, 200, { records: await domainsLib.removeRecord(user, id, f) }); }
if (b === 'propagation' && req.method === 'GET') return send(res, 200, await domainsLib.propagation(id));
// v1.6.35 (요구 1-2 ②) 네임서버 변경 확인 — confirmed: 사용자가 "바꿨다" 고 확인(시각 기록 후 실제 확인)
if (b === 'transfer-check' && req.method === 'POST') { const f = await body(); const r = await domainsLib.transferCheck(user, id, { confirmed: f.confirmed === true || f.confirmed === 'true' }); audit.log({ by: user.id, action: 'domain.transfer-check', targetType: 'domain', target: id, userId: user.id, detail: { step: r.step, done: r.done, confirmed: !!f.confirmed } }); return send(res, 200, r); }
// v1.6.35 (요구 1-2 ③) 필요한 DNS 설정 / 설정 완료 확인 — siteId 가 있으면 그 사이트의 메일 설정(MX 호스트·Resend)을 기준으로
if (b === 'required' && req.method === 'GET') { const sid = url.searchParams.get('siteId'); const site = sid ? own(sid) : null; return send(res, 200, await domainsLib.requiredRecords(user, id, { site, mail: url.searchParams.get('mail') === '1' })); }
if (b === 'verify' && req.method === 'POST') { const f = await body(); const site = f.siteId ? own(f.siteId) : null; const r = await domainsLib.verifyManual(user, id, { site, confirmed: f.confirmed === true || f.confirmed === 'true', mail: f.mail === true || f.mail === 'true' }); audit.log({ by: user.id, action: 'domain.verify', targetType: 'domain', target: id, userId: user.id, detail: { siteOk: r.siteOk, mailOk: r.mailOk, confirmed: !!f.confirmed } }); return send(res, 200, r); }
// v1.6.35 (요구 1-2 ④) 단계별 상태 — 연결된 사이트(siteId 또는 기록의 siteId)의 배포·메일 상태까지
if (b === 'status' && req.method === 'GET') { const rec = domainsLib.listFor(user.id).find((x) => x.id === id); const sid = url.searchParams.get('siteId') || (rec && rec.siteId); let site = null; try { site = sid ? own(sid) : null; } catch (_) { site = null; } return send(res, 200, await domainsLib.status(user, id, site)); }
if (b === 'connect' && req.method === 'POST') {
const f = await body(); const site = own(f.siteId); if (jobOf(site.id).running || jobOf(site.id).deploying) return send(res, 409, { error: '작업·배포가 끝난 뒤 연결하세요' });
const r = await domainsLib.connect(user, id, site, { host: f.host }); // v1.7.2: 하위 이름(host) 선택
store.save(site); audit.log({ by: user.id, action: 'domain.connect', targetType: 'domain', target: id, userId: user.id, siteId: site.id, detail: { names: r.domains, host: r.host } });
broadcast(site.id, { type: 'log', level: 'info', text: `도메인 연결: ${r.domains.join(', ')} — 다음 발행·재배포 때 웹서버 설정과 SSL 이 적용됩니다` });
return send(res, 200, { ...r, site: publicView(site) });
}
// v1.7.2 (요구 1): 사이트 연결 끊기 — DNS 레코드는 남고 사이트의 주소 목록에서만 빠진다(다음 발행 때 웹서버 설정에서 빠짐)
if (b === 'disconnect' && req.method === 'POST') {
const f = await body(); const site = own(f.siteId); if (jobOf(site.id).running || jobOf(site.id).deploying) return send(res, 409, { error: '작업·배포가 끝난 뒤 끊으세요' });
try { const r = domainsLib.disconnect(user, id, site); store.save(site); audit.log({ by: user.id, action: 'domain.disconnect', targetType: 'domain', target: id, userId: user.id, siteId: site.id, detail: { remaining: r.domains } }); return send(res, 200, { ...r, site: publicView(site) }); }
catch (e) { return send(res, e.status || 500, { error: e.message }); }
}
if (b === 'mail' && req.method === 'POST') {
const f = await body(); const site = f.siteId ? own(f.siteId) : null;
const r = await domainsLib.setupMail(user, id, site);
// 메일 시스템 켜기: 운영자 설정이 허용하면(도메인 설정 userMail) 그 사이트의 메일 시스템을 이 도메인으로 켠다
if (site && domainsLib.config().userMail !== false) { try { const rec = domainsLib.listFor(user.id).find((x) => x.id === id); await mailsys.enableSite(site, { domain: rec.name }, user.id); try { mailsys.syncStub(site, path.join(SITES_ROOT, site.id), formBase()); } catch (_) {} store.save(site); } catch (e) { r.mailNote = e.message; } }
return send(res, 200, { ...r, propagation: await domainsLib.propagation(id) });
}
if (!b && req.method === 'DELETE') return send(res, 200, domainsLib.release(user, id));
}
return send(res, 404, { error: 'not found' });
}
/**
* v1.7.2 (요구 1): 메일 화면 "한눈에 보기" 에 필요한 값 — 계정 형식(아이디@도메인), 접속 주소(웹메일·메일 서버 웹메일·IMAP/POP/SMTP), 초기 관리자(사이트 관리자 계정과 그 메일 상태).
* 비밀값은 넣지 않는다(비밀번호·서버 관리자 로그인은 제외). 메일 시스템이 꺼져 있으면 빈 값.
* @param {object} site @param {object} st mailsys.status(site)
*/
function mailAtAGlance(site, st) {
if (!st || !st.enabled) return { accountFormat: null, server: null, admins: [], accounts: [] };
const ms = st.mailServer || null;
let admins = []; try { admins = (require('./lib/modules').load(site.id).members || []).filter((m) => m.role === 'admin' && m.loginId).map((m) => ({ id: m.id, loginId: m.loginId, name: m.name || '' })); } catch (_) {}
let accounts = []; try { accounts = mailsys.listAccounts({ siteId: site.id }).map((a) => ({ memberId: a.memberId, login: a.login, address: a.address, state: a.state || null })); } catch (_) {}
const byMember = Object.fromEntries(accounts.map((a) => [a.memberId, a]));
return {
accountFormat: st.domain ? `아이디@${st.domain}` : null,
server: ms ? { host: ms.host || null, nickname: ms.nickname || null, webmail: ms.webmail || null, client: ms.client || null, ok: ms.ok !== false } : null,
admins: admins.map((m) => ({ ...m, mail: byMember[m.id] ? { address: byMember[m.id].address, state: byMember[m.id].state } : null })),
accounts: accounts.length,
};
}
/**
* 사이트 응답 뷰. 사용자에게는 내부 정보(모델명, 배포 서버 이름/ID, 명령 프롬프트)를 숨긴다.
* @param {object} site
* @param {{admin?: boolean}} [opts] admin=true 면 배포 서버 정보를 포함(관리자 콘솔용)
*/
function publicView(site, opts = {}) {
const g = site.git || { seq: 0, changes: [] };
const versions = (g.changes || []).map((c) => ({ no: c.no, kind: c.kind, message: c.message, at: c.at, commit: c.commit, files: c.files || [], stat: c.stat || '' }));
const dl = deploy.list(); const tid = site.deployTargetId || dl.defaultId; const tgt = tid ? dl.targets.find((t) => t.id === tid) : null;
const deployInfo = site.source
? { targetId: 'source', targetName: `원본 서버 ${site.source.user}@${site.source.host}:${site.source.docroot || '?'}`, targetUrl: site.source.url || null, source: true, last: (site.deployments || []).slice(-1)[0] || null, history: (site.deployments || []).slice(-20) }
: { targetId: opts.admin ? (tid || null) : null, targetName: opts.admin ? (tgt ? tgt.name : null) : null, targetUrl: tgt ? deploy.siteUrlOn(tgt, site.id) : null, assigned: !!site.deployTargetId, last: (site.deployments || []).slice(-1)[0] || null, history: (site.deployments || []).slice(-20).map((d) => (opts.admin ? d : { ...d, targetId: undefined, targetName: undefined })) };
return { ...site, modelRef: undefined, commands: (site.commands || []).map(stripCmd), tier: settings.validTier(site.tier), credit: credits.info(site.ownerId), liveUrl: liveUrlOf(site), editLimit: editLimitOf(site), deployState: deployState(site), unpublishedAt: site.unpublishedAt || null, contactEmail: site.contactEmail || ((site.plan && site.plan.brief && site.plan.brief.facts && site.plan.brief.facts.email && site.plan.brief.facts.email.status === 'provided') ? site.plan.brief.facts.email.value : ''), formEndpoint: `${formBase()}/api/forms/${site.id}/contact`, mail: (() => { try { const st = mailsys.status(site); return { level: st.level, label: st.label, message: st.message, domain: st.domain, webmailUrl: st.enabled ? `${formBase()}/webmail/${site.id}/` : null, ...mailAtAGlance(site, st) }; } catch (_) { return null; } })(), domains: site.domains || [], mode: site.mode || 'quick', source: remote.publicSource(site.source), importError: site.importError || null, plan: site.plan || null, attachments: site.attachments || [], versions, deploy: deployInfo, git: { ...gitstore.info(site.id), seq: g.seq, changes: g.changes }, url: siteUrl(site.id), files: listFiles(path.join(SITES_ROOT, site.id)) };
}
/**
* 사이트의 "운영 주소": 배포가 완료된 서버의 주소. 도메인(https 우선) → 배포 서버 공개 URL → 가져온 사이트의 원본 주소 → 없으면 null.
* 배포 전에는 null 이며, 화면은 그때만 32자리 미리보기 주소를 보여 준다.
*/
function liveUrlOf(site) {
if (site.source && site.source.url) return site.source.url;
const last = [...(site.deployments || [])].reverse().find((d) => d.ok && d.kind !== 'unpublish');
if (!last) return null;
const urls = (last.domainUrls || []).slice().sort((a, b) => (b.startsWith('https') ? 1 : 0) - (a.startsWith('https') ? 1 : 0));
const ok = (last.access && last.access.items ? last.access.items.filter((i) => i.ok).map((i) => i.url) : []);
// v1.6.31 (요구 14): 접속 확인에 성공한 주소만 운영 주소로. 확인된 것이 없으면 공개 주소(있을 때)만 — 열리지 않는 도메인을 운영 주소로 보이지 않는다
return urls.find((u) => ok.includes(u)) || (ok.find((u) => u === last.url) ? last.url : null) || (last.phase === 'ready' || last.phase == null ? (urls[0] || last.url || null) : (last.url || null));
}
/** AI에게 맡기기 사이트의 수정 횟수 한도 상태 (관리자 설정 maxEdits) */
function editLimitOf(site) {
if (!site.plan || site.plan.mode !== 'ai') return null;
const max = settings.load().maxEdits || 0; const used = site.plan.editsSincePublish || 0;
return { max, used, locked: max > 0 && used >= max };
}
// ---------- git 보관 ----------
function gitAuthor(site) { return { name: site.ownerName || site.ownerId || 'webhosting-agent', email: `${site.ownerId || 'agent'}@webhosting.local` }; }
/** 변경을 커밋·푸시하고 site.git 에 기록한다. 실패해도 예외를 던지지 않는다. */
async function gitRecord(site, kind, message) {
site.git = site.git || { seq: 0, changes: [] };
const no = site.git.seq + 1;
try {
const rec = await gitstore.recordChange({ siteId: site.id, siteDir: path.join(SITES_ROOT, site.id), no, kind, message, author: gitAuthor(site) });
if (!rec) return null;
if (site.plan && site.plan.brief) rec.briefSnapshot = JSON.parse(JSON.stringify(site.plan.brief));
site.git.seq = no; site.git.changes.push(rec); store.save(site);
broadcast(site.id, { type: 'log', level: 'info', text: `버전 #${no} 저장됨 (${message})` });
const failed = site.git.changes.filter((c) => !c.pushed && c !== rec);
if (failed.length) gitstore.retryPush(site.id, path.join(SITES_ROOT, site.id), failed).then(() => store.save(site)).catch(() => {});
return rec;
} catch (e) {
broadcast(site.id, { type: 'log', level: 'debug', text: `버전 저장 실패: ${e.message}` });
return null;
}
}
// ---------- 작업 실행 ----------
async function runCommand(site, kind, instruction, prompt, attachedNames = [], opts = {}) {
// v1.4.14 (요구 7): 이번 작업 모델. 직전 작업과 다른 모델(특히 다른 제공자)이면 그 사이 다른 모델이 한 작업을 프롬프트에 넣어 맥락을 잇는다
const modelRef = settings.tierRef(opts.tier || site.tier);
const switchNote = engineSwitchSection(site, modelRef);
// v1.2.0: 생성 코드에 빌더 화면 언어로 상세 주석 (가져온·복사한 사이트는 고친 부분에만)
// 규칙은 앞에(필수 요구), 확인 한 줄은 끝에 — 끝에만 두면 긴 위젯 코드 뒤라 모델이 놓친다
if (!opts.continuation) { const ul = site.uiLang || 'ko'; const ex = site.mode === 'import' || site.mode === 'copy'; prompt = `${commentRule(ul, ex)}\n${multilang.isMulti(site) ? multilang.promptRules(site) + '\n' : ''}${switchNote ? switchNote + '\n' : ''}${prompt}\n\n${commentReminder(ul, ex)}`; }
else if (switchNote) prompt = `${switchNote}\n${prompt}`; // 캐시 충전 후 이어하기도 그 사이 모델이 바뀌었을 수 있다
const job = jobOf(site.id);
if (job.running) throw Object.assign(new Error('이미 실행 중인 작업이 있습니다'), { status: 409 });
if (job.deploying) throw Object.assign(new Error('배포가 진행 중입니다. 완료 후 다시 시도하세요'), { status: 409 });
assertJobCapacity(); // v1.6.31 동시 작업 상한
job.running = true; job.log = []; job.cancelRequested = false;
const ownerAdmin = isAdminUser(site.ownerId);
const startBalance = credits.balance(site.ownerId);
if (!ownerAdmin && startBalance <= 0) { job.running = false; throw Object.assign(new Error('캐시가 부족합니다. 관리자에게 캐시 충전을 요청하세요.'), { status: 402, creditStop: true }); }
job.creditStop = false;
try { await assertEngine(settings.tierRef(opts.tier || site.tier)); } catch (e) { job.running = false; if (e.adminDetail) broadcast(site.id, { type: 'log', level: 'debug', text: `엔진 준비 실패: ${e.adminDetail}` }); throw e; }
const cmd = {
no: site.commands.length + 1, kind, instruction, prompt, model: settings.tierRef(opts.tier || site.tier), tier: settings.validTier(opts.tier || site.tier),
startedAt: new Date().toISOString(), finishedAt: null, status: 'running',
usage: null, costUsd: 0, numTurns: 0, durationMs: 0, resultText: '', error: null,
attachments: attachedNames,
};
for (const a of site.attachments || []) if (!a.usedInCommands.length || attachedNames.includes(a.name)) a.usedInCommands.push(cmd.no);
cmd.log = []; job.currentCmd = cmd; cmd.userIdForEstimate = site.ownerId;
site.commands.push(cmd); site.status = 'running'; store.save(site);
broadcast(site.id, { type: 'status', status: 'running', commandNo: cmd.no, label: kind === 'create' ? '페이지 생성' : '수정 반영' });
broadcast(site.id, { type: 'log', level: 'info', text: `${kind === 'create' ? '생성' : '수정'} 작업 #${cmd.no} 시작 — 모델 ${cmd.model}` });
if (switchNote) { const prev = [...site.commands].reverse().find((c) => c !== cmd && c.model && ['create', 'update'].includes(c.kind)); broadcast(site.id, { type: 'log', level: 'info', text: `모델 변경 ${prev ? shortModel(prev.model) : '?'} → ${shortModel(cmd.model)} — 그 사이 다른 모델이 한 작업을 맥락으로 함께 전달했습니다 (세션 기억에 의존하지 않음)` }); }
const siteDir = path.join(SITES_ROOT, site.id);
await primeRate(job);
broadcast(site.id, { type: 'progress', text: '작업을 준비하고 있습니다' });
try {
const r = await runModel({
ref: cmd.model, userId: site.ownerId,
siteDir, prompt, systemPrompt: SYSTEM_PROMPT, toolset: 'files',
sessionId: site.sessionId, resume: opts.resume !== undefined ? opts.resume : kind !== 'create',
onEvent: (ev) => {
const e = liveUsageEvent(job, cmd, ev);
if (e.type === 'usage' && e.krwEstimate != null) {
e.creditLeft = startBalance - e.krwEstimate;
// 캐시 한도 도달: 작업을 멈추고(지금까지의 파일은 유지) 충전될 때까지 대기
if (!ownerAdmin && creditExhausted(startBalance, e.creditLeft) && !job.creditStop) { job.creditStop = true; job.cancelRequested = true; if (job.cancel) job.cancel(); }
}
broadcast(site.id, e);
},
onStart: (h) => { job.cancel = h.cancel; if (job.cancelRequested) h.cancel(); },
});
cmd.userKey = r.source === 'user';
if (opts.tier && settings.TIERS.some((t) => t.id === opts.tier)) site.tier = opts.tier; // 마지막으로 쓴 등급을 기억
chmodTree(siteDir);
Object.assign(cmd, {
finishedAt: new Date().toISOString(),
status: r.ok ? 'done' : 'error',
usage: r.usage, costUsd: r.costUsd, numTurns: r.numTurns, durationMs: r.durationMs,
// v1.4.5 이번 실행 비용의 근거 (lib/claude.js runCost) — 세션 누적값을 그대로 청구하던 문제 수정 후 기록
costSource: r.costSource || 'priced', costCumulativeUsd: r.costCumulativeUsd, costPricedUsd: r.costPricedUsd,
// v1.5.7: 엔진 이름을 결과에 따라 (codex 실패가 "Claude 종료: error" 로 남아 원인을 찾기 어려웠음 — 2026-10-14 운영 로그)
resultText: r.resultText, error: r.ok ? null : `${r.engine === 'codex' ? 'Codex' : r.engine === 'openai' ? 'AI 엔진' : 'Claude'} 종료: ${r.subtype}${r.errorText ? ' — ' + r.errorText : ''}`,
modelUsage: r.modelUsage, model: r.model || cmd.model,
});
Object.assign(cmd, await chargeFor(r.costUsd, cmd.startedAt, cmd.userKey));
costcal.observe(cmd.model, estimateUsdFor(r.usage, cmd.model), r.costUsd);
deductCredit(site, cmd, `${kind === 'create' ? '생성' : '수정'} #${cmd.no}`);
if (r.sessionId) site.sessionId = r.sessionId;
site.status = r.ok ? 'idle' : 'error';
if (opts.onResult) { try { await opts.onResult(r, cmd); } catch (e) { broadcast(site.id, { type: 'log', level: 'debug', text: `후처리 오류: ${e.message}` }); } }
store.save(site);
if (r.ok) broadcast(site.id, { type: 'log', level: 'info', text: `작업 #${cmd.no} 완료 — 토큰 ${r.usage.total.toLocaleString()} (입력 ${r.usage.input.toLocaleString()} / 출력 ${r.usage.output.toLocaleString()} / 캐시생성 ${r.usage.cacheCreate.toLocaleString()} / 캐시읽기 ${r.usage.cacheRead.toLocaleString()}), 비용 $${r.costUsd.toFixed(4)}` });
else userWarn(site.id, '작업을 완료하지 못했습니다. 잠시 후 다시 시도해 주세요.', `작업 #${cmd.no} 실패: ${cmd.error}${r.permissionDenials && r.permissionDenials.length ? ' / 권한 거부: ' + JSON.stringify(r.permissionDenials).slice(0, 500) : ''}`);
} catch (err) {
chmodTree(siteDir);
if (err.cancelled && job.creditStop) {
// 캐시 소진: 지금까지의 작업을 저장하고 충전될 때까지 대기 (충전되면 자동으로 이어서 진행)
Object.assign(cmd, { finishedAt: new Date().toISOString(), status: 'paused-credit', error: '캐시 소진으로 일시 중지', usage: err.usage, usageEstimated: true, costUsd: estimateUsdCal(err.usage, cmd.model) });
Object.assign(cmd, await chargeFor(cmd.costUsd, cmd.startedAt));
deductCredit(site, cmd, `${kind === 'create' ? '생성' : '수정'} #${cmd.no} (일시 중지)`);
site.status = 'waiting-credit';
site.creditWait = { at: new Date().toISOString(), kind, instruction: opts.continuation ? instruction.replace(/^\(이어서\) /, '') : instruction, prompt: opts.originalPrompt || prompt, cmdNo: cmd.no };
store.save(site);
broadcast(site.id, { type: 'log', level: 'warn', text: '캐시가 모두 소진되어 작업을 잠시 멈췄습니다. 지금까지의 작업은 저장되었고, 캐시가 충전되면 남은 작업을 자동으로 이어서 진행합니다.' });
} else if (err.cancelled) {
Object.assign(cmd, { finishedAt: new Date().toISOString(), status: 'cancelled', error: err.message, usage: err.usage, usageEstimated: true, costUsd: estimateUsdCal(err.usage, cmd.model) });
Object.assign(cmd, await chargeFor(cmd.costUsd, cmd.startedAt));
deductCredit(site, cmd, `${kind === 'create' ? '생성' : '수정'} #${cmd.no} (중단)`);
site.status = 'idle';
if (site.plan && site.plan.stage === 'generating') site.plan.stage = site.commands.some((c) => c.kind === 'create' && c.status === 'done') ? 'generated' : 'confirmed';
store.save(site);
broadcast(site.id, { type: 'log', level: 'warn', text: '작업이 중단되었습니다. 지금까지 만들어진 내용은 유지됩니다.' });
} else {
Object.assign(cmd, { finishedAt: new Date().toISOString(), status: 'error', error: err.message });
site.status = 'error'; store.save(site);
userWarn(site.id, '작업을 완료하지 못했습니다. 잠시 후 다시 시도해 주세요.', `작업 #${cmd.no} 오류: ${err.message}`);
}
}
job.cancel = null; job.currentCmd = null;
// v1.3.10 다국어: 기본 언어 페이지가 바뀌었으면 다른 언어 폴더를 같은 구조로 번역·복사 (작업 잠금 안에서)
if (cmd.status === 'done' && multilang.isMulti(site)) await syncLanguages(site).catch((e) => userWarn(site.id, `다른 언어 페이지를 만들지 못했습니다: ${e.message}`, `다국어 맞추기 실패: ${e.message}`));
const gmsg = cmd.status === 'paused-credit' ? `캐시 소진으로 일시 중지된 작업 저장 #${cmd.no}` : (kind === 'create' ? `사이트 생성: ${site.title}` : `AI 수정 #${cmd.no}: ${instruction.slice(0, 80)}`);
broadcast(site.id, { type: 'progress', text: '버전을 저장하고 있습니다' });
try { stats.ensureBeacon(siteDir, site.id, formBase()); } catch (_) {} // 접속 통계 비컨(모든 HTML)
cmd.git = await gitRecord(site, kind === 'create' ? 'create' : 'ai-update', gmsg);
store.save(site);
job.running = false;
broadcast(site.id, { type: 'done', site: publicView(site), command: stripCmd(cmd) });
if (cmd.status === 'paused-credit') broadcast(site.id, { type: 'status', status: 'waiting-credit', label: kind === 'create' ? '페이지 생성' : '수정 반영' });
return cmd;
}
/**
* v1.3.16 방문자에게 보이는 모듈 설정 값(문의 유형·게시판·후기 분류 등 관리자가 정한 목록) — 다른 언어 페이지에서 번역해 보이도록
* 다국어 번역 대상에 넣는다(public/wm-modules.js LV()).
*/
function moduleValues(site) {
const out = []; let d; try { d = modules.load(site.id); } catch (_) { return out; }
for (const [m, st] of Object.entries(d.settings || {})) { if (!d.enabled[m]) continue; for (const [k, v] of Object.entries(st || {})) if (Array.isArray(v) && /categor|tags/i.test(k)) for (const x of v) if (typeof x === 'string' && x.trim()) out.push(x.trim()); }
return [...new Set(out)].slice(0, 200);
}
/**
* v1.3.10 다국어 맞추기: 기본 언어 폴더(예: ko/)의 모든 페이지를 다른 언어 폴더(en/, ja/ …)로 글자만 번역해 복사한다.
* 번역은 기본 등급 모델로 문장 배열만 보내는 짧은 호출(파일 도구 없음)이고, 이미 번역한 문장은 다시 보내지 않는다(lib/multilang 저장소).
* 비용은 "다국어 번역" 작업 기록으로 남기고 사용자 캐시에서 차감한다. 생성·수정 작업 잠금(job.running) 안에서 부른다.
*/
async function syncLanguages(site) {
const siteDir = path.join(SITES_ROOT, site.id); const ref = settings.tierRef('basic'); const startedAt = new Date().toISOString();
const usage = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0, total: 0 }; let usd = 0; let userKey = false; let calls = 0;
const b = (site.plan && site.plan.brief) || {}; const ctx = [site.title, b.business && b.business.industry && [b.business.industry.major, b.business.industry.minor].filter(Boolean).join(' > ')].filter(Boolean).join(' — ');
const translate = async (texts, to, from) => {
const { data, result: r } = await planner.runJson({ siteDir, prompt: multilang.translatePrompt(texts, from || site.langs.default, to, ctx), ref, userId: site.ownerId, toolset: 'none' });
calls++; usd += r.costUsd || 0; userKey = userKey || r.source === 'user'; for (const k of Object.keys(usage)) usage[k] += (r.usage && r.usage[k]) || 0;
return Array.isArray(data && data.t) ? data.t : [];
};
broadcast(site.id, { type: 'progress', text: '다른 언어 페이지를 만들고 있습니다' });
const res = await multilang.sync(site, siteDir, translate, (text) => broadcast(site.id, { type: 'progress', text }), { values: moduleValues(site) });
chmodTree(siteDir);
if (calls) {
const cmd = { no: site.commands.length + 1, kind: 'site-translate', instruction: '다국어 번역', prompt: '', model: ref, tier: 'basic', startedAt, finishedAt: new Date().toISOString(), status: 'done', usage, costUsd: usd, costSource: 'sum', numTurns: calls, durationMs: Date.now() - Date.parse(startedAt), resultText: JSON.stringify(res).slice(0, 2000), error: null, attachments: [], userKey };
Object.assign(cmd, await chargeFor(usd, startedAt, userKey)); site.commands.push(cmd); deductCredit(site, cmd, '다국어 번역');
}
try { seo.materialize(site, siteDir, liveBase(site)); } catch (e) { broadcast(site.id, { type: 'log', level: 'debug', text: `언어별 SEO 반영 실패: ${e.message}` }); } // 언어 페이지마다 그 언어의 검색 값·대체 링크
site.langs.syncedAt = new Date().toISOString(); store.save(site);
broadcast(site.id, { type: 'log', level: 'info', text: `다국어 페이지 맞춤 완료 — ${site.langs.list.map((c) => c + '/').join(' ')} (페이지 ${res.pages}개${res.translated ? `, 새로 번역한 문장 ${res.translated}개` : ''})` });
return res;
}
/** v1.3.16 다국어 URL 원칙 (관리 탭 다국어 카드·README 에 같은 문장) */
const LOCALE_URL_PRINCIPLE = '다국어 사이트의 주소는 로케일 코드로 구분합니다: /<로케일 코드>/<페이지> (예: /ko/, /en/, /zh-CN/, /pt-PT/). 로케일 코드는 BCP 47 언어 태그(언어 코드, 필요할 때만 지역 코드)이고 기본 언어도 폴더를 씁니다. 사이트 맨 위 주소(/)는 언어 선택 페이지이며, 어떤 언어에도 맞지 않는 방문자는 x-default 로 정한 언어로 갑니다.';
/**
* v1.3.16 다국어 설정 저장 (AI 없음): x-default, 사업장 현지 설정(시간대·통화·국가), 사이트 관리 언어(사이트 관리자 화면·알림 메일).
* 저장한 뒤 언어 선택 페이지와 현지 형식·검색 값(hreflang x-default)을 다시 쓴다.
* @param {object} b { xDefault, timeZone, currency, country, adminLang }
*/
async function saveLangSettings(site, b) {
const job = jobOf(site.id); if (job.running || job.deploying) throw Object.assign(new Error('다른 작업이 진행 중입니다. 끝난 뒤 하세요'), { status: 409 });
const siteDir = path.join(SITES_ROOT, site.id);
if (b.adminLang !== undefined) { const c = multilang.codeOf(b.adminLang); site.adminLang = c; }
const loc = { ...(site.locale || {}) };
if (b.timeZone !== undefined) { const tz = String(b.timeZone || '').trim(); if (tz) { try { new Intl.DateTimeFormat('en', { timeZone: tz }); } catch (_) { throw Object.assign(new Error(`시간대 이름이 올바르지 않습니다: ${tz} (예: Asia/Seoul)`), { status: 400 }); } loc.timeZone = tz; } else delete loc.timeZone; }
if (b.currency !== undefined) { const c = String(b.currency || '').trim().toUpperCase(); if (c && !/^[A-Z]{3}$/.test(c)) throw Object.assign(new Error('통화는 ISO 4217 세 글자 코드로 입력하세요 (예: KRW, USD)'), { status: 400 }); if (c) loc.currency = c; else delete loc.currency; }
if (b.country !== undefined) { const c = String(b.country || '').trim().toUpperCase(); if (c && !/^[A-Z]{2}$/.test(c)) throw Object.assign(new Error('국가는 ISO 3166 두 글자 코드로 입력하세요 (예: KR)'), { status: 400 }); if (c) loc.country = c; else delete loc.country; }
site.locale = loc;
if (multilang.isMulti(site) && b.xDefault !== undefined) {
const x = String(b.xDefault || ''); if (x !== 'root' && !site.langs.list.includes(x)) throw Object.assign(new Error('x-default 는 사이트의 언어 중 하나이거나 "언어 선택 페이지" 여야 합니다'), { status: 400 });
site.langs.xDefault = x; site.langs.forceRoot = false;
const rootFp = path.join(siteDir, 'index.html'); let cur = null; try { cur = fs.readFileSync(rootFp, 'utf8'); } catch (_) {}
if (cur === null || cur.includes(multilang.ROOT_MARK)) fs.writeFileSync(rootFp, multilang.rootPage(site, site.langs.list, site.langs.default, multilang.xDefaultOf(site)), { mode: 0o644 });
}
// 현지 형식(시간대·통화)을 언어 폴더마다 다시 쓴다 — 번역은 하지 않는다(원래 값은 data-wm-* 속성에 있음)
const changed = [];
const dirs = multilang.isMulti(site) ? site.langs.list : [null];
for (const c of dirs) { const dir = c ? path.join(siteDir, c) : siteDir; for (const f of multilang.htmlUnder(dir)) { if (!c && /^(webmail|uploads)\//.test(f)) continue; const fp = path.join(dir, f); const h = fs.readFileSync(fp, 'utf8'); const out = multilang.renderFormats(h, c || contentLang(site), { site, home: multilang.siteLocale(site) }); if (out !== h) { fs.writeFileSync(fp, out, { mode: 0o644 }); changed.push(c ? `${c}/${f}` : f); } } }
try { const mr = seo.materialize(site, siteDir, liveBase(site)); changed.push(...mr.changed); } catch (_) {}
store.save(site);
if (changed.length) await gitRecord(site, 'ai-update', '다국어·현지 설정 변경');
broadcast(site.id, { type: 'done', site: publicView(site), command: null });
return { ok: true, langs: site.langs || null, locale: multilang.siteLocale(site), adminLang: ownerLang(site), changed: changed.length };
}
/**
* v1.3.10 언어 바꾸기 (관리 탭 › 다국어).
* - 이미 다국어인 사이트: 언어를 더하거나 빼고(기본 언어는 그대로) 다른 언어 폴더를 다시 맞춘다. 뺀 언어 폴더는 지운다.
* - 단일 언어 사이트: AI 수정 작업으로 지금 페이지를 기본 언어 폴더(ko/)로 옮기고 공용 파일을 assets/ 로 정리한 뒤, 다른 언어를 만든다.
* - 원본 서버에서 가져온 사이트(사이트 관리하기)는 원래 구조로 되돌려 반영해야 하므로 바꾸지 않는다.
*/
async function changeLanguages(site, b, user) {
const job = jobOf(site.id); if (job.running || job.deploying) throw Object.assign(new Error('다른 작업이 진행 중입니다. 끝난 뒤 하세요'), { status: 409 });
if (site.source) throw Object.assign(new Error('원본 서버에서 가져온 사이트는 구조를 바꿀 수 없어 다국어로 만들 수 없습니다. "사이트 복사하기" 로 새 사이트를 만든 뒤 사용하세요'), { status: 409 });
if (site.plan && ['input', 'analyzing', 'understanding', 'brief', 'structure', 'design', 'confirmed', 'generating', 'importing'].includes(site.plan.stage)) throw Object.assign(new Error('사이트 생성이 끝난 뒤 언어를 추가할 수 있습니다'), { status: 409 });
const siteDir = path.join(SITES_ROOT, site.id); const was = site.langs || null;
const next = multilang.normalize({ default: was ? was.default : b.default, list: b.list, xDefault: b.xDefault });
if (next.list.length < 2) throw Object.assign(new Error('추가 언어를 하나 이상 고르세요 (다국어 사이트를 단일 언어로 되돌리는 것은 지원하지 않습니다)'), { status: 400 });
if (was && was.list.length > 1) {
for (const c of was.list) if (!next.list.includes(c)) { try { fs.rmSync(path.join(siteDir, c), { recursive: true, force: true }); } catch (_) {} } // 뺀 언어 폴더
site.langs = { ...was, list: next.list, xDefault: b.xDefault !== undefined ? next.xDefault : (next.list.includes(was.xDefault) || was.xDefault === 'root' ? was.xDefault : next.default) }; store.save(site);
job.running = true;
(async () => { try { await syncLanguages(site); await gitRecord(site, 'ai-update', `다국어 언어 변경: ${next.list.join(', ')}`); } catch (e) { userWarn(site.id, `다른 언어 페이지를 만들지 못했습니다: ${e.message}`, `다국어 맞추기 실패: ${e.message}`); } finally { job.running = false; broadcast(site.id, { type: 'done', site: publicView(site), command: null }); } })();
return { ok: true, mode: 'sync', langs: site.langs };
}
// 단일 언어 → 다국어: AI 가 구조를 옮긴다 (실패하면 원래대로)
if (!listFiles(siteDir).some((f) => /\.html?$/i.test(f))) throw Object.assign(new Error('사이트에 HTML 페이지가 없습니다'), { status: 409 });
site.langs = { ...next, forceRoot: true }; store.save(site); const d = next.default;
const prompt = `이 사이트를 다국어 사이트 구조로 옮깁니다. 내용·디자인·문구는 바꾸지 말고 파일 위치와 참조 경로만 바꾸세요.
1) 지금의 모든 HTML 페이지를 "${d}/" 폴더로 옮깁니다(파일 이름·하위 경로 그대로). 사이트 맨 위에 남는 index.html 은 지웁니다(언어 선택 페이지는 서비스가 만듭니다).
2) CSS·JS·이미지·글꼴 등 공용 파일은 "assets/" 폴더로 옮기고(uploads/ 폴더는 그대로 둠), 모든 페이지의 참조를 "../assets/…", "../uploads/…" 로 고칩니다. 페이지끼리의 링크는 같은 폴더 상대 경로로 둡니다.
3) 각 페이지 머리글에 언어 선택 자리 <nav data-wm-langs aria-label="Language"></nav> 를 넣습니다.
4) 옮긴 뒤 Glob 으로 ${d}/ 와 assets/ 를 확인하고, 깨진 참조가 없는지 점검합니다.`;
runCommand(site, 'update', `다국어 구조로 옮기기 (${next.list.join(', ')})`, prompt, [], { tier: 'basic' })
.then((cmd) => { if (!cmd || cmd.status !== 'done' || !fs.existsSync(path.join(siteDir, d, 'index.html'))) { if (cmd && cmd.status === 'done') userWarn(site.id, `페이지를 ${d}/ 폴더로 옮기지 못해 다국어 전환을 취소했습니다. 다시 시도해 주세요.`, `다국어 전환: ${d}/index.html 없음`); site.langs = was; store.save(site); broadcast(site.id, { type: 'done', site: publicView(site), command: null }); return; }
// 전환 뒤 맨 위에 남은 옛 파일 정리 (AI 는 파일을 옮길 때 복사만 함)
const removed = multilang.cleanupAfterConvert(site, siteDir); if (removed.length) { broadcast(site.id, { type: 'log', level: 'info', text: `다국어 전환 정리: 옮겨진 옛 파일 ${removed.length}개 삭제 (${removed.slice(0, 6).join(', ')}${removed.length > 6 ? ' 외' : ''})` }); return gitRecord(site, 'ai-update', '다국어 전환: 옮겨진 옛 파일 정리').then(() => { store.save(site); broadcast(site.id, { type: 'done', site: publicView(site), command: null }); }); } })
.catch((e) => { site.langs = was; store.save(site); userWarn(site.id, `다국어로 바꾸지 못했습니다: ${e.message}`, `다국어 전환 실패: ${e.message}`); });
return { ok: true, mode: 'convert', langs: site.langs };
}
/** 모델 참조로 비용 추정 (claude 는 가격표, 그 외는 제공자 단가) */
function estimateUsdFor(usage, ref) {
try { const mi = providers.resolveModel(ref, null); if (mi.kind === 'claude-cli') return settings.estimateUsd(usage, mi.model); return (((usage && usage.input) || 0) * mi.price.input + ((usage && usage.output) || 0) * mi.price.output) / 1e6; } catch (_) { return settings.estimateUsd(usage, ref); }
}
/** 보정 배율을 곱한 추정 비용 — 중단·일시중지된 작업의 청구액에 쓴다 */
function estimateUsdCal(usage, ref) { return estimateUsdFor(usage, ref) * costcal.factor(ref); }
/** 확정 청구액을 사용자 캐시에서 차감 */
function deductCredit(site, cmd, memo) {
if (!site.ownerId || cmd.userKey || !(cmd.chargeKrw > 0) || cmd.creditDeducted) return;
try { const c = credits.charge(site.ownerId, cmd.chargeKrw, { siteId: site.id, cmdNo: cmd.no }, `${site.title}: ${memo}`); cmd.creditDeducted = true; cmd.creditAfter = c.balance; }
catch (e) { broadcast(site.id, { type: 'log', level: 'debug', text: `캐시 차감 실패: ${e.message}` }); }
}
/** 캐시가 충전되면 대기 중이던 작업을 이어서 진행 */
/** 캐시 소진으로 멈춘 생성·수정을 이어서 하도록 하는 지시문 (원래 지시 전문 포함) */
function continuationPrompt(w) {
return `이전 작업이 캐시 부족으로 중단되었다가 충전 후 재개되었습니다. 중단 전까지의 변경은 이미 파일에 반영되어 있습니다. 먼저 현재 파일 상태를 확인(Read/Glob)한 뒤, 아래 원래 지시 중 아직 완료되지 않은 부분만 이어서 완료하세요. 이미 끝난 부분은 다시 하지 않습니다.\n\n[원래 지시 전문]\n${w.prompt}`;
}
async function resumeWaiting(userId) {
for (const s0 of store.list(userId)) {
const site = store.load(s0.id); if (!site || !site.creditWait) continue;
const job = jobOf(site.id); if (job.running || job.deploying) continue;
const w = site.creditWait; site.creditWait = null; site.status = 'idle'; store.save(site);
broadcast(site.id, { type: 'log', level: 'warn', text: '캐시가 충전되어 중단되었던 작업을 이어서 진행합니다.' });
// v1.1.6: 대기 종류별 재개 — 자동 파이프라인(멈춘 단계부터), 기획 단계(같은 단계 재실행), 생성·수정(이어서 지시)
if (w.kind === 'auto') { runAutoPipeline(site, { resume: true, inner: w.inner }).catch(() => {}); continue; }
if (w.kind === 'plan') { if (w.step === 'plan-analyze') doAnalyze(site, 'analyze').catch(() => {}); else if (w.step === 'plan-verify') runVerification(site).catch(() => {}); continue; }
runCommand(site, w.kind, `(이어서) ${w.instruction}`, continuationPrompt(w), [], { continuation: true, resume: true, originalPrompt: w.prompt }).catch(() => {});
}
}
credits.onChange(({ userId, after }) => { if (after > 0) resumeWaiting(userId).catch(() => {}); });
// ---------- 접속 통계 ----------
/** 통계 요약에 필요한 경로 묶음 (디스크/DB 사용량 계산용) */
function statPaths(siteId) { return { siteDir: path.join(SITES_ROOT, siteId), siteJson: path.join(store.DATA_DIR, `${siteId}.json`), inquiriesJson: path.join(path.dirname(store.DATA_DIR), 'inquiries', `${siteId}.json`), gitDir: path.join(path.dirname(store.DATA_DIR), 'git', siteId), mailDir: path.join(path.dirname(store.DATA_DIR), 'mail', siteId) /* v1.7.2: 메일함 파일(data/mail/<site>) */ }; }
const STATS_LOG = process.env.STATS_LOG || '/var/log/nginx/webmaker-sites.log'; // 로컬 nginx 가 쓰는 사이트 접속 로그
const statsState = { local: null, remote: [] }; // 마지막 수집 결과 (관리자 콘솔 표시용)
/** 로컬 nginx 로그에서 새 부분을 집계 (URI 의 /<32hex>/ 로 사이트 식별) */
function collectLocalStats() {
try { const r = stats.collectLocalFile(STATS_LOG, (e) => (e.uri.match(stats.SITE_ID_RE) || [])[1], 'local'); statsState.local = { ...r, at: new Date().toISOString(), path: STATS_LOG }; }
catch (e) { statsState.local = { ok: false, detail: e.message, at: new Date().toISOString(), path: STATS_LOG }; }
}
/** 원격 배포 서버(SSH, sudo 허용, vhost 설정됨)의 사이트별 로그를 읽어 집계 */
async function collectRemoteStats() {
const out = []; const dl = deploy.list();
for (const t of dl.targets) {
if (t.type !== 'ssh' || !t.hasKey || t.allowSudo !== true || t.manageVhost === false) continue;
for (const s0 of store.list()) {
const site = store.load(s0.id); if (!site || !(site.domains || []).length) continue;
if (!(site.deployments || []).some((d) => d.ok && d.targetId === t.id && d.vhostOk)) continue;
const key = `remote:${t.id}:${site.id}`; const off = stats.offsetOf(key);
try {
const r = await deploy.readSiteLog(t.id, site.id, off.offset, off.inode);
if (!r.ok) { out.push({ target: t.name, site: site.title, ok: false, detail: r.detail }); continue; }
// 덜 쓰인 마지막 줄은 다음 수집으로 넘긴다
let text = r.text; const nl = text.lastIndexOf('\n'); text = nl >= 0 ? text.slice(0, nl + 1) : '';
const lines = stats.ingestText(text, () => site.id);
stats.setOffset(key, r.inode, r.start + Buffer.byteLength(text));
out.push({ target: t.name, site: site.title, ok: true, lines });
} catch (e) { out.push({ target: t.name, site: site.title, ok: false, detail: e.message }); }
}
}
statsState.remote = { at: new Date().toISOString(), items: out };
}
setTimeout(collectLocalStats, 15000); setInterval(collectLocalStats, 5 * 60000);
setTimeout(() => collectRemoteStats().catch(() => {}), 60000); setInterval(() => collectRemoteStats().catch(() => {}), 15 * 60000);
/**
* 웹메일 DNS 자동 재확인 (v1.3.0). DNS 는 등록 후 반영까지 몇 분~하루가 걸리므로,
* 웹메일을 켠 사이트 중 아직 미완료인 곳은 30분마다, 완료된 곳은 6시간마다 다시 조회한다(레코드가 지워진 경우 감지).
* 메일 시스템이 꺼져 있으면 아무것도 하지 않는다.
*/
async function recheckMailDns() {
for (const x of store.list()) {
const site = store.load(x.id); const w = site && site.webmail; if (!w || !w.enabled || !w.domain) continue;
const age = w.dns && w.dns.checkedAt ? Date.now() - new Date(w.dns.checkedAt).getTime() : Infinity;
if (age < ((w.dns && w.dns.ok) ? 6 * 3600000 : 30 * 60000)) continue;
try { const fresh = store.load(site.id); await mailsys.recheck(fresh); store.save(fresh); } catch (_) {}
}
}
setInterval(() => recheckMailDns().catch(() => {}), 10 * 60000);
// ---------- 배포 ----------
/** 사이트의 배포 상태: working(작업 중) | empty(완성 전) | pending(배포 대기중) | deployed(배포 완료) */
function deployState(site) {
if (jobOf(site.id).running) return { state: 'working' };
if (jobOf(site.id).deploying) return { state: 'deploying' };
const cmds = site.commands || [];
const hasContent = cmds.some((c) => c.kind === 'create' && c.status === 'done') || (site.git && site.git.changes && site.git.changes.length) || (site.source && site.source.importedAt);
if (!hasContent) return { state: 'empty' };
const lastDeploy = [...(site.deployments || [])].reverse().find((d) => d.ok && d.kind !== 'unpublish') || null;
const changes = (site.git && site.git.changes) || [];
const lastChange = changes.length ? changes[changes.length - 1] : null;
const lastChangeAt = lastChange ? lastChange.at : (cmds.filter((c) => c.status === 'done').map((c) => c.finishedAt).sort().pop() || site.updatedAt);
// 배포 직후 만들어지는 '배포본' 정리 기록은 변경으로 보지 않는다
const pending = !lastDeploy || !!site.unpublishedAt || (lastChange ? lastChange.kind !== 'deploy' : (lastChangeAt && lastChangeAt > lastDeploy.at));
// v1.6.31 (요구 14·16): 전송은 됐지만 준비가 안 된 단계(dns-wait·cert-wait·check-failed·vhost-failed)는 그 단계로 보인다. 변경 없이 준비 완료면 'deployed'(재배포 버튼 없음)
const phase = lastDeploy && lastDeploy.phase ? lastDeploy.phase : (lastDeploy ? 'ready' : null);
const state = pending ? 'pending' : (phase && phase !== 'ready' && phase !== 'transferred' ? phase : 'deployed');
return { state, phase, phaseDetail: lastDeploy ? lastDeploy.phaseDetail || null : null, lastChangeAt, lastChangeKind: lastChange ? lastChange.kind : null, lastDeployAt: lastDeploy ? lastDeploy.at : null, lastDeployTarget: lastDeploy ? lastDeploy.targetName : null, neverDeployed: !lastDeploy, hasPrev: !!(lastDeploy && lastDeploy.hasPrev) };
}
/** 사이트를 지정(또는 기본) 배포 서버로 전송하고 이력을 남긴다 */
/**
* v1.6.31 (요구 15): 이전 배포본으로 되돌리기 — 배포 서버에 보관된 직전 릴리스(.prev / releases)로 링크를 바꾼다. 내용 되돌리기(버전)와는 별개로 운영 주소만 되돌린다.
* 빌더(plan/rollback)·관리자 콘솔(운영 복구)이 같이 쓴다. 실패해도 기록(audit)은 남긴다.
*/
async function rollbackSite(site, byId) {
if (jobOf(site.id).deploying) throw Object.assign(new Error('배포가 진행 중입니다'), { status: 409 });
const last = [...(site.deployments || [])].reverse().find((d) => d.ok && d.kind !== 'unpublish' && d.targetId && d.targetId !== 'source'); if (!last) throw Object.assign(new Error('배포 기록이 없습니다'), { status: 404 });
const rb = await deploy.rollback(site.id, last.targetId); audit.log({ by: byId, action: 'deploy.rollback', targetType: 'site', target: site.id, siteId: site.id, userId: site.ownerId, ok: rb.ok, detail: rb.detail });
if (rb.ok) { site.deployments = (site.deployments || []).concat({ at: new Date().toISOString(), targetId: last.targetId, targetName: last.targetName, ok: true, ready: true, phase: 'ready', kind: 'rollback', url: last.url, domains: last.domains || [], domainUrls: last.domainUrls || [], detail: '[되돌리기] ' + rb.detail, by: byId }).slice(-50); store.save(site); broadcast(site.id, { type: 'log', level: 'info', notify: true, text: '이전 배포본으로 되돌렸습니다 — ' + rb.detail }); }
return rb;
}
/**
* v1.6.33 (요구 0·1.6.31-14): 접속 확인을 다시 해서 마지막 배포 기록의 단계(phase)를 현재 상태로 바꾼다 — 예전 오류(cert-wait·vhost-wait·check-failed)가 해결됐는데도 계속 표시되는 문제.
* 전송(ok)·vhost 결과는 그대로 두고 access·phase·ready·phaseDetail 만 갱신한다. 재배포가 아니므로 캐시·파일은 건드리지 않는다.
*/
async function rejudgeAccess(site, targetId) {
const last = [...(site.deployments || [])].reverse().find((d) => d.kind !== 'unpublish' && d.ok); if (!last) throw Object.assign(new Error('배포 기록이 없습니다'), { status: 404 });
const tid = targetId || last.targetId || site.deployTargetId || deploy.list().defaultId; if (!tid || tid === 'source') throw Object.assign(new Error('접속 확인할 배포 서버가 없습니다'), { status: 400 });
const access = await deploy.checkAccess(tid, site.id, site.domains || []);
const judged = judgeDeploy({ ok: true, access, samePath: last.samePath }, last.vhostOk === false ? { ok: false, detail: last.vhostDetail || '' } : { ok: true }, (site.domains || []));
const before = last.phase; Object.assign(last, { phase: judged.phase, ready: judged.ready, phaseDetail: judged.detail, access, recheckedAt: new Date().toISOString() });
if (judged.ready && judged.phase === 'ready') { const u = (access.items.find((i) => i.kind === 'domain' && i.https) || {}).url; if (u) last.domainUrls = [...new Set([u, ...(last.domainUrls || [])])]; }
store.save(site); audit.log({ by: '', action: 'deploy.recheck', targetType: 'site', target: site.id, siteId: site.id, userId: site.ownerId, ok: judged.ready, detail: { from: before, to: judged.phase } });
return { access, phase: judged.phase, ready: judged.ready, detail: judged.detail, changed: before !== judged.phase };
}
/**
* 사이트 완전 삭제(폐기): 배포 서버의 파일·vhost, 로컬 파일, 버전 저장소(로컬 git), 통계·모듈·문의 데이터, 사이트 기록.
* 가져온 사이트의 원본 서버 파일은 건드리지 않는다(사용자 소유). 되돌릴 수 없으므로 호출자가 확인을 받는다.
*/
/**
* 사이트 폐기. v1.3.17 (B09): 배포 서버의 사이트 파일 삭제가 실패하면 운영 주소에 내용이 남으므로, 다시 시도할 수 있게
* 로컬 기록을 지우지 않고 실패를 알린다(502). vhost(도메인 설정) 삭제와 파일 삭제는 서로 독립적으로 시도하고,
* vhost 삭제 실패(sudo 없음 등)는 경고로만 돌려준다(파일이 지워지면 그 도메인은 더 이상 사이트를 보여 주지 않음).
*/
async function destroySite(s1, by) {
const warnings = []; const failed = [];
audit.log({ by: by || '', action: 'site.destroy', targetType: 'site', target: s1.id, siteId: s1.id, userId: s1.ownerId, detail: { title: s1.title, domains: s1.domains || [] } }); // v1.6.31 (요구 21)
for (const tid of [...new Set((s1.deployments || []).filter((d) => d.ok && d.targetId !== 'source').map((d) => d.targetId))]) {
if ((s1.domains || []).length) { try { const v = await deploy.removeVhost(s1.id, tid); if (!v.ok) warnings.push(`${tid}: 도메인 설정 삭제 실패 — ${String(v.detail || '').slice(0, 160)}`); } catch (e) { warnings.push(`${tid}: 도메인 설정 삭제 실패 — ${e.message}`); } }
try { const r = await deploy.removeSite(s1.id, tid); if (!r.ok) failed.push(`${tid}: ${String(r.detail || '삭제 실패').slice(0, 200)}`); } catch (e) { failed.push(`${tid}: ${e.message}`); }
}
if (failed.length) {
s1.deleteFailed = { at: new Date().toISOString(), detail: failed }; store.save(s1);
throw Object.assign(new Error(`배포 서버에서 사이트 파일을 지우지 못해 폐기를 멈췄습니다(운영 주소에 내용이 남아 있을 수 있음). 배포 서버 접속을 확인한 뒤 다시 시도하세요: ${failed.join(' / ')}`), { status: 502 });
}
fs.rmSync(path.join(SITES_ROOT, s1.id), { recursive: true, force: true });
if (s1.source && s1.source.keyId) remote.deleteKey(s1.source.keyId);
stats.remove(s1.id); modules.remove(s1.id); mailsys.removeSite(s1.id); multilang.removeSite(s1.id);
try { fs.rmSync(path.join(path.dirname(store.DATA_DIR), 'git', s1.id), { recursive: true, force: true }); } catch (_) {}
try { fs.unlinkSync(path.join(path.dirname(store.DATA_DIR), 'inquiries', `${s1.id}.json`)); } catch (_) {}
try { fs.unlinkSync(path.join(store.DATA_DIR, `${s1.id}.json`)); } catch (_) {}
jobs.delete(s1.id);
return { ok: true, deleted: s1.id, warnings };
}
/** 발행 취소 안내 페이지 HTML (서버 정체·사이트 내용 비노출) */
function maintenanceHtml(site) { return require('./lib/maintpage').html({ title: site.title }); } // v1.5.27 (요구 5): 영어 제목 + 한·일·중·서 안내 + "supported by <도메인>" (lib/maintpage.js)
/**
* 발행 취소 시 배포된 모든 서버에 안내 페이지만 올린다(기존 파일은 교체되어 보이지 않음).
* 가져온 사이트(원본 서버)는 발행 흐름이 없으므로 대상이 아니다. 다시 발행하면 정상 내용이 다시 배포된다.
*/
async function deployMaintenance(site, by) {
const tids = [...new Set((site.deployments || []).filter((d) => d.ok && d.targetId && d.targetId !== 'source' && d.kind !== 'unpublish').map((d) => d.targetId))];
if (!tids.length) { site.unpublishedAt = new Date().toISOString(); store.save(site); return { ok: true, targets: 0, detail: '배포된 서버가 없어 안내 페이지를 올릴 곳이 없습니다' }; }
// 안내 페이지 디렉터리: 웹서버(www-data)가 읽을 수 있도록 0755/0644 (mkdtemp 기본 0700 이면 403 이 난다 — v1.1.4 수정)
const tmp = fs.mkdtempSync(path.join(require('os').tmpdir(), 'wm-maint-')); fs.chmodSync(tmp, 0o755); fs.writeFileSync(path.join(tmp, 'index.html'), maintenanceHtml(site), { mode: 0o644 });
const results = [];
for (const tid of tids) {
const t = deploy.get(tid);
try {
if (deploy.isSamePathTarget(t, site.id)) {
// 서비스 서버 자체가 작업 폴더를 그대로 서비스하는 경우: 파일을 바꾸면 작업 내용이 사라지므로 표식 파일만 두고 nginx 가 안내 페이지를 내보낸다
fs.writeFileSync(path.join(SITES_ROOT, site.id, '.unpublished'), new Date().toISOString() + '\n', { mode: 0o644 });
results.push({ targetId: tid, ok: true, detail: '표식(.unpublished) 설정 — 웹서버가 안내 페이지를 표시' });
} else { const r = await deploy.deploySite(site.id, tmp, tid); results.push({ targetId: tid, ok: r.ok, detail: r.detail }); }
} catch (e) { results.push({ targetId: tid, ok: false, detail: e.message }); }
}
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {}
const ok = results.every((r) => r.ok);
site.unpublishedAt = new Date().toISOString();
site.deployments = (site.deployments || []).concat({ at: site.unpublishedAt, targetId: tids[0], targetName: results.map((r) => (deploy.get(r.targetId) || {}).name || r.targetId).join(', '), ok, kind: 'unpublish', url: null, domains: site.domains || [], domainUrls: [], detail: '[발행 취소] 안내 페이지로 교체: ' + results.map((r) => `${(deploy.get(r.targetId) || {}).name || r.targetId}: ${r.ok ? '완료' : '실패 — ' + r.detail}`).join(' / '), by }).slice(-50);
store.save(site);
audit.log({ by, action: 'deploy.unpublish', targetType: 'site', target: site.id, siteId: site.id, userId: site.ownerId, ok, detail: { targets: tids.length } });
broadcast(site.id, { type: 'log', level: ok ? 'info' : 'warn', notify: true, text: ok ? '발행을 취소했습니다. 운영 주소에는 "사이트 수정 중" 안내가 표시됩니다.' : '발행은 취소했지만 일부 서버에 안내 페이지를 올리지 못했습니다. 관리자에게 문의하세요.' }); // v1.5.27 notify: 메시지창
return { ok, targets: tids.length, results };
}
/** 배포 시작/완료를 진행 상태 이벤트로 알리고, 배포 중에는 다른 작업이 겹치지 않게 한다 */
// ---------- 검색·AI 최적화 · 검색사이트 등록 (v1.3.5) ----------
/**
* 이번 배포의 공개 기준 주소: 가져온 사이트는 원본 주소, 도메인이 있으면 https://첫 도메인/, 없으면 배포 서버의 사이트 주소.
* (도메인 SSL 은 배포 중 발급되므로 https 를 기준으로 삼는다 — 검색엔진은 https 주소를 대표 주소로 선호)
*/
function deployBaseFor(site, targetId) {
if (site.source && site.source.url) return site.source.url.replace(/\/?$/, '/');
const d = (site.domains || [])[0]; if (d) return `https://${d}/`;
try { const dl = deploy.list(); const tid = targetId || site.deployTargetId || dl.defaultId; const t = tid ? deploy.get(tid) : null; const u = t ? deploy.siteUrlOn(t, site.id) : null; return u ? u.replace(/\/?$/, '/') : null; } catch (_) { return null; }
}
/**
* v1.3.16 사이트 방문자 요청의 언어 코드. 위젯이 보내는 X-WM-Lang(페이지 <html lang>) → ?lang= → 본문 lang → 'ko'.
* <html lang> 은 BCP 47 표기(zh-Hans 등)라 서비스의 언어 코드(zh-CN)로 바꾼다.
*/
function visitorLang(req, url, b) { return multilang.codeOf((req.headers['x-wm-lang'] || (url && url.searchParams.get('lang')) || (b && b.lang) || 'ko').toString()); }
/**
* v1.3.16 사이트 소유자(사이트 관리자 화면·알림 메일) 언어: 사이트 관리 언어(site.adminLang) → 소유자의 빌더 화면 언어 → 사이트 생성 때 화면 언어 → 한국어
*/
function ownerLang(site) { const pref = site.ownerId ? auth.uiLangOf(site.ownerId) : null; return multilang.codeOf(site.adminLang || (pref && pref.lang) || site.uiLang || 'ko'); } // v1.3.17 (B12): 공개 사용자 정보에는 uiLang 이 없어 저장 접근자로 읽는다
/** 소유자 메일용 언어·날짜 로케일·시간대 (lib/forms compose 옵션) */
function ownerFmt(site) { const lang = ownerLang(site); const L = multilang.LOCALES[lang] || multilang.LOCALES.ko; return { lang, locale: L.locale || L.html, tz: (site.locale && site.locale.timeZone) || 'Asia/Seoul' }; }
/** 지금 운영 주소(배포가 끝난 경우) — 검색 등록·반영 미리보기에 쓴다 */
const liveBase = (site) => { const u = liveUrlOf(site); return u ? u.replace(/\/?$/, '/') : null; };
/**
* AI 로 SEO·AEO·GEO 초안 만들기 (생성 직후 자동, 또는 관리 탭에서 다시 만들기).
* 초안은 site.optimize.draft 에만 두고 사이트 파일은 바꾸지 않는다 — 사용자가 검토 후 "반영" 해야 들어간다.
* 비용을 줄이려고 기본 등급 모델을 쓰며 사용자 캐시에서 차감된다(작업 내역 "검색·AI 최적화 초안").
*/
async function draftOptimize(site, wanted) {
const w = wanted || (site.optimize && site.optimize.wanted); if (!seo.anyWanted(w)) return null;
const siteDir = path.join(SITES_ROOT, site.id); const files = seo.pageFiles(site, siteDir); if (!files.length) return null;
site.optimize = { ...(site.optimize || {}), wanted: w, status: 'drafting', error: null }; store.save(site); broadcast(site.id, { type: 'done', site: publicView(site), command: null });
const multi = multilang.isMulti(site); const locOf = (c) => (multi ? multilang.LOCALES[c].og : undefined);
try {
const data = await runPlanStep(site, 'seo-draft', seo.draftPrompt(site, w, files), { tier: 'basic' });
site.optimize.draft = seo.normalize(data || {}, files, w, multi ? locOf(site.langs.default) : undefined, multi ? `${site.langs.default}/` : undefined); site.optimize.draftAt = new Date().toISOString();
// v1.3.10 다국어: 다른 언어마다 기본 언어 초안을 그 언어 검색 표현으로 현지화한 초안을 따로 만든다 (파일 읽기 없는 짧은 호출)
if (multi) {
site.optimize.langDrafts = {}; site.optimize.langErrors = {}; store.save(site);
for (const c of site.langs.list.slice(1)) {
try { const d = await runPlanStep(site, 'seo-draft', seo.localizePrompt(site, site.optimize.draft, c), { tier: 'basic', toolset: 'none' }); site.optimize.langDrafts[c] = seo.normalize(d || {}, files, w, locOf(c)); }
catch (e) { if (e.creditStop) throw e; site.optimize.langErrors[c] = e.message; }
store.save(site);
}
}
site.optimize.status = 'draft'; // 다국어는 모든 언어 초안이 끝난 뒤 검토 대기로
broadcast(site.id, { type: 'log', level: 'info', text: '검색·AI 최적화(SEO·AEO·GEO) 초안이 준비되었습니다. 관리 탭에서 검토한 뒤 반영하세요.' });
} catch (e) { site.optimize.status = 'error'; site.optimize.error = e.message; broadcast(site.id, { type: 'log', level: 'warn', text: `검색·AI 최적화 초안을 만들지 못했습니다: ${e.message}` }); }
store.save(site); broadcast(site.id, { type: 'done', site: publicView(site), command: null });
return site.optimize;
}
/** 관리 탭 카드용 상태 */
function optimizeView(site) {
const o = site.optimize || {}; const multi = multilang.isMulti(site);
return { wanted: o.wanted || { seo: false, aeo: false, geo: false }, status: o.status || 'none', error: o.error || null, draft: o.draft || null, draftAt: o.draftAt || null, final: o.final || null, applied: !!o.applied, appliedAt: o.appliedAt || null, liveBase: liveBase(site), files: seo.pageFiles(site, path.join(SITES_ROOT, site.id)),
// v1.3.10 다국어: 언어 탭 (기본 언어 값은 draft/final, 다른 언어는 langDrafts/langFinals)
langs: multi ? site.langs.list.map((c) => ({ code: c, name: multilang.LOCALES[c].name, label: multilang.LOCALES[c].label, dir: multilang.LOCALES[c].dir, isDefault: c === site.langs.default })) : null,
langDrafts: multi ? (o.langDrafts || {}) : null, langFinals: multi ? (o.langFinals || {}) : null, langErrors: multi ? (o.langErrors || {}) : null };
}
/** 검색 등록 카드·관리자 탭용 상태 */
function searchView(site) {
const live = liveBase(site); const busy = !!(jobOf(site.id).running || jobOf(site.id).deploying);
const reg = site.searchReg || {};
const opts = { googleAuto: !!googleKey() };
return { availability: seo.regAvailability(site, live, busy), liveUrl: live, googleAuto: opts.googleAuto, engines: Object.keys(seo.ENGINES).map((e) => seo.engineStatus(site, e, live, opts)), indexnow: reg.indexnow ? { key: !!reg.indexnow.key, lastPingAt: reg.indexnow.lastPingAt || null, last: reg.indexnow.last || null } : null, seoApplied: !!(site.optimize && site.optimize.applied) };
}
/**
* 검색 등록 동작 (사용자 관리 탭과 관리자 콘솔이 같이 쓴다).
* @param {string} action start | code | apply | step | check | indexnow | stop
* @param {object} b { engine, method, code, step, done }
*/
async function searchAction(site, action, b, by) {
const v = searchView(site); if (!v.availability.ok) throw Object.assign(new Error(v.availability.reason), { status: 409 });
const eng = b.engine; if (action !== 'indexnow' && !seo.ENGINES[eng]) throw Object.assign(new Error('검색사이트를 고르세요 (google, naver)'), { status: 400 });
site.searchReg = site.searchReg || {}; const r = eng ? (site.searchReg[eng] = site.searchReg[eng] || {}) : null;
if (action === 'start') {
r.started = new Date().toISOString(); r.method = eng === 'google' && b.method === 'dns' ? 'dns' : 'meta'; r.steps = r.steps || {};
if (eng === 'google' && r.method === 'meta' && googleKey()) { store.save(site); runGoogleAuto(site, by); return searchView(site); } // v1.3.10 키가 있으면 끝까지 자동
}
else if (action === 'auto') { if (eng !== 'google' || !googleKey()) throw Object.assign(new Error('구글 서비스 계정 키가 없습니다 (관리자 콘솔 › 연동 키 › 구글 검색 등록 자동화)'), { status: 409 }); if (r.auto && r.auto.state === 'running') throw Object.assign(new Error('이미 자동 진행 중입니다'), { status: 409 }); r.method = 'meta'; store.save(site); runGoogleAuto(site, by); return searchView(site); }
else if (action === 'stop') { delete site.searchReg[eng]; }
else if (action === 'code') {
r.code = seo.codeFrom(b.code); r.appliedAt = null; r.liveOk = false; r.steps = { ...(r.steps || {}), verify: null };
// v1.3.10: meta 방식은 코드를 저장하면 바로 태그를 넣고 재배포한 뒤 운영 페이지에서 태그를 확인한다 (사람이 할 일은 검색사이트에서 "확인" 누르기만)
if (r.method !== 'dns') { store.save(site); await applyVerifyTag(site, eng, v.liveUrl, by); }
}
else if (action === 'apply') {
if (!r.code) throw Object.assign(new Error('먼저 확인 코드를 입력하세요'), { status: 400 });
await applyVerifyTag(site, eng, v.liveUrl, by);
} else if (action === 'step') { if (!['verify', 'sitemap', 'dns'].includes(b.step)) throw Object.assign(new Error('알 수 없는 단계'), { status: 400 }); r.steps = { ...(r.steps || {}), [b.step]: b.done === false ? null : new Date().toISOString() }; }
else if (action === 'check') {
if (r.method === 'dns') { const host = new URL(v.liveUrl).hostname.replace(/^www\./, ''); const ok = await seo.checkTxt(host, `google-site-verification=${r.code}`); if (ok) r.steps = { ...(r.steps || {}), dns: new Date().toISOString() }; store.save(site); return { ...searchView(site), check: { ok, detail: ok ? 'DNS 에 TXT 레코드가 보입니다. 이제 Search Console 에서 "확인" 을 누르세요' : 'DNS 에서 TXT 레코드를 찾지 못했습니다 (등록 직후면 전파를 기다리세요)' } }; }
const ok = await seo.liveHasMeta(v.liveUrl, seo.ENGINES[eng].meta, r.code || ''); r.liveOk = ok; store.save(site); return { ...searchView(site), check: { ok, detail: ok ? '운영 첫 페이지에 확인 태그가 있습니다. 검색사이트에서 "확인" 을 누르세요' : '운영 첫 페이지에서 확인 태그를 찾지 못했습니다 (재배포가 끝났는지 확인)' } };
} else if (action === 'indexnow') {
const key = seo.ensureIndexNowKey(site); const mr = seo.materialize(site, path.join(SITES_ROOT, site.id), v.liveUrl); store.save(site);
if (mr.changed.length) { await gitRecord(site, 'seo', 'IndexNow 키 파일'); const dep = await deploySiteTo(site, null, by); if (!dep.ok) throw Object.assign(new Error(`재배포 실패: ${dep.detail || ''}`), { status: 502 }); }
const res = await seo.pingIndexNow(v.liveUrl, key, seo.htmlFiles(path.join(SITES_ROOT, site.id)));
site.searchReg.indexnow.lastPingAt = new Date().toISOString(); site.searchReg.indexnow.last = res;
} else throw Object.assign(new Error('알 수 없는 동작'), { status: 400 });
store.save(site); return searchView(site);
}
/** v1.3.10 구글 서비스 계정 키 (보관소 'google-search'). 없으면 null → 구글 단계는 사람이 한다 */
function googleKey() { try { const x = vault.resolve('google-search'); return x && x.secret ? x : null; } catch (_) { return null; } }
/**
* 확인 태그를 첫 페이지에 넣고 재배포한 뒤, 운영 페이지에 태그가 실제로 보이는지 확인한다 (최대 약 30초 재시도).
* 검색사이트 "확인" 은 운영 페이지를 읽으므로 태그가 보여야 성공한다.
*/
async function applyVerifyTag(site, eng, liveUrl, by) {
const r = site.searchReg[eng];
const mr = seo.materialize(site, path.join(SITES_ROOT, site.id), liveUrl); store.save(site);
if (mr.changed.length) await gitRecord(site, 'seo', `${seo.ENGINES[eng].label} 소유 확인 태그`);
const dep = await deploySiteTo(site, null, by); if (!dep.ok) throw Object.assign(new Error(`재배포 실패: ${dep.detail || ''}`), { status: 502 });
r.appliedAt = new Date().toISOString(); r.liveOk = false;
for (let i = 0; i < 6 && !r.liveOk; i++) { if (i) await new Promise((ok) => setTimeout(ok, 5000)); r.liveOk = await seo.liveHasMeta(liveUrl, seo.ENGINES[eng].meta, r.code); }
store.save(site); return r.liveOk;
}
/**
* v1.3.10 구글 등록 자동 진행 (백그라운드): 확인 토큰 받기 → 태그 넣고 재배포 → 운영 페이지 확인 → 소유 확인 → Search Console 속성 추가 → 사이트맵 제출.
* 진행 상태는 site.searchReg.google.auto = { state: running|done|error, step, detail, at } 로 남겨 화면이 보여 준다.
* 실패하면 그 단계와 원인(설정 안내 포함)을 남기고, "자동 진행 다시" 로 이어서 할 수 있다(끝난 단계는 건너뜀).
*/
async function runGoogleAuto(site, by) {
const r = site.searchReg.google; const key = googleKey(); const live = liveBase(site);
const set = (state, step, detail) => { r.auto = { state, step, detail: detail || '', at: new Date().toISOString() }; store.save(site); broadcast(site.id, { type: 'log', level: state === 'error' ? 'warn' : 'info', text: `구글 검색 등록 자동 진행 — ${detail || step}` }); };
try {
if (!r.code) { set('running', 'code', '확인 코드 받는 중'); r.code = await google.verificationToken(key.secret, live); }
if (!r.appliedAt || !r.liveOk) { set('running', 'apply', '확인 태그를 넣고 재배포하는 중'); const ok = await applyVerifyTag(site, 'google', live, by); if (!ok) throw new Error('재배포 뒤에도 운영 첫 페이지에서 확인 태그가 보이지 않습니다 (배포 서버·도메인 연결을 확인한 뒤 다시 시도)'); }
if (!(r.steps || {}).verify) { set('running', 'verify', '구글에 소유 확인 요청 중'); const res = await google.verify(key.secret, live, (key.settings || {}).OWNER_EMAIL || ''); r.steps = { ...(r.steps || {}), verify: new Date().toISOString() }; if (res.ownerAdded) r.ownerAdded = res.ownerAdded; if (res.ownerError) r.ownerError = res.ownerError; }
if (!(r.steps || {}).sitemap) {
set('running', 'sitemap', 'Search Console 에 사이트 추가·사이트맵 제출 중');
await google.addSite(key.secret, live);
if (!fs.existsSync(path.join(SITES_ROOT, site.id, 'sitemap.xml'))) { seo.materialize(site, path.join(SITES_ROOT, site.id), live); store.save(site); }
await google.submitSitemap(key.secret, live, `${live}sitemap.xml`); r.steps = { ...(r.steps || {}), sitemap: new Date().toISOString() };
}
set('done', 'done', `구글 등록 완료 (${live})${r.ownerAdded ? ` · Search Console 소유자 추가: ${r.ownerAdded}` : ''}${r.ownerError ? ` · 소유자 추가 실패: ${r.ownerError}` : ''}`);
} catch (e) { set('error', (r.auto && r.auto.step) || 'code', e.message); }
}
/** v1.3.10 배포 뒤 IndexNow 자동 색인 요청 (검색 등록을 시작한 사이트만, 실패해도 배포 결과에는 영향 없음) */
async function autoIndexNow(site) {
const reg = site.searchReg || {}; if (!(reg.indexnow && reg.indexnow.key)) return;
const live = liveBase(site); if (!live) return;
try { const res = await seo.pingIndexNow(live, reg.indexnow.key, seo.htmlFiles(path.join(SITES_ROOT, site.id))); reg.indexnow.lastPingAt = new Date().toISOString(); reg.indexnow.last = { ...res, auto: true }; store.save(site); broadcast(site.id, { type: 'log', level: res.ok ? 'info' : 'debug', text: `IndexNow 자동 색인 요청: ${res.detail}` }); }
catch (e) { broadcast(site.id, { type: 'log', level: 'debug', text: `IndexNow 자동 요청 실패: ${e.message}` }); }
}
async function deploySiteTo(site, targetId, by) {
const job = jobOf(site.id);
if (job.running) throw Object.assign(new Error('작업 실행 중에는 배포할 수 없습니다'), { status: 409 });
if (job.deploying) throw Object.assign(new Error('이미 배포가 진행 중입니다'), { status: 409 });
const label = site.source ? '원본 서버 반영' : '배포';
job.deploying = { startedAt: new Date().toISOString(), label, by };
broadcast(site.id, { type: 'status', status: 'running', label, startedAt: job.deploying.startedAt, deploy: true });
broadcast(site.id, { type: 'log', level: 'info', text: `${label} 시작 (요청: ${by || '-'})` });
// v1.3.5: 반영한 SEO·AEO·GEO 값과 검색사이트 확인 태그를 "배포 주소" 기준으로 다시 써서(canonical·og:url·sitemap 의 절대 주소) 함께 올린다
{ const reg = site.searchReg || {}; if ((reg.google && reg.google.started) || (reg.naver && reg.naver.started)) seo.ensureIndexNowKey(site); } // v1.3.10 검색 등록 중이면 IndexNow 키 파일도 함께 올린다
try { const base = deployBaseFor(site, targetId); const mr = seo.materialize(site, path.join(SITES_ROOT, site.id), base); if (mr.changed.length) { store.save(site); broadcast(site.id, { type: 'log', level: 'info', text: `검색·AI 최적화 값을 배포 주소(${base || '미정'}) 기준으로 갱신: ${mr.changed.slice(0, 6).join(', ')}${mr.changed.length > 6 ? ' 외' : ''}` }); } } catch (e) { broadcast(site.id, { type: 'log', level: 'debug', text: `SEO 반영 실패: ${e.message}` }); }
// v1.3.16 다국어 품질 게이트를 통과하지 못한 언어가 있으면 배포는 하되 이유를 알린다 (관리 탭 다국어 카드에서 확인·검수)
if (multilang.isMulti(site)) { try { const q = multilang.quality(site, path.join(SITES_ROOT, site.id)); const bad = Object.entries(q.languages).filter(([, x]) => !x.pass).map(([c, x]) => `${c}(${Object.entries(x.checks || {}).filter(([, ok]) => !ok).map(([k]) => ({ missing: '번역 누락', residue: '원문 잔존', links: '링크 불일치', review: '검수 전' }[k])).join('·')})`); if (bad.length) broadcast(site.id, { type: 'log', level: 'warn', text: `다국어 품질 게이트 미통과 언어: ${bad.join(', ')} — 관리 탭 › 다국어에서 확인하고 검수하세요` }); } catch (_) {} }
let r;
try { r = site.source ? await deployToSource(site, by) : await deployToTarget(site, targetId, by); }
catch (e) { r = { ok: false, detail: e.message, targetId: targetId || null }; broadcast(site.id, { type: 'log', level: 'debug', text: `${label} 오류: ${e.message}` }); }
finally { job.deploying = null; }
const urls = (r.access && r.access.items ? r.access.items.filter((i) => i.ok).map((i) => i.url) : []).concat(r.domainUrls || []).filter((u, i, a) => a.indexOf(u) === i);
if (r.ok) { site.unpublishedAt = null; try { fs.unlinkSync(path.join(SITES_ROOT, site.id, '.unpublished')); } catch (_) {} store.save(site); setTimeout(() => autoIndexNow(site).catch(() => {}), 3000); }
broadcast(site.id, { type: 'deploy', ok: !!r.ok, redeploy: !!r.redeploy, detail: r.detail || '', urls, url: r.url || null, label, access: r.access || null });
broadcast(site.id, { type: 'status', status: r.ok ? 'done' : 'error', label, deploy: true });
broadcast(site.id, { type: 'done', site: publicView(site), command: null, deploy: r });
return r;
}
async function deployToTarget(site, targetId, by) {
const dl = deploy.list(); const tid = targetId || site.deployTargetId || dl.defaultId;
if (!tid) return { ok: false, detail: '배포 서버가 설정되지 않았습니다 (관리자 콘솔 › 배포 서버)', targetId: null };
const t = deploy.get(tid); const domains = site.domains || [];
const redeploy = (site.deployments || []).some((d) => d.ok && d.targetId === tid);
broadcast(site.id, { type: 'progress', text: redeploy ? '재배포: 변경된 파일을 전송하고 있습니다' : '배포 서버로 전송하고 있습니다' });
try { stats.ensureBeacon(path.join(SITES_ROOT, site.id), site.id, formBase()); } catch (_) {}
try { mailsys.syncStub(site, path.join(SITES_ROOT, site.id), formBase()); } catch (_) {} // v1.3.0: 웹메일이 켜져 있으면 /webmail 진입 파일 함께 배포
const r = await deploy.deploySite(site.id, path.join(SITES_ROOT, site.id), tid);
r.redeploy = redeploy; r.detail = (redeploy ? '[재배포] ' : '[첫 배포] ') + r.detail;
let vhost = null; let defaultServer = null;
if (r.ok && t && t.manageVhost !== false && t.allowSudo === true) {
// 매 배포마다: 미등록 호스트/경로가 다른 서비스로 흘러가지 않도록 기본 오류 서버를 점검·적용
broadcast(site.id, { type: 'progress', text: '배포 서버의 기본 오류 페이지 설정을 점검하고 있습니다' });
try { defaultServer = await deploy.ensureDefaultServer(tid); } catch (e) { defaultServer = { ok: false, detail: e.message }; }
r.detail += ` / 기본 오류 서버: ${defaultServer.detail}`; r.defaultServer = defaultServer;
if (!defaultServer.ok && !defaultServer.skipped) broadcast(site.id, { type: 'log', level: 'debug', text: `기본 오류 서버 점검 경고(${t.name}): ${defaultServer.detail}` });
}
if (r.ok && domains.length && t && t.manageVhost !== false) {
broadcast(site.id, { type: 'progress', text: `도메인(${domains.join(', ')}) 연결을 설정하고 있습니다` });
try { vhost = await deploy.configureVhost(site.id, tid, domains); } catch (e) { vhost = { ok: false, detail: e.message }; }
r.detail += ` / ${vhost.detail}`; r.vhost = vhost;
if (vhost.ok) r.domainUrls = domains.map((d) => `http://${d}/`);
// v1.3.17 (B25): 사이트별 SSL 선택(site.sslOff)은 그 사이트에만 적용 — 서버(배포 대상) 공용 설정을 바꾸지 않는다
const sslOn = t.enableSsl !== false && !site.sslOff;
// v1.4.14 (요구 9): 도메인이 두 묶음으로 나뉜다 — 플랫폼 서브 도메인(와일드카드 인증서, 운영자가 관리자 콘솔에서 한 번 발급)과
// 고객 도메인(사이트별 인증서, 여기서 webroot 발급). configureVhost 가 묶음과 "아직 인증서가 없는 고객 도메인(needCert)" 을 돌려준다.
const httpsNow = (gs) => new Set((gs || []).filter((g) => g.ssl).flatMap((g) => g.domains));
if (vhost.ok && sslOn && vhost.sslKept) {
// 재배포: 모든 묶음이 이미 인증서로 덮여 있으면 재발급하지 않음 (만료 갱신은 certbot 타이머)
r.ssl = { ok: true, kept: true, detail: `기존 SSL 인증서 유지 (${(vhost.groups || []).map((g) => `${g.domains.join(', ')}: ${g.certName}`).join(' · ')}${vhost.cert && vhost.cert.expires ? `, 사이트 인증서 만료 ${vhost.cert.expires}` : ''})` }; r.detail += ` / ${r.ssl.detail}`;
r.domainUrls = domains.map((d) => `https://${d}/`);
} else if (vhost.ok && sslOn) {
const need = vhost.needCert || []; const on = httpsNow(vhost.groups);
if (need.length) {
// 고객 도메인 첫 배포 또는 새 도메인 추가: DNS 가 이 서버를 가리키는 도메인만 발급 대상 (검증 실패로 전체가 실패하지 않도록)
broadcast(site.id, { type: 'progress', text: 'SSL 인증서를 발급하고 있습니다 (Let\'s Encrypt)' });
let eligible = [];
try { const pre = await deploy.checkAccess(tid, site.id, need); eligible = pre.items.filter((i) => i.kind === 'domain' && i.pointsToTarget).map((i) => i.url.replace(/^https?:\/\//, '').replace(/\/$/, '')); } catch (_) {}
if (eligible.length) {
try { r.ssl = await deploy.issueSsl(site.id, tid, eligible, settings.load().letsEncryptEmail); } catch (e) { r.ssl = { ok: false, detail: e.message }; }
if (r.ssl.ok) { for (const d of eligible) on.add(d); if (r.ssl.vhost && r.ssl.vhost.groups) for (const d of httpsNow(r.ssl.vhost.groups)) on.add(d); }
} else r.ssl = { ok: false, skipped: true, detail: `DNS 가 배포 서버를 가리키는 고객 도메인이 없어 사이트 인증서 발급을 건너뜀 (${need.join(', ')})` };
} else r.ssl = { ok: false, skipped: true, detail: '발급할 고객 도메인 없음' };
if (vhost.platformNoWild) { const pd = vhost.wildcard && vhost.wildcard.platformDomain; r.ssl.detail += ` / 플랫폼 서브 도메인(${(vhost.groups || []).filter((g) => g.kind === 'platform').flatMap((g) => g.domains).join(', ')})은 와일드카드 인증서(*.${pd})가 없어 HTTP 로 열립니다 — 관리자 콘솔 › 설정 › SSL 에서 발급하거나 서버에서 certbot 으로 *.${pd} 인증서를 받아 두면(이름 무관) 재배포 없이 HTTPS 가 적용됩니다`; r.ssl.platformNoWild = true; }
r.detail += ` / ${r.ssl.detail}`;
r.domainUrls = domains.map((d) => (on.has(d) ? `https://${d}/` : `http://${d}/`));
}
}
if (r.ok) { try { fs.unlinkSync(path.join(SITES_ROOT, site.id, '.unpublished')); } catch (_) {} broadcast(site.id, { type: 'progress', text: '접속 가능 여부를 확인하고 있습니다' }); try { r.access = await deploy.checkAccess(tid, site.id, domains); } catch (e) { r.access = { items: [], error: e.message }; } }
// v1.6.31 (요구 14): 파일 전송 성공(r.ok)과 "발행 준비 완료(ready)" 를 구분한다. phase: transferred(전송만) · vhost-failed · dns-wait(도메인이 아직 이 서버를 가리키지 않음) ·
// cert-wait(인증서 미적용 — HTTP 로는 열림) · check-failed(접속 확인 실패) · ready. 도메인이 없으면 공개 주소 접속 확인으로 판단한다.
const judged = judgeDeploy(r, vhost, domains);
r.phase = judged.phase; r.ready = judged.ready; r.phaseDetail = judged.detail;
site.deployments = (site.deployments || []).concat({ at: new Date().toISOString(), targetId: tid, targetName: t ? t.name : tid, ok: r.ok, ready: r.ready, phase: r.phase, phaseDetail: r.phaseDetail, url: r.url, domains: domains.slice(), domainUrls: r.domainUrls || [], vhostOk: vhost ? vhost.ok : null, sslOk: r.ssl ? r.ssl.ok : null, sslDetail: r.ssl ? r.ssl.detail : null, sslKept: r.ssl ? !!r.ssl.kept : null, redeploy, defaultServer: r.defaultServer || null, access: r.access || null, detail: r.detail, ms: r.ms, by, hasPrev: !!r.hasPrev }).slice(-50);
audit.log({ by, action: 'deploy.site', targetType: 'site', target: site.id, siteId: site.id, userId: site.ownerId, ok: !!r.ready, detail: { phase: r.phase, target: t ? t.name : tid, domains, ms: r.ms } });
store.save(site);
broadcast(site.id, { type: 'log', level: r.ok ? 'info' : 'debug', text: `배포 ${r.ok ? '완료' : '실패'}: ${r.detail}` });
if (!r.ok) userWarn(site.id, '배포 서버로 전송하지 못했습니다. 관리자에게 문의하세요.', `배포 실패(${t ? t.name : tid}): ${r.detail}`);
else if (vhost && !vhost.ok) userWarn(site.id, '파일은 배포되었지만 도메인 연결 설정에 실패했습니다. 관리자에게 문의하세요.', `vhost 실패(${t ? t.name : tid}): ${vhost.detail}`);
else if (r.ssl && !r.ssl.ok && !r.ssl.skipped) userWarn(site.id, '도메인은 연결되었지만 SSL 인증서 발급에 실패했습니다. 관리자에게 문의하세요.', `SSL 실패(${t ? t.name : tid}): ${r.ssl.detail}`);
else if (r.ssl && r.ssl.platformNoWild) userWarn(site.id, '서브 도메인 주소는 운영자가 와일드카드 인증서를 발급하면 HTTPS 로 열립니다 (지금은 HTTP).', `와일드카드 인증서 없음(${t ? t.name : tid}): ${r.ssl.detail}`);
if (r.ok) {
// 배포 완료: 되돌리기용 이전 버전을 모두 지우고 현재 버전만 남긴다
broadcast(site.id, { type: 'progress', text: '이전 버전을 정리하고 있습니다' });
try {
const rec = await gitstore.squashToCurrent(site.id, path.join(SITES_ROOT, site.id), `배포본 ${new Date().toISOString().slice(0, 16).replace('T', ' ')} → ${t ? t.name : tid}`);
if (site.plan && site.plan.brief) rec.briefSnapshot = JSON.parse(JSON.stringify(site.plan.brief));
site.git = { seq: 1, changes: [rec] }; store.save(site);
broadcast(site.id, { type: 'log', level: 'info', text: `이전 버전 정리 완료 — 현재 배포본만 보관${rec.pushed ? ' (소스 저장소 반영됨)' : ''}` });
if (!rec.pushed) broadcast(site.id, { type: 'log', level: 'debug', text: `소스 저장소(Gitea) 반영 실패 — 다음 변경 때 다시 시도합니다: ${rec.error}` });
} catch (e) { broadcast(site.id, { type: 'log', level: 'debug', text: `버전 정리 실패: ${e.message}` }); }
}
return r;
}
/**
* v1.6.31 (요구 14) 배포 결과 판정 — 전송·vhost·접속 확인·인증서를 따로 본다.
* @returns {{phase:string, ready:boolean, detail:string}}
*/
function judgeDeploy(r, vhost, domains) {
if (!r.ok) return { phase: 'failed', ready: false, detail: r.detail || '전송 실패' };
const items = (r.access && r.access.items) || [];
if (domains.length && vhost && !vhost.ok) return { phase: 'vhost-failed', ready: false, detail: '파일은 전송됐지만 도메인 연결(웹서버 설정)에 실패: ' + (vhost.detail || '') };
const dom = items.filter((i) => i.kind === 'domain');
if (domains.length) {
if (dom.some((i) => i.dns && i.dns.length && !i.pointsToTarget)) return { phase: 'dns-wait', ready: false, detail: '도메인이 아직 이 서버를 가리키지 않습니다 (DNS 전파 대기 또는 A 레코드 확인): ' + dom.filter((i) => !i.pointsToTarget).map((i) => i.note).join(' · ') };
if (dom.some((i) => !i.dns || !i.dns.length)) return { phase: 'dns-wait', ready: false, detail: 'DNS 레코드 없음: ' + dom.filter((i) => !i.dns || !i.dns.length).map((i) => i.note).join(' · ') };
if (dom.some((i) => !i.ok)) return { phase: 'check-failed', ready: false, detail: '도메인 접속 확인 실패: ' + dom.filter((i) => !i.ok).map((i) => i.note).join(' · ') };
if (dom.some((i) => i.vhostPending)) return { phase: 'vhost-wait', ready: false, detail: '웹서버가 아직 새 설정을 적용하지 않았습니다(기본 페이지 응답) — 잠시 뒤 "다시 확인" 하세요: ' + dom.filter((i) => i.vhostPending).map((i) => i.url).join(' · ') }; // v1.6.33
if (dom.length && dom.every((i) => !i.https)) return { phase: 'cert-wait', ready: true, detail: 'HTTP 로 열립니다 — 인증서가 아직 적용되지 않았습니다 (와일드카드 인증서 또는 사이트 인증서 발급 뒤 자동 적용)' };
return { phase: 'ready', ready: true, detail: '운영 주소 접속·HTTPS 정상' };
}
const pub = items.find((i) => i.kind === 'public');
if (pub && !pub.ok) return { phase: 'check-failed', ready: false, detail: '공개 주소 접속 확인 실패: ' + (pub.note || '') };
if (!items.length && !r.samePath) return { phase: 'transferred', ready: true, detail: '전송 완료 (접속 확인 생략)' };
return { phase: 'ready', ready: true, detail: '공개 주소 접속 정상' };
}
/** 가져온 기존 사이트: 수정본을 원본 서버(문서 루트)에 되돌려 반영한다 */
async function deployToSource(site, by) {
const src = site.source; const siteDir = path.join(SITES_ROOT, site.id);
const started = Date.now(); const name = `${src.user}@${src.host}:${src.docroot}`;
const redeploy = (site.deployments || []).some((d) => d.ok);
broadcast(site.id, { type: 'progress', text: redeploy ? '원본 서버에 변경된 파일을 반영하고 있습니다' : '원본 서버에 수정본을 반영하고 있습니다' });
let r;
try { stats.ensureBeacon(siteDir, site.id, formBase()); } catch (_) {}
try { r = await remote.push(src, siteDir, (t) => broadcast(site.id, { type: 'progress', text: t })); r.detail = (redeploy ? '[재배포] ' : '[첫 배포] ') + r.detail; }
catch (e) { r = { ok: false, detail: e.message, ms: Date.now() - started }; }
r.redeploy = redeploy; r.targetId = 'source'; r.url = src.url || null;
if (r.ok) { broadcast(site.id, { type: 'progress', text: '사이트가 정상적으로 열리는지 확인하고 있습니다' }); try { r.access = await remote.verifyLive(src, siteDir); } catch (e) { r.access = { items: [], error: e.message }; } if (r.access.items.length) r.domainUrls = r.access.items.map((i) => i.url); }
site.deployments = (site.deployments || []).concat({ at: new Date().toISOString(), targetId: 'source', targetName: name, ok: r.ok, url: r.url, domains: [], domainUrls: r.domainUrls || [], vhostOk: null, sslOk: null, sslDetail: null, sslKept: null, redeploy, access: r.access || null, detail: r.detail, ms: r.ms, by }).slice(-50);
store.save(site);
broadcast(site.id, { type: 'log', level: r.ok ? 'info' : 'debug', text: `원본 서버 반영 ${r.ok ? '완료' : '실패'}: ${r.detail}` });
if (!r.ok) userWarn(site.id, '원본 서버에 반영하지 못했습니다. 접속 정보(계정·비밀번호·경로)를 확인하거나 관리자에게 문의하세요.', `원본 서버 반영 실패(${name}): ${r.detail}`);
if (r.ok) {
broadcast(site.id, { type: 'progress', text: '이전 버전을 정리하고 있습니다' });
try {
const rec = await gitstore.squashToCurrent(site.id, siteDir, `배포본 ${new Date().toISOString().slice(0, 16).replace('T', ' ')} → 원본 서버 ${src.host}`);
site.git = { seq: 1, changes: [rec] }; store.save(site);
broadcast(site.id, { type: 'log', level: 'info', text: `이전 버전 정리 완료 — 현재 배포본만 보관${rec.pushed ? ' (소스 저장소 반영됨)' : ''}` });
if (!rec.pushed) broadcast(site.id, { type: 'log', level: 'debug', text: `소스 저장소(Gitea) 반영 실패 — 다음 변경 때 다시 시도합니다: ${rec.error}` });
} catch (e) { broadcast(site.id, { type: 'log', level: 'debug', text: `버전 정리 실패: ${e.message}` }); }
}
return r;
}
/** 기존 사이트 가져오기 작업(비동기): 파일 내려받기 → 버전 기록 → 대기 상태 */
async function runImport(site) {
const job = jobOf(site.id);
if (job.running) throw Object.assign(new Error('이미 실행 중인 작업이 있습니다'), { status: 409 });
if (job.deploying) throw Object.assign(new Error('배포가 진행 중입니다. 완료 후 다시 시도하세요'), { status: 409 });
assertJobCapacity(); // v1.6.31 동시 작업 상한
job.running = true; job.log = []; job.cancelRequested = false;
const siteDir = path.join(SITES_ROOT, site.id); const src = site.source;
site.status = 'importing'; site.importError = null; store.save(site);
broadcast(site.id, { type: 'status', status: 'running', label: '기존 사이트 가져오기' });
broadcast(site.id, { type: 'log', level: 'info', text: `기존 사이트 가져오기 시작 — ${src.protocol.toUpperCase()} ${src.user}@${src.host}:${src.port} ${src.docroot}` });
let ok = false;
try {
const r = await remote.pull(src, siteDir, (t) => broadcast(site.id, { type: 'progress', text: t }));
chmodTree(siteDir);
if (!site.titleCustom && r.title) site.title = r.title.slice(0, 80);
site.importInfo = { at: src.importedAt, fileCount: r.fileCount, bytes: r.bytes, index: r.index, dynamic: r.dynamic };
site.status = 'idle'; store.save(site);
broadcast(site.id, { type: 'log', level: 'info', text: `가져오기 완료 — 파일 ${r.fileCount}개, ${(r.bytes / 1024 / 1024).toFixed(1)}MB${r.index ? ', 첫 페이지 ' + r.index : ', 첫 페이지 파일(index.html) 없음'}${r.dynamic ? ' · 서버 스크립트(PHP 등) 포함 — 이 도구는 파일 수정만 지원하며 서버 실행 결과는 미리보기에 나타나지 않습니다' : ''}` });
if (!r.index) broadcast(site.id, { type: 'log', level: 'warn', text: '첫 페이지 파일(index.html)을 찾지 못했습니다. 사이트 경로가 맞는지 확인하세요.' });
if (r.dynamic) broadcast(site.id, { type: 'log', level: 'warn', text: '이 사이트에는 서버에서 실행되는 스크립트(PHP 등)가 있습니다. 미리보기는 정적 파일만 보여 주며, 수정은 원본 서버에 반영한 뒤 실제 주소에서 확인하세요.' });
await gitRecord(site, 'import', `기존 사이트 가져옴: ${src.host}${src.docroot}`);
ok = true;
} catch (err) {
site.status = 'error'; site.importError = err.message; store.save(site);
userWarn(site.id, `사이트를 가져오지 못했습니다: ${err.message}`, `가져오기 실패(${src.user}@${src.host}): ${err.message}`);
} finally {
job.running = false; job.cancel = null;
broadcast(site.id, { type: 'done', site: publicView(site), command: null });
}
// v1.2.0: 가져온 뒤 현황 분석 — 작업 잠금을 푼 다음에 시작해야 분석 단계가 자기 잠금을 정상적으로 잡는다
if (ok) analyzeSite(site).catch((e) => broadcast(site.id, { type: 'log', level: 'debug', text: `사이트 분석 실패: ${e.message}` }));
}
/**
* 사이트 파일 현황 통계 (v1.2.0): 파일 종류별 개수·크기, HTML 페이지 목록, 서버 실행 코드·CMS·프레임워크 흔적.
* AI 를 쓰지 않으므로 비용이 없고, 가져오기·복사 직후 항상 계산한다.
*/
function siteStats(siteDir) {
const byExt = {}; let files = 0; let bytes = 0; const pages = []; const markers = new Set(); let newest = 0;
const walk = (dir, rel) => { let ents = []; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; } for (const e of ents) { if (e.name.startsWith('.') && e.name !== '.htaccess') continue; const p = path.join(dir, e.name); const r = rel ? rel + '/' + e.name : e.name; if (e.isDirectory()) { if (['node_modules', '.git'].includes(e.name)) continue; if (/^(wp-admin|wp-content|wp-includes)$/.test(e.name)) markers.add('WordPress'); if (e.name === 'vendor') markers.add('PHP Composer'); walk(p, r); continue; } if (!e.isFile()) continue; let st; try { st = fs.statSync(p); } catch (_) { continue; } files += 1; bytes += st.size; newest = Math.max(newest, st.mtimeMs); const ext = (path.extname(e.name).slice(1) || '(없음)').toLowerCase(); byExt[ext] = byExt[ext] || { n: 0, bytes: 0 }; byExt[ext].n += 1; byExt[ext].bytes += st.size; if (/\.html?$/i.test(e.name) && pages.length < 200) pages.push(r); if (e.name === 'wp-config.php') markers.add('WordPress'); if (e.name === 'artisan') markers.add('Laravel'); if (e.name === 'package.json') markers.add('Node.js 프로젝트'); if (e.name === '.htaccess') markers.add('Apache .htaccess'); if (/\.php$/i.test(e.name)) markers.add('PHP'); if (/^(gnuboard|_common\.php)$/i.test(e.name)) markers.add('그누보드'); } };
walk(siteDir, '');
const top = Object.entries(byExt).sort((a, b) => b[1].bytes - a[1].bytes).slice(0, 12).map(([ext, v]) => ({ ext, n: v.n, bytes: v.bytes }));
return { files, bytes, pages: pages.slice(0, 200), pageCount: pages.length, byExt: top, markers: [...markers], newestAt: newest ? new Date(newest).toISOString() : null };
}
/**
* 사이트 현황 분석 (v1.2.0, 사이트 관리하기·복사하기 공통): 통계 + (가능하면) 서버 정보 + AI 요약.
* AI 요약은 비용을 줄이려고 "기본" 등급으로 실행하며 사용자 캐시에서 차감된다(작업 목록에 "사이트 분석" 으로 표시).
*/
async function analyzeSite(site) {
const siteDir = path.join(SITES_ROOT, site.id); const stats = siteStats(siteDir);
site.analysis = { at: new Date().toISOString(), stats, server: site.source ? { webServers: site.source.webServers || [], vhost: site.source.vhost || null, host: site.source.host, docroot: site.source.docroot } : null, copy: site.copyInfo || null, ai: null };
store.save(site); broadcast(site.id, { type: 'done', site: publicView(site), command: null });
if (!isAdminUser(site.ownerId) && credits.balance(site.ownerId) <= 0) { site.analysis.ai = { skipped: '캐시가 없어 AI 요약을 건너뛰었습니다' }; store.save(site); broadcast(site.id, { type: 'done', site: publicView(site), command: null }); return site.analysis; }
const prompt = `현재 디렉터리는 ${site.mode === 'copy' ? '웹에서 복사해 온' : '서버에서 가져온'} 운영 중인 웹사이트 파일입니다. 파일을 훑어보고(Read/Glob, 큰 파일은 앞부분만) 사이트 현황을 JSON 으로만 요약하세요.
[파일 통계] ${JSON.stringify({ files: stats.files, bytes: stats.bytes, pageCount: stats.pageCount, byExt: stats.byExt, markers: stats.markers })}
[페이지 일부] ${stats.pages.slice(0, 40).join(', ')}
출력 JSON: {"summary": "어떤 사이트인지 2~3문장", "structure": ["주요 페이지/섹션 구성 (최대 8개)"], "tech": ["사용 기술: 정적 HTML, PHP, CMS, 프레임워크, 주요 라이브러리 등"], "editability": "AI 로 수정하기 쉬운 정도와 이유 (예: 정적 HTML 이라 쉬움 / PHP 템플릿이라 HTML 부분만 수정 가능)", "issues": ["눈에 띄는 문제: 깨진 링크, 오래된 라이브러리, 모바일 대응 부족, 접근성 등 (최대 6개)"], "suggestions": ["개선 제안 (최대 5개)"]}`;
try { site.analysis.ai = await runPlanStep(site, 'site-analyze', prompt, { tier: 'basic' }); }
catch (e) { site.analysis.ai = { error: e.message }; }
store.save(site); broadcast(site.id, { type: 'done', site: publicView(site), command: null });
return site.analysis;
}
/** 사이트 복사하기 작업 (v1.2.0): 웹 주소에서 HTML·CSS·JS·이미지를 내려받아 사이트 폴더로 */
async function runCopy(site, allowPrivate) {
const job = jobOf(site.id); if (job.running) throw Object.assign(new Error('이미 실행 중인 작업이 있습니다'), { status: 409 });
assertJobCapacity(); // v1.6.31 동시 작업 상한
job.running = true; job.log = []; job.cancelRequested = false;
const siteDir = path.join(SITES_ROOT, site.id);
site.status = 'importing'; site.importError = null; store.save(site);
broadcast(site.id, { type: 'status', status: 'running', label: '사이트 복사하기' });
broadcast(site.id, { type: 'log', level: 'info', text: `사이트 복사 시작 — ${site.copyFrom}` });
let ok = false;
try {
const r = await crawler.copySite(site.copyFrom, siteDir, { allowPrivate, onProgress: (t) => broadcast(site.id, { type: 'progress', text: t }), isCancelled: () => job.cancelRequested });
chmodTree(siteDir);
if (!site.titleCustom && r.title) site.title = r.title.slice(0, 80);
site.copyInfo = { at: new Date().toISOString(), from: site.copyFrom, pages: r.pages, assets: r.assets, bytes: r.bytes, skipped: r.skipped, errors: r.errors.length, dynamic: r.dynamic };
if (site.plan) { site.plan.stage = 'generated'; site.plan.brief.business.name = site.title; }
site.status = 'idle'; store.save(site);
broadcast(site.id, { type: 'log', level: 'info', text: `복사 완료 — 페이지 ${r.pages}개, 파일 ${r.assets}개, ${(r.bytes / 1048576).toFixed(1)}MB${r.skipped ? ` (상한으로 ${r.skipped}개 건너뜀)` : ''}${r.errors.length ? ` · 가져오지 못한 주소 ${r.errors.length}개` : ''}` });
if (r.dynamic) broadcast(site.id, { type: 'log', level: 'warn', text: '서버에서 실행되는 페이지(PHP 등)는 결과 화면(HTML)만 복사되었습니다. 문의·로그인 같은 서버 기능은 동작하지 않을 수 있습니다.' });
await gitRecord(site, 'import', `사이트 복사: ${site.copyFrom}`);
ok = true;
} catch (err) {
site.status = 'error'; site.importError = err.message; if (site.plan && site.plan.stage === 'importing') site.plan.stage = 'generated'; store.save(site);
userWarn(site.id, `사이트를 복사하지 못했습니다: ${err.message}`, `복사 실패(${site.copyFrom}): ${err.message}`);
} finally { job.running = false; job.cancel = null; broadcast(site.id, { type: 'done', site: publicView(site), command: null }); }
if (ok) analyzeSite(site).catch((e) => broadcast(site.id, { type: 'log', level: 'debug', text: `사이트 분석 실패: ${e.message}` })).then(() => draftOptimize(site)).catch(() => {}); // v1.3.5: 분석 뒤 SEO 초안
}
// ---------- 기획(플랜) ----------
/**
* 1~2단계 분석: 참고 사이트 수집 → 이해 확인 카드 → Brief 반영. 라우트와 캐시 충전 후 재개가 함께 쓴다 (v1.1.6 함수화).
* @param {object} site
* @param {'analyze'|'answers'} step analyze 면 참고 사이트를 다시 수집
*/
async function doAnalyze(site, step) {
const plan = site.plan; const B = () => plan.brief;
if (!plan.refsFetched || step === 'analyze') await prefetchRefs(site);
const data = await runPlanStep(site, 'plan-analyze', planner.analyzePrompt(site));
plan.understanding = data;
const b = B();
mergeBrief(b, { business: data.business, customers: data.customers, goals: data.goals, features: data.features, origin: data.origin, facts: data.facts });
if (data.regulated && data.regulated.flag && data.regulated.category && briefLib.REGULATIONS[data.regulated.category]) b.regulated = { flag: true, category: data.regulated.category, constraints: briefLib.REGULATIONS[data.regulated.category].constraints };
else { const ind = briefLib.findIndustry(b.business.industry.major); if (ind && ind.defaults.regulated) b.regulated = { flag: true, category: ind.defaults.regulated, constraints: briefLib.REGULATIONS[ind.defaults.regulated].constraints }; }
if (data.recommendedStyle && data.recommendedStyle.tone) { b.design.recommendedTone = data.recommendedStyle.tone; b.design.tone = data.recommendedStyle.tone; }
if (b.business.name) site.title = b.business.name;
briefLib.applyRegulation(b);
plan.stage = (data.questions && data.questions.length) ? 'questions' : 'understanding';
store.save(site); planEvent(site);
return data;
}
const PLAN_LABEL = { 'site-translate': '다국어 번역', 'plan-analyze': 'AI 이해 확인', 'plan-structure': '구조 제안', 'plan-design': '디자인 3안', 'plan-verify': '반영 검증', 'site-analyze': '사이트 분석', 'seo-draft': '검색·AI 최적화 초안' };
/** 기획 단계 AI 호출: 명령 이력/토큰 기록, 잠금, JSON 반환 */
async function runPlanStep(site, kind, prompt, opts = {}) {
const job = jobOf(site.id);
if (job.running) throw Object.assign(new Error('이미 실행 중인 작업이 있습니다'), { status: 409 });
if (job.deploying) throw Object.assign(new Error('배포가 진행 중입니다. 완료 후 다시 시도하세요'), { status: 409 });
assertJobCapacity(); // v1.6.31 동시 작업 상한
job.running = true; job.log = []; job.cancelRequested = false;
const ownerAdmin = isAdminUser(site.ownerId); const startBalance = credits.balance(site.ownerId);
if (!ownerAdmin && startBalance <= 0) { job.running = false; throw Object.assign(new Error('캐시가 부족합니다. 관리자에게 캐시 충전을 요청하세요.'), { status: 402, creditStop: true }); }
job.creditStop = false;
const stepRef = opts.tier ? settings.tierRef(opts.tier) : siteRef(site); // opts.tier: 이 단계만 다른 등급으로 (예: 사이트 분석은 기본 등급)
try { await assertEngine(stepRef); } catch (e) { job.running = false; if (e.adminDetail) broadcast(site.id, { type: 'log', level: 'debug', text: `엔진 준비 실패: ${e.adminDetail}` }); throw e; }
const cmd = { no: site.commands.length + 1, kind, instruction: PLAN_LABEL[kind] || kind, prompt, model: stepRef, tier: settings.validTier(opts.tier || site.tier), startedAt: new Date().toISOString(), finishedAt: null, status: 'running', usage: null, costUsd: 0, numTurns: 0, durationMs: 0, resultText: '', error: null, attachments: [] };
cmd.log = []; job.currentCmd = cmd; cmd.userIdForEstimate = site.ownerId;
site.commands.push(cmd); site.status = 'running'; store.save(site);
broadcast(site.id, { type: 'status', status: 'running', commandNo: cmd.no, label: PLAN_LABEL[kind] || kind });
broadcast(site.id, { type: 'log', level: 'info', text: `${PLAN_LABEL[kind] || kind} 시작 — 모델 ${cmd.model}` });
await primeRate(job);
broadcast(site.id, { type: 'progress', text: `${PLAN_LABEL[kind] || kind}을(를) 준비하고 있습니다` });
try {
const { data, result: r } = await planner.runJson({ siteDir: path.join(SITES_ROOT, site.id), prompt, ref: cmd.model, userId: site.ownerId, toolset: opts.toolset || 'read', onEvent: (ev) => { const e = liveUsageEvent(job, cmd, ev); if (e.type === 'usage' && e.krwEstimate != null) { e.creditLeft = startBalance - e.krwEstimate; if (!ownerAdmin && creditExhausted(startBalance, e.creditLeft) && !job.creditStop) { job.creditStop = true; job.cancelRequested = true; if (job.cancel) job.cancel(); } } broadcast(site.id, e); }, onStart: (h) => { job.cancel = h.cancel; if (job.cancelRequested) h.cancel(); } });
cmd.userKey = r.source === 'user';
Object.assign(cmd, { finishedAt: new Date().toISOString(), status: 'done', usage: r.usage, costUsd: r.costUsd, costSource: r.costSource || 'priced', costPricedUsd: r.costPricedUsd, numTurns: r.numTurns, durationMs: r.durationMs, resultText: JSON.stringify(data).slice(0, 4000), modelUsage: r.modelUsage, model: r.model || cmd.model });
Object.assign(cmd, await chargeFor(r.costUsd, cmd.startedAt, cmd.userKey));
costcal.observe(cmd.model, estimateUsdFor(r.usage, cmd.model), r.costUsd);
deductCredit(site, cmd, PLAN_LABEL[kind] || kind);
site.status = 'idle'; store.save(site);
broadcast(site.id, { type: 'log', level: 'info', text: `${PLAN_LABEL[kind] || kind} 완료 — 토큰 ${r.usage.total.toLocaleString()}, 비용 $${r.costUsd.toFixed(4)}` });
return data;
} catch (err) {
const r = err.result;
if (err.cancelled && job.creditStop) { err.creditStop = true; if (!pipelineSites.has(site.id) && ['plan-analyze', 'plan-verify'].includes(kind)) { site.creditWait = { at: new Date().toISOString(), kind: 'plan', step: kind, instruction: PLAN_LABEL[kind] || kind, cmdNo: cmd.no }; } }
if (err.cancelled) { Object.assign(cmd, { finishedAt: new Date().toISOString(), status: job.creditStop ? 'paused-credit' : 'cancelled', error: job.creditStop ? '캐시 소진으로 중단' : err.message, usage: err.usage, usageEstimated: true, costUsd: estimateUsdCal(err.usage, cmd.model) }); Object.assign(cmd, await chargeFor(cmd.costUsd, cmd.startedAt)); deductCredit(site, cmd, `${PLAN_LABEL[kind] || kind} (중단)`); site.status = 'idle'; if (job.creditStop) { err.message = '캐시가 소진되어 중단되었습니다. 충전되면 자동으로 다시 진행합니다.'; if (site.creditWait || pipelineSites.has(site.id)) site.status = 'waiting-credit'; } }
else { Object.assign(cmd, { finishedAt: new Date().toISOString(), status: 'error', error: err.message, usage: r ? r.usage : null, costUsd: r ? r.costUsd : 0, resultText: err.rawText ? err.rawText.slice(0, 20000) : '' }); site.status = 'error'; if (err.rawText) cmd.log.push({ ts: Date.now(), level: 'debug', text: `파싱하지 못한 AI 응답 (끝부분): …${err.rawText.slice(-1500)}` }); } // v1.3.10: 원인 분석용 원문 보관
store.save(site);
if (err.cancelled) broadcast(site.id, { type: 'log', level: 'warn', text: job.creditStop ? `캐시가 모두 소진되어 ${PLAN_LABEL[kind] || kind} 을(를) 멈췄습니다. 캐시가 충전되면 이 단계부터 자동으로 이어서 진행합니다.` : `${PLAN_LABEL[kind] || kind} 이(가) 중단되었습니다.` });
else userWarn(site.id, `${PLAN_LABEL[kind] || kind} 을(를) 완료하지 못했습니다. 다시 시도해 주세요.`, `${PLAN_LABEL[kind] || kind} 실패: ${err.message}`);
throw Object.assign(err, { status: err.cancelled ? 499 : (err.status || 502) });
} finally {
job.cancel = null; job.currentCmd = null;
job.running = false;
broadcast(site.id, { type: 'done', site: publicView(site), command: stripCmd(cmd) });
}
}
function newPlan(mode) { return { mode, stage: mode === 'ai' ? 'input' : 'manual', input: { text: '', existingUrl: '', references: [], answers: [] }, understanding: null, structure: null, designOptions: null, brief: briefLib.defaultBrief(mode), warnings: [], verification: null, published: null, confirmedAt: null }; }
function planEvent(site) { broadcast(site.id, { type: 'plan', plan: site.plan, site: publicView(site) }); }
/**
* 사실정보 반영 (v1.0.3): 사용자가 값을 입력한 항목만 'provided', 값이 없으면 'removed'(홈페이지에서 항목 자체를 뺌).
* 이전의 내용유지/빈항목유지/항목삭제 선택은 없앴다 — "입력한 값만 쓴다"는 한 가지 규칙으로 단순화.
*/
function setFacts(target, src) {
for (const k of Object.keys(target.facts)) {
if (!src || !src[k]) continue;
const f = src[k]; const value = String(f.value || '').trim();
target.facts[k] = { value, source: value ? (f.source || 'user_text') : null, status: value ? 'provided' : 'removed' };
}
}
function mergeBrief(b, patch) {
if (!patch || typeof patch !== 'object') return b;
for (const k of ['business', 'customers', 'design', 'copy', 'assets', 'regulated']) if (patch[k] && typeof patch[k] === 'object') b[k] = { ...b[k], ...patch[k] };
if (patch.business && patch.business.industry) b.business.industry = { ...b.business.industry, ...patch.business.industry };
if (patch.business && patch.business.region) b.business.region = { ...b.business.region, ...patch.business.region };
if (Array.isArray(patch.goals)) b.goals = patch.goals;
if (patch.features && typeof patch.features === 'object') b.features = { ...b.features, ...patch.features };
if (Array.isArray(patch.pages)) b.pages = patch.pages.map((p) => ({ id: String(p.id || '').replace(/[^a-z0-9-]/gi, '').toLowerCase() || 'page', title: p.title || p.id, purpose: p.purpose || '', sections: (p.sections || []).map((sx) => (typeof sx === 'string' ? { id: sx, title: (briefLib.SECTIONS[sx] || {}).label || sx, feature: (briefLib.SECTIONS[sx] || {}).feature || null } : { id: sx.id, title: sx.title || (briefLib.SECTIONS[sx.id] || {}).label || sx.id, goal: sx.goal || null, feature: sx.feature !== undefined ? sx.feature : ((briefLib.SECTIONS[sx.id] || {}).feature || null), note: sx.note || '' })) }));
if (patch.facts) setFacts(b, patch.facts);
if (Array.isArray(patch.references)) b.references = patch.references.slice(0, 10);
if (typeof patch.notes === 'string') b.notes = patch.notes;
if (patch.origin) b.origin = { ...b.origin, ...patch.origin };
return b;
}
function applyDesignOption(b, opt) {
const tone = briefLib.TONES.find((t) => t.id === opt.tone) || briefLib.TONES[0];
const fonts = briefLib.FONT_SETS.find((f) => f.id === (opt.fonts && opt.fonts.id ? opt.fonts.id : opt.fonts)) || briefLib.FONT_SETS[0];
b.design = { ...b.design, tone: tone.id, palette: opt.palette && Array.isArray(opt.palette.colors) && opt.palette.colors.length >= 4 ? opt.palette : tone.palettes[0], fonts, density: opt.density || 'standard', imagery: opt.imagery || 'photo', optionId: opt.id, optionName: opt.name, heroIdea: opt.heroIdea || '' };
}
function normRefs(refs) { return (Array.isArray(refs) ? refs : []).filter((r) => r && r.url).slice(0, 10).map((r) => ({ url: String(r.url).trim(), notes: String(r.notes || '').trim() })); }
/** 참고 사이트/기존 사이트 본문을 .refs/ 에 미리 저장 (실패해도 진행) */
async function prefetchRefs(site) {
const inp = site.plan.input;
if (!(inp.references || []).length && !inp.existingUrl) { site.plan.refsFetched = []; return; }
broadcast(site.id, { type: 'log', level: 'info', text: `참고 사이트 ${(inp.references || []).length + (inp.existingUrl ? 1 : 0)}개 내용을 가져옵니다…` });
broadcast(site.id, { type: 'progress', text: '참고 사이트 내용을 가져오고 있습니다' });
try {
const out = await refsLib.prefetchReferences(path.join(SITES_ROOT, site.id), inp.references, inp.existingUrl, { allowPrivate: isAdminUser(site.ownerId) }); // v1.3.17 (S08): 내부 주소는 관리자 사이트만
site.plan.refsFetched = out; store.save(site);
for (const r of out) broadcast(site.id, { type: 'log', level: r.ok ? 'info' : 'debug', text: r.ok ? `참고 사이트 수집: ${r.url} (${r.title || '제목 없음'})` : `참고 사이트 수집 실패: ${r.url} — ${r.error}` });
} catch (e) { broadcast(site.id, { type: 'log', level: 'debug', text: `참고 사이트 수집 오류: ${e.message}` }); }
}
/**
* AI에게 맡기기 자동 파이프라인: 이해 확인(사용자가 사실정보만 입력) 이후의 모든 단계를 순서대로 실행한다.
* 구조 제안 → 디자인 3안(사용자가 고른 톤이 있으면 그 톤의 안, 없으면 추천안) → Brief 확정 → 생성 → 검증.
* 각 단계는 기존 단계 핸들러와 같은 규칙을 쓰며, 중간 실패 시 plan.autoRun.error 에 남기고 그 단계에서 멈춘다(사용자가 이어서 진행 가능).
*/
/**
* AI에게 맡기기 자동 파이프라인 (v1.1.6: 재개 가능).
* 구조 → 디자인 → 확정 → 생성 → 검증. 끝낸 단계는 plan.autoRun.done 에 남겨, 캐시 소진으로 멈췄다가 충전 후 재개될 때 건너뛴다.
* @param {object} site
* @param {{resume?: boolean, inner?: object}} [opts] inner: 생성 단계가 캐시 소진으로 일시 중지된 경우 그 이어하기 정보(runCommand 의 creditWait)
*/
async function runAutoPipeline(site, opts = {}) {
const plan = site.plan; const b = plan.brief;
const done = new Set((opts.resume && plan.autoRun && plan.autoRun.done) || []);
const mark = (stage) => { plan.autoRun = { ...(plan.autoRun || {}), stage, done: [...done], error: null }; store.save(site); planEvent(site); };
const finish = (name) => { done.add(name); plan.autoRun = { ...(plan.autoRun || {}), done: [...done] }; store.save(site); };
pipelineSites.add(site.id);
try {
if (!done.has('structure')) {
mark('structure');
const st = await runPlanStep(site, 'plan-structure', planner.structurePrompt(site));
plan.structure = st; mergeBrief(b, { pages: st.pages || [] }); briefLib.applyRegulation(b); briefLib.reconcileFeatures(b); plan.warnings = briefLib.checkConsistency(b); plan.stage = 'structure';
finish('structure');
}
if (!done.has('design')) {
mark('design');
const ds = await runPlanStep(site, 'plan-design', planner.designPrompt(site));
plan.designOptions = ds;
const wantTone = plan.input.design && plan.input.design.tone;
const pick = (ds.options || []).find((o) => wantTone && o.tone === wantTone) || (ds.options || []).find((o) => o.id === ds.recommended) || (ds.options || [])[0];
if (pick) applyDesignOption(b, pick);
if (plan.input.design && plan.input.design.color && b.design.palette && Array.isArray(b.design.palette.colors)) { b.design.palette = { ...b.design.palette, name: `${b.design.palette.name} (사용자 색상 반영)`, colors: [b.design.palette.colors[0], b.design.palette.colors[1], b.design.palette.colors[2], plan.input.design.color] }; }
plan.stage = 'design'; finish('design');
}
if (!done.has('confirm')) {
mark('confirm');
if (!b.business.name) b.business.name = site.title || '내 사이트';
if (!b.pages.length) briefLib.applyIndustryDefaults(b); if (!b.pages.length) b.pages = briefLib.genericPages(); if (!b.goals.length) b.goals = ['intro', 'inquiry'];
briefLib.applyRegulation(b); briefLib.reconcileFeatures(b); plan.warnings = briefLib.checkConsistency(b);
if (!b.design.tone) b.design.tone = b.design.recommendedTone || 'minimal'; if (!b.design.palette) b.design.palette = briefLib.TONES.find((t) => t.id === b.design.tone).palettes[0]; if (!b.design.fonts) b.design.fonts = briefLib.FONT_SETS[0];
plan.confirmedAt = new Date().toISOString(); plan.stage = 'confirmed'; site.title = b.business.name; finish('confirm');
}
if (!done.has('generate')) {
mark('generate'); plan.stage = 'generating'; store.save(site); planEvent(site);
let cmd;
if (opts.inner) cmd = await runCommand(site, opts.inner.kind, `(이어서) ${opts.inner.instruction}`, continuationPrompt(opts.inner), [], { continuation: true, resume: true, originalPrompt: opts.inner.prompt });
else {
const kind = site.commands.some((c) => c.kind === 'create' && c.status === 'done') ? 'update' : 'create';
const prompt = buildBriefPrompt(b, attachmentsSection(site.attachments.filter((a) => !a.usedInCommands.length), site.attachments.filter((a) => a.usedInCommands.length)), widgetFor(site, ''));
cmd = await runCommand(site, kind, kind === 'create' ? '(Brief 기반 생성)' : '(Brief 기반 재생성)', prompt, site.attachments.map((a) => a.name));
}
if (cmd && cmd.status === 'paused-credit') {
// 생성 중 캐시 소진: runCommand 가 남긴 이어하기 정보를 파이프라인 대기로 감싸 충전 후 생성 → 검증까지 이어간다
const inner = site.creditWait; site.creditWait = { at: new Date().toISOString(), kind: 'auto', instruction: 'AI 자동 생성', inner };
plan.autoRun = { ...(plan.autoRun || {}), stage: 'waiting-credit', done: [...done] }; site.status = 'waiting-credit'; store.save(site); planEvent(site); return;
}
if (!cmd || cmd.status !== 'done' || jobOf(site.id).cancelRequested) { if (plan.stage === 'generating') plan.stage = 'confirmed'; plan.autoRun = { ...(plan.autoRun || {}), stage: 'error', error: '생성이 완료되지 않았습니다' }; store.save(site); planEvent(site); return; }
finish('generate');
}
if (!done.has('verify')) { mark('verify'); await runVerification(site); finish('verify'); }
// v1.3.5: 생성 옵션으로 고른 SEO·AEO·GEO 초안 (실패해도 파이프라인은 완료로 둔다 — 관리 탭에서 다시 만들 수 있음)
if (!done.has('optimize') && site.optimize && site.optimize.status === 'waiting') { mark('optimize'); await draftOptimize(site).catch(() => {}); finish('optimize'); }
plan.autoRun = { ...(plan.autoRun || {}), stage: 'done', finishedAt: new Date().toISOString() }; store.save(site); planEvent(site);
} catch (e) {
if (e.creditStop) {
// 기획 단계 도중 캐시 소진: 끝낸 단계는 두고 충전되면 멈춘 단계부터 다시
site.creditWait = { at: new Date().toISOString(), kind: 'auto', instruction: 'AI 자동 생성' };
plan.autoRun = { ...(plan.autoRun || {}), stage: 'waiting-credit', done: [...done], error: null }; site.status = 'waiting-credit'; store.save(site); planEvent(site);
broadcast(site.id, { type: 'status', status: 'waiting-credit', label: 'AI 자동 생성' });
return;
}
plan.autoRun = { ...(plan.autoRun || {}), stage: 'error', error: e.message }; store.save(site); planEvent(site);
userWarn(site.id, '자동 생성이 중간에 멈췄습니다. 아래 단계 버튼으로 이어서 진행할 수 있습니다.', `자동 파이프라인 오류(${plan.autoRun.stage}): ${e.message}`);
} finally { pipelineSites.delete(site.id); }
}
async function runVerification(site) {
const siteDir = path.join(SITES_ROOT, site.id);
const rules = briefLib.verifyFiles(siteDir, site.plan.brief, multilang.isMulti(site) ? site.langs.default + '/' : ''); // 다국어 사이트는 기본 언어 폴더만 검사
let ai = null;
try { ai = await runPlanStep(site, 'plan-verify', planner.verifyPrompt(site)); } catch (e) { if (e.creditStop && pipelineSites.has(site.id)) throw e; ai = { error: e.message, items: [], fixes: [] }; }
// v1.3.16 다국어 품질 게이트(번역 누락률·원문 잔존율·링크 대응·언어별 검수)를 검증 결과에 함께 넣는다
const i18nq = multilang.isMulti(site) ? multilang.quality(site, siteDir) : null;
site.plan.verification = { at: new Date().toISOString(), rules, ai, i18n: i18nq };
if (site.plan.stage === 'generating') site.plan.stage = 'generated';
store.save(site); planEvent(site);
return site.plan.verification;
}
function applyBriefFromText(site, text) {
const m = (text || '').match(/<brief>([\s\S]*?)<\/brief>/);
if (!m) return false;
const data = planner.extractJson(m[1]);
site.plan.brief = mergeBrief(site.plan.brief, data);
briefLib.applyRegulation(site.plan.brief);
site.plan.warnings = briefLib.checkConsistency(site.plan.brief);
return true;
}
// ---------- 라우팅 ----------
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon' };
// ---------- 미리보기 사이트의 "루트 기준 주소" 되돌리기 (v1.3.3) ----------
/**
* 사이트 미리보기는 서비스 주소의 하위 경로(/<사이트ID>/)에서 열린다. 가져오거나(사이트 관리하기) 복사한(사이트 복사하기)
* 운영 사이트는 도메인 루트에서 돌던 사이트라 "/ko/", "/assets/app.css", location.replace("/en/") 처럼 루트 기준 주소를 쓴다.
* 그 요청이 미리보기 폴더가 아닌 서비스 루트(/ko/ 등)로 와서 서비스의 "페이지를 찾을 수 없습니다" 가 보이던 문제(1.3.2 까지).
* 원본 파일은 운영 서버에 그대로 되돌려 반영해야 하므로 고치지 않고, 요청 시점에 바로잡는다:
* 요청을 보낸 페이지(Referer)가 같은 서비스 주소의 /<사이트ID>/… 이고, 요청 경로가 서비스 자체 경로가 아니면
* → /<사이트ID><경로> 로 302 이동. (Referer 를 보내지 않는 페이지는 바로잡을 수 없다 — ERRORS.md 참고)
* 서비스 자체 경로(API, 위젯 스크립트, 웹메일, 사이트 관리자, 테마·번역)는 사이트 안에서 불러도 그대로 둔다.
*/
/** v1.3.17 (S02) 요청을 보낸 페이지가 같은 서비스 주소의 생성 사이트(/<32자리 ID>/…)인지 */
function fromTenantPage(req) {
const ref = req.headers.referer || req.headers.referrer; if (!ref) return false;
let u; try { u = new URL(ref); } catch (_) { return false; }
const hosts = new Set([...SERVICE_HOSTS, req.headers.host, req.headers['x-forwarded-host']].filter(Boolean));
return hosts.has(u.host) && /^\/[a-f0-9]{32}(\/|$)/.test(u.pathname);
}
const PREVIEW_PASS = /^\/(api\/|wm-[\w-]+\.js$|webmail\/|site-admin\/|theme\.css$|i18n\.js$)/;
const SERVICE_HOSTS = new Set([(() => { try { return new URL(PUBLIC_BASE_URL).host; } catch (_) { return ''; } })()].filter(Boolean));
function previewRedirect(req, url) {
if (req.method !== 'GET' && req.method !== 'HEAD') return null;
const p = url.pathname; if (/^\/[a-f0-9]{32}(\/|$)/.test(p) || PREVIEW_PASS.test(p)) return null;
const ref = req.headers.referer || req.headers.referrer; if (!ref) return null;
let u; try { u = new URL(ref); } catch (_) { return null; }
const hosts = new Set([...SERVICE_HOSTS, req.headers.host, req.headers['x-forwarded-host']].filter(Boolean));
if (!hosts.has(u.host)) return null;
const m = u.pathname.match(/^\/([a-f0-9]{32})\//); if (!m || !store.load(m[1])) return null;
return `/${m[1]}${p}${url.search}`;
}
/** v1.6.31 요청 출처에 따른 세션 사용 판단 (위 주석) */
function sessionUser(req) {
const u = auth.userFromRequest(req); if (!u) return null;
const mode = String(req.headers['sec-fetch-mode'] || ''); const dest = String(req.headers['sec-fetch-dest'] || '');
if (mode === 'navigate' || dest === 'document') return u; // 화면 이동(응답을 스크립트가 읽을 수 없음)
if (fromTenantPage(req)) return null;
const ref = req.headers.referer || req.headers.referrer;
if (ref) { let r; try { r = new URL(ref); } catch (_) { r = null; } const hosts = new Set([...SERVICE_HOSTS, req.headers.host, req.headers['x-forwarded-host']].filter(Boolean)); if (r && hosts.has(r.host)) return u; }
if (auth.csrfOk(u, req.headers['x-wm-csrf'])) return u; // Referer 가 없거나 다른 호스트: CSRF 토큰이 있어야 한다
if (!ref && !mode && !dest && req.method === 'GET') return u; // 브라우저가 아닌 호출(curl 등 Fetch Metadata 없음)의 GET — 쿠키를 가진 스크립트는 이미 그 사용자
return null;
}
/** 요청이 HTTPS 로 들어왔는지 (앞단 nginx 의 X-Forwarded-Proto 또는 공개 주소가 https) */
function isSecureReq(req) { const xf = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim(); if (xf) return xf === 'https'; return /^https:/i.test(PUBLIC_BASE_URL); }
/** v1.6.31 (요구 3·25): 서버 전체 동시 AI 작업 수 제한 — 넘으면 429 */
function assertJobCapacity() { const max = Number(config.get('ai.maxConcurrentJobs') || 4); const running = [...jobs.values()].filter((j) => j.running).length; if (running >= max) throw Object.assign(new Error(`지금 서버에서 실행 중인 AI 작업이 많습니다(${running}/${max}). 잠시 후 다시 시도하세요`), { status: 429 }); }
/**
* [Ariweb Suite 변경] 동시 편집 충돌 검사(§9.3). 화면이 보낸 rev(본문 rev 또는 If-Match 헤더)가 저장된 site.rev 와 다르면
* "마지막 저장으로 조용히 덮어쓰지 않고" 409 와 함께 두 버전의 요약(현재 rev·갱신 시각·마지막 변경)을 돌려준다. rev 를 보내지 않은 옛 화면은 예전처럼 동작한다.
*/
function revConflict(site, b, req) {
const given = b && b.rev !== undefined && b.rev !== null && b.rev !== '' ? Number(b.rev) : (req.headers['if-match'] ? Number(String(req.headers['if-match']).replace(/"/g, '')) : null);
if (given === null || !Number.isFinite(given)) return null;
const cur = Number(site.rev) || 0; if (given === cur) return null;
const last = (site.git && site.git.changes || []).slice(-1)[0] || null;
return { error: '다른 사용자(또는 다른 창)가 먼저 저장해 내용이 달라졌습니다. 최신 내용을 불러온 뒤 다시 적용하거나, 내 변경을 그대로 덮어쓰려면 최신 rev 로 다시 저장하세요', conflict: { yourRev: given, currentRev: cur, updatedAt: site.updatedAt || null, lastChange: last ? { no: last.no, kind: last.kind, message: last.message, at: last.at } : null, manualEdits: (site.manualEdits || []).slice(-3) } };
}
/**
* [Ariweb Suite 변경] 포털 내부 API. 서명 검증은 호출자가 끝냈다. 경로 조각 parts = ['projects', id, ...].
* GET projects?owner=<login> 소유자의 프로젝트 목록(제목·상태·rev·갱신 시각·연결)
* POST projects {owner, ownerName, title, purpose, link?} 빈 프로젝트 만들기(캐시 확인 없음 — 포털이 상품으로 관리) + 선택 연결
* GET projects/<id> 프로젝트 요약·저장본(versions)·연결·git 정보
* POST projects/<id>/link {siteId, groupId, siteName, groupName, portalUrl} / DELETE projects/<id>/link
* GET projects/<id>/export?version=N 저장본 N(없으면 현재 작업 트리)의 tar.gz
* GET projects/<id>/source Source 화면용: 저장소·브랜치·변경 이력·저장소 크기
* GET credits/<login> AI Wallet(캐시) 잔액·사용 — 호스팅 사용량과 분리해 보이기 위한 값
* GET health
*/
async function handleAriwebInternal(req, res, parts, url, b) {
const [kind, id, action] = parts;
if (kind === 'health') return send(res, 200, { ok: true, version: VERSION, product: 'AI Webmaker', portalUrl: ariweb.portalUrl() || null });
if (kind === 'credits' && id && req.method === 'GET') return send(res, 200, { ok: true, credit: credits.info(String(id).toLowerCase()), unit: credits.unit() });
if (kind !== 'projects') return send(res, 404, { error: 'not_found' });
const summary = (s1) => { const l = ariweb.linkOf(s1.id); const last = (s1.git && s1.git.changes || []).slice(-1)[0] || null; return { id: s1.id, title: s1.title, ownerId: s1.ownerId, status: s1.status, mode: s1.mode || 'quick', stage: s1.plan ? s1.plan.stage : null, rev: Number(s1.rev) || 0, createdAt: s1.createdAt, updatedAt: s1.updatedAt, lastChange: last ? { no: last.no, kind: last.kind, at: last.at, message: last.message } : null, versions: (s1.git && s1.git.changes || []).length, link: l, hasContent: fs.existsSync(path.join(SITES_ROOT, s1.id, 'index.html')) || (multilang.isMulti(s1) && fs.existsSync(path.join(SITES_ROOT, s1.id, s1.langs.default, 'index.html'))) }; };
if (!id && req.method === 'GET') { const owner = String(url.searchParams.get('owner') || '').toLowerCase(); if (!owner) return send(res, 400, { error: 'owner 가 필요합니다' }); return send(res, 200, { ok: true, projects: store.list(owner).map((s0) => summary(store.load(s0.id))) }); }
if (!id && req.method === 'POST') {
const owner = String(b.owner || '').trim().toLowerCase(); if (!owner) return send(res, 400, { error: 'owner 가 필요합니다' });
const u = auth.getUser(owner) || auth.ensureSsoUser(owner, b.ownerName || owner);
const title = String(b.title || '').trim().slice(0, 120); if (!title) return send(res, 400, { error: 'title 이 필요합니다' });
const pid = crypto.randomBytes(16).toString('hex'); fs.mkdirSync(path.join(SITES_ROOT, pid), { recursive: true, mode: 0o755 });
const site = store.create({ id: pid, ownerId: u.id, ownerName: u.name, title, purpose: String(b.purpose || ''), content: String(b.content || ''), design: String(b.design || ''), extra: String(b.extra || ''), sessionId: crypto.randomUUID() });
site.mode = 'quick'; site.uiLang = 'ko'; site.tier = settings.validTier(b.tier); site.titleCustom = true;
// 포털이 "AI에게 맡기기(ai)·내가 고르기(manual)" 로 시작하려 하면 기획(plan) 모드 프로젝트로 만든다(사용자가 Webmaker 에서 입력을 이어서 채운다)
if (b.mode === 'ai' || b.mode === 'manual') { site.mode = b.mode; site.plan = newPlan(b.mode); site.plan.input = { text: String(b.text || ''), existingUrl: '', references: [], answers: [] }; }
store.save(site);
if (b.link && b.link.siteId) ariweb.link(pid, b.link);
audit.log({ by: 'portal', action: 'ariweb.project.create', targetType: 'site', target: pid, detail: { owner: u.id, siteId: b.link ? b.link.siteId : null } });
return send(res, 200, { ok: true, project: summary(store.load(pid)) });
}
const site = store.load(id); if (!site) return send(res, 404, { error: '프로젝트가 없습니다' });
if (!action && req.method === 'GET') return send(res, 200, { ok: true, project: { ...summary(site), versionList: (site.git && site.git.changes || []).map((c) => ({ no: c.no, kind: c.kind, message: c.message, at: c.at, commit: c.commit, files: (c.files || []).length })), git: gitstore.info(site.id), liveUrl: liveUrlOf(site), deployState: deployState(site), domains: site.domains || [] } });
if (action === 'link' && req.method === 'POST') { if (!b.siteId || !b.groupId) return send(res, 400, { error: 'siteId·groupId 가 필요합니다' }); const l = ariweb.link(site.id, b); audit.log({ by: 'portal', action: 'ariweb.link', targetType: 'site', target: site.id, detail: { siteId: l.siteId, groupId: l.groupId } }); return send(res, 200, { ok: true, link: l }); }
if (action === 'link' && req.method === 'DELETE') { const had = ariweb.unlink(site.id); audit.log({ by: 'portal', action: 'ariweb.unlink', targetType: 'site', target: site.id, detail: {} }); return send(res, 200, { ok: true, removed: had }); }
if (action === 'source' && req.method === 'GET') {
const gitDir = path.join(DATA_ROOT_FOR_GIT, 'git', site.id); let bytes = 0; const walk = (d) => { let st; try { st = fs.statSync(d); } catch (_) { return; } if (st.isDirectory()) { for (const f of fs.readdirSync(d)) walk(path.join(d, f)); } else bytes += st.size; }; walk(gitDir);
const siteDir = path.join(SITES_ROOT, site.id); let siteBytes = 0; let uploadBytes = 0; const walk2 = (d, up) => { let st; try { st = fs.statSync(d); } catch (_) { return; } if (st.isDirectory()) { for (const f of fs.readdirSync(d)) walk2(path.join(d, f), up || f === 'uploads'); } else { siteBytes += st.size; if (up) uploadBytes += st.size; } }; walk2(siteDir, false);
return send(res, 200, { ok: true, source: { ...gitstore.info(site.id), repoBytes: bytes, siteBytes, uploadBytes, lfsBytes: 0, lfsSupported: false, changes: (site.git && site.git.changes || []).map((c) => ({ no: c.no, kind: c.kind, message: c.message, at: c.at, commit: c.commit, pushed: !!c.pushed, branch: c.branch || null, url: c.url || null })), remoteStatus: gitstore.remoteStatus() } });
}
if (action === 'export' && req.method === 'GET') {
const { spawn } = require('child_process'); const siteDir = path.join(SITES_ROOT, site.id);
const vno = url.searchParams.get('version'); const change = vno ? (site.git && site.git.changes || []).find((c) => String(c.no) === String(vno)) : null;
if (vno && !(change && change.commit)) return send(res, 404, { error: '해당 저장본이 없습니다' });
res.writeHead(200, { 'Content-Type': 'application/gzip', 'Cache-Control': 'no-store', 'X-Ariweb-Version': vno || 'current', 'X-Ariweb-Commit': change ? change.commit : '' });
const child = change
? spawn('git', ['archive', '--format=tar.gz', change.commit], { env: { ...process.env, GIT_DIR: path.join(DATA_ROOT_FOR_GIT, 'git', site.id), GIT_WORK_TREE: siteDir }, cwd: siteDir })
: spawn('tar', ['-C', siteDir, '--exclude=./.refs', '-czf', '-', '.']);
child.stdout.pipe(res); child.on('error', () => { try { res.end(); } catch (_) {} }); return;
}
return send(res, 404, { error: 'not_found' });
}
const DATA_ROOT_FOR_GIT = process.env.DATA_ROOT || path.join(__dirname, 'data'); // lib/gitstore.js 의 GIT_ROOT 와 같은 기준
async function handle(req, res) {
const url = new URL(req.url, 'http://localhost');
const p = url.pathname;
{ const to = previewRedirect(req, url); if (to) { res.writeHead(302, { Location: to, 'Cache-Control': 'no-store' }); return res.end(); } }
// ---------- 공개: 생성 사이트의 문의 접수 (로그인 불필요, CORS) ----------
const fm = p.match(/^\/api\/forms\/([a-f0-9]{32})\/contact$/);
if (fm) {
const site = store.load(fm[1]); const origin = req.headers.origin || '';
// v1.3.17: 빌더 미리보기는 sandbox(별도 출처)라 Origin 이 'null' 로 온다 — 공개 접수 창구이므로 허용 (속도 제한은 그대로)
const allowed = (() => { if (!origin || origin === 'null') return true; let h; try { h = new URL(origin).hostname.toLowerCase(); } catch (_) { return false; } const hosts = new Set([new URL(PUBLIC_BASE_URL).hostname, new URL(formBase()).hostname, ...(site ? (site.domains || []).map((d) => d.toLowerCase()) : [])]); for (const t of deploy.list().targets) { try { if (t.publicBaseUrl) hosts.add(new URL(t.publicBaseUrl).hostname); } catch (_) {} } return hosts.has(h) || [...hosts].some((x) => h.endsWith('.' + x)); })();
const cors = { 'Access-Control-Allow-Origin': allowed && origin ? origin : (origin ? 'null' : '*'), 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, X-WM-Lang', 'Access-Control-Max-Age': '600', Vary: 'Origin' };
if (req.method === 'OPTIONS') { res.writeHead(204, cors); return res.end(); }
if (req.method !== 'POST') return send(res, 405, { error: 'method not allowed' }, cors);
let vl = visitorLang(req, url); // v1.3.16 응답 문구는 방문자가 보던 페이지 언어로
if (!site) return send(res, 404, { ok: false, error: sitemsg.tr(vl, 'common.siteNotFound') }, cors);
if (!allowed) return send(res, 403, { ok: false, error: sitemsg.tr(vl, 'common.originDenied') }, cors);
const ip = (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
if (forms.rateLimited('ip:' + ip, 10, 10 * 60000) || forms.rateLimited('site:' + site.id, 120, 3600000)) return send(res, 429, { ok: false, error: sitemsg.tr(vl, 'common.tooMany') }, cors);
const fb = (await readBody(req)).fields; vl = visitorLang(req, url, fb);
let d; try { d = forms.validate(fb); } catch (e) { return send(res, e.status || 400, { ok: false, error: sitemsg.errorText(e, vl) }, cors); }
if (d.website) return send(res, 200, { ok: true, message: sitemsg.tr(vl, 'common.received') }, cors); // 허니팟
const to = publicView(site).contactEmail;
const rec = { at: new Date().toISOString(), ...d, website: undefined, ip, sent: false, error: null, to: to || null };
if (!to) { rec.error = '문의 수신 이메일 미설정'; forms.add(site.id, rec); broadcast(site.id, { type: 'log', level: 'warn', text: '문의가 접수되었지만 수신 이메일이 설정되지 않아 메일을 보내지 못했습니다. 기본 사항에서 문의 수신 이메일을 설정하세요.' }); return send(res, 200, { ok: true, message: sitemsg.tr(vl, 'common.receivedContact') }, cors); }
try {
const from = await mailer.pickFrom(site.domains || []); const mail = forms.compose(site, d, ip, ownerFmt(site));
const r = await mailer.send({ from, to, subject: mail.subject, text: mail.text, html: mail.html, replyTo: d.email });
rec.sent = true; rec.from = from.address; rec.providerId = r && r.id;
broadcast(site.id, { type: 'log', level: 'info', text: `문의 접수: ${d.name} (${d.email}) → ${to} 로 메일 발송` });
} catch (e) { rec.error = e.message; broadcast(site.id, { type: 'log', level: 'warn', text: `문의가 접수되었지만 메일 발송에 실패했습니다 (${String(e.message || '').slice(0, 120)}). 관리 탭 › 문의 접수에서 재발송할 수 있습니다.` }); broadcast(site.id, { type: 'log', level: 'debug', text: `문의 메일 발송 실패: ${e.message}` }); }
forms.add(site.id, rec);
return send(res, 200, { ok: true, message: sitemsg.tr(vl, 'common.receivedContact') }, cors);
}
// v1.3.17 (S02): 생성 사이트 페이지(/<사이트ID>/…)가 서비스와 같은 주소에서 열릴 때, 그 페이지의 스크립트가 보낸 요청에는
// 빌더 세션을 쓰지 않는다(요청을 보낸 페이지 = Referer 로 판단). 공개 API(/api/forms·/api/m·통계)는 세션이 필요 없어 그대로 동작한다.
// v1.6.31 (요구 4, 검토 S4): 같은 주소에서 열린 생성 사이트 페이지가 관리 세션을 쓰지 못하게 — Referer 만 믿지 않는다.
// 세션 쿠키를 인정하는 경우: (1) 화면 이동(navigate) (2) 출처(Referer)가 이 서비스의 빌더·관리자 화면(생성 사이트 경로가 아님) (3) X-WM-CSRF 헤더가 세션과 맞음.
// 생성 사이트 페이지는 Referer 가 /<사이트ID>/… 이거나(거부) Referer 를 없애면 (3)이 필요한데 토큰은 (2)를 만족하는 요청에만 주므로 얻을 수 없다.
const user = sessionUser(req);
// ---------- [Ariweb Suite 변경] 포털 SSO 진입: GET /api/auth/sso?ticket=… ----------
// 포털이 서명한 60초짜리 1회용 티켓을 검증해 그 사용자의 Webmaker 계정(없으면 생성)으로 세션 쿠키를 주고, 지정한 프로젝트 화면으로 보낸다.
// 비밀번호는 오가지 않는다. 티켓에 든 그룹·사이트 이름은 서명 쿠키(wm_ariweb)에 두어 상단 배너에 보인다.
if (req.method === 'GET' && p === '/api/auth/sso') {
const cip = ratelimit.clientIp(req); if (ratelimit.limited(`sso:${cip}`, 60, 600000)) return send(res, 429, { error: '요청이 너무 많습니다' });
const t = ariweb.verifyTicket(url.searchParams.get('ticket'));
if (t.error) { audit.log({ by: 'sso', action: 'auth.sso.denied', targetType: 'user', target: '-', detail: { reason: t.error } }); return send(res, 403, { error: t.error }); }
let u; try { u = auth.ensureSsoUser(t.login, t.name); } catch (e) { return send(res, 400, { error: e.message }); }
audit.log({ by: u.id, action: 'auth.sso', targetType: 'user', target: u.id, detail: { groupId: t.groupId || null, siteId: t.siteId || null, projectId: t.projectId || null } });
const secure = isSecureReq(req);
const dest = t.path && /^\/[^\s]*$/.test(String(t.path)) ? String(t.path) : (t.projectId ? `/?site=${encodeURIComponent(t.projectId)}` : '/');
res.writeHead(302, { Location: dest, 'Cache-Control': 'no-store', 'Set-Cookie': [auth.makeSessionCookie(u.id, { secure }), ariweb.ctxCookie(t, { secure })] }); return res.end();
}
// ---------- [Ariweb Suite 변경] 포털 → Webmaker 내부 API (/api/internal/ariweb/*) — 서비스 서명(lib/ariweb.verifyService)으로만 ----------
if (p.startsWith('/api/internal/ariweb/')) {
const raw = await new Promise((resolve, reject) => { let d = ''; req.on('data', (c) => { d += c; if (d.length > 6e6) { reject(new Error('본문이 너무 큽니다')); req.destroy(); } }); req.on('end', () => resolve(d)); req.on('error', reject); });
const v = ariweb.verifyService(req, p + url.search, raw);
if (!v.ok) { console.log(`[ariweb] 내부 API 거부 ${req.method} ${p} (${v.reason}) from ${ratelimit.clientIp(req)}`); return send(res, 401, { error: 'service_auth_failed', reason: v.reason }); }
let b = {}; try { b = raw ? JSON.parse(raw) : {}; } catch (_) { return send(res, 400, { error: 'JSON 파싱 실패' }); }
try { return await handleAriwebInternal(req, res, p.slice('/api/internal/ariweb/'.length).split('/').filter(Boolean).map(decodeURIComponent), url, b); }
catch (e) { return send(res, e.status || 500, { error: e.message }); }
}
// ---------- v1.5.11 (요구 2): 설정 페이지 API 를 본 서비스에서도 — 토큰(data/setup.token) 또는 관리자 로그인 ----------
// 설치 직후 첫 화면이 /setup 으로 넘어오는데, 그때는 관리자 비밀번호를 모를 수 있으므로 별도 포트의 setup-server.js 와 같은 토큰 로그인을 받는다.
// 세션은 wm_setup 쿠키(lib/setupauth.js). 이미 관리자로 로그인한 사람(wh_session, role=admin)은 그대로 같은 권한이다.
if (p === '/api/setup' || p.startsWith('/api/setup/')) {
const ip = req.socket.remoteAddress || '';
const sess = setupauth.readSession(req) || (user && user.role === 'admin' && !user.mustChangePassword ? { who: `admin:${user.id}` } : null);
if (p === '/api/setup/session' && req.method === 'GET') return send(res, 200, { loggedIn: !!sess, who: sess ? sess.who : null, version: VERSION, standalone: false, publicBaseUrl: PUBLIC_BASE_URL, csrf: sess && user ? auth.csrfToken(user) : (sess ? setupauth.csrfOf(sess.who) : null) });
if (p === '/api/setup/login' && req.method === 'POST') { const cip = ratelimit.clientIp(req); if (ratelimit.limited(`setup:${cip}`, 20, 900000)) return send(res, 429, { error: '요청이 너무 많습니다' }); const r = setupauth.login((await readBody(req)).fields, cip, isSecureReq(req)); audit.log({ by: r.body && r.body.who ? r.body.who : 'anonymous', action: 'setup.login', ok: r.status === 200, ip: cip }); return send(res, r.status, r.body, r.cookie ? { 'Set-Cookie': r.cookie } : {}); }
if (p === '/api/setup/logout' && req.method === 'POST') return send(res, 200, { ok: true }, { 'Set-Cookie': setupauth.clearCookie() });
if (!sess) return send(res, 401, { error: '로그인이 필요합니다 (접속 토큰 또는 관리자 계정)' });
if (req.method !== 'GET' && fromTenantPage(req)) return send(res, 403, { error: '생성 사이트 페이지에서는 설정 API 를 쓸 수 없습니다' });
if (req.method !== 'GET' && !(req.headers.referer || req.headers.referrer) && !(user ? auth.csrfOk(user, req.headers['x-wm-csrf']) : setupauth.csrfOk(sess.who, req.headers['x-wm-csrf']))) return send(res, 403, { error: '요청 출처를 확인할 수 없습니다 (CSRF)' });
const sub = p.slice('/api/setup'.length).split('/').filter(Boolean).map(decodeURIComponent);
const body = req.method === 'PUT' || req.method === 'POST' ? (await readBody(req)).fields : null;
const r = await setupapi.handle({ method: req.method, sub, body, by: `setup:${sess.who}` });
return send(res, r.status, r.body);
}
// v1.5.11 (요구 1): 비밀번호를 바꿔야 하는 계정(기본 관리자 admin/admin)은 비밀번호 변경 전에는 다른 화면·API 를 쓸 수 없다.
// 허용: 로그인 화면(변경 폼), 인증 API(/api/auth/*: 변경·로그아웃), 문구·언어, 정적 파일. 그 밖의 화면은 변경 폼으로, API 는 403.
if (user && user.mustChangePassword && !/^\/api\/(auth|i18n)(\/|$)/.test(p) && p !== '/login' && p !== '/theme.css' && !/^\/[\w.-]+\.(js|css|svg|png|ico)$/.test(p)) {
if (p.startsWith('/api/')) return send(res, 403, { error: '먼저 비밀번호를 바꿔야 합니다 (기본 비밀번호로 로그인한 계정)', mustChangePassword: true });
res.writeHead(302, { Location: '/login?change=1', 'Cache-Control': 'no-store' }); return res.end();
}
// 로그인/회원가입
if (req.method === 'GET' && p === '/login') {
if (user && !user.mustChangePassword) { res.writeHead(302, { Location: '/' }); return res.end(); }
// v1.5.11 (요구 2): 설치 점검표(관리자 비밀번호·AI 인증·공개 주소)가 끝나기 전에는 로그인 화면 대신 설정 페이지로. ?direct=1 이면 그대로 로그인 화면
if (!user && !url.searchParams.has('direct') && !url.searchParams.has('change')) { const st = await setupstate.check().catch(() => null); if (st && st.redirect) { res.writeHead(302, { Location: '/setup', 'Cache-Control': 'no-store' }); return res.end(); } }
return sendPage(req, res, 'login.html');
}
// v1.5.11: 로그인 화면이 알아야 할 것 — 회원가입이 열려 있나, 설치 점검이 남았나 (로그인 불필요)
if (req.method === 'GET' && p === '/api/auth/info') { const st = await setupstate.check().catch(() => null); return send(res, 200, { version: VERSION, signupOpen: config.get('service.allowSignup') !== false, signupApproval: settings.load().signupApproval || 'immediate', setupRequired: !!(st && st.required), setupRedirect: !!(st && st.redirect) }); }
// v1.3.17 지금 비밀번호 규칙 (로그인 화면·빌더·사이트 관리자 화면의 안내 문구용, 로그인 불필요)
if (req.method === 'GET' && p === '/api/auth/password-policy') { const pol = passpolicy.current(); return send(res, 200, { policy: pol, text: passpolicy.describe(pol) }); }
if (req.method === 'POST' && (p === '/api/auth/signup' || p === '/api/auth/login')) {
// v1.6.31 (요구 7, 검토 S11·S12): 신뢰 프록시 기준 IP 로 속도 제한(로그인 IP 당 10분 30회, 가입 IP 당 1시간 10회) + 계정별 실패 잠금(15분 10회)
const cip = ratelimit.clientIp(req);
if (ratelimit.limited(`auth:${p.endsWith('signup') ? 'signup' : 'login'}:${cip}`, p.endsWith('signup') ? 10 : 30, p.endsWith('signup') ? 3600000 : 600000)) return send(res, 429, { error: '요청이 너무 많습니다. 잠시 후 다시 시도하세요' });
const { fields: b } = await readBody(req);
const acct = String(b.id || '').trim().toLowerCase();
if (p.endsWith('login') && acct && ratelimit.count(`auth:fail:${acct}`, 900000) >= 10) return send(res, 429, { error: '이 계정의 로그인 실패가 많아 15분 동안 잠겼습니다' });
// v1.5.11: 회원가입을 설정으로 닫을 수 있다(service.allowSignup). 웹서버 규칙으로 막으면 안내 없는 403 이 되므로 여기서 이유를 돌려준다
if (p.endsWith('signup') && config.get('service.allowSignup') === false) return send(res, 403, { error: '회원가입이 닫혀 있습니다. 관리자에게 계정을 요청하세요', signupClosed: true });
// v1.5.27 (요구 6): 승인 방식(settings.signupApproval=approval)이면 가입은 "신청" 으로 접수되고 세션을 주지 않는다 — 관리자 콘솔 › 회원 · 청구에서 승인
const approval = p.endsWith('signup') && settings.load().signupApproval === 'approval';
let u; try { u = p.endsWith('signup') ? auth.signup(b, { pending: approval }) : auth.login(b); } catch (e) { if (p.endsWith('login') && acct && (e.status === 401 || e.status === 403)) ratelimit.limited(`auth:fail:${acct}`, 1e9, 900000); throw e; }
if (p.endsWith('login')) ratelimit.reset(`auth:fail:${acct}`);
// v1.7.3 추가(운영자 요청): 서비스 관리자 로그인 = 비밀번호 원문을 아는 순간 → 메일 서버(Arimail)의 같은 ID 계정 비밀번호를 맞춘다(응답을 기다리지 않음, 실패해도 로그인은 됨)
if (p.endsWith('login') && u.role === 'admin') { const pw = String(b.password || ''); setImmediate(() => { mailsys.syncServiceAdmin(u, pw, { fingerprint: auth.passwordFingerprint(u.id), by: u.id }).catch((e) => console.error('mail admin sync failed:', e.message)); }); }
audit.log({ by: u.id, action: p.endsWith('signup') ? 'auth.signup' : 'auth.login', targetType: 'user', target: u.id, ip: cip, detail: p.endsWith('signup') ? { name: u.name, pending: approval } : undefined });
if (p.endsWith('signup')) { const init = settings.load().signupCredit || 0; if (init > 0) { try { credits.apply({ userId: u.id, type: 'add', amount: init, memo: '신규 회원 기본 캐시', by: 'system' }); } catch (e) { console.error('signup credit failed:', e.message); } } }
if (approval) return send(res, 202, { user: u, pendingApproval: true, message: '가입 신청이 접수되었습니다. 관리자가 승인하면 로그인할 수 있습니다' });
return send(res, 200, { user: u }, { 'Set-Cookie': auth.makeSessionCookie(u.id, { secure: isSecureReq(req) }) });
}
if (req.method === 'POST' && p === '/api/auth/logout') return send(res, 200, { ok: true }, { 'Set-Cookie': auth.clearSessionCookie() });
if (req.method === 'GET' && p === '/api/auth/me') return user ? send(res, 200, { user }) : send(res, 401, { error: '로그인이 필요합니다' });
if (req.method === 'GET' && p === '/api/auth/csrf') { if (!user) return send(res, 401, { error: '로그인이 필요합니다' }); if (fromTenantPage(req) || !(req.headers.referer || req.headers.referrer)) return send(res, 403, { error: '이 화면에서는 토큰을 받을 수 없습니다' }); return send(res, 200, { csrf: auth.csrfToken(user) }); } // v1.6.31
if (req.method === 'POST' && p === '/api/auth/password') { if (!user) return send(res, 401, { error: '로그인이 필요합니다' }); const { fields: b } = await readBody(req); const u2 = auth.changePassword(user.id, b.current, b.next); setupstate.invalidate(); /* v1.5.11 기본 비밀번호 변경 → 점검표 갱신 */ audit.log({ by: user.id, action: 'auth.password', targetType: 'user', target: user.id, ip: ratelimit.clientIp(req) }); return send(res, 200, { user: u2 }, { 'Set-Cookie': auth.makeSessionCookie(user.id, { secure: isSecureReq(req) }) }); } // v1.3.17 S05: 다른 세션은 끊기고 지금 화면은 새 세션으로 계속
// 정적 UI (로그인 필요)
if (req.method === 'GET' && (p === '/' || p === '/index.html')) {
// v1.5.11 (요구 2): 처음 설치해 운영할 수 없는 동안(설치 점검표 미완)은 로그인 화면 대신 설정 페이지(/setup)로. 끝나면 보내지 않는다
if (!user) { const st = await setupstate.check().catch(() => null); res.writeHead(302, { Location: st && st.redirect ? '/setup' : '/login', 'Cache-Control': 'no-store' }); return res.end(); }
if (user.role === 'admin') { res.writeHead(302, { Location: '/admin' }); return res.end(); }
return sendPage(req, res, 'index.html');
}
// v1.3.10: 관리자도 빌더로 사이트를 만든다(템플릿 원본). 관리자는 / 가 콘솔로 가므로 /builder 로 연다
if (req.method === 'GET' && p === '/builder') {
if (!user) { res.writeHead(302, { Location: '/login' }); return res.end(); }
if (user.role !== 'admin') { res.writeHead(302, { Location: '/' }); return res.end(); }
return sendPage(req, res, 'index.html');
}
// 화면 테마 CSS (v1.2.0): 정적 파일 규칙보다 먼저 — 관리자 설정으로 만든 CSS
if (req.method === 'GET' && p === '/theme.css') return send(res, 200, Buffer.from(settings.themeCss()), { 'Content-Type': 'text/css; charset=utf-8', 'Cache-Control': 'no-cache' });
// v1.3.16 생성 사이트 모듈 런타임: 시스템 문구 원문 표(locales/site/ko.json)를 넣어 보낸다 — 코드에는 문구 id 만 있다
if (req.method === 'GET' && p === '/wm-modules.js') {
if (!wmModulesJs) { const base = Object.fromEntries(Object.entries(sitemsg.source()).map(([k, v]) => [k, v.text])); wmModulesJs = fs.readFileSync(path.join(PUBLIC_DIR, 'wm-modules.js'), 'utf8').replace('/*@WM_BASE_MESSAGES@*/{}', JSON.stringify(base).replace(/</g, '\\u003c')); }
// v1.5.11: max-age=600 → no-cache + ETag. 배포 직후 방문자가 10분 동안 옛 런타임(예: 1.5.7 의 모듈 별칭 전)을 쓰던 틈을 없앤다 (재검증은 304 로 가볍다)
const etag = `"${crypto.createHash('sha1').update(wmModulesJs).digest('hex').slice(0, 16)}"`;
if (req.headers['if-none-match'] === etag) { res.writeHead(304, { ETag: etag, 'Cache-Control': 'no-cache' }); return res.end(); }
return send(res, 200, Buffer.from(wmModulesJs), { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-cache', ETag: etag });
}
if (req.method === 'GET' && /^\/[\w.-]+\.(js|css|svg|png|ico)$/.test(p)) return staticFile(res, p.slice(1));
// v1.5.7 (요구 2): 설정 페이지를 서비스 안에서도 — 관리자 로그인으로 /setup (별도 포트의 setup-server.js 와 같은 화면, API 는 /api/admin/config)
// v1.5.11 (요구 2): /setup 은 로그인 없이도 열린다 — 화면 안에서 접속 토큰(data/setup.token) 또는 관리자 계정으로 들어온다(API /api/setup/*).
// 이미 관리자로 로그인했으면 바로 설정 화면. 처음 설치한 서버의 첫 화면이 여기로 넘어오므로(점검표 미완) 관리자 비밀번호를 몰라도 토큰으로 설정할 수 있다.
if (req.method === 'GET' && (p === '/setup' || p === '/setup/')) {
if (user && user.role !== 'admin') return send(res, 403, '관리자만 접근할 수 있습니다');
return sendPage(req, res, 'setup.html', { inject: '<meta name="wm-setup-inline" content="1">' });
}
if (req.method === 'GET' && p === '/admin') {
if (!user) { res.writeHead(302, { Location: '/login' }); return res.end(); }
if (user.role !== 'admin') return send(res, 403, '관리자만 접근할 수 있습니다');
return sendPage(req, res, 'admin.html');
}
const rm = p.match(/^\/report\/([a-f0-9]{32})$/);
if (req.method === 'GET' && rm) {
if (!user) { res.writeHead(302, { Location: '/login' }); return res.end(); }
const site = store.load(rm[1]);
if (!site || (site.ownerId !== user.id && user.role !== 'admin')) return staticFile(res, '404.html', 404);
const html = report.render(publicView(site));
const headers = { 'Content-Type': 'text/html; charset=utf-8' };
if (url.searchParams.get('download') === '1') headers['Content-Disposition'] = `attachment; filename="verification-report-${site.id.slice(0, 8)}.html"`;
return send(res, 200, Buffer.from(html), headers);
}
// ---- 화면 테마 CSS (v1.2.0, 로그인 화면에서도 쓰므로 인증 없음) ----
// ---- 다국어: 활성 언어 목록과 사전 (로그인 화면에서도 쓰므로 인증 없음) ----
if (req.method === 'GET' && p === '/api/i18n/languages') return send(res, 200, { languages: i18n.activeLanguages(), source: i18n.SOURCE, version: i18n.VERSION }, { 'Cache-Control': 'public, max-age=60' });
const lm = p.match(/^\/api\/i18n\/([a-z]{2,3}(?:-[A-Za-z]{2,4})?)\.json$/);
if (lm && req.method === 'GET') { const dict = i18n.dictionary(lm[1]); if (!dict) return send(res, 404, { error: '사용할 수 없는 언어입니다' }); return send(res, 200, { lang: lm[1], dict }, { 'Cache-Control': 'public, max-age=300' }); }
// ---- 웹메일 (v1.3.0): /webmail/<사이트ID>/ 화면과 API. 로그인은 메일 사용이 켜진 사이트 관리자 계정(같은 아이디·비밀번호) ----
if (req.method === 'GET' && /^\/webmail\/[a-f0-9]{32}\/?$/.test(p)) {
// v1.5.7: 메일 서버(Arimail)가 연결된 사이트는 그 웹메일로 보낸다 (사이트의 /webmail 진입 파일 → 이 주소 → Arimail). 아니면 내장 웹메일 화면
try { const ws = store.load(p.split('/')[2]); const st = ws ? mailsys.status(ws) : null; if (st && st.backend === 'arimail' && st.mailServer && st.mailServer.webmail) { res.writeHead(302, { Location: st.mailServer.webmail, 'Cache-Control': 'no-store' }); return res.end(); } } catch (_) {}
return sendPage(req, res, 'webmail.html');
}
const wm = p.match(/^\/api\/webmail\/([a-f0-9]{32})\/([a-z]+)$/);
if (wm) {
const site = store.load(wm[1]); if (!site) return send(res, 404, { error: '사이트를 찾을 수 없습니다' });
try {
const st = mailsys.status(site);
// v1.4.0 (요구 10): 메일 도메인의 DNS(MX 등)가 아직 전파 중이면 상태 띠에 "약 N분 후에 다시 이용" 안내를 보이고 보내기를 막는다
{ const wait = st.domain ? await domainsLib.gateName(st.domain).catch(() => null) : null; if (wait) { st.message = wait; st.canMail = false; st.dnsPending = true; } }
const info = { site: { id: site.id, title: site.title, liveUrl: liveUrlOf(site) }, status: st };
if (wm[2] === 'info' && req.method === 'GET') return send(res, 200, info);
if (wm[2] === 'login' && req.method === 'POST') {
const { fields: b } = await readBody(req);
if (st.level === 'off') return send(res, 403, { error: st.message });
const r = modules.login(site.id, { id: String(b.id || '').trim().replace(/@.*$/, ''), password: b.password });
const m = modules.getSiteAdmin(site.id, r.member.id);
if (!m) return send(res, 403, { error: '사이트 관리자 계정만 웹메일을 쓸 수 있습니다' });
if (!m.mail) return send(res, 403, { error: '이 계정은 관리용 계정으로 메일 사용이 꺼져 있습니다. 사이트 관리자 페이지 › 메일 에서 켤 수 있습니다' });
mailsys.syncPassword(site, m, String(b.password || ''), m.loginId).catch(() => {}); // v1.6.39 (요구 3): 로그인 성공 = 원문을 아는 순간 → 메일 서버 비밀번호를 맞춘다 (응답은 기다리지 않음)
return send(res, 200, { token: r.token, member: modules.pubMember(m), ...info });
}
// 이하 로그인 필요 (Authorization: Bearer <사이트 관리자 토큰>)
const tok = (req.headers.authorization || '').replace(/^Bearer\s+/i, ''); const mem = modules.memberFromToken(modules.load(site.id), site.id, tok);
if (!mem || mem.role !== 'admin' || !mem.mail) return send(res, 401, { error: '로그인이 필요합니다' });
if (wm[2] === 'box' && req.method === 'GET') { const box = mailsys.loadBox(site.id, mem.id); const folder = url.searchParams.get('folder') || 'inbox'; return send(res, 200, { address: mem.mailAddress, folder, messages: box.messages.filter((x) => (x.folder || 'inbox') === folder).sort((a, c) => (c.at || '').localeCompare(a.at || '')), ...info }); }
if (wm[2] === 'send' && req.method === 'POST') { if (st.dnsPending) return send(res, 409, { error: st.message, dnsPending: true }); const { fields: b } = await readBody(req); return send(res, 200, await mailsys.sendAs(site, mem, b)); }
return send(res, 404, { error: 'not found' });
} catch (e) { return send(res, e.status || 500, { error: e.message }); }
}
// ---- 사이트 관리자 페이지 (별도 페이지; 인증은 페이지 안에서 API 로) ----
if (req.method === 'GET' && /^\/site-admin\/[a-f0-9]{32}\/?$/.test(p)) { const sa0 = store.load(p.split('/')[2]); return sendPage(req, res, 'site-admin.html', { siteLang: sa0 ? ownerLang(sa0) : null }); } // v1.3.16 사이트 관리 언어가 기본값
// ---- 공통 모듈 공개 API: 생성 사이트의 위젯이 호출 (CORS 허용, 회원 토큰은 Authorization: Bearer) ----
const mm = p.match(/^\/api\/m\/([a-f0-9]{32})\/([a-z]+)(?:\/(.*))?$/);
if (mm) {
const cors = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-WM-Lang', 'Access-Control-Max-Age': '600' };
if (req.method === 'OPTIONS') { res.writeHead(204, cors); return res.end(); }
const vl = visitorLang(req, url); // v1.3.16 위젯이 보내는 페이지 언어 — 오류·안내 문구를 그 언어로
const vt = sitemsg.forLang(vl);
const site = store.load(mm[1]); if (!site) return send(res, 404, { error: vt('common.siteNotFound') }, cors);
const ip = (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
if (req.method !== 'GET' && forms.rateLimited('mod:' + ip, 60, 10 * 60000)) return send(res, 429, { error: vt('common.tooMany') }, cors);
const ot = sitemsg.forLang(ownerLang(site)); const adminUrl = `${formBase()}/site-admin/${site.id}/`; // 소유자 알림 메일은 소유자 언어로
try {
const mod = mm[2]; const rest = (mm[3] || '').split('/').filter(Boolean); const q = Object.fromEntries(url.searchParams.entries());
const d0 = modules.load(site.id); const tok = (req.headers.authorization || '').replace(/^Bearer\s+/i, ''); const member = modules.memberFromToken(d0, site.id, tok);
const body = async () => (req.method === 'GET' ? {} : (await readBody(req)));
const ok = (data, status = 200) => send(res, status, data, cors);
if (mod === 'members') {
if (req.method === 'POST' && rest[0] === 'signup') return ok(modules.signup(site.id, (await body()).fields), 201);
if (req.method === 'POST' && rest[0] === 'login') {
const lb = (await body()).fields; const lr = modules.login(site.id, lb);
// v1.6.39 (요구 3): 사이트 관리자(메일 사용 중)가 로그인하면 메일 서버(Arimail) 계정 비밀번호를 같은 값으로 맞춘다 — 응답은 기다리지 않는다
if (lr.member && lr.member.role === 'admin' && lr.member.mail) { const sa = modules.getSiteAdmin(site.id, lr.member.id); if (sa) mailsys.syncPassword(site, sa, String(lb.password || ''), sa.loginId).catch(() => {}); }
return ok(lr);
}
if (req.method === 'GET' && rest[0] === 'me') { if (!member) return ok({ error: vt('err.loginRequired') }, 401); return ok({ member: modules.pubMember(member) }); }
}
if (mod === 'board') {
if (req.method === 'GET' && rest[0] === 'settings') return ok(modules.publicSettings(d0, 'board'));
if (req.method === 'GET' && rest[0] === 'posts' && !rest[1]) return ok(modules.listPosts(site.id, q, member));
// v1.3.17 (S04): 비밀글 비밀번호 확인은 GET 이어도 횟수를 제한한다 (IP·사이트별 10분 20회)
if (req.method === 'GET' && rest[0] === 'posts' && rest[1]) { if (q.pw && forms.rateLimited(`pw:${site.id}:${ip}`, 20, 10 * 60000)) return ok({ error: vt('common.tooMany') }, 429); return ok(modules.getPost(site.id, rest[1], member, q.pw)); }
if (req.method === 'POST' && rest[0] === 'posts' && !rest[1]) return ok(modules.createPost(site.id, (await body()).fields, member), 201);
if (req.method === 'PUT' && rest[0] === 'posts' && rest[1]) return ok(modules.updatePost(site.id, rest[1], (await body()).fields, member));
if (req.method === 'DELETE' && rest[0] === 'posts' && rest[1] && !rest[2]) return ok(modules.deletePost(site.id, rest[1], (await body()).fields, member));
if (req.method === 'POST' && rest[0] === 'posts' && rest[1] && rest[2] === 'comments') return ok(modules.addComment(site.id, rest[1], (await body()).fields, member), 201);
if (req.method === 'DELETE' && rest[0] === 'comments' && rest[1]) return ok(modules.deleteComment(site.id, rest[1], (await body()).fields, member));
}
if (mod === 'inquiry') {
if (req.method === 'GET' && rest[0] === 'settings') return ok(modules.publicSettings(d0, 'inquiry'));
if (req.method === 'POST' && rest[0] === 'submit') {
if (!modules.isEnabled(d0, 'inquiry')) return ok({ error: vt('err.inquiryOff') }, 404);
const ms = d0.settings.inquiry; const { fields: b, files } = await body();
let dd; try { dd = forms.validate(b); } catch (e) { return ok({ ok: false, error: sitemsg.errorText(e, vl) }, e.status || 400); }
if (dd.website) return ok({ ok: true, message: vt('common.received') });
const category = (ms.categories || []).includes(b.category) ? b.category : (b.category ? String(b.category).slice(0, 40) : '');
const saved = []; const attachments = [];
if (ms.allowFiles !== false) for (const f of files.slice(0, 5)) { const max = (ms.maxFileMb || 10) * 1048576; if (f.size > max) return ok({ ok: false, error: vt('inquiry.fileTooBig', { mb: ms.maxFileMb || 10 }) }, 400); const buf = Buffer.from(await f.arrayBuffer()); const safe = String(f.name || 'file').replace(/[^\w.가-힣-]+/g, '_').slice(0, 80); const stored = `${Date.now()}-${crypto.randomBytes(3).toString('hex')}-${safe}`; fs.writeFileSync(path.join(modules.filesDir(site.id), stored), buf); saved.push({ name: f.name, stored, size: f.size }); attachments.push({ filename: f.name, content: buf }); }
const to = publicView(site).contactEmail;
const rec = { at: new Date().toISOString(), ...dd, website: undefined, category, files: saved, ip, sent: false, error: null, to: to || null };
if (category) rec.extra = { ...(rec.extra || {}), [ot('inquiry.type')]: category };
if (to && ms.notifyEmail !== false) {
try { const from = await mailer.pickFrom(site.domains || []); const mail = forms.compose(site, rec, ip, ownerFmt(site)); const r = await mailer.send({ from, to, subject: mail.subject, text: mail.text + (saved.length ? `\n\n${ot('mail.attachments', { files: saved.map((x) => x.name).join(', ') })}` : ''), html: mail.html, replyTo: dd.email, attachments }); rec.sent = true; rec.from = from.address; rec.providerId = r && r.id; broadcast(site.id, { type: 'log', level: 'info', text: `문의 접수(모듈): ${dd.name} → ${to} 로 메일 발송${saved.length ? ' (첨부 ' + saved.length + '개)' : ''}` }); }
catch (e) { rec.error = e.message; broadcast(site.id, { type: 'log', level: 'debug', text: `문의 메일 발송 실패: ${e.message}` }); }
} else if (!to) rec.error = '문의 수신 이메일 미설정';
forms.add(site.id, rec);
return ok({ ok: true, message: vt('common.receivedContact') });
}
}
if (mod === 'survey') {
if (!modules.isEnabled(d0, 'survey')) return ok({ error: vt('err.surveyOff') }, 404);
if (req.method === 'GET' && rest[0] === 'forms' && !rest[1]) return ok({ items: d0.surveys.filter((f) => f.active).map((f) => ({ id: f.id, title: f.title, description: f.description })) });
if (req.method === 'GET' && rest[0] === 'forms' && rest[1]) { const f = d0.surveys.find((x) => x.id === rest[1]); if (!f || !f.active) return ok({ error: vt('err.surveyClosed') }, 404); return ok({ survey: { id: f.id, title: f.title, description: f.description, questions: f.questions, thanks: f.thanks } }); }
if (req.method === 'POST' && rest[0] === 'forms' && rest[1] && rest[2] === 'responses') {
const r = modules.submitResponse(site.id, rest[1], (await body()).fields, member, ip);
if (r.notify) { const to = publicView(site).contactEmail; if (to) { try { const from = await mailer.pickFrom(site.domains || []); const lines = r.survey.questions.map((qq) => `${qq.label}: ${Array.isArray(r.response.answers[qq.id]) ? r.response.answers[qq.id].join(', ') : (r.response.answers[qq.id] ?? '')}`); await mailer.send({ from, to, subject: ot('mail.surveySubject', { site: site.title, survey: r.survey.title, who: r.response.respondent }), text: `${ot('mail.surveyIntro', { site: site.title, survey: r.survey.title })}\n\n${lines.join('\n')}\n\n${ot('mail.adminPage', { url: adminUrl })}` }); } catch (e) { broadcast(site.id, { type: 'log', level: 'debug', text: `설문 응답 메일 실패: ${e.message}` }); } } }
return ok({ ok: true, message: r.survey.thanks }, 201);
}
}
if (mod === 'booking') {
if (req.method === 'GET' && rest[0] === 'settings') { if (!modules.isEnabled(d0, 'booking')) return ok({ error: vt('err.bookingOff') }, 404); return ok(modules.publicSettings(d0, 'booking')); }
if (req.method === 'GET' && rest[0] === 'slots') return ok(modules.slotsFor(site.id, String(q.date || '')));
if (req.method === 'POST' && rest[0] === 'reserve') {
const r = modules.reserve(site.id, (await body()).fields, member, ip); const bk = r.booking;
if (r.notify) { const to = publicView(site).contactEmail; if (to) { try { const from = await mailer.pickFrom(site.domains || []); await mailer.send({ from, to, subject: ot('mail.bookingSubject', { site: site.title, state: ot(bk.status === 'confirmed' ? 'mail.bookingConfirmed' : 'mail.bookingRequested'), date: bk.date, time: bk.time, name: bk.name }), text: `${ot('mail.bookingIntro', { site: site.title })}\n\n${ot('common.date')}: ${bk.date} ${bk.time}\n${ot('common.name')}: ${bk.name}\n${ot('common.phone')}: ${bk.contact}\n${ot('mail.fieldRequest')}: ${bk.note || '-'}\n${ot('mail.fieldStatus')}: ${ot(bk.status === 'confirmed' ? 'mail.statusAuto' : 'mail.statusManual')}\n\n${ot('mail.adminPage', { url: adminUrl })}` }); } catch (e) { broadcast(site.id, { type: 'log', level: 'debug', text: `예약 메일 실패: ${e.message}` }); } } }
return ok({ ok: true, booking: { id: bk.id, date: bk.date, time: bk.time, status: bk.status }, message: vt(bk.status === 'confirmed' ? 'booking.confirmed' : 'booking.received') }, 201);
}
}
if (mod === 'reviews') {
if (req.method === 'GET' && rest[0] === 'list') return ok(modules.listReviews(site.id));
if (req.method === 'POST' && rest[0] === 'submit') { const r = modules.submitReview(site.id, (await body()).fields, member, ip); if (r.notify) { const to = publicView(site).contactEmail; if (to) { try { const from = await mailer.pickFrom(site.domains || []); await mailer.send({ from, to, subject: ot('mail.reviewSubject', { site: site.title, stars: '★'.repeat(r.review.rating), name: r.review.name }), text: `${r.review.text}\n\n${ot('mail.reviewHint', { url: adminUrl })}` }); } catch (_) {} } } return ok({ ok: true, pending: r.pending }, 201); }
}
// v1.4.0 블로그: 목록·검색(q·cat·tag·page), 글 보기, 댓글 쓰기 (댓글은 POST 라 위의 IP 속도 제한을 받는다)
if (mod === 'blog') {
if (req.method === 'GET' && rest[0] === 'posts' && !rest[1]) return ok(modules.listBlog(site.id, q));
if (req.method === 'GET' && rest[0] === 'posts' && rest[1] && !rest[2]) return ok(modules.getBlog(site.id, rest[1], member));
if (req.method === 'POST' && rest[0] === 'posts' && rest[1] && rest[2] === 'comments') { const r = modules.addBlogComment(site.id, rest[1], (await body()).fields, member, ip); return ok({ ...r, message: vt(r.pending ? 'blog.commentPending' : 'blog.commentPosted') }, 201); }
}
// v1.4.0 자료실: 목록·검색, 내려받기(회원만 설정이면 토큰 필요 — 링크로 받을 때는 ?t=토큰)
if (mod === 'library') {
if (req.method === 'GET' && rest[0] === 'files' && !rest[1]) return ok(modules.listLibrary(site.id, q));
if (req.method === 'GET' && rest[0] === 'files' && rest[1] && rest[2] === 'download') {
const mem = member || (q.t ? modules.memberFromToken(d0, site.id, String(q.t)) : null);
if (forms.rateLimited(`dl:${site.id}:${ip}`, 120, 10 * 60000)) return ok({ error: vt('common.tooMany') }, 429);
const f = modules.libraryDownload(site.id, rest[1], mem);
const ascii = f.name.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
res.writeHead(200, { ...cors, 'Content-Type': f.mime, 'Content-Length': f.size, 'Content-Disposition': `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(f.name)}`, 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'private, no-store' });
return fs.createReadStream(f.path).pipe(res);
}
}
if (mod === 'faq' && req.method === 'GET' && rest[0] === 'list') return ok(modules.listFaq(site.id));
// v1.4.18 일정관리: 기간 조회(음력·간지·절기 + 공개 일정 발생), 설정, 한 날 정보, 음력 → 양력
if (mod === 'calendar' && req.method === 'GET') {
if (rest[0] === 'settings') return ok(modules.publicSettings(d0, 'calendar'));
if (rest[0] === 'range') return ok(modules.listCalendar(site.id, q));
if (rest[0] === 'day') return ok(modules.calendarDay(site.id, String(q.date || '')));
if (rest[0] === 'lunar') return ok(modules.calendarLunar(site.id, q));
}
if (mod === 'gallery' && req.method === 'GET' && rest[0] === 'list') return ok(modules.listGallery(site.id));
if (mod === 'map') {
if (req.method === 'GET' && rest[0] === 'settings') return ok(modules.publicSettings(d0, 'map'));
if (req.method === 'GET' && rest[0] === 'geocode') { if (forms.rateLimited('geo:' + ip, 20, 60000)) return ok({ error: vt('err.tryLater') }, 429); return ok((await modules.geocode(q.q)) || { error: vt('err.addressNotFound') }); }
}
return ok({ error: 'not found' }, 404);
} catch (e) { return send(res, e.status || 500, { error: sitemsg.errorText(e, vl), code: e.msgId || undefined }, cors); }
}
// ---- 사이트 관리자 API: 빌더 세션(소유자/운영자) 또는 사이트 관리자 회원 토큰 ----
const sa = p.match(/^\/api\/site-admin\/([a-f0-9]{32})\/([a-z-]+)(?:\/(.*))?$/);
if (sa) {
const site = store.load(sa[1]); if (!site) return send(res, 404, { error: '사이트를 찾을 수 없습니다' });
const d0 = modules.load(site.id); const tok = (req.headers.authorization || '').replace(/^Bearer\s+/i, '') || url.searchParams.get('token') || '';
const member = modules.memberFromToken(d0, site.id, tok);
const allowed = (user && (user.role === 'admin' || user.id === site.ownerId)) || (member && member.role === 'admin');
if (!allowed) return send(res, 401, { error: '로그인이 필요합니다' });
const who = user && (user.role === 'admin' || user.id === site.ownerId) ? `${user.name} (빌더 ${user.role === 'admin' ? '운영자' : '소유자'})` : `${member.name} (사이트 관리자)`;
try {
const what = sa[2]; const rest = (sa[3] || '').split('/').filter(Boolean);
if (what === 'overview' && req.method === 'GET') { const o = modules.overview(site.id); o.counts.unsentInquiries = forms.list(site.id).filter((x) => !x.sent).length; return send(res, 200, { ...o, mail: mailsys.status(site), who, site: { id: site.id, title: site.title, liveUrl: liveUrlOf(site), url: siteUrl(site.id) } }); }
if (what === 'enabled' && req.method === 'PUT') { const { fields: b } = await readBody(req); return send(res, 200, modules.setEnabled(site.id, b.module, b.on)); }
if (what === 'settings' && req.method === 'PUT' && rest[0]) { const { fields: b } = await readBody(req); return send(res, 200, modules.updateSettings(site.id, rest[0], b)); }
if (what === 'c' && rest[0]) {
if (req.method === 'GET' && !rest[1]) return send(res, 200, modules.adminList(site.id, rest[0]));
if (req.method === 'POST' && !rest[1]) { const { fields: b } = await readBody(req); return send(res, 201, modules.adminCreate(site.id, rest[0], b)); }
if (req.method === 'PUT' && rest[1]) { const { fields: b } = await readBody(req); return send(res, 200, modules.adminUpdate(site.id, rest[0], rest[1], b)); }
if (req.method === 'DELETE' && rest[1]) return send(res, 200, modules.adminDelete(site.id, rest[0], rest[1]));
}
if (what === 'export' && req.method === 'GET' && rest[0] === 'survey' && rest[1]) { const id = rest[1].replace(/\.xlsx$/, ''); const { title, rows } = modules.surveyRows(site.id, id); const buf = xlsx.build(title, rows); return send(res, 200, buf, { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Content-Disposition': `attachment; filename="survey-${id}.xlsx"; filename*=UTF-8''${encodeURIComponent(title)}.xlsx` }); }
// v1.4.0 자료실 파일 올리기 (여러 개, 크기 상한은 자료실 설정 — 사이트 폴더 밖에 저장)
if (what === 'library' && req.method === 'POST') {
const { fields: b, files } = await readBody(req); const list = [];
for (const f of files.slice(0, 20)) list.push({ name: f.name, size: f.size, type: f.type, buf: Buffer.from(await f.arrayBuffer()) });
if (!list.length) return send(res, 400, { error: '올릴 파일을 고르세요' });
return send(res, 201, modules.addLibraryFiles(site.id, list, b));
}
if (what === 'upload' && req.method === 'POST') {
// 갤러리 이미지: 사이트 폴더 uploads/gallery/ 에 저장해 사이트와 함께 배포되게 한다 (v1.4.0: ?to=blog 면 블로그 대표 이미지 uploads/blog/)
const { files } = await readBody(req); const dir = path.join(SITES_ROOT, site.id, 'uploads', url.searchParams.get('to') === 'blog' ? 'blog' : 'gallery'); fs.mkdirSync(dir, { recursive: true, mode: 0o755 }); const added = [];
for (const f of files.slice(0, 20)) { if (!/^image\//.test(f.type || '')) continue; if (f.size > 15 * 1048576) continue; const safe = String(f.name || 'image').replace(/[^\w.가-힣-]+/g, '_').slice(0, 80); const name = `${Date.now().toString(36)}-${crypto.randomBytes(2).toString('hex')}-${safe}`; fs.writeFileSync(path.join(dir, name), Buffer.from(await f.arrayBuffer()), { mode: 0o644 }); const toBlog = url.searchParams.get('to') === 'blog'; const src = `${liveUrlOf(site) ? liveUrlOf(site).replace(/\/$/, '') + '/' : siteUrl(site.id)}uploads/${toBlog ? 'blog' : 'gallery'}/${encodeURIComponent(name)}`; if (toBlog) { added.push({ src: `uploads/blog/${name}`, url: src }); continue; } added.push(modules.adminCreate(site.id, 'gallery', { src: `uploads/gallery/${name}`, title: f.name.replace(/\.[^.]+$/, ''), caption: '' })); void src; }
return send(res, 201, { added });
}
if (what === 'inquiries' && req.method === 'GET') return send(res, 200, forms.list(site.id).slice().reverse());
if (what === 'files' && req.method === 'GET' && rest[0]) { const name = decodeURIComponent(rest[0]); if (!/^[\w.가-힣-]+$/.test(name)) return send(res, 400, { error: 'bad name' }); const fp = path.join(modules.filesDir(site.id), name); if (!fs.existsSync(fp)) return send(res, 404, { error: '파일이 없습니다' }); return send(res, 200, fs.readFileSync(fp), { 'Content-Type': 'application/octet-stream', 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(name.replace(/^\d+-[a-f0-9]+-/, ''))}` }); }
// ---- 메일 (v1.3.0): 사이트 웹메일 켜기/끄기, DNS 안내·확인, 계정별 메일 사용 ----
if (what === 'mail') {
const mailView = () => { const st = mailsys.status(site); let records = null; let recordsError = null; const dom = site.webmail && site.webmail.enabled ? site.webmail.domain : null; if (dom) { try { records = mailsys.dnsRecords(dom, site.webmail.resend, mailsys.siteConfig(site)); } catch (e) { recordsError = e.message; } } const accounts = modules.adminList(site.id, 'members').filter((m) => m.role === 'admin' && m.loginId).map((m) => ({ id: m.id, name: m.name, loginId: m.loginId, mail: m.mail, mailAddress: m.mailAddress, sync: (() => { const full = modules.getSiteAdmin(site.id, m.id); return full ? mailsys.syncState(site, full) : { state: 'n/a' }; })() /* v1.6.39: 메일 비밀번호 동기화 상태 */ })); return { status: st, records, recordsError, previewDomain: dom || null, accounts, webmailUrl: `${formBase()}/webmail/${site.id}/`, siteWebmailUrl: liveUrlOf(site) ? `${liveUrlOf(site).replace(/\/+$/, '')}/webmail` : null }; };
if (req.method === 'GET' && !rest[0]) return send(res, 200, mailView());
// v1.3.2: 메일 시스템 켜기/끄기·도메인 설정은 운영자(관리자 콘솔 › 메일 시스템)만 한다
if (req.method === 'POST' && (rest[0] === 'enable' || rest[0] === 'disable')) return send(res, 403, { error: '메일 시스템 켜기·끄기와 도메인 설정은 운영자가 관리자 콘솔에서 합니다. 운영자에게 요청하세요' });
if (req.method === 'POST' && rest[0] === 'check') { await mailsys.recheck(site); store.save(site); return send(res, 200, mailView()); }
if (req.method === 'PUT' && rest[0] === 'account' && rest[1]) {
const { fields: b } = await readBody(req); const acc = modules.getSiteAdmin(site.id, rest[1]); if (!acc) return send(res, 404, { error: '사이트 관리자 계정이 없습니다' });
const on = b.on === true || b.on === 'true';
// v1.5.7: 메일 서버(Arimail)가 연결된 사이트는 b.password(메일 비밀번호)로 메일 서버 계정을 만든다 — 없으면 mailsys 가 400 으로 입력을 요구
if (on) {
const stOn = mailsys.status(site); if (!stOn.enabled) return send(res, 409, { error: '이 사이트는 메일 시스템이 켜져 있지 않습니다. 운영자에게 요청하세요' });
// v1.6.39 (요구 3): 메일 비밀번호 = 사이트 관리자 비밀번호. 입력한 비밀번호가 그 계정의 비밀번호와 같으면 synced 로 등록부에 적고,
// 관리자 본인이 다른 값을 넣었으면 거절(헷갈리는 두 비밀번호를 만들지 않음). 소유자·운영자가 대신 켤 때는 그 계정 비밀번호를 모를 수 있으므로 임시 비밀번호로 받되 다음 로그인 때 자동으로 맞춰진다
const pwIn = String(b.password || ''); const synced = !!pwIn && modules.verifyPassword(site.id, acc.id, pwIn);
if (stOn.backend === 'arimail' && pwIn && !synced && member && member.id === acc.id) return send(res, 400, { error: '사이트 관리자 비밀번호와 다릅니다 — 메일 비밀번호는 이 계정의 비밀번호와 같게 유지되므로 지금 로그인한 비밀번호를 입력하세요' });
let r; try { r = await mailsys.enableMember(site, acc, { password: pwIn, synced }); } catch (e) { audit.log({ by: user ? user.id : member.loginId, action: 'mail.account.enable', targetType: 'site', target: site.id, siteId: site.id, userId: site.ownerId, ok: false, detail: { member: acc.loginId, error: e.message } }); throw e; } modules.setMemberMail(site.id, acc.id, true, r.address); audit.log({ by: user ? user.id : member.loginId, action: 'mail.account.enable', targetType: 'site', target: site.id, siteId: site.id, userId: site.ownerId, detail: { member: acc.loginId, address: r.address } }); return send(res, 200, { ...mailView(), note: r.note }); }
// 끄기: 메일함이 지워지므로 (1) 화면에서 확인 문구를 그대로 입력받고 (2) 지금 작업하는 관리자의 비밀번호로 본인 확인
if (String(b.confirm || '').trim() !== '삭제') return send(res, 400, { error: '메일 사용을 끄면 이 계정의 메일이 모두 지워집니다. 확인란에 "삭제" 를 입력하세요' });
const okPw = user && (user.role === 'admin' || user.id === site.ownerId) ? (() => { try { auth.login({ id: user.id, password: String(b.password || '') }); return true; } catch (_) { return false; } })() : modules.verifyPassword(site.id, member.id, b.password);
if (!okPw) return send(res, 403, { error: '관리자 비밀번호가 맞지 않습니다' });
let r; try { r = await mailsys.disableMember(site, acc); } catch (e) { audit.log({ by: user ? user.id : member.loginId, action: 'mail.account.disable', targetType: 'site', target: site.id, siteId: site.id, userId: site.ownerId, ok: false, detail: { member: acc.loginId, error: e.message, pending: !!e.pending, needsAdmin: !!e.needsAdmin } }); return send(res, e.status || 502, { error: e.message, pending: !!e.pending, needsAdmin: !!e.needsAdmin }); } modules.setMemberMail(site.id, acc.id, false); audit.log({ by: user ? user.id : member.loginId, action: 'mail.account.disable', targetType: 'site', target: site.id, siteId: site.id, userId: site.ownerId, detail: { member: acc.loginId } }); return send(res, 200, { ...mailView(), deletedMessages: r.deletedMessages });
}
}
if (what === 'admin-account' && req.method === 'POST') { const { fields: b } = await readBody(req); return send(res, 201, modules.createSiteAdmin(site.id, b)); }
return send(res, 404, { error: 'not found' });
} catch (e) { return send(res, e.status || 500, { error: e.message }); }
}
// ---- 접속 통계 비컨 (생성 사이트의 wm-stats.js 가 호출, 인증 없음, CORS 허용) ----
if (p === '/api/stats/beacon') {
const cors = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', 'Access-Control-Max-Age': '600' };
if (req.method === 'OPTIONS') { res.writeHead(204, cors); return res.end(); }
if (req.method !== 'POST') return send(res, 405, { error: 'method not allowed' }, cors);
let b = {}; try { const raw = await new Promise((resolve, reject) => { let d = ''; req.on('data', (c) => { d += c; if (d.length > 8192) { req.destroy(); reject(new Error('too large')); } }); req.on('end', () => resolve(d)); req.on('error', reject); }); b = JSON.parse(raw || '{}'); } catch (_) { return send(res, 400, { ok: false }, cors); }
const site = typeof b.site === 'string' && store.ID_RE.test(b.site) ? store.load(b.site) : null;
if (!site) return send(res, 404, { ok: false }, cors);
const ip = (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
stats.recordBeacon(site.id, { ip, ua: req.headers['user-agent'] || '', path: String(b.path || '/').slice(0, 300), ref: String(b.ref || '').slice(0, 500), host: String(b.host || '').slice(0, 100), transfer: b.transfer, lang: b.lang, w: b.w, h: b.h });
res.writeHead(204, cors); return res.end();
}
// ---- 작업 서버용 "다음 작업 반영 목록" 수집 API (X-Backlog-Key, 읽기 + 수집 표시만) — v1.1.6 ----
if (p === '/api/service/backlog' || p === '/api/service/backlog/collected' || p === '/api/service/backlog/implemented') {
const key = settings.load().backlogKey; const given = String(req.headers['x-backlog-key'] || '');
if (!key || !given || given.length !== key.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(key))) return send(res, 401, { error: '수집 키가 올바르지 않습니다' });
if (req.method === 'GET' && p === '/api/service/backlog') { const items = feedback.backlog(); return send(res, 200, { server: PUBLIC_BASE_URL, version: VERSION, at: new Date().toISOString(), items }); }
if (req.method === 'POST' && p === '/api/service/backlog/collected') { const { fields: b } = await readBody(req); return send(res, 200, { ok: true, marked: feedback.markCollected(Array.isArray(b.ids) ? b.ids.map(String) : [], b.by || 'work-server') }); }
// 릴리스에 반영 완료된 항목 표시 → 목록에서 빠지고 사용자에게 "반영됨(v…)" 으로 보인다
if (req.method === 'POST' && p === '/api/service/backlog/implemented') { const { fields: b } = await readBody(req); return send(res, 200, { ok: true, marked: feedback.markImplemented(Array.isArray(b.ids) ? b.ids.map(String) : [], String(b.version || ''), b.note || '') }); }
return send(res, 405, { error: 'method not allowed' });
}
// ---- 외부 서비스용 캐시 조정 API (X-Service-Key) ----
const sm = p.match(/^\/api\/service\/credits\/([^/]+)$/);
if (sm) {
const key = settings.load().serviceApiKey; const given = req.headers['x-service-key'] || '';
if (!key || !given || given.length !== key.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(key))) return send(res, 401, { error: '서비스 키가 올바르지 않습니다' });
const uid = decodeURIComponent(sm[1]); if (!auth.getUser(uid)) return send(res, 404, { error: '사용자가 없습니다' });
if (req.method === 'GET') return send(res, 200, { ...credits.info(uid), ledger: credits.ledger(uid, 50) });
if (req.method === 'POST') { const { fields: b } = await readBody(req); const type = ['set', 'add', 'adjust', 'refund'].includes(b.type) ? b.type : 'add'; const r = credits.apply({ userId: uid, type, amount: b.amount, memo: b.memo || `외부 서비스(${b.source || 'service'})`, by: `service:${b.source || 'external'}`, ref: b.ref || null }); return send(res, 200, { ok: true, ...credits.info(uid), entry: r.entry }); }
return send(res, 405, { error: 'method not allowed' });
}
if (p.startsWith('/api/') && !user) return send(res, 401, { error: '로그인이 필요합니다' });
if (p.startsWith('/api/admin/')) {
if (user.role !== 'admin') return send(res, 403, { error: '관리자 권한이 필요합니다' });
// v1.5.7 (요구 2·7): 설정 파일·연동 상태 — 초기 설정 페이지(setup-server.js /api/setup/*)와 같은 처리기(lib/setupapi.js)
if (p === '/api/admin/config' || p.startsWith('/api/admin/config/')) {
const sub = p.slice('/api/admin/config'.length).split('/').filter(Boolean).map(decodeURIComponent);
const body = req.method === 'PUT' || req.method === 'POST' ? (await readBody(req)).fields : null;
const r = await setupapi.handle({ method: req.method, sub, body, by: `admin:${user.id}` });
return send(res, r.status, r.body);
}
// v1.6.33 (요구 1): 'dns' 가 이 목록에 없어 1.5.36 부터 DNS 영역 관리 API(/api/admin/dns/*)가 모두 404 "not found" 였다 ("이전된 영역 받기 → 실패: not found" 의 원인)
const am = p.match(/^\/api\/admin\/(overview|recovery|audit|dns|errors|users|sites|settings|billing|deploy-targets|providers|credits|stats|i18n|feedback|mail|credentials|logs|search|templates|template-dirs|site-messages|ssl|mail-servers|prepaid)(?:\/([^/]+))?(?:\/([a-z0-9-]+))?$/); // v1.6.38: mail-servers (메일 서버 등록부) · v1.6.40: prepaid (선불 충전 상품·주문)
if (!am) return send(res, 404, { error: 'not found' });
const [, what, arg, sub2] = am;
// v1.5.36 (요구 4·5) 관리자 DNS 영역·도메인 관리 — 모든 변경은 lib/dnszone 감사 기록에 남는다
if (what === 'dns') {
const dz = require('./lib/dnszone'); const q = url.searchParams; const prov = q.get('provider') || undefined;
if (req.method === 'GET' && !arg) return send(res, 200, { catalog: domainsLib.publicCatalog(), audit: dz.auditList(100), domains: domainsLib.adminList() });
if (arg === 'zones' && !sub2 && req.method === 'GET') { try { return send(res, 200, { provider: prov || dz.defaultId(), zones: await dz.listZones(prov) }); } catch (e) { return send(res, e.status || 500, { error: e.message }); } }
if (arg === 'zones' && !sub2 && req.method === 'POST') { const { fields: b } = await readBody(req); const zone = String(b.domain || '').trim().toLowerCase(); if (!/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,24}$/.test(zone)) return send(res, 400, { error: '도메인 이름이 올바르지 않습니다' }); try { return send(res, 201, b.import ? await dz.importZone(zone, user.id, b.provider || undefined) : await dz.ensureZone(zone, [], user.id, b.provider || undefined)); } catch (e) { return send(res, e.status || 500, { error: e.message }); } }
if (arg === 'zones' && sub2 && req.method === 'DELETE') { const zone = sub2.toLowerCase(); const owned = domainsLib.byName(zone); if (owned && q.get('force') !== '1') return send(res, 409, { error: `${zone} 은(는) 회원(${owned.userId})이 쓰는 도메인입니다. 정말 지우려면 force=1 로 다시 요청하세요` }); try { return send(res, 200, await dz.deleteZone(zone, user.id, prov)); } catch (e) { return send(res, e.status || 500, { error: e.message }); } }
if (arg === 'records' && sub2 && req.method === 'GET') { try { return send(res, 200, await dz.listRecords(sub2.toLowerCase(), prov)); } catch (e) { return send(res, e.status || 500, { error: e.message }); } }
if (arg === 'records' && sub2 && (req.method === 'POST' || req.method === 'DELETE')) { const { fields: b } = await readBody(req); const recs = (Array.isArray(b.records) ? b.records : [b]).map((r) => ({ name: String(r.name || '').trim().toLowerCase().replace(/^@$/, ''), type: String(r.type || '').toUpperCase(), value: String(r.value || '').trim(), ttl: Math.min(86400, Math.max(300, Number(r.ttl) || 900)), prio: r.prio != null && r.prio !== '' ? Number(r.prio) : undefined })).filter((r) => r.type && r.value); if (!recs.length) return send(res, 400, { error: '레코드(type·value)를 주세요' }); try { return send(res, 200, req.method === 'POST' ? await dz.addRecords(sub2.toLowerCase(), recs, user.id, b.provider || prov) : await dz.removeRecords(sub2.toLowerCase(), recs, user.id, b.provider || prov)); } catch (e) { return send(res, e.status || 500, { error: e.message }); } }
if (arg === 'domains' && sub2 && req.method === 'POST') { const { fields: b } = await readBody(req); try { return send(res, 200, domainsLib.setDnsProvider(sub2, b.dnsProvider || '')); } catch (e) { return send(res, e.status || 500, { error: e.message }); } }
return send(res, 404, { error: 'not found' });
}
// v1.6.31 (요구 20·21): 운영 복구 목록·재시도, 관리자 작업 기록 조회
if (what === 'recovery') {
const ctx = { store, deploy, domains: domainsLib, mailsys, jsonstore, ssl: sslLib };
if (req.method === 'GET' && !arg) return send(res, 200, { items: recovery.items(ctx), at: new Date().toISOString() });
if (req.method === 'POST' && arg === 'action') {
const { fields: b } = await readBody(req); const kind = String(b.kind || ''); const id = String(b.id || ''); const action = String(b.action || '');
const done = (ok, detail, extra) => { audit.log({ by: user.id, action: 'recovery.' + action, targetType: kind, target: id, ok, detail }); return send(res, ok ? 200 : 409, { ok, detail, ...(extra || {}) }); };
try {
if (kind === 'deploy' && (action === 'redeploy' || action === 'rollback' || action === 'recheck')) {
const site = store.load(id); if (!site) return send(res, 404, { error: '사이트가 없습니다' });
if (action === 'rollback') { const rb = await rollbackSite(site, user.id); return done(rb.ok, rb.detail); }
if (action === 'recheck') { const rc = await rejudgeAccess(site); return done(rc.ready, (rc.changed ? '상태 변경: ' : '변화 없음: ') + rc.detail); } // v1.6.33
const last = [...(site.deployments || [])].reverse().find((d) => d.kind !== 'unpublish'); const r = await deploySiteTo(site, last ? last.targetId : undefined, user.id);
return done(!!r.ready, `${r.ok ? '전송 완료' : '전송 실패'} · 단계 ${r.phase}: ${r.phaseDetail || r.detail || ''}`);
}
if (kind === 'domain' && action === 'finalize-domain') { const f = await domainsLib.finalizeRegistration(id, user.id); return done(!!f.done, f.detail); } // v1.6.32
if (kind === 'purchase' && action === 'reconcile-purchase') { const p = await domainsLib.reconcilePurchase(id, user.id); return done(p.state === 'persisted' || p.state === 'failed', `${p.name}: ${p.state === 'persisted' ? '업체에 등록돼 있어 차감·기록을 마쳤습니다' : (p.state === 'failed' ? '업체에 등록되지 않아 실패로 닫았습니다(다시 구매 가능)' : p.state)}${p.note ? ' — ' + p.note : ''}`); }
if (kind === 'mail' && ['mail-delete-retry', 'mail-delete-confirmed', 'mail-forget'].includes(action)) {
const [siteId, memberId] = id.split(':'); const site = store.load(siteId); if (!site) return send(res, 404, { error: '사이트가 없습니다' });
const r = await mailsys.adminResolveAccount(site, memberId, action === 'mail-delete-retry' ? 'retry' : (action === 'mail-forget' ? 'forget' : 'confirmed'));
try { modules.setMemberMail(site.id, memberId, false); } catch (_) {}
return done(true, r.detail);
}
return send(res, 400, { error: '알 수 없는 작업입니다' });
} catch (e) { return done(false, e.message); }
}
}
if (what === 'audit' && req.method === 'GET') { const q = url.searchParams; return send(res, 200, { items: audit.list({ userId: q.get('userId') || undefined, siteId: q.get('siteId') || undefined, action: q.get('action') || undefined, q: q.get('q') || undefined, limit: Math.min(1000, parseInt(q.get('limit'), 10) || 300) }) }); }
if (what === 'overview' && req.method === 'GET') {
const sites = store.list().map((s0) => { const s1 = store.load(s0.id); const cmds = s1.commands || []; return { id: s1.id, title: s1.title, ownerId: s1.ownerId, ownerName: s1.ownerName, mode: s1.mode || 'quick', stage: s1.plan ? s1.plan.stage : null, status: jobOf(s1.id).running ? 'running' : (jobOf(s1.id).deploying ? 'deploying' : s1.status), tier: settings.validTier(s1.tier), model: siteRef(s1), creditWait: !!s1.creditWait, createdAt: s1.createdAt, updatedAt: s1.updatedAt, commands: cmds.length, errors: cmds.filter((c) => c.status === 'error').length, cancelled: cmds.filter((c) => c.status === 'cancelled').length, toolErrors: cmds.reduce((a, c) => a + ((c.log || []).filter((l) => /^도구 오류/.test(l.text)).length), 0), totals: s1.totals, url: siteUrl(s1.id), deploy: publicView(s1, { admin: true }).deploy, deployState: deployState(s1), domains: s1.domains || [], source: s1.source ? `${s1.source.user}@${s1.source.host}:${s1.source.docroot}` : null, deployTargetId: s1.deployTargetId || null, sourceUrl: s1.source ? s1.source.url : null }; });
let mail = null; try { mail = await mailer.status(); } catch (e) { mail = { configured: false, error: e.message }; }
const fb = feedback.counts();
return send(res, 200, { users: auth.listUsers(), pendingApprovals: auth.pendingCount(), sites, git: gitstore.info('<siteId>'), model: providers.internalRef(), version: VERSION, tiers: settings.publicView().tiers, mail: { configured: !!mail.configured, error: mail.error || null, verifiedDomains: mail.verifiedDomains || [] }, targets: deploy.list(), stats: statsState, feedback: fb, languages: i18n.listLanguages().filter((l) => l.active).length });
}
// ---- v1.3.10 사이트 템플릿 (관리자): 등록·수정·다시 담기·삭제, 별도 디렉터리 ----
if (what === 'templates') {
if (req.method === 'GET' && !arg) { const sp = url.searchParams; return send(res, 200, { tree: templates.tree(briefLib.INDUSTRIES, false), items: templates.search({ q: sp.get('q') || '', dir: sp.get('dir') || '' }), tags: templates.popularTags(false), mySites: store.list(user.id).map((x) => ({ id: x.id, title: x.title, stage: x.stage, mode: x.mode })) }); }
if (req.method === 'POST' && !arg) { const { fields: b } = await readBody(req); const st = store.load(String(b.siteId || '')); if (!st || st.ownerId !== user.id) return send(res, 404, { error: '관리자가 만든 사이트만 템플릿으로 등록할 수 있습니다' }); if (jobOf(st.id).running) return send(res, 409, { error: '사이트 작업이 끝난 뒤 등록하세요' }); return send(res, 201, templates.register(st, path.join(SITES_ROOT, st.id), b, briefLib.INDUSTRIES, user.id)); }
if (req.method === 'PUT' && arg && !sub2) { const { fields: b } = await readBody(req); return send(res, 200, templates.update(arg, b, briefLib.INDUSTRIES)); }
if (req.method === 'POST' && arg && sub2 === 'resnapshot') { const t = templates.get(arg); if (!t) return send(res, 404, { error: '템플릿이 없습니다' }); const st = store.load(t.sourceSiteId); if (!st) return send(res, 404, { error: '원본 사이트가 지워져 다시 담을 수 없습니다' }); return send(res, 200, templates.resnapshot(arg, path.join(SITES_ROOT, st.id))); }
if (req.method === 'DELETE' && arg) { templates.remove(arg); return send(res, 200, { ok: true }); }
}
if (what === 'template-dirs') {
const { fields: b } = req.method === 'GET' ? { fields: {} } : await readBody(req);
if (req.method === 'POST' && !arg) return send(res, 201, templates.addDir(b.name, b.parent));
if (req.method === 'PUT' && arg) return send(res, 200, templates.renameDir(arg, b.name));
if (req.method === 'DELETE' && arg) { templates.removeDir(arg); return send(res, 200, { ok: true }); }
}
// v1.3.5 검색 등록: 등록을 시작한 사이트와 사람이 해야 할 작업(코드 받기·DNS·검색사이트에서 확인·사이트맵 제출)
// v1.3.16 생성 사이트 시스템 문구(위젯·오류·동의문·메일 알림) 번역 보기·고치기 — GET ?lang=en, PUT /<lang> { messages: { id: 번역 } }
if (what === 'site-messages') {
if (req.method === 'GET' && !arg) { const lang = multilang.codeOf(url.searchParams.get('lang') || 'en'); const items = sitemsg.listFor(lang); return send(res, 200, { lang, items, missing: items.filter((x) => !x.translation).length, locales: Object.entries(multilang.LOCALES).filter(([c]) => c !== 'ko').map(([code, l]) => ({ code, name: l.name, label: l.label })) }); }
if (req.method === 'PUT' && arg) { const { fields: b } = await readBody(req); sitemsg.saveOverride(multilang.codeOf(arg), b.messages || {}); return send(res, 200, { ok: true, items: sitemsg.listFor(multilang.codeOf(arg)) }); }
}
if (what === 'search') {
if (req.method === 'GET' && !arg) { const out = []; for (const x of store.list()) { const st = store.load(x.id); if (!st) continue; const v = searchView(st); if (!v.engines.some((e) => e.started) && !v.indexnow) continue; out.push({ id: st.id, title: st.title, ownerId: st.ownerId, ownerName: st.ownerName || '', ...v }); } return send(res, 200, { items: out }); }
if (req.method === 'POST' && arg && sub2) { const st = store.load(arg); if (!st) return send(res, 404, { error: '사이트가 없습니다' }); const { fields: b } = await readBody(req); const r = await searchAction(st, sub2, b, user.id); return send(res, 200, { id: st.id, title: st.title, ownerId: st.ownerId, ownerName: st.ownerName || '', ...r }); }
}
// v1.3.2 로그 보기: 종류·기간·회사·사이트·검색어 (lib/logview.js)
if (what === 'logs' && req.method === 'GET') {
const sp = url.searchParams; const types = (sp.get('types') || '').split(',').filter((t) => logview.TYPES[t]);
return send(res, 200, logview.query(store.list().map((x) => store.load(x.id)).filter(Boolean), auth.listUsers(), { types, from: sp.get('from') || '', to: sp.get('to') || '', owner: sp.get('owner') || '', site: sp.get('site') || '', q: sp.get('q') || '', limit: sp.get('limit') }));
}
if (what === 'errors' && req.method === 'GET') {
const out = [];
for (const s0 of store.list()) { const s1 = store.load(s0.id); for (const c of s1.commands || []) { if (c.status === 'error' || c.status === 'cancelled') out.push({ siteId: s1.id, title: s1.title, ownerId: s1.ownerId, no: c.no, kind: c.kind, status: c.status, at: c.finishedAt || c.startedAt, text: c.error || '' }); for (const l of c.log || []) if (l.level === 'debug' || l.level === 'warn' || (l.level !== 'assistant' && /^도구 오류|^작업 #\d+ (오류|실패)|실패:/.test(l.text))) out.push({ siteId: s1.id, title: s1.title, ownerId: s1.ownerId, no: c.no, kind: c.kind, status: 'log', at: new Date(l.ts).toISOString(), text: l.text }); } }
out.sort((a, b) => (b.at || '').localeCompare(a.at || ''));
return send(res, 200, out.slice(0, 500));
}
if (what === 'users') {
// v1.4.0 계정 종류 바꾸기 {accountType: subdomain|domain}
if (req.method === 'POST' && arg && sub2 === 'account-type') { const { fields: b } = await readBody(req); const r = auth.setAccountType(arg, String(b.accountType || '')); audit.log({ by: user.id, action: 'user.accountType', targetType: 'user', target: arg, userId: arg, detail: { accountType: b.accountType } }); return send(res, 200, r); }
if (req.method === 'GET') return send(res, 200, auth.listUsers());
if (req.method === 'POST' && arg && sub2 === 'password') { const { fields: b } = await readBody(req); const r = auth.resetPassword(arg, b.password); audit.log({ by: user.id, action: 'user.resetPassword', targetType: 'user', target: arg, userId: arg }); return send(res, 200, r); }
// v1.5.27 (요구 6) 가입 승인·거절
if (req.method === 'POST' && arg && sub2 === 'approve') { const r = auth.approve(arg, user.id); audit.log({ by: user.id, action: 'user.approve', targetType: 'user', target: arg, userId: arg }); return send(res, 200, r); }
if (req.method === 'POST' && arg && sub2 === 'reject') { if (arg === user.id) return send(res, 400, { error: '자기 자신은 거절할 수 없습니다' }); const r = auth.reject(arg); audit.log({ by: user.id, action: 'user.reject', targetType: 'user', target: arg, userId: arg }); return send(res, 200, r); }
if (req.method === 'POST' && arg && sub2 === 'role') { const { fields: b } = await readBody(req); if (arg === user.id && b.role !== 'admin') return send(res, 400, { error: '자기 자신의 관리자 권한은 해제할 수 없습니다' }); const r = auth.setRole(arg, b.role); audit.log({ by: user.id, action: 'user.role', targetType: 'user', target: arg, userId: arg, detail: { role: b.role } }); return send(res, 200, r); }
}
if (what === 'settings') {
if (req.method === 'GET' && arg === 'mail') return send(res, 200, await mailer.status());
if (req.method === 'GET' && arg === 'git') return send(res, 200, await gitstore.remoteStatus());
// v1.3.2: 저장하지 않은 테마로 CSS 를 만들어 화면 설정 미리보기에 쓴다
// v1.3.2: 화면 설정 미리보기용 실제 화면 HTML (관리자 전용, 정적 파일 그대로 — 관리자는 / 와 /login 이 /admin 으로 이동하므로 따로 준다)
if (req.method === 'GET' && arg === 'preview-page' && ['index', 'login', 'site-admin'].includes(sub2)) return send(res, 200, { html: fs.readFileSync(path.join(PUBLIC_DIR, `${sub2}.html`), 'utf8') });
if (req.method === 'POST' && arg === 'theme-preview') { const { fields: b } = await readBody(req); return send(res, 200, { css: settings.themeCss(b.theme || null) }); }
if (req.method === 'GET' && arg === 'theme') return send(res, 200, { theme: settings.getTheme(), saved: !!settings.load().theme, defaults: settings.THEME_DEFAULTS, presets: settings.THEME_PRESETS });
if (req.method === 'GET') return send(res, 200, { ...settings.publicView(), availableModels: providers.availableModels(null), unavailableProviders: providers.unavailableProviders(), serverLogin: settings.serverLoginInfo(), claude: claudeInfo(), version: VERSION, welcomeCandidates: welcomeCandidates(), domainProviders: domainsLib.publicCatalog() /* v1.5.36 */ });
if (req.method === 'PUT') {
const { fields: b } = await readBody(req);
// v1.4.5 첫 화면 페이지는 관리자가 만든 사이트만 (다른 회원 사이트를 모든 사용자에게 보이지 않게)
if (b.welcomeSiteId) { const c = welcomeCandidates().find((x) => x.id === String(b.welcomeSiteId)); if (!c) return send(res, 400, { error: '첫 화면 페이지는 관리자 계정이 만든 사이트 중에서 고르세요' }); if (!c.published && !c.hasIndex) return send(res, 400, { error: '그 사이트에는 아직 첫 페이지(index.html)가 없습니다. 생성을 마친 뒤 고르세요' }); }
settings.save(b); audit.log({ by: user.id, action: 'settings.update', targetType: 'settings', target: 'settings', detail: { keys: Object.keys(b || {}) } }); return send(res, 200, { ...settings.publicView(), availableModels: providers.availableModels(null), unavailableProviders: providers.unavailableProviders(), serverLogin: settings.serverLoginInfo(), claude: claudeInfo(), version: VERSION, welcomeCandidates: welcomeCandidates(), domainProviders: domainsLib.publicCatalog() /* v1.5.36 */ });
}
if (req.method === 'POST' && arg === 'test-connection') {
const tmp = path.join(require('os').tmpdir(), 'webhosting-conn-test'); fs.mkdirSync(tmp, { recursive: true });
const info = claudeInfo();
if (!info.bin) return send(res, 200, { ok: false, error: `claude 실행 파일을 찾지 못했습니다 (탐색: ${info.searched.join(', ')}). 서버에 Claude Code 를 설치하세요.`, claude: info });
try {
const r = await runClaude({ siteDir: tmp, prompt: 'Reply with exactly: OK', systemPrompt: 'Reply with the single word OK.', tools: 'Read', noPersist: true, onEvent: () => {} });
return send(res, 200, { ok: r.ok, model: r.model, reply: r.resultText.slice(0, 80), usage: r.usage, costUsd: r.costUsd, subtype: r.subtype, error: r.ok ? null : (r.resultText || r.subtype || '알 수 없는 오류').slice(0, 400), claude: info });
} catch (e) { return send(res, 200, { ok: false, error: e.message.slice(0, 600), claude: info }); }
}
if (req.method === 'POST' && arg === 'mail-test') { const { fields: b } = await readBody(req); try { const from = await mailer.pickFrom(b.domain ? [b.domain] : []); const r = await mailer.send({ from, to: b.to, subject: '[웹메이커] 메일 발송 테스트', text: `메일 발송 설정이 정상입니다. 발신: ${from.address}`, html: `<p>메일 발송 설정이 정상입니다. 발신: ${from.address}</p>` }); return send(res, 200, { ok: true, from: from.address, id: r && r.id }); } catch (e) { return send(res, 200, { ok: false, error: e.message }); } }
if (req.method === 'POST' && arg === 'refresh-rate') { try { return send(res, 200, await fx.getRate()); } catch (e) { return send(res, 502, { error: e.message }); } }
}
// v1.4.14 (요구 9) 플랫폼 도메인 와일드카드 인증서: 상태 조회 / 발급·갱신 시작(비동기) / 진행 취소
if (what === 'ssl') {
if (req.method === 'GET') return send(res, 200, await sslLib.status(url.searchParams.get('targetId') || undefined));
if (req.method === 'POST' && arg === 'wildcard') { const { fields: b } = await readBody(req); const r = await sslLib.issue(b.targetId || undefined, { force: b.force === true || b.force === 'true' }); return send(res, 202, { ...r, status: await sslLib.status(b.targetId || undefined) }); }
if (req.method === 'DELETE' && arg === 'wildcard') return send(res, 200, { cancelled: sslLib.cancel() });
return send(res, 404, { error: 'not found' });
}
if (what === 'providers') {
if (req.method === 'GET' && !arg) return send(res, 200, { providers: providers.listAdminProviders(), catalog: providers.providerCatalog() });
if (req.method === 'PUT' && arg && !sub2) { const { fields: b } = await readBody(req); if (typeof b.baseUrl === 'string' && b.baseUrl.trim()) { try { await require('./lib/netguard').assertExternalUrl(b.baseUrl.trim(), { allowHttp: true }); } catch (e) { return send(res, 400, { error: '제공자 기본 URL: ' + e.message }); } } const out = providers.saveAdminProvider(arg, b); const u = providers.usability(arg); Object.assign(out, { usable: u.usable, unavailableReason: u.reason, usableModels: u.models }); audit.log({ by: user.id, action: 'provider.save', targetType: 'setting', target: arg, detail: { enabled: b.enabled, authMode: b.authMode, models: Array.isArray(b.models) ? b.models.length : undefined, keyChanged: !!(b.apiKey && b.apiKey !== '••••'), usable: u.usable, reason: u.reason } }); return send(res, 200, out); } // v1.6.38 (요구 1): 저장 뒤 등급 목록에 들어가는지와 이유를 함께 돌려준다 (키 없이 저장 → "반영했습니다" 라고만 보이던 문제) // v1.6.31 요구 5·21
if (req.method === 'POST' && arg && sub2 === 'test') {
const { fields: b } = await readBody(req); const cur = providers.listAdminProviders().find((x) => x.id === arg);
const serverLogin = arg === 'openai' && cur && cur.authMode === 'server-login';
const key = serverLogin ? '' : ((b.apiKey && b.apiKey !== '••••') ? b.apiKey : (cur && cur.hasKey ? providers.dec((settings.load().providers || {})[arg].apiKey) : ''));
// v1.6.38 (요구 1): 키가 필요한 제공자는 키 없이 조회하지 않는다 — OpenRouter 처럼 모델 목록은 키 없이도 내려오는 곳이 있어 "조회 성공 → 저장" 뒤 등급 목록에 안 나오는 원인이 됐다 (webmaker.ibit.kr 2026-10-21)
if (!key && !serverLogin && arg !== 'custom') return send(res, 200, { ok: false, error: `${(providers.PROVIDERS[arg] || {}).label || arg} 은(는) API 키가 필요합니다 — 위 칸에 키를 넣은 뒤 "모델 조회" 를 누르세요. 키 없이 저장하면 모델이 설정 탭의 등급 목록에 나오지 않습니다` });
try { const models = await providers.fetchModels(arg, key, b.baseUrl || (cur && cur.baseUrl)); providers.recordTest(arg, { ok: true, detail: `모델 ${models.length}개 조회` }); return send(res, 200, { ok: true, models }); }
catch (e) { providers.recordTest(arg, { ok: false, detail: e.message }); return send(res, 200, { ok: false, error: e.message }); }
}
}
// v1.6.38 (요구 2): 메일 서버(Arimail) 등록부 — 다른 서버에 설치된 Arimail 을 여러 대 등록하고 닉네임·IP 로 구분해 고른다. 비밀번호는 보관소에만, 응답·작업 기록에는 넣지 않는다
if (what === 'mail-servers') {
const pubBody = (b) => ({ id: b.id, nickname: b.nickname, ip: b.ip, url: b.url, apiUrl: b.apiUrl, domain: b.domain, adminLogin: b.adminLogin, passwordChanged: !!b.password });
if (req.method === 'GET' && !arg) return send(res, 200, { servers: mailServers.list(), provider: require('./lib/connectors').providerOf('mail') });
if ((req.method === 'POST' && !arg) || (req.method === 'PUT' && arg && !sub2)) {
const { fields: b } = await readBody(req); const input = { ...(b || {}) }; if (req.method === 'PUT') input.id = arg;
let out; try { out = mailServers.upsert(input, user.id); } catch (e) { audit.log({ by: user.id, action: 'mailserver.save', targetType: 'setting', target: input.id || input.nickname || '', ok: false, detail: { ...pubBody(input), error: e.message } }); throw e; }
audit.log({ by: user.id, action: 'mailserver.save', targetType: 'setting', target: out.id, detail: pubBody({ ...input, id: out.id }) });
return send(res, req.method === 'POST' ? 201 : 200, { server: out, servers: mailServers.list() });
}
if (req.method === 'DELETE' && arg && !sub2) { mailServers.remove(arg); audit.log({ by: user.id, action: 'mailserver.remove', targetType: 'setting', target: arg }); return send(res, 200, { servers: mailServers.list() }); }
if (req.method === 'POST' && arg && sub2 === 'default') { const servers = mailServers.setDefault(arg); audit.log({ by: user.id, action: 'mailserver.default', targetType: 'setting', target: arg }); return send(res, 200, { servers }); }
// v1.6.39 (요구 4): [보이기] — 저장된 연동 관리자 비밀번호 원문. 관리자 전용, 호출 사실만 기록(비밀번호는 기록·캐시하지 않음)
if (req.method === 'GET' && arg && sub2 === 'password') { const r = mailServers.revealPassword(arg); audit.log({ by: user.id, action: 'mailserver.reveal', targetType: 'setting', target: arg, detail: { adminLogin: r.adminLogin } }); return send(res, 200, r, { 'Cache-Control': 'no-store' }); }
// v1.6.39 (요구 2): 이전 비밀번호로 되돌리기
if (req.method === 'POST' && arg && sub2 === 'revert-password') { const server = mailServers.revertPassword(arg, user.id); audit.log({ by: user.id, action: 'mailserver.revert-password', targetType: 'setting', target: arg }); return send(res, 200, { server, servers: mailServers.list() }); }
// v1.6.39 (요구 4): 비밀번호 초기화 — 새 무작위 비밀번호를 Arimail 에 설정하고 보관. body.via {login,password} 가 있으면 그 관리자 계정으로 로그인해 바꾼다(저장하지 않음)
if (req.method === 'POST' && arg && sub2 === 'reset-password') {
const { fields: b } = await readBody(req); const via = b && b.via && b.via.login ? { login: String(b.via.login), password: String(b.via.password || '') } : null;
let r; try { r = await mailServers.resetPassword(arg, { via }, user.id); } catch (e) { audit.log({ by: user.id, action: 'mailserver.reset-password', targetType: 'setting', target: arg, ok: false, detail: { via: via ? via.login : null, error: e.message } }); throw e; }
audit.log({ by: user.id, action: 'mailserver.reset-password', targetType: 'setting', target: arg, detail: { via: via ? via.login : null, created: r.created, how: r.how, testOk: r.test ? r.test.ok : null } });
return send(res, 200, { ...r, servers: mailServers.list() }, { 'Cache-Control': 'no-store' });
}
// v1.7.3 (요구 2): 사용량 — 서버의 모든 도메인 요약(캐시 5분, ?refresh=1 이면 새로 받음, ?days=)
if (req.method === 'GET' && arg && sub2 === 'usage') return send(res, 200, await mailusage.forServer(arg, { force: url.searchParams.get('refresh') === '1', days: Math.min(366, Math.max(1, Number(url.searchParams.get('days')) || 30)) }), { 'Cache-Control': 'no-store' });
// v1.7.3 (요구 2): 사용량 API 키 — issue(저장된 관리자 계정으로 Arimail 에서 발급·보관) · set(Arimail 전체 설정 화면에서 만든 키 붙여 넣기) · clear(보관 키 삭제). 키 원문은 기록에 남기지 않는다
if (req.method === 'POST' && arg && sub2 === 'usage-key') {
const { fields: b } = await readBody(req); const action = String((b && b.action) || 'issue');
try {
let out;
if (action === 'issue') { const r = await mailServers.issueUsageKey(arg, user.id); out = { ok: true, server: r.server, endpoint: r.endpoint, replaced: r.replaced, detail: `Arimail 에서 사용량 API 키를 ${r.replaced ? '다시 발급받아 바꿨습니다(이전 키는 무효)' : '발급받아 보관했습니다'}${r.endpoint ? ` — ${r.endpoint}` : ''}` }; }
else if (action === 'set') { const server = mailServers.writeUsageKey(arg, b.key, user.id); out = { ok: true, server, detail: '사용량 API 키를 보관했습니다' }; }
else if (action === 'clear') { const server = mailServers.clearUsageKey(arg); out = { ok: true, server, detail: '보관한 사용량 API 키를 지웠습니다 (Arimail 쪽 키는 그대로)' }; }
else return send(res, 400, { error: 'action 은 issue · set · clear 중 하나입니다' });
audit.log({ by: user.id, action: 'mailserver.usage-key', targetType: 'setting', target: arg, detail: { action, replaced: !!out.replaced } });
const usage = action === 'clear' ? null : await mailusage.forServer(arg, { force: true }); // 바로 확인 — 키가 맞는지 한 번 불러 본다
return send(res, 200, { ...out, usage, servers: mailServers.list() }, { 'Cache-Control': 'no-store' });
} catch (e) { audit.log({ by: user.id, action: 'mailserver.usage-key', targetType: 'setting', target: arg, ok: false, detail: { action, error: e.message } }); throw e; }
}
if (req.method === 'POST' && arg && sub2 === 'test') { const r = await mailServers.test(arg); audit.log({ by: user.id, action: 'mailserver.test', targetType: 'setting', target: arg, ok: !!r.ok, detail: { detail: r.detail } }); return send(res, 200, { ok: !!r.ok, detail: r.detail, version: r.version || null, users: r.users, serverDomain: r.serverDomain || null, locked: !!r.locked, lockedUntil: r.lockedUntil || null, code: r.code || null /* v1.6.39: 잠금 정보 */, server: r.server, servers: mailServers.list() }); }
}
if (what === 'deploy-targets') {
if (req.method === 'GET' && !arg) return send(res, 200, deploy.list());
if (req.method === 'POST' && !arg) { const { fields: b } = await readBody(req); return send(res, 201, deploy.upsert(b)); }
if (req.method === 'PUT' && arg && !sub2) { const { fields: b } = await readBody(req); return send(res, 200, deploy.upsert({ ...b, id: arg })); }
if (req.method === 'DELETE' && arg && !sub2) return send(res, 200, deploy.remove(arg));
if (req.method === 'POST' && arg && sub2 === 'default') return send(res, 200, deploy.setDefault(arg));
if (req.method === 'POST' && arg && sub2 === 'generate-key') return send(res, 200, await deploy.generateKey(arg));
if (req.method === 'POST' && arg && sub2 === 'test') return send(res, 200, await deploy.test(arg));
if (req.method === 'POST' && arg && sub2 === 'default-server') return send(res, 200, await deploy.ensureDefaultServer(arg));
}
if (what === 'i18n') {
if (req.method === 'GET' && !arg) { const c = i18n.buildCatalog(); return send(res, 200, { serviceVersion: VERSION, languages: i18n.listLanguages(), source: { lang: i18n.SOURCE, version: c.version, date: c.date, hash: c.hash, count: c.count } }); }
if (req.method === 'GET' && arg === 'export') { const lang = url.searchParams.get('lang') || ''; const out = i18n.exportFile(lang, lang ? i18n.loadLocale(lang) : null); return send(res, 200, Buffer.from(JSON.stringify(out, null, 1)), { 'Content-Type': 'application/json; charset=utf-8', 'Content-Disposition': `attachment; filename="ai-webmaker-locale-${lang || 'ko'}-v${out.sourceVersion}.json"` }); }
if (req.method === 'POST' && arg === 'upload') { const { files } = await readBody(req); if (!files.length) return send(res, 400, { error: '파일이 없습니다' }); const list = []; for (const f of files) list.push({ name: f.name, data: Buffer.from(await f.arrayBuffer()) }); return send(res, 200, { results: i18n.importFiles(list), languages: i18n.listLanguages(), version: VERSION }); }
if (req.method === 'PUT' && arg) { const { fields: b } = await readBody(req); return send(res, 200, i18n.setEnabled(arg, b.enabled)); }
if (req.method === 'DELETE' && arg) { i18n.remove(arg); return send(res, 200, { ok: true, languages: i18n.listLanguages() }); }
}
// ---- 외부 연동 접속 정보 (v1.3.1): 키 원문은 응답에 넣지 않는다 ----
if (what === 'credentials') {
const kinds = Object.fromEntries(Object.entries(vault.KINDS).map(([k, v]) => [k, { label: v.label, fixedId: v.fixedId, defaultUrl: v.defaultUrl, secretLabel: v.secretLabel }]));
if (req.method === 'GET' && !arg) return send(res, 200, { items: vault.list(), kinds });
if (req.method === 'POST' && !arg) { const { fields: b } = await readBody(req); const r = vault.set(b, 'input'); audit.log({ by: user.id, action: 'vault.set', targetType: 'vault', target: (r && r.id) || b.id || b.kind, detail: { kind: b.kind, label: b.label } }); return send(res, 200, r); }
// v1.3.2: .key 파일 자체를 올려 가져오기 (multipart: kind, id?, label?, file) — 파일은 저장하지 않고 메모리에서 읽는다
if (req.method === 'POST' && arg === 'upload') { const { fields: b, files } = await readBody(req); const f = files[0]; if (!f) return send(res, 400, { error: '키 파일을 고르세요' }); if (f.size > 64 * 1024) return send(res, 400, { error: '키 파일이 너무 큽니다 (64KB 이하)' }); const text = Buffer.from(await f.arrayBuffer()).toString('utf8'); const r = vault.importText(b.kind, text, { id: b.id, label: b.label }, `upload:${String(f.name || 'key').slice(0, 60)}`); audit.log({ by: user.id, action: 'vault.upload', targetType: 'vault', target: (r && r.id) || b.id || b.kind, detail: { kind: b.kind, label: b.label, file: String(f.name || '').slice(0, 60) } }); return send(res, 200, r); }
if (req.method === 'POST' && arg === 'import') { const { fields: b } = await readBody(req); const r = vault.importFile(b.kind, b.file, { id: b.id, label: b.label }); audit.log({ by: user.id, action: 'vault.import', targetType: 'vault', target: (r && r.id) || b.id || b.kind, detail: { kind: b.kind, label: b.label, file: b.file } }); return send(res, 200, r); }
if (req.method === 'POST' && arg && sub2 === 'test') return send(res, 200, await vault.test(arg));
if (req.method === 'DELETE' && arg) { vault.remove(arg); audit.log({ by: user.id, action: 'vault.remove', targetType: 'vault', target: arg }); return send(res, 200, { ok: true }); }
}
// ---- 메일 시스템 (v1.3.0): 전체 켜기/끄기·메일 서버 주소, 웹메일을 켠 사이트 목록 ----
// ---- 메일 시스템 (v1.3.2: 사이트(호스팅 도메인) 단위로 켜기) ----
// GET /api/admin/mail 기본값 + 모든 사이트의 메일 상태
// PUT /api/admin/mail 기본값(MX 호스트·SPF include·DMARC) 저장
// GET /api/admin/mail/<사이트ID> 사이트 메일 설정·DNS 안내·확인 결과
// POST /api/admin/mail/<사이트ID> 메일 시스템 사용 켜기 / 설정 변경 {domain, mxHost, spfInclude, dmarc}
// POST /api/admin/mail/<사이트ID>/check DNS·보내기 인증 다시 확인
// POST /api/admin/mail/<사이트ID>/disable 끄기 (계정·메일함은 남김)
if (what === 'mail') {
// v1.5.7: arimail — 연결된 메일 서버(Arimail) 정보(주소·도메인·상태). 화면의 "받는 메일 서버" 줄과 초기 설정 페이지 링크에 쓴다
const be = () => { const b0 = mailsys.backendFor(); return { id: b0.id, canSend: b0.canSend, connected: b0.connected, reason: b0.reason, arimail: b0.arimail }; };
const row = (x) => { const st = mailsys.status(x); return { id: x.id, title: x.title, ownerId: x.ownerId, ownerName: x.ownerName || '', domains: mailsys.domainChoices(x), enabled: st.enabled, domain: st.domain, level: st.level, label: st.label, pending: st.pending || [], checkedAt: st.dns && st.dns.checkedAt, accounts: st.enabled ? modules.adminList(x.id, 'members').filter((m) => m.mail).length : 0 }; };
const detail = (x) => { const st = mailsys.status(x); let records = null; let recordsError = null; if (st.enabled) { try { records = mailsys.dnsRecords(st.domain, x.webmail.resend, mailsys.siteConfig(x)); } catch (e) { recordsError = e.message; } } return { ...row(x), status: st, settings: st.enabled ? { mxHost: x.webmail.mxHost || '', spfInclude: x.webmail.spfInclude || '', dmarc: x.webmail.dmarc || '', mailServer: x.webmail.mailServer || '' } : null, mailServer: (x.webmail && x.webmail.mailServer) || '', pickedServer: mailServers.pub(mailServers.pick(x)) /* v1.6.38: 실제로 쓰일 서버(자동 선택 포함) */, effective: mailsys.siteConfig(x), records, recordsError, webmailUrl: `${formBase()}/webmail/${x.id}/` }; };
if (!arg) {
if (req.method === 'GET') return send(res, 200, { config: mailsys.config(), backend: be(), servers: mailServers.list() /* v1.6.38 */, sites: store.list().map((x) => store.load(x.id)).filter(Boolean).map(row) });
if (req.method === 'PUT') { const { fields: b } = await readBody(req); const c = mailsys.normalizeConfig(b, settings.load().mailSystem); settings.save({ mailSystem: c }); audit.log({ by: user.id, action: 'settings.mailSystem', targetType: 'settings', target: 'mailSystem', detail: { keys: Object.keys(b || {}) } }); return send(res, 200, { config: mailsys.config(), backend: be() }); }
} else {
const ms = store.load(arg); if (!ms) return send(res, 404, { error: '사이트가 없습니다' });
if (req.method === 'GET' && !sub2) return send(res, 200, { ...detail(ms), usage: await mailusage.forSite(ms, { force: url.searchParams.get('refresh') === '1' }) /* v1.7.3: 이 사이트 메일 도메인의 사용량(계정은 이 사이트 것만) */ });
if (req.method === 'POST' && !sub2) {
const { fields: b } = await readBody(req); const prevDomain = ms.webmail && ms.webmail.domain; await mailsys.enableSite(ms, b, user.id); store.save(ms);
// 메일 도메인이 바뀌면 메일 사용 중인 계정의 주소(아이디@도메인)도 새 도메인으로 바꾼다
if (prevDomain && prevDomain !== ms.webmail.domain) for (const m of modules.adminList(ms.id, 'members').filter((x) => x.mail && x.loginId)) { try { modules.setMemberMail(ms.id, m.id, true, mailsys.addressFor(ms, m)); } catch (_) {} } mailsys.syncStub(ms, path.join(SITES_ROOT, ms.id), formBase()); return send(res, 200, detail(ms));
}
if (req.method === 'POST' && sub2 === 'check') { await mailsys.recheck(ms); store.save(ms); return send(res, 200, detail(ms)); }
if (req.method === 'POST' && sub2 === 'disable') { mailsys.disableSite(ms); store.save(ms); mailsys.syncStub(ms, path.join(SITES_ROOT, ms.id), formBase()); return send(res, 200, detail(ms)); }
}
}
if (what === 'feedback') {
if (req.method === 'GET' && !arg) return send(res, 200, { items: feedback.list(), counts: feedback.counts() });
if (req.method === 'GET' && arg === 'backlog') { const items = feedback.backlog(); return send(res, 200, { items, markdown: feedback.backlogMarkdown(items, { server: PUBLIC_BASE_URL }) }); }
if (req.method === 'PUT' && arg) { const { fields: b } = await readBody(req); let x = feedback.get(arg); if (!x) return send(res, 404, { error: '글이 없습니다' }); if (b.planned !== undefined) x = feedback.setPlan(arg, { planned: b.planned === true || b.planned === 'true', planNote: b.planNote, by: user.id }); if (b.status) x = feedback.update(arg, { status: b.status }); if (b.reply && String(b.reply).trim()) x = feedback.comment(arg, { by: 'admin', text: b.reply, byName: user.name }); return send(res, 200, x); }
if (req.method === 'POST' && arg && sub2 === 'analyze') { const x = feedback.get(arg); if (!x) return send(res, 404, { error: '글이 없습니다' }); analyzeFeedback(x).catch(() => {}); return send(res, 202, { ok: true }); }
}
if (what === 'stats') {
if (req.method === 'GET') return send(res, 200, { ...statsState, log: STATS_LOG });
if (req.method === 'POST' && arg === 'collect') { collectLocalStats(); await collectRemoteStats(); return send(res, 200, { ...statsState, log: STATS_LOG }); }
}
if (what === 'credits') {
if (req.method === 'GET' && !arg) { const users = auth.listUsers(); return send(res, 200, { users: users.map((u) => ({ ...u, ...credits.info(u.id), waiting: store.list(u.id).map((x) => store.load(x.id)).filter((x) => x && x.creditWait).length })), tiers: settings.TIERS }); }
const uid = arg ? decodeURIComponent(arg) : null;
if (req.method === 'GET' && uid) return send(res, 200, { ...credits.info(uid), ledger: credits.ledger(uid, 200) });
if (req.method === 'POST' && uid) { if (!auth.getUser(uid)) return send(res, 404, { error: '사용자가 없습니다' }); const { fields: b } = await readBody(req); const type = b.type === 'set' ? 'set' : (Number(b.amount) < 0 ? 'adjust' : 'add'); const r = credits.apply({ userId: uid, type, amount: b.amount, memo: b.memo || (type === 'set' ? '관리자 설정' : '관리자 추가'), by: user.id }); audit.log({ by: user.id, action: 'credits.' + type, targetType: 'user', target: uid, userId: uid, detail: { amount: Number(b.amount), memo: b.memo || '' } }); return send(res, 200, { ok: true, ...credits.info(uid), entry: r.entry }); }
}
// v1.6.40 (요구 1): 선불 충전 상품·주문 — 상품은 금액 + 인센티브(정률·정액), 주문은 PG 미연동이라 "결제 대기" 로 들어오고 관리자가 입금 확인 → 적립(캐시 원장 add, ref.orderId)
if (what === 'prepaid') {
const prepaid = require('./lib/prepaid');
if (req.method === 'GET' && !arg) return send(res, 200, { packages: prepaid.listPackages({ all: true }), orders: prepaid.listOrders({ status: url.searchParams.get('status') || undefined }), summary: prepaid.summary(), users: Object.fromEntries(auth.listUsers().map((u) => [u.id, u.name || ''])), cashUnit: credits.unit() });
if (arg === 'packages' && req.method === 'POST' && !sub2) { const { fields: b } = await readBody(req); try { const p = prepaid.savePackage(b, user.id); audit.log({ by: user.id, action: 'prepaid.package.save', targetType: 'prepaid-package', target: p.id, detail: { name: p.name, amount: p.amount, bonusType: p.bonusType, bonusValue: p.bonusValue, active: p.active } }); return send(res, 200, { ok: true, package: p, packages: prepaid.listPackages({ all: true }) }); } catch (e) { return send(res, e.status || 500, { error: e.message }); } }
if (arg === 'packages' && req.method === 'DELETE' && sub2) { try { const r = prepaid.removePackage(sub2); audit.log({ by: user.id, action: 'prepaid.package.remove', targetType: 'prepaid-package', target: sub2, detail: { removed: r.removed, deactivated: r.deactivated, name: r.package.name } }); return send(res, 200, { ok: true, ...r, packages: prepaid.listPackages({ all: true }) }); } catch (e) { return send(res, e.status || 500, { error: e.message }); } }
if (arg === 'orders' && req.method === 'POST' && sub2) {
const { fields: b } = await readBody(req);
try {
if (b.action === 'paid') { const r = prepaid.markPaid(sub2, user.id, b.memo); audit.log({ by: user.id, action: 'prepaid.order.paid', targetType: 'prepaid-order', target: sub2, userId: r.order.userId, detail: { name: r.order.name, amount: r.order.amount, bonus: r.order.bonus, credit: r.order.credit, memo: r.order.memo } }); return send(res, 200, { ok: true, order: r.order, balance: r.balance }); }
if (b.action === 'cancel') { const o = prepaid.cancelOrder(sub2, user.id, { reason: b.memo }); audit.log({ by: user.id, action: 'prepaid.order.cancel', targetType: 'prepaid-order', target: sub2, userId: o.userId, detail: { name: o.name, amount: o.amount, reason: o.memo } }); return send(res, 200, { ok: true, order: o }); }
return send(res, 400, { error: 'action 은 paid 또는 cancel 이어야 합니다' });
} catch (e) { return send(res, e.status || 500, { error: e.message }); }
}
}
if (what === 'billing') {
if (req.method === 'GET') { const sites = store.list().map((x) => store.load(x.id)).filter(Boolean); return send(res, 200, await billing.summarize(sites, auth.listUsers())); }
if (req.method === 'POST' && arg === 'charge') { const { fields: b } = await readBody(req); return send(res, 201, billing.addCharge({ userId: b.userId, amountKrw: b.amountKrw, memo: b.memo, by: user.id })); }
if (req.method === 'DELETE' && arg === 'charge' && sub2) return send(res, 200, billing.removeCharge(sub2));
}
if (what === 'sites' && arg && store.ID_RE.test(arg)) {
const s1 = store.load(arg); if (!s1) return send(res, 404, { error: '사이트를 찾을 수 없습니다' });
if (req.method === 'GET' && !sub2) return send(res, 200, { ...publicView(s1, { admin: true }), commands: s1.commands, stats: stats.summary(s1.id, statPaths(s1.id)), modelRef: siteRef(s1), tier: settings.validTier(s1.tier), creditWait: s1.creditWait || null, running: jobOf(s1.id).running, deploying: jobOf(s1.id).deploying || null, liveLog: jobOf(s1.id).log, deployState: deployState(s1) });
if (req.method === 'POST' && sub2 === 'deploy') { const { fields: b } = await readBody(req); if (jobOf(s1.id).running) return send(res, 409, { error: '작업 실행 중에는 배포할 수 없습니다' }); if (jobOf(s1.id).deploying) return send(res, 409, { error: '이미 배포가 진행 중입니다' }); if (b.targetId && !s1.source && b.targetId !== 'source') { s1.deployTargetId = b.targetId; store.save(s1); } if (b.ssl === false || b.ssl === true) { s1.sslOff = b.ssl === false; store.save(s1); } const r = await deploySiteTo(s1, b.targetId, user.id); /* v1.3.17 (B25) ssl: 이 사이트만 */ return send(res, r.ok ? 200 : 502, { ...r, deploy: publicView(s1).deploy }); }
if (req.method === 'POST' && sub2 === 'ssl') { const { fields: b } = await readBody(req); const tid = b.targetId || s1.deployTargetId || deploy.list().defaultId; if (!tid) return send(res, 400, { error: '배포 서버가 없습니다' }); const pre = await deploy.checkAccess(tid, s1.id, s1.domains || []); const eligible = pre.items.filter((i) => i.kind === 'domain' && i.pointsToTarget).map((i) => i.url.replace(/^https?:\/\//, '').replace(/\/$/, '')); if (!eligible.length) return send(res, 400, { error: 'DNS 가 배포 서버를 가리키는 도메인이 없습니다' }); const r = await deploy.issueSsl(s1.id, tid, eligible, settings.load().letsEncryptEmail); const access = await deploy.checkAccess(tid, s1.id, s1.domains || []); return send(res, r.ok ? 200 : 502, { ...r, access }); }
if (req.method === 'POST' && sub2 === 'access-check') { const { fields: b } = await readBody(req); const tid = b.targetId || s1.deployTargetId || deploy.list().defaultId; if (!tid) return send(res, 400, { error: '배포 서버가 없습니다' }); try { const r = await rejudgeAccess(s1, tid); return send(res, 200, { ...r.access, phase: r.phase, ready: r.ready, phaseDetail: r.detail, changed: r.changed }); } catch (e) { if (e.status === 404) return send(res, 200, await deploy.checkAccess(tid, s1.id, s1.domains || [])); return send(res, e.status || 500, { error: e.message }); } } /* v1.6.33: 확인 결과로 배포 단계도 갱신 */
if (req.method === 'POST' && sub2 === 'domains') { const { fields: b } = await readBody(req); s1.domains = deploy.normalizeDomains(b.domains); store.save(s1); return send(res, 200, { domains: s1.domains }); }
if (req.method === 'POST' && sub2 === 'target') { const { fields: b } = await readBody(req); s1.deployTargetId = b.targetId || null; store.save(s1); return send(res, 200, publicView(s1).deploy); }
if (req.method === 'POST' && sub2 === 'cancel') { const job = jobOf(s1.id); if (!job.running) return send(res, 409, { error: '실행 중인 작업이 없습니다' }); job.cancelRequested = true; if (job.cancel) job.cancel(); return send(res, 202, { ok: true }); }
if (req.method === 'DELETE' && !sub2) {
if (jobOf(s1.id).running || jobOf(s1.id).deploying) return send(res, 409, { error: '작업 실행 중에는 삭제할 수 없습니다' });
return send(res, 200, await destroySite(s1, user.id));
}
if (req.method === 'GET' && sub2 === 'events') {
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
res.write(':ok\n\n'); res.isAdmin = true;
const job = jobOf(s1.id); for (const ev of job.log) res.write(`data: ${JSON.stringify(ev)}\n\n`);
job.subscribers.add(res); const ping = setInterval(() => { try { res.write(':ping\n\n'); } catch (_) {} }, 15000);
req.on('close', () => { clearInterval(ping); job.subscribers.delete(res); });
return;
}
}
return send(res, 404, { error: 'not found' });
}
// ---- 사용자 제안·오류 보고 (v1.1.2): 저장 즉시 고급 모델이 자동 분석해 댓글로 붙인다 ----
if (req.method === 'POST' && p === '/api/feedback') {
const { fields: b } = await readBody(req);
if (forms.rateLimited('fb:' + user.id, 10, 3600000)) return send(res, 429, { error: '잠시 후 다시 올려 주세요 (시간당 10건)' });
// v1.3.17 (S13): 글에 붙일 사이트는 본인 소유(관리자는 모든 사이트)만 — 남의 사이트 ID 로 제목·작업 기록을 보지 못하게 한다
const site0 = b.siteId && store.ID_RE.test(b.siteId) ? store.load(b.siteId) : null;
const site = site0 && (site0.ownerId === user.id || user.role === 'admin') ? site0 : null;
const env = { ...(b.env && typeof b.env === 'object' ? b.env : {}), serverVersion: VERSION, ipCountry: undefined, userAgentHeader: String(req.headers['user-agent'] || '').slice(0, 300) };
const item = feedback.add({ userId: user.id, userName: user.name, siteId: site ? site.id : null, siteTitle: site ? site.title : '', type: b.type, title: b.title, body: b.body, page: b.page, env });
analyzeFeedback(item).catch(() => {});
return send(res, 201, item);
}
if (req.method === 'GET' && p === '/api/feedback') return send(res, 200, { items: feedback.list(user.id).map((x) => ({ ...x, comments: x.comments.map((c) => ({ ...c, usd: undefined, krw: undefined })), cost: undefined })) });
if (req.method === 'PUT' && p === '/api/auth/me') { const { fields: b } = await readBody(req); const name = String(b.name || '').trim().slice(0, 80); if (!name) return send(res, 400, { error: '이름(회사명)을 입력하세요' }); const u = auth.updateProfile(user.id, { name }); return send(res, 200, { user: u }); }
// v1.3.16 화면 언어 선택 저장 (관리자가 강제한 뒤 사용자가 다시 고르면 그 사용자에게는 이 선택이 우선)
if (req.method === 'POST' && p === '/api/me/ui-lang') { const { fields: b } = await readBody(req); const l = String(b.lang || ''); if (l !== 'ko' && !i18n.activeLanguages().some((x) => x.lang === l)) return send(res, 400, { error: '사용할 수 없는 언어입니다' }); return send(res, 200, auth.setUiLang(user.id, l)); }
// ---- v1.4.0 도메인 탭 ----
if (p === '/api/domains' || p.startsWith('/api/domains/')) return domainRoutes(req, res, url, user);
if (req.method === 'GET' && p === '/api/me/credit') return send(res, 200, { ...credits.info(user.id), ledger: credits.ledger(user.id, 30), usageAll: credits.usageSince(user.id, new Date(Date.now() - 400 * 86400000).toISOString()), waiting: store.list(user.id).map((x) => store.load(x.id)).filter((x) => x && x.creditWait).map((x) => ({ id: x.id, title: x.title, instruction: x.creditWait.instruction, at: x.creditWait.at })) });
// v1.6.40 (요구 1): 캐시·충전 화면 — 선불 충전 상품 목록과 내 주문. "결제 진행" 은 결제 대기 주문만 만든다(PG 미연동 — 실제 결제 없음, 관리자 입금 확인 뒤 적립)
if (p === '/api/credits/packages' || p === '/api/credits/orders' || /^\/api\/credits\/orders\/[a-f0-9]{12}\/cancel$/.test(p)) {
const prepaid = require('./lib/prepaid');
if (req.method === 'GET' && p === '/api/credits/packages') return send(res, 200, { packages: prepaid.listPackages(), cashUnit: credits.unit(), pgReady: false, maxPending: prepaid.MAX_PENDING_PER_USER });
if (req.method === 'GET' && p === '/api/credits/orders') return send(res, 200, { orders: prepaid.myOrders(user.id), credit: credits.info(user.id) });
if (req.method === 'POST' && p === '/api/credits/orders') { const { fields: b } = await readBody(req); try { const o = prepaid.createOrder(user.id, String(b.packageId || '')); audit.log({ by: user.id, action: 'prepaid.order', targetType: 'prepaid-order', target: o.id, userId: user.id, detail: { name: o.name, amount: o.amount, bonus: o.bonus, credit: o.credit } }); return send(res, 201, { ok: true, order: o, orders: prepaid.myOrders(user.id), pgReady: false }); } catch (e) { return send(res, e.status || 500, { error: e.message }); } }
if (req.method === 'POST') { const oid = p.split('/')[4]; try { const o = prepaid.cancelOrder(oid, user.id, { userId: user.id, reason: '사용자 취소' }); audit.log({ by: user.id, action: 'prepaid.order.cancel', targetType: 'prepaid-order', target: oid, userId: user.id, detail: { name: o.name, amount: o.amount } }); return send(res, 200, { ok: true, order: o, orders: prepaid.myOrders(user.id) }); } catch (e) { return send(res, e.status || 500, { error: e.message }); } }
return send(res, 405, { error: 'method not allowed' });
}
if (req.method === 'GET' && p === '/api/config') return send(res, 200, { version: VERSION, product: 'AI Webmaker', ariweb: ariweb.readCtx(req), ariwebLinks: ariweb.allLinks(), cashUnit: credits.unit(), welcomePage: welcomePage(), tiers: settings.TIERS, tierGuide: await tierGuideKrw(), maxEdits: settings.load().maxEdits || 0, defaultTier: settings.load().defaultTier || 'standard', credit: credits.info(user.id), publicBaseUrl: PUBLIC_BASE_URL, user, git: gitstore.info('<siteId>'), upload: { maxFileBytes: uploads.MAX_FILE_BYTES, extensions: uploads.allowedExtensions() }, siteLocales: Object.entries(multilang.LOCALES).map(([code, l]) => ({ code, name: l.name, label: l.label, dir: l.dir })) });
if (req.method === 'GET' && p === '/api/catalog') return send(res, 200, briefLib.catalog());
if (p === '/api/models' || p === '/api/me/connections' || p.startsWith('/api/me/connections/')) return send(res, 410, { error: '모델 선택 기능은 종료되었습니다. 모델 등급(기본/중급/고급)을 선택하세요.' });
if (req.method === 'GET' && p === '/api/sites') return send(res, 200, store.list(user.id));
// ---- 기존 사이트 가져오기 ----
// v1.3.4: "사이트 관리하기(서버 접속으로 가져오기)" 를 새로 시작하는 API 는 닫는다(생성 탭에서 삭제, 사용자 요청).
// 이미 가져온 사이트의 원본 반영(/deploy)·접속 정보 변경(/source)·다시 가져오기(/reimport)는 그대로 둔다.
if (req.method === 'POST' && ['/api/import/keygen', '/api/import/test', '/api/sites/import'].includes(p)) return send(res, 410, { error: '사이트 관리하기(서버에서 가져오기)는 1.3.4 부터 제공하지 않습니다. 기존 사이트의 모양으로 새 사이트를 만들려면 "사이트 복사하기" 를 쓰세요' });
if (req.method === 'POST' && p === '/api/import/keygen') { const { fields: b } = await readBody(req); return send(res, 201, await remote.newKey(b.host || user.id)); }
if (req.method === 'POST' && p === '/api/import/test') {
const { fields: b } = await readBody(req);
const src = remote.buildSource(b);
const t = await remote.test(src);
if (!t.ok) return send(res, 200, { ok: false, detail: t.detail, keyId: src.keyId || null });
if (typeof t.shell === 'boolean') src.shell = t.shell;
let det = { candidates: [], live: null }; let detectError = null;
try { det = await remote.detectDocroots(src); } catch (e) { detectError = e.message; }
return send(res, 200, { ok: true, detail: t.detail, shell: src.shell !== false, keyId: src.keyId || null, candidates: det.candidates, live: det.live, detectError, multiSite: !!det.multiSite, webServers: det.webServers || [], proxies: det.proxies || [] });
}
// ---- 사이트 복사하기 (v1.2.0): 웹 주소만으로 복사. 결과는 "생성 완료" 상태의 사이트가 되어 수정·발행할 수 있다 ----
if (req.method === 'POST' && p === '/api/sites/copy') {
const { fields: b } = await readBody(req);
const url = String(b.url || '').trim(); try { await crawler.checkUrl(url, user.role === 'admin'); } catch (e) { return send(res, e.status || 400, { error: e.message }); }
// 복사 자체는 AI 를 쓰지 않아 무료다. 복사 뒤 사이트 분석(AI 요약)은 analyzeSite 가 잔액을 확인해 건너뛴다.
const id = crypto.randomBytes(16).toString('hex'); fs.mkdirSync(path.join(SITES_ROOT, id), { recursive: true, mode: 0o755 });
const site = store.create({ id, ownerId: user.id, ownerName: user.name, title: (b.title || '').trim() || new URL(url).hostname, purpose: '', content: '', design: '', extra: '', sessionId: crypto.randomUUID() });
site.uiLang = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(String(req.headers['x-ui-lang'] || '')) ? req.headers['x-ui-lang'] : 'ko';
site.attachments = []; site.mode = 'copy'; site.copyFrom = url; site.titleCustom = !!(b.title || '').trim(); site.contactEmail = (b.contactEmail || '').trim(); site.tier = settings.validTier(b.tier);
site.plan = newPlan('manual'); site.plan.mode = 'copy'; site.plan.stage = 'importing'; site.plan.input.existingUrl = url;
{ const w = seo.wantedFrom(b.optimize); if (seo.anyWanted(w)) site.optimize = { wanted: w, status: 'waiting' }; } // v1.3.5
store.save(site);
runCopy(site, user.role === 'admin').catch(() => {});
return send(res, 201, publicView(site));
}
if (req.method === 'POST' && p === '/api/sites/import') {
const { fields: b } = await readBody(req);
const src = remote.buildSource(b);
if (!src.docroot) return send(res, 400, { error: '사이트 경로(문서 루트)를 선택하세요' });
if (b.shell === false || b.shell === 'false') src.shell = false;
const id = crypto.randomBytes(16).toString('hex');
const siteDir = path.join(SITES_ROOT, id); fs.mkdirSync(siteDir, { recursive: true, mode: 0o755 });
const title = (b.title || '').trim() || src.host;
const site = store.create({ id, ownerId: user.id, ownerName: user.name, title, purpose: '', content: '', design: '', extra: '', sessionId: crypto.randomUUID() });
site.uiLang = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(String(req.headers['x-ui-lang'] || '')) ? req.headers['x-ui-lang'] : 'ko';
site.attachments = []; site.mode = 'import'; site.source = src; site.titleCustom = !!(b.title || '').trim(); site.contactEmail = (b.contactEmail || '').trim();
if (b.vhost && typeof b.vhost === 'object') src.vhost = { names: (b.vhost.names || []).slice(0, 10).map(String), server: String(b.vhost.server || ''), conf: String(b.vhost.conf || '') };
if (Array.isArray(b.webServers)) src.webServers = b.webServers.slice(0, 5).map(String);
site.tier = settings.validTier(b.tier);
store.save(site);
runImport(site).catch(() => {});
return send(res, 201, publicView(site));
}
if (req.method === 'POST' && p === '/api/sites') {
const { fields: b, files } = await readBody(req);
const mode = b.mode === 'ai' || b.mode === 'manual' ? b.mode : 'quick';
if (mode === 'quick' && !(b.title || '').trim()) return send(res, 400, { error: '페이지 제목은 필수입니다' });
if (mode === 'ai' && !(b.text || '').trim() && !files.length && !(b.existingUrl || '').trim()) return send(res, 400, { error: '만들고 싶은 사이트를 설명하거나 자료를 첨부하세요' });
if (mode === 'quick') assertCredit(user); // v1.3.17 (B13): 캐시 확인을 폴더·기록을 만들기 전에 (실패해도 빈 사이트가 남지 않게)
const id = crypto.randomBytes(16).toString('hex'); // 32자리 16진 문자열
const siteDir = path.join(SITES_ROOT, id);
fs.mkdirSync(siteDir, { recursive: true, mode: 0o755 });
let saved;
try { saved = await uploads.saveUploads(siteDir, files); }
catch (e) { fs.rmSync(siteDir, { recursive: true, force: true }); throw e; }
let refs = []; try { refs = normRefs(typeof b.references === 'string' ? JSON.parse(b.references) : b.references); } catch (_) {}
const title = mode === 'quick' ? b.title.trim() : (mode === 'manual' ? ((b.businessName || '').trim() || '새 사이트') : ((b.text || '').trim().split('\n')[0].slice(0, 40) || '새 사이트'));
const site = store.create({ id, ownerId: user.id, ownerName: user.name, title, purpose: b.purpose || '', content: b.content || '', design: b.design || '', extra: b.extra || '', sessionId: crypto.randomUUID() });
site.attachments = saved; site.mode = mode; site.uiLang = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(String(req.headers['x-ui-lang'] || '')) ? req.headers['x-ui-lang'] : 'ko';
site.tier = settings.validTier(b.tier);
{ const w = seo.wantedFrom(b.optimize); if (seo.anyWanted(w)) site.optimize = { wanted: w, status: 'waiting' }; } // v1.3.5 생성 옵션: SEO·AEO·GEO 초안
{ const L = multilang.normalize(b.langs); if (L.list.length > 1) site.langs = L; } // v1.3.10 생성 옵션: 다국어 (기본 언어 + 추가 언어)
if (mode !== 'quick') {
site.plan = newPlan(mode);
site.plan.input = { text: (b.text || '').trim(), existingUrl: (b.existingUrl || '').trim(), references: refs, answers: [] };
site.plan.brief.references = refs; site.plan.brief.business.existingUrl = site.plan.input.existingUrl;
if (mode === 'manual') { site.plan.brief.business.name = (b.businessName || '').trim(); }
if (mode === 'ai' && b.pageCount !== undefined) site.plan.input.pageCount = Math.max(0, Math.min(12, parseInt(b.pageCount, 10) || 0));
if (mode === 'ai' && b.design) { try { const d = typeof b.design === 'string' ? JSON.parse(b.design) : b.design; site.plan.input.design = { color: String(d.color || '').slice(0, 40), tone: briefLib.TONES.some((t) => t.id === d.tone) ? d.tone : '', direction: String(d.direction || '').slice(0, 500) }; if (site.plan.input.design.tone) { site.plan.brief.design.recommendedTone = site.plan.input.design.tone; site.plan.brief.design.tone = site.plan.input.design.tone; } } catch (_) {} }
const logo = saved.find((a) => a.kind === 'image'); if (logo) site.plan.brief.assets.logo = logo.rel;
site.plan.brief.assets.photos = saved.filter((a) => a.kind === 'image' && a !== logo).map((a) => a.rel);
store.save(site);
return send(res, 201, publicView(site));
}
store.save(site);
const prompt = buildCreatePrompt(site, saved, widgetFor(site, b.extra || ''));
runCommand(site, 'create', '(초기 생성)', prompt, saved.map((a) => a.name)).catch(() => {});
return send(res, 201, publicView(site));
}
// ---- v1.3.10 사이트 템플릿 (사용자): 허용된 템플릿 목록·디렉터리·해시태그, 미리보기, 템플릿으로 시작 ----
if (req.method === 'GET' && p === '/api/templates') {
const admin = user.role === 'admin'; const sp = url.searchParams;
return send(res, 200, { tree: templates.tree(briefLib.INDUSTRIES, !admin), items: templates.search({ q: sp.get('q') || '', dir: sp.get('dir') || '', enabledOnly: !admin }).map((t) => ({ id: t.id, title: t.title, description: t.description, tags: t.tags, dir: t.dir, enabled: t.enabled, files: t.files, updatedAt: t.updatedAt })), tags: templates.popularTags(!admin) });
}
const tpm = p.match(/^\/api\/templates\/([a-f0-9]{16})\/preview(?:\/(.*))?$/);
if (tpm && req.method === 'GET') {
const t = templates.get(tpm[1]); if (!t || (!t.enabled && user.role !== 'admin')) return send(res, 404, { error: '템플릿이 없습니다' });
if (tpm[2] === undefined) { res.writeHead(302, { Location: `/api/templates/${t.id}/preview/` }); return res.end(); }
let fp; try { fp = templates.previewPath(t.id, tpm[2]); } catch (e) { return send(res, 400, { error: e.message }); }
return fs.readFile(fp, (err, buf) => { if (err) return send(res, 404, '페이지를 찾을 수 없습니다'); send(res, 200, buf, { 'Content-Type': MIME[path.extname(fp).toLowerCase()] || 'application/octet-stream', 'Cache-Control': 'no-cache' }); });
}
if (req.method === 'POST' && p === '/api/sites/template') {
const { fields: b } = await readBody(req); const t = templates.get(String(b.templateId || ''));
if (!t || (!t.enabled && user.role !== 'admin')) return send(res, 404, { error: '사용할 수 있는 템플릿이 아닙니다' });
const id = crypto.randomBytes(16).toString('hex'); const siteDir = path.join(SITES_ROOT, id); fs.mkdirSync(siteDir, { recursive: true, mode: 0o755 });
try { templates.instantiate(t.id, siteDir, id); } catch (e) { fs.rmSync(siteDir, { recursive: true, force: true }); throw e; }
chmodTree(siteDir);
const site = store.create({ id, ownerId: user.id, ownerName: user.name, title: clipText(b.title, 80) || t.title, purpose: '', content: '', design: '', extra: '', sessionId: crypto.randomUUID() });
site.uiLang = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(String(req.headers['x-ui-lang'] || '')) ? req.headers['x-ui-lang'] : 'ko';
site.attachments = []; site.mode = 'template'; site.templateId = t.id; site.contactEmail = clipText(b.contactEmail, 120); site.tier = settings.validTier(b.tier); site.titleCustom = !!clipText(b.title, 80);
site.plan = newPlan('manual'); site.plan.mode = 'template'; site.plan.stage = 'generated'; site.plan.brief.business.name = site.title;
{ const [maj, min] = t.dir.startsWith('ind:') ? t.dir.slice(4).split('/') : []; if (maj) site.plan.brief.business.industry = { ...(site.plan.brief.business.industry || {}), major: maj, minor: min || '' }; }
{ const w = seo.wantedFrom(b.optimize); if (seo.anyWanted(w)) site.optimize = { wanted: w, status: 'waiting' }; }
store.save(site); await gitRecord(site, 'create', `템플릿 "${t.title}" 으로 시작`);
if (site.optimize && site.optimize.status === 'waiting') draftOptimize(site).catch(() => {});
return send(res, 201, publicView(site));
}
const m = p.match(/^\/api\/sites\/([a-f0-9]{32})(\/[a-z]+)?(?:\/(.+))?$/);
if (m) {
const site = store.load(m[1]);
if (!site || site.ownerId !== user.id) return send(res, 404, { error: '사이트를 찾을 수 없습니다' });
{ const ul = String(req.headers['x-ui-lang'] || ''); if (/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(ul) && site.uiLang !== ul) { site.uiLang = ul; store.save(site); } } // 생성 코드 주석 언어
const sub = m[2] || '';
if (req.method === 'GET' && sub === '') return send(res, 200, { ...publicView(site), ariweb: ariweb.linkOf(site.id), running: jobOf(site.id).running, deploying: jobOf(site.id).deploying || null, status: jobOf(site.id).running ? 'running' : (site.status === 'running' ? 'idle' : site.status) });
if (req.method === 'PATCH' && sub === '') {
const { fields: b } = await readBody(req);
{ const c = revConflict(site, b, req); if (c) return send(res, 409, c); } // [Ariweb Suite 변경] 동시 편집 충돌(§9.3)
const changed = [];
const labels = { title: '페이지 제목', purpose: '목적', content: '주요 컨텐츠', design: '디자인 방향', extra: '추가 요구사항' };
if (typeof b.contactEmail === 'string') { const ce = b.contactEmail.trim(); if (ce && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(ce)) return send(res, 400, { error: '문의 수신 이메일 형식이 올바르지 않습니다' }); if (ce !== (site.contactEmail || '')) { site.contactEmail = ce; changed.push('문의 수신 이메일'); if (site.plan && site.plan.brief && site.plan.brief.facts) site.plan.brief.facts.email = { value: ce, source: 'user_text', status: ce ? 'provided' : 'missing', mode: 'keep' }; } }
for (const k of Object.keys(labels)) {
if (typeof b[k] !== 'string') continue;
const v = b[k].trim();
if (k === 'title' && !v) return send(res, 400, { error: '페이지 제목은 비울 수 없습니다' });
if (v !== (site[k] || '')) { site[k] = v; changed.push(labels[k]); if (k === 'title') site.titleCustom = true; }
}
if (changed.length) {
site.basicsChanged = [...new Set([...(site.basicsChanged || []), ...changed])];
site.basicsUpdatedAt = new Date().toISOString(); store.save(site);
broadcast(site.id, { type: 'log', level: 'info', text: `기본 사항 저장: ${changed.join(', ')} 변경 — 다음 수정 지시에 함께 전달됩니다` });
}
return send(res, 200, { ...publicView(site), changed });
}
const siteDir = path.join(SITES_ROOT, site.id);
site.attachments = site.attachments || [];
if (req.method === 'POST' && sub === '/commands') {
const { fields: b, files } = await readBody(req);
const instruction = (b.instruction || '').trim();
if (!instruction) return send(res, 400, { error: '수정 지시를 입력하세요' });
if (jobOf(site.id).running) return send(res, 409, { error: '이미 실행 중인 작업이 있습니다' });
if (site.creditWait) return send(res, 409, { error: '캐시 충전을 기다리는 작업이 있습니다. 충전되면 먼저 그 작업이 이어서 진행됩니다.' });
assertCredit(user);
const saved = await uploads.saveUploads(siteDir, files);
site.attachments.push(...saved);
const newOnes = site.attachments.filter((a) => !a.usedInCommands.length);
const existing = site.attachments.filter((a) => a.usedInCommands.length);
store.save(site);
const basicsChanged = site.basicsChanged || [];
const cmdTier = settings.TIERS.some((t) => t.id === b.tier) ? b.tier : undefined; // v1.1.0: 수정마다 모델 등급 선택
site.basicsChanged = []; store.save(site);
if (site.plan && site.plan.brief && ['generated', 'published'].includes(site.plan.stage) && site.mode !== 'copy') {
// AI에게 맡기기: 관리자가 정한 횟수까지만 수정 허용, 한도에 이르면 발행(배포) 후에만 다시 수정 가능
const lim = editLimitOf(site);
if (lim && lim.locked) return send(res, 409, { error: `수정 횟수 한도(${lim.max}회)에 도달했습니다. 발행(배포)한 뒤 다시 수정할 수 있습니다.`, editLimit: lim });
if (lim) site.plan.editsSincePublish = (site.plan.editsSincePublish || 0) + 1;
const wantWidget = widgetWanted(instruction) || !briefLib.verifyFiles(siteDir, site.plan.brief).widget.present;
const prompt = planner.editPrompt(site, instruction, attachmentsSection(newOnes, existing) + (wantWidget ? '\n' + widgetFor(site, instruction) : ''));
runCommand(site, 'update', instruction, prompt, newOnes.map((a) => a.name), { tier: cmdTier, onResult: async (r, cmd) => {
if (applyBriefFromText(site, r.resultText)) { cmd.resultText = r.resultText.replace(/<brief>[\s\S]*?<\/brief>/, '').trim(); broadcast(site.id, { type: 'log', level: 'info', text: 'Brief 가 갱신되었습니다' }); }
site.plan.verification = { at: new Date().toISOString(), rules: briefLib.verifyFiles(siteDir, site.plan.brief), ai: (site.plan.verification || {}).ai || null, stale: true };
planEvent(site);
} }).catch(() => {});
return send(res, 202, { ok: true, attachments: site.attachments });
}
const wantWidget = widgetWanted(instruction) || (site.mode !== 'import' && !briefLib.verifyFiles(siteDir, briefLib.defaultBrief('quick')).widget.present);
if (site.mode === 'import' && !site.source.importedAt) return send(res, 409, { error: '아직 사이트를 가져오지 않았습니다' });
runCommand(site, 'update', instruction, buildUpdatePrompt(instruction, newOnes, existing, site, basicsChanged, wantWidget ? widgetFor(site, instruction) : ''), newOnes.map((a) => a.name), { tier: cmdTier }).catch(() => {});
return send(res, 202, { ok: true, attachments: site.attachments });
}
if (sub === '/files' && m[3]) {
const { abs, rel } = resolveTextFile(siteDir, m[3]);
if (req.method === 'GET') {
let st; try { st = fs.statSync(abs); } catch (_) { return send(res, 404, { error: '파일이 없습니다' }); }
if (st.size > MAX_TEXT_BYTES) return send(res, 413, { error: '파일이 너무 큽니다' });
return send(res, 200, { path: rel, content: fs.readFileSync(abs, 'utf8'), size: st.size, mtime: st.mtime.toISOString() });
}
if (req.method === 'PUT') {
if (jobOf(site.id).running) return send(res, 409, { error: 'AI 작업 실행 중에는 저장할 수 없습니다' });
const { fields: b } = await readBody(req);
if (typeof b.content !== 'string') return send(res, 400, { error: 'content 가 필요합니다' });
{ const c = revConflict(site, b, req); if (c) return send(res, 409, c); } // [Ariweb Suite 변경] 동시 편집 충돌(§9.3)
if (Buffer.byteLength(b.content) > MAX_TEXT_BYTES) return send(res, 413, { error: '내용이 너무 큽니다' });
fs.mkdirSync(path.dirname(abs), { recursive: true, mode: 0o755 });
fs.writeFileSync(abs, b.content, { mode: 0o644 });
try { fs.chmodSync(abs, 0o644); } catch (_) {}
const st = fs.statSync(abs);
site.manualEdits = (site.manualEdits || []).concat({ path: rel, at: new Date().toISOString() }).slice(-200); store.save(site);
broadcast(site.id, { type: 'log', level: 'info', text: `소스 편집기에서 ${rel} 저장 (${st.size.toLocaleString()} bytes)` });
const rec = await gitRecord(site, 'manual-edit', `수동 편집: ${rel}`);
return send(res, 200, { path: rel, size: st.size, mtime: st.mtime.toISOString(), files: listFiles(siteDir), git: publicView(site).git, change: rec });
}
}
if (req.method === 'DELETE' && sub === '') {
// v1.1.0: 사용자(소유자)의 사이트 폐기. 확인 문자열(사이트 제목)을 함께 받아 실수를 막는다.
const { fields: b } = await readBody(req);
if (jobOf(site.id).running || jobOf(site.id).deploying) return send(res, 409, { error: '작업이 진행 중입니다. 끝난 뒤 폐기하세요' });
if (String(b.confirm || '').trim() !== String(site.title || '').trim()) return send(res, 400, { error: '확인을 위해 사이트 제목을 정확히 입력하세요' });
// [Ariweb Suite 변경] 포털에 연결된 프로젝트: 운영 사이트·배포·도메인에 미치는 영향을 포털에 물어 보이고, 사용자가 영향을 확인(acknowledgeImpact)했을 때만 프로젝트를 폐기한다.
// 포털의 운영 사이트·도메인·Source 는 여기서 절대 지우지 않는다(§9.1). 포털에 닿지 못하면 영향을 모르므로 폐기를 보류한다.
{ const l = ariweb.linkOf(site.id); if (l) { const r = await ariweb.impactFromPortal(site.id); if (!r.reachable) return send(res, 503, { error: '포털에서 운영 사이트 영향을 확인하지 못해 폐기를 보류합니다. 잠시 후 다시 시도하세요', ariweb: { reason: r.reason } });
if (r.impact && r.impact.blocked) return send(res, 409, { error: r.impact.message || '운영 중인 사이트가 이 프로젝트를 쓰고 있어 폐기할 수 없습니다. 포털에서 사이트를 먼저 중지·해지하세요', ariweb: { impact: r.impact } });
if (!b.acknowledgeImpact) return send(res, 409, { error: '이 프로젝트는 Ariweb Suite 사이트에 연결되어 있습니다. 아래 영향을 확인한 뒤 다시 요청하세요', ariweb: { impact: r.impact, needAcknowledge: true } });
ariweb.unlink(site.id); audit.log({ by: user.id, action: 'ariweb.unlink', targetType: 'site', target: site.id, detail: { siteId: l.siteId, reason: 'project destroyed' } }); } }
return send(res, 200, await destroySite(site, user.id));
}
if (site.source && req.method === 'POST' && sub === '/deploy') {
if (jobOf(site.id).running) return send(res, 409, { error: '작업 실행 중에는 반영할 수 없습니다' });
if (site.status === 'importing' || !(site.source.importedAt)) return send(res, 409, { error: '아직 사이트를 가져오지 않았습니다' });
const r = await deploySiteTo(site, null, user.id);
return send(res, r.ok ? 200 : 502, { ...r, site: publicView(site) });
}
if (site.source && req.method === 'PUT' && sub === '/source') {
if (jobOf(site.id).running) return send(res, 409, { error: '작업 실행 중에는 접속 정보를 바꿀 수 없습니다' });
const { fields: b } = await readBody(req);
const next = remote.buildSource({ ...remote.publicSource(site.source), ...b, keyId: b.keyId || site.source.keyId }, site.source);
const t = await remote.test(next); if (!t.ok) return send(res, 400, { error: t.detail });
if (typeof t.shell === 'boolean') next.shell = t.shell;
next.manifest = site.source.manifest; next.importedAt = site.source.importedAt; next.lastPushAt = site.source.lastPushAt; next.backupNote = site.source.backupNote;
if (site.source.keyId && next.keyId !== site.source.keyId) remote.deleteKey(site.source.keyId);
site.source = next; store.save(site);
broadcast(site.id, { type: 'log', level: 'info', text: `원본 서버 접속 정보 변경: ${next.user}@${next.host}:${next.port} ${next.docroot}` });
return send(res, 200, { ok: true, detail: t.detail, source: remote.publicSource(site.source) });
}
if (site.source && req.method === 'POST' && sub === '/reimport') {
if (jobOf(site.id).running) return send(res, 409, { error: '이미 실행 중인 작업이 있습니다' });
if (site.source.importedAt && !(await readBody(req)).fields.force) return send(res, 409, { error: '이미 가져온 사이트입니다. 다시 가져오면 여기서 수정한 내용이 서버 파일로 덮어써집니다.' });
runImport(site).catch(() => {});
return send(res, 202, { ok: true });
}
if (req.method === 'POST' && sub === '/cancel') {
const job = jobOf(site.id);
if (!job.running) return send(res, 409, { error: '실행 중인 작업이 없습니다' });
job.cancelRequested = true;
broadcast(site.id, { type: 'log', level: 'info', text: '중단 요청을 받았습니다…' });
if (job.cancel) job.cancel();
return send(res, 202, { ok: true });
}
if (req.method === 'POST' && sub === '/versions' && /^\d+\/restore$/.test(m[3] || '')) {
const no = parseInt(m[3], 10);
const rec = ((site.git || {}).changes || []).find((c) => c.no === no);
if (!rec) return send(res, 404, { error: '해당 버전이 없습니다' });
if (jobOf(site.id).running) return send(res, 409, { error: '작업 실행 중에는 되돌릴 수 없습니다' });
await gitstore.restoreTo(site.id, siteDir, rec.commit);
chmodTree(siteDir);
if (rec.briefSnapshot && site.plan) { site.plan.brief = JSON.parse(JSON.stringify(rec.briefSnapshot)); site.plan.warnings = briefLib.checkConsistency(site.plan.brief); }
store.save(site);
broadcast(site.id, { type: 'log', level: 'info', text: `버전 #${no} (${rec.message}) 로 되돌렸습니다` });
await gitRecord(site, 'restore', `버전 #${no} 복원: ${rec.message}`);
if (site.plan && ['generated', 'published'].includes(site.plan.stage)) { site.plan.verification = { at: new Date().toISOString(), rules: briefLib.verifyFiles(siteDir, site.plan.brief), ai: null, stale: true }; }
store.save(site);
broadcast(site.id, { type: 'done', site: publicView(site), command: null });
return send(res, 200, publicView(site));
}
if (req.method === 'POST' && sub === '/git' && m[3] === 'retry') {
site.git = site.git || { seq: 0, changes: [] };
await gitstore.retryPush(site.id, siteDir, site.git.changes); store.save(site);
return send(res, 200, publicView(site).git);
}
// ---------- 기획(플랜) 라우트 ----------
if (sub === '/plan') {
if (!site.plan) return send(res, 400, { error: '기획 모드로 만든 사이트가 아닙니다' });
const plan = site.plan; const B = () => plan.brief;
const step = m[3] || '';
const body = req.method === 'GET' ? {} : (await readBody(req)).fields;
if (req.method === 'POST' && ['analyze', 'answers', 'structure', 'design', 'generate', 'verify', 'fix'].includes(step)) { if (site.creditWait) return send(res, 409, { error: '캐시 충전을 기다리는 작업이 있습니다. 충전되면 먼저 그 작업이 이어서 진행됩니다.' }); assertCredit(user); }
const done = (extra = {}) => { store.save(site); planEvent(site); return send(res, 200, { plan, site: publicView(site), ...extra }); };
if (req.method === 'GET' && step === '') return send(res, 200, { plan });
// 입력 갱신(자유서술·기존 URL·참고 사이트)
if (req.method === 'PUT' && step === 'input') {
if (typeof body.text === 'string') plan.input.text = body.text.trim();
if (typeof body.existingUrl === 'string') { plan.input.existingUrl = body.existingUrl.trim(); B().business.existingUrl = plan.input.existingUrl; }
if (body.references !== undefined) { plan.input.references = normRefs(body.references); B().references = plan.input.references; plan.refsFetched = null; }
if (body.pageCount !== undefined) plan.input.pageCount = Math.max(0, Math.min(12, parseInt(body.pageCount, 10) || 0));
if (body.design && typeof body.design === 'object') { plan.input.design = { color: String(body.design.color || '').slice(0, 40), tone: briefLib.TONES.some((t) => t.id === body.design.tone) ? body.design.tone : '', direction: String(body.design.direction || '').slice(0, 500) }; if (plan.input.design.tone) { B().design.recommendedTone = plan.input.design.tone; B().design.tone = plan.input.design.tone; } }
return done();
}
// AI에게 맡기기 (v1.0.3): 추가 입력(사실정보) 반영 후 구조 → 디자인(추천안) → 확정 → 생성 → 검증까지 멈추지 않고 진행
if (req.method === 'POST' && step === 'auto') {
if (jobOf(site.id).running) return send(res, 409, { error: '이미 실행 중인 작업이 있습니다' });
if (body.brief) { mergeBrief(B(), body.brief); briefLib.applyRegulation(B()); if (B().business.name) site.title = B().business.name; }
plan.autoRun = { startedAt: new Date().toISOString(), stage: 'structure', error: null }; store.save(site); planEvent(site);
runAutoPipeline(site).catch(() => {});
return send(res, 202, { ok: true, plan });
}
// 1~2단계: 분석 → 이해 확인 카드
if (req.method === 'POST' && (step === 'analyze' || step === 'answers')) {
if (step === 'answers') plan.input.answers = (Array.isArray(body.answers) ? body.answers : []).map((a) => ({ q: String(a.q || ''), a: String(a.a || '') }));
await doAnalyze(site, step);
return done();
}
// 2단계 확인(사용자 수정 반영) → 3단계 구조 제안
if (req.method === 'POST' && step === 'understanding') {
mergeBrief(B(), body.brief || body);
if (body.brief && body.brief.regulated) { const c = body.brief.regulated.category; B().regulated = c && briefLib.REGULATIONS[c] ? { flag: true, category: c, constraints: briefLib.REGULATIONS[c].constraints } : { flag: false, category: null, constraints: [] }; }
briefLib.applyRegulation(B());
if (B().business.name) site.title = B().business.name;
store.save(site);
const data = await runPlanStep(site, 'plan-structure', planner.structurePrompt(site));
plan.structure = data;
mergeBrief(B(), { pages: data.pages || [] }); briefLib.applyRegulation(B());
plan.warnings = briefLib.checkConsistency(B());
plan.stage = 'structure';
return done();
}
// 3단계 확정(수정된 페이지 반영) → 4단계 디자인 3안
if (req.method === 'POST' && step === 'structure') {
if (Array.isArray(body.pages)) mergeBrief(B(), { pages: body.pages });
briefLib.applyRegulation(B());
const added = briefLib.reconcileFeatures(B()); if (added.length) broadcast(site.id, { type: 'log', level: 'info', text: `기능에 맞는 섹션을 자동 추가: ${added.join(', ')}` });
plan.warnings = briefLib.checkConsistency(B());
if (plan.warnings.some((w) => w.level === 'error') && !body.force) return send(res, 400, { error: '구조에 오류가 있습니다', warnings: plan.warnings });
store.save(site);
const data = await runPlanStep(site, 'plan-design', planner.designPrompt(site));
plan.designOptions = data;
const rec = (data.options || []).find((o) => o.id === data.recommended) || (data.options || [])[0];
if (rec) applyDesignOption(B(), rec);
plan.stage = 'design';
return done();
}
// 4단계 선택 → Brief 단계
if (req.method === 'POST' && step === 'design') {
const opt = (plan.designOptions && plan.designOptions.options || []).find((o) => o.id === body.optionId);
if (opt) applyDesignOption(B(), opt);
if (body.design) B().design = { ...B().design, ...body.design };
plan.stage = 'brief';
return done();
}
// Brief 부분 저장 (내가 고르기: 선택 즉시 저장 / AI: 카드 수정)
if (req.method === 'PUT' && step === 'brief') {
const b = B();
mergeBrief(b, body.brief || body);
if (body.applyIndustryDefaults) briefLib.applyIndustryDefaults(b);
if (body.brief && body.brief.regulated !== undefined) { const c = body.brief.regulated && body.brief.regulated.category; b.regulated = c && briefLib.REGULATIONS[c] ? { flag: true, category: c, constraints: briefLib.REGULATIONS[c].constraints } : { flag: false, category: null, constraints: [] }; }
else { const ind = briefLib.findIndustry(b.business.industry.major); if (ind && ind.defaults.regulated && !b.regulated.flag) b.regulated = { flag: true, category: ind.defaults.regulated, constraints: briefLib.REGULATIONS[ind.defaults.regulated].constraints }; }
briefLib.applyRegulation(b);
if (b.business.name) site.title = b.business.name;
plan.warnings = briefLib.checkConsistency(b);
return done();
}
// 5/9단계: Brief 확정
if (req.method === 'POST' && step === 'brief' && m[3] === 'brief') { /* unreachable */ }
if (req.method === 'POST' && step === 'confirm') {
const b = B();
if (!b.business.name) b.business.name = site.title || '내 사이트';
if (!b.pages.length) { briefLib.applyIndustryDefaults(b); }
if (!b.pages.length) b.pages = briefLib.genericPages();
if (!b.goals.length) b.goals = ['intro', 'inquiry'];
briefLib.applyRegulation(b);
const added = briefLib.reconcileFeatures(b);
if (added.length) broadcast(site.id, { type: 'log', level: 'info', text: `기능에 맞는 섹션을 자동 추가: ${added.join(', ')}` });
plan.warnings = briefLib.checkConsistency(b);
if (plan.warnings.some((w) => w.level === 'error') && !body.force) return send(res, 400, { error: 'Brief 에 오류가 있어 확정할 수 없습니다: ' + plan.warnings.filter((w) => w.level === 'error').map((w) => w.text).join(' '), warnings: plan.warnings });
if (!b.design.tone) b.design.tone = b.design.recommendedTone || 'minimal';
if (!b.design.palette) b.design.palette = briefLib.TONES.find((t) => t.id === b.design.tone).palettes[0];
if (!b.design.fonts) b.design.fonts = briefLib.FONT_SETS[0];
plan.confirmedAt = new Date().toISOString(); plan.stage = 'confirmed'; site.title = b.business.name;
return done();
}
// 6/10단계: 생성 (Brief 만 입력) → 완료 후 자동 검증
if (req.method === 'POST' && step === 'generate') {
if (plan.stage !== 'confirmed' && plan.stage !== 'generated' && plan.stage !== 'published' && !body.force) return send(res, 400, { error: 'Brief 를 먼저 확정하세요' });
if (body.brief) mergeBrief(B(), body.brief);
if (body.force) { const b = B(); if (!b.business.name) b.business.name = site.title || '내 사이트'; if (!b.pages.length) briefLib.applyIndustryDefaults(b); if (!b.pages.length) b.pages = briefLib.genericPages(); if (!b.goals.length) b.goals = ['intro', 'inquiry']; briefLib.applyRegulation(b); briefLib.reconcileFeatures(b); if (!b.design.tone) b.design.tone = b.design.recommendedTone || 'minimal'; if (!b.design.palette) b.design.palette = briefLib.TONES.find((t) => t.id === b.design.tone).palettes[0]; if (!b.design.fonts) b.design.fonts = briefLib.FONT_SETS[0]; plan.warnings = briefLib.checkConsistency(b); if (!plan.confirmedAt) plan.confirmedAt = new Date().toISOString(); site.title = b.business.name; }
if (jobOf(site.id).running) return send(res, 409, { error: '이미 실행 중인 작업이 있습니다' });
plan.stage = 'generating'; store.save(site); planEvent(site);
const kind = site.commands.some((c) => c.kind === 'create' && c.status === 'done') ? 'update' : 'create';
const prompt = buildBriefPrompt(B(), attachmentsSection(site.attachments.filter((a) => !a.usedInCommands.length), site.attachments.filter((a) => a.usedInCommands.length)), widgetFor(site, ''));
runCommand(site, kind, kind === 'create' ? '(Brief 기반 생성)' : '(Brief 기반 재생성)', prompt, site.attachments.map((a) => a.name))
.then(async (cmd) => { if (cmd && cmd.status === 'done' && !jobOf(site.id).cancelRequested) { await runVerification(site); if (site.optimize && site.optimize.status === 'waiting') await draftOptimize(site); return; } if (plan.stage === 'generating') { plan.stage = 'confirmed'; store.save(site); planEvent(site); } }).catch(() => {});
return send(res, 202, { ok: true, plan });
}
if (req.method === 'POST' && step === 'verify') { const v = await runVerification(site); return send(res, 200, { verification: v, plan }); }
// 검증 실패 항목 자동 수정
if (req.method === 'POST' && step === 'fix') {
const v = plan.verification; if (!v) return send(res, 400, { error: '먼저 검증을 실행하세요' });
const lines = [];
for (const e of v.rules.extraneous) lines.push(`${e.file} 에서 Brief 에 없는 ${e.type} "${e.value}" 를 제거하거나 "[입력 필요]" 로 바꾸세요.`);
for (const e of v.rules.regulated) lines.push(`${e.file} 의 규제 위반 표현 "${e.value}" 를 삭제하거나 중립 표현으로 바꾸세요.`);
for (const pg of v.rules.pages.filter((x) => !x.exists)) lines.push(`누락된 페이지 파일 ${pg.file} (${pg.page}) 를 Brief 대로 만드세요.`);
for (const m of v.rules.mailto || []) lines.push(`${m.file} 의 mailto: 링크 ${m.count}곳을 제거하고, 문의 접수는 아래 표준 위젯 코드로 대체하세요.`);
for (const f of (v.ai && v.ai.fixes) || []) lines.push(f);
if (!lines.length) return send(res, 400, { error: '수정할 항목이 없습니다' });
if (jobOf(site.id).running) return send(res, 409, { error: '이미 실행 중인 작업이 있습니다' });
const prompt = planner.editPrompt(site, '검증에서 발견된 다음 항목을 수정하세요:\n- ' + lines.join('\n- '), (v.rules.mailto || []).length || !v.rules.widget ? widgetFor(site, '') : '');
runCommand(site, 'update', '(검증 결과 자동 수정)', prompt, [], { onResult: async (r, cmd) => { if (applyBriefFromText(site, r.resultText)) cmd.resultText = r.resultText.replace(/<brief>[\s\S]*?<\/brief>/, '').trim(); } })
.then((cmd) => { if (cmd && cmd.status === 'done' && !jobOf(site.id).cancelRequested) return runVerification(site); }).catch(() => {});
return send(res, 202, { ok: true });
}
// 9/13단계: 발행
if (req.method === 'POST' && step === 'publish') {
// [Ariweb Suite 변경] 포털에 연결된 프로젝트는 Webmaker 발행이 운영 버전을 바로 덮어쓰지 않도록 막고, 포털의 "배포" 화면(저장본 선택 → 미리보기 → 배포)으로 안내한다(§0.2).
{ const l = ariweb.linkOf(site.id); if (l) return send(res, 409, { error: '이 프로젝트는 Ariweb Suite 운영 사이트에 연결되어 있습니다. 배포는 포털의 사이트 › 배포 화면에서 저장본을 골라 진행합니다(운영 중인 버전을 바로 덮어쓰지 않습니다).', ariweb: { siteId: l.siteId, deployUrl: `${l.portalUrl || ariweb.portalUrl()}/sites/${l.siteId}/deploy` } }); }
if (body.domains !== undefined) { const nd = deploy.normalizeDomains(body.domains); const bad = subdomainOnlyViolation(user, nd); if (bad) return send(res, 403, { error: bad }); site.domains = nd; store.save(site); }
// v1.4.0 (요구 10): 연결한 도메인의 DNS 가 아직 전파 중이면 발행(웹서버·SSL 설정)을 미루고 예상 시간을 알린다
{ const wait = await domainsLib.gate(site).catch(() => null); if (wait) return send(res, 409, { error: wait, dnsPending: true }); }
const rules = briefLib.verifyFiles(siteDir, B(), multilang.isMulti(site) ? site.langs.default + '/' : ''); // v1.3.17 (B03): 검증과 같은 기준 — 다국어는 기본 언어 폴더
const blockers = { placeholders: rules.placeholders, pagesMissing: rules.pages.filter((x) => !x.exists), regulated: rules.regulated };
const blocked = blockers.placeholders.length || blockers.pagesMissing.length || blockers.regulated.length;
if (blocked && !body.force) return send(res, 409, { error: '"[입력 필요]" 항목이나 누락 페이지, 규제 표현이 남아 있어 발행을 보류합니다', blockers });
// v1.3.17 (B08): 배포가 실패(ok:false)하면 발행 상태·수정 횟수를 원래대로 되돌린다 (예전에는 예외만 되돌려 실패해도 "발행됨")
const prev = { published: plan.published || null, stage: plan.stage, edits: plan.editsSincePublish };
plan.published = { at: new Date().toISOString(), forced: !!blocked, url: siteUrl(site.id) }; plan.stage = 'published';
plan.editsSincePublish = 0; // 발행 후에는 수정 횟수를 다시 센다
store.save(site);
const rollback = () => { plan.published = prev.published; plan.stage = prev.stage === 'published' ? 'published' : 'generated'; plan.editsSincePublish = prev.edits; store.save(site); };
let dep; try { dep = await deploySiteTo(site, null, user.id); } catch (e) { rollback(); return send(res, e.status || 500, { error: e.message }); }
if (!dep.ok) { rollback(); return done({ blockers, deploy: dep }); }
if (dep.ok && dep.url) plan.published.deployedUrl = dep.url;
plan.published.deploy = { ok: dep.ok, detail: dep.detail, targetId: dep.targetId, vhostOk: dep.vhost ? dep.vhost.ok : null, sslOk: dep.ssl ? dep.ssl.ok : null, sslDetail: dep.ssl ? dep.ssl.detail : null, domainUrls: dep.domainUrls || [], domains: site.domains || [], access: dep.access || null };
return done({ blockers, deploy: dep });
}
// v1.6.31 (요구 15): 이전 배포본으로 되돌리기 — 배포 서버에 보관된 직전 릴리스(.prev / releases)로 링크를 바꾼다. 내용 되돌리기(버전)와는 별개로 운영 주소만 되돌린다
// v1.6.33: 사용자 쪽 "다시 확인" — 접속 확인을 다시 해 발행 단계(dns-wait·cert-wait·vhost-wait·check-failed)를 현재 상태로 바꾼다(재배포 아님)
if (req.method === 'POST' && step === 'recheck') { try { const r = await rejudgeAccess(site); return send(res, 200, { ...r, site: publicView(site) }); } catch (e) { return send(res, e.status || 500, { error: e.message }); } }
if (req.method === 'POST' && step === 'rollback') { let rb; try { rb = await rollbackSite(site, user.id); } catch (e) { return send(res, e.status || 500, { error: e.message }); } return send(res, rb.ok ? 200 : 409, { ...rb, site: publicView(site) }); }
if (req.method === 'POST' && step === 'unpublish') {
// v1.1.0: 발행 취소 = 게시 중단. 배포된 서버의 내용을 "사이트 수정 중" 안내 페이지로 바꾼다(운영 주소에서 기존 내용이 보이지 않게).
plan.published = null; plan.stage = 'generated';
if (jobOf(site.id).deploying) return send(res, 409, { error: '배포가 진행 중입니다. 완료 후 다시 시도하세요' });
const r = await deployMaintenance(site, user.id);
return done({ maintenance: r });
}
return send(res, 404, { error: '알 수 없는 기획 단계' });
}
if (req.method === 'GET' && sub === '/stats') {
const st = stats.summary(site.id, { ...statPaths(site.id), mailEnabled: !!(site.webmail && site.webmail.enabled) }); // v1.7.2: 메일 미설정이면 disk.mail 은 null(화면에서 숨김)
// v1.7.3 (요구 2): 메일 서버(Arimail) 사용량 API 가 되면 "메일 데이터" 를 서버 메일함 용량으로 바꾼다(이 서비스의 메일함 파일 크기는 disk.mailLocal 로 따로). 실패해도 통계는 그대로 — 이유는 mailUsage 에
if (site.webmail && site.webmail.enabled) {
const mu = await mailusage.forSite(site, { days: 30 }); st.mailUsage = mu;
if (mu.ok && mu.storage) { st.disk.mailLocal = st.disk.mail; st.disk.mail = mu.storage.bytes; st.disk.mailSource = 'arimail'; st.disk.mailLimit = mu.storage.limit || null; } else st.disk.mailSource = 'local';
}
return send(res, 200, st);
}
// v1.7.3 (요구 2): 메일 화면 "한눈에 보기" 의 사용량(경로 /mailusage — 사이트 하위 경로는 영문자만 허용) — 이 사이트의 메일 도메인 보고서(이 사이트 계정만). ?refresh=1 이면 캐시를 건너뛴다
if (req.method === 'GET' && sub === '/mailusage') return send(res, 200, await mailusage.forSite(site, { days: Math.min(366, Math.max(1, Number(url.searchParams.get('days')) || 30)), force: url.searchParams.get('refresh') === '1' }), { 'Cache-Control': 'no-store' });
// ---- v1.3.10 다국어: 언어 목록 보기·바꾸기 ----
if (sub === '/langs') {
const act = m[3] || '';
if (req.method === 'GET' && !act) return send(res, 200, { locales: Object.entries(multilang.LOCALES).map(([code, l]) => ({ code, name: l.name, label: l.label, dir: l.dir, tz: l.tz, currency: l.currency, country: l.country })), langs: site.langs || null, locale: multilang.siteLocale(site), adminLang: ownerLang(site), principle: LOCALE_URL_PRINCIPLE });
if (req.method === 'PUT' && !act) { const { fields: b } = await readBody(req); return send(res, 202, await changeLanguages(site, b, user)); }
// v1.3.16 x-default·사업장 현지 설정(시간대·통화·국가)·사이트 관리 언어 — AI 없이 바로 반영 (언어 선택 페이지·검색 값·현지 형식 다시 쓰기)
if (req.method === 'PUT' && act === 'settings') { const { fields: b } = await readBody(req); return send(res, 200, await saveLangSettings(site, b)); }
// v1.3.16 품질 게이트·로케일별 메타 현황
if (req.method === 'GET' && act === 'quality') return send(res, 200, { quality: multilang.quality(site, path.join(SITES_ROOT, site.id)), matrix: seo.localeMatrix(site, path.join(SITES_ROOT, site.id), liveBase(site)) });
// v1.3.16 언어별 검수 결정 {lang, state: approved|rejected|pending, note}
if (req.method === 'POST' && act === 'review') { const { fields: b } = await readBody(req); const r = multilang.setReview(site, String(b.lang || ''), String(b.state || ''), user.id, b.note); store.save(site); return send(res, 200, { review: r, quality: multilang.quality(site, path.join(SITES_ROOT, site.id)) }); }
}
// ---- v1.3.5 검색·AI 최적화 (SEO·AEO·GEO): 초안 → 사용자 검토 → 반영 ----
if (sub === '/optimize') {
const act = m[3] || '';
if (req.method === 'GET' && !act) return send(res, 200, optimizeView(site));
if (req.method === 'POST' && act === 'draft') { if (jobOf(site.id).running) return send(res, 409, { error: '다른 작업이 진행 중입니다' }); const { fields: b } = await readBody(req); const w = seo.wantedFrom(b.wanted || b); if (!seo.anyWanted(w)) return send(res, 400, { error: 'SEO·AEO·GEO 중 하나 이상 고르세요' }); if (!seo.pageFiles(site, path.join(SITES_ROOT, site.id)).length) return send(res, 409, { error: '사이트에 HTML 페이지가 없습니다' }); draftOptimize(site, w).catch(() => {}); return send(res, 202, { ok: true }); }
if (req.method === 'POST' && act === 'apply') {
if (jobOf(site.id).running || jobOf(site.id).deploying) return send(res, 409, { error: '작업·배포가 끝난 뒤 반영하세요' });
const { fields: b } = await readBody(req); const files = seo.pageFiles(site, path.join(SITES_ROOT, site.id)); const multi = multilang.isMulti(site);
site.optimize = { ...(site.optimize || {}), final: seo.normalizeFinal(b.final || {}, files, multi ? multilang.LOCALES[site.langs.default].og : undefined), applied: true, appliedAt: new Date().toISOString(), status: 'applied' };
if (multi) { const lf = {}; for (const c of site.langs.list.slice(1)) if (b.langs && b.langs[c]) lf[c] = seo.normalizeFinal(b.langs[c], files, multilang.LOCALES[c].og); site.optimize.langFinals = lf; } // v1.3.10 언어별 반영 값
const mr = seo.materialize(site, path.join(SITES_ROOT, site.id), liveBase(site)); store.save(site);
if (mr.changed.length) await gitRecord(site, 'seo', '검색·AI 최적화(SEO·AEO·GEO) 반영');
return send(res, 200, { ...optimizeView(site), changed: mr.changed, site: publicView(site) });
}
if (req.method === 'POST' && act === 'remove') {
if (jobOf(site.id).running || jobOf(site.id).deploying) return send(res, 409, { error: '작업·배포가 끝난 뒤 하세요' });
site.optimize = { ...(site.optimize || {}), applied: false, status: site.optimize && site.optimize.draft ? 'draft' : 'none' };
const mr = seo.materialize(site, path.join(SITES_ROOT, site.id), liveBase(site)); store.save(site);
if (mr.changed.length) await gitRecord(site, 'seo', '검색·AI 최적화 값 빼기');
return send(res, 200, { ...optimizeView(site), changed: mr.changed, site: publicView(site) });
}
}
// ---- v1.3.5 검색사이트 등록 (구글·네이버, IndexNow) — 배포가 끝난 사이트만 ----
if (sub === '/search') {
if (req.method === 'GET' && !m[3]) return send(res, 200, searchView(site));
if (req.method === 'POST' && m[3]) { const { fields: b } = await readBody(req); return send(res, 200, await searchAction(site, m[3], b, user.id)); }
}
if (req.method === 'POST' && sub === '/analyze') { if (jobOf(site.id).running) return send(res, 409, { error: '이미 실행 중인 작업이 있습니다' }); analyzeSite(site).catch(() => {}); return send(res, 202, { ok: true }); }
if (sub === '/modules') {
if (req.method === 'GET' && !m[3]) { const o = modules.overview(site.id); return send(res, 200, { enabled: o.enabled, counts: o.counts, catalog: o.catalog, adminUrl: `/site-admin/${site.id}/` }); }
if (req.method === 'PUT' && m[3] === 'enabled') { const { fields: b } = await readBody(req); return send(res, 200, { enabled: modules.setEnabled(site.id, b.module, b.on) }); }
}
if (req.method === 'POST' && sub === '/inquiries' && /^\d+\/resend$/.test(m[3] || '')) {
// 미발송 문의 재발송: 메일 설정이 나중에 갖춰졌거나 일시 오류였을 때 사용
const idx = parseInt(m[3], 10); const q = forms.list(site.id)[idx]; if (!q) return send(res, 404, { error: '접수 기록이 없습니다' });
const to = publicView(site).contactEmail; if (!to) return send(res, 400, { error: '문의 수신 이메일이 설정되어 있지 않습니다. 기본 사항에서 먼저 설정하세요.' });
try {
const from = await mailer.pickFrom(site.domains || []); const mail = forms.compose(site, q, q.ip, ownerFmt(site));
const r = await mailer.send({ from, to, subject: mail.subject, text: mail.text, html: mail.html, replyTo: q.email });
forms.update(site.id, idx, { sent: true, error: null, to, from: from.address, providerId: r && r.id, resentAt: new Date().toISOString() });
broadcast(site.id, { type: 'log', level: 'info', notify: true, text: `문의 재발송: ${q.name} → ${to}` });
return send(res, 200, { ok: true });
} catch (e) { forms.update(site.id, idx, { error: e.message }); broadcast(site.id, { type: 'log', level: 'debug', text: `문의 재발송 실패: ${e.message}` }); return send(res, 502, { error: '메일 발송에 실패했습니다. 관리자에게 문의하세요.' }); }
}
if (req.method === 'GET' && sub === '/inquiries') return send(res, 200, { inquiries: forms.list(site.id).slice().reverse(), contactEmail: publicView(site).contactEmail, formEndpoint: publicView(site).formEndpoint });
if (req.method === 'PUT' && sub === '/model') { const { fields: b } = await readBody(req); if (!settings.TIERS.some((t) => t.id === b.tier)) return send(res, 400, { error: '모델 등급을 선택하세요' }); site.tier = b.tier; store.save(site); broadcast(site.id, { type: 'log', level: 'info', text: `모델 등급 변경: ${b.tier} (${siteRef(site)})` }); return send(res, 200, { tier: site.tier }); }
if (req.method === 'PUT' && sub === '/domains') { const { fields: b } = await readBody(req); { const bad = subdomainOnlyViolation(user, deploy.normalizeDomains(b.domains)); if (bad) return send(res, 403, { error: bad }); } site.domains = deploy.normalizeDomains(b.domains); store.save(site); return send(res, 200, { domains: site.domains }); }
if (req.method === 'GET' && sub === '/uploads') return send(res, 200, site.attachments);
if (req.method === 'POST' && sub === '/uploads') {
const { files } = await readBody(req);
if (!files.length) return send(res, 400, { error: '업로드할 파일이 없습니다' });
const saved = await uploads.saveUploads(siteDir, files);
site.attachments.push(...saved);
if (site.plan) { const b = site.plan.brief; for (const a of saved) if (a.kind === 'image') { if (!b.assets.logo) b.assets.logo = a.rel; else if (!b.assets.photos.includes(a.rel)) b.assets.photos.push(a.rel); } }
store.save(site);
broadcast(site.id, { type: 'log', level: 'info', text: `첨부 파일 ${saved.length}개 업로드: ${saved.map((a) => a.name).join(', ')}` });
return send(res, 201, { attachments: site.attachments, saved });
}
if (req.method === 'DELETE' && sub === '/uploads' && m[3]) {
const name = decodeURIComponent(m[3]);
const idx = site.attachments.findIndex((a) => a.name === name);
if (idx < 0) return send(res, 404, { error: '첨부 파일을 찾을 수 없습니다' });
if (jobOf(site.id).running) return send(res, 409, { error: '작업 실행 중에는 삭제할 수 없습니다' });
uploads.deleteUpload(siteDir, site.attachments[idx]);
site.attachments.splice(idx, 1); store.save(site);
return send(res, 200, { attachments: site.attachments });
}
if (req.method === 'GET' && sub === '/events') {
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
res.write(':ok\n\n');
const job = jobOf(site.id); res.isAdmin = user.role === 'admin';
for (const ev of job.log) if (res.isAdmin || USER_LOG_LEVELS.has(ev.level)) res.write(`data: ${JSON.stringify(ev)}\n\n`);
const runningCmd = job.running ? site.commands.find((c) => c.status === 'running') : null;
if (job.deploying) res.write(`data: ${JSON.stringify({ type: 'status', status: 'running', label: job.deploying.label, startedAt: job.deploying.startedAt, deploy: true, ts: Date.now() })}\n\n`);
else res.write(`data: ${JSON.stringify({ type: 'status', status: job.running ? 'running' : (site.status === 'running' ? 'idle' : site.status), label: runningCmd ? (PLAN_LABEL[runningCmd.kind] || (runningCmd.kind === 'create' ? '페이지 생성' : '수정 반영')) : '', startedAt: runningCmd ? runningCmd.startedAt : null, ts: Date.now() })}\n\n`);
job.subscribers.add(res);
const ping = setInterval(() => { try { res.write(':ping\n\n'); } catch (_) {} }, 15000);
req.on('close', () => { clearInterval(ping); job.subscribers.delete(res); });
return;
}
}
if (p.startsWith('/api/')) return send(res, 404, { error: 'not found' });
return staticFile(res, '404.html', 404);
}
/**
* 화면 HTML 보내기 (v1.3.10): 쿠키 wm_lang 에 고른 언어가 있으면 서버에서 미리 번역해 보낸다(i18n.translateHtml).
* 언어를 바꾸면 브라우저가 쿠키를 바꾸고 페이지를 다시 연다.
*/
/** 입력 문자열 정리: 공백 하나로, 최대 n 자 (v1.3.10) */
const clipText = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
/**
* v1.3.16 화면 언어 결정 (서버가 정하고 페이지에 <meta name="wm-ui-lang"> 로 알려 준다):
* 1) 로그인 사용자가 직접 고른 언어 — 단, 관리자가 서비스 기본 언어를 "강제" 한 시각보다 나중에 고른 것만
* 2) 관리자가 강제한 서비스 기본 언어
* 3) 브라우저에 저장된 선택(쿠키 wm_lang)
* 4) 사이트 관리자 화면이면 그 사이트의 관리 언어, 아니면 서비스 기본 언어(강제하지 않은 기본값)
* 화면에 켜져 있지 않은(번역이 없거나 숨겨진) 언어는 고르지 않는다.
*/
function effectiveUiLang(req, opts = {}) {
const st = settings.load().uiLang || {}; const ok = (l) => l && (l === 'ko' || i18n.activeLanguages().some((x) => x.lang === l));
const user = auth.userFromRequest(req); const pref = user ? auth.uiLangOf(user.id) : null;
if (pref && ok(pref.lang) && (!st.forcedAt || pref.at > st.forcedAt)) return pref.lang;
if (st.forcedAt && ok(st.default)) return st.default;
const ck = auth.parseCookies(req).wm_lang; if (ok(ck)) return ck;
if (ok(opts.siteLang)) return opts.siteLang;
return ok(st.default) ? st.default : 'ko';
}
function sendPage(req, res, name, opts = {}) {
const lang = effectiveUiLang(req, opts);
fs.readFile(path.join(PUBLIC_DIR, name), 'utf8', (err, html) => {
if (err) return send(res, 404, '페이지를 찾을 수 없습니다');
let out = html; if (lang !== 'ko') { try { out = i18n.translateHtml(html, lang, `${name}|${VERSION}`); } catch (_) {} }
out = out.replace(/<head>/i, `<head><meta name="wm-ui-lang" content="${lang}">${opts.inject || ''}`); // public/i18n.js 가 이 값을 먼저 쓴다. inject: 화면별 표시(v1.5.11 /setup 의 wm-setup-inline)
send(res, 200, Buffer.from(out), { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache', Vary: 'Cookie', 'Set-Cookie': `wm_lang=${encodeURIComponent(lang)}; Path=/; Max-Age=31536000; SameSite=Lax` });
});
}
function staticFile(res, name, status = 200) {
const fp = path.join(PUBLIC_DIR, name);
fs.readFile(fp, (err, buf) => {
if (err) return send(res, 404, '페이지를 찾을 수 없습니다');
send(res, status, buf, { 'Content-Type': MIME[path.extname(name)] || 'application/octet-stream' });
});
}
http.createServer((req, res) => {
handle(req, res).catch((err) => {
if (!res.headersSent) send(res, err.status || 500, { error: err.message });
else res.end();
});
}).listen(PORT, HOST, () => {
console.log(`webhosting-agent listening on http://${HOST}:${PORT} sites=${SITES_ROOT} public=${PUBLIC_BASE_URL} model=${MODEL}`);
// v1.6.31 (요구 12): 이전 프로세스가 남긴 AI 자식 프로세스 정리 · (요구 10) 손상 파일 안내
try { const n = require('./lib/jobpids').sweep(); if (n) console.log(`[jobs] 이전 실행이 남긴 AI 작업 프로세스 ${n}개를 종료했습니다`); } catch (_) {}
try { const bad = jsonstore.health(); if (bad.length) console.error('[data] 손상된 데이터 파일 — 관리자 콘솔 › 운영 복구에서 확인하세요: ' + bad.map((b) => b.file).join(', ')); } catch (_) {}
// v1.5.17 (요구 1): 기동 20초 뒤 — 서버에 와일드카드 인증서(설치 때 미리 받아 둔 것 포함)가 있으면 HTTP 로 배포돼 있던 서브 도메인 사이트의 nginx 설정을 HTTPS 로 다시 쓴다
setTimeout(() => { sslLib.autoApply().then((r) => { console.log(`[ssl] 와일드카드 인증서: ${r.certName ? r.certName + (r.external ? ' (서버에 미리 발급됨)' : '') : (r.reason || '없음')}${r.reconfigured ? ` — 서브 도메인 사이트 ${r.reconfigured}개 설정을 HTTPS 로 갱신` : ''}`); }).catch((e) => console.log('[ssl] 와일드카드 인증서 확인 실패: ' + e.message)); }, 20000);
});
// v1.4.0: 다국어 사이트의 언어 선택 링크 복구(1.3.16 오류) — 시작할 때 한 번, 번역 없이 링크만 다시 그린다. 고친 사이트는 다음 발행·재배포 때 배포 서버에도 반영된다
setImmediate(() => { for (const s0 of store.list()) { try { const s1 = store.load(s0.id); const n = s1 ? multilang.repairSwitchers(s1, path.join(SITES_ROOT, s1.id)) : 0; if (n) console.log(`language switcher repaired: ${s1.id} (${n} files)`); } catch (e) { console.error('language switcher repair failed:', s0.id, e.message); } } });