2026-10-26 11:02:58 +00:00
'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, 캐시)
HAL 0.4.0: 통합 설정 관제 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리(설정 목록·범위 global/server·비밀값 Vault·유효값 hash, /ops/api/settings·재인증, settings.apply → settings.apply.target → Agent module.configure(HMAC 설정 받기·hash 확인·hal-managed.json 0600·재시작·모듈 적용 확인), /ops/settings 화면·운영 현황 "설정 적용 필요", AI 도구 hal_settings_get/set/apply(비밀값 거부)), 도입 모듈 Webmaker 1.9.0·AriMail 1.1.0(HAL 0.4.0 대응 halCompat, hal-managed 적용·관리 항목 409 managed_by_hal 잠금·"HAL 관리" 표시) 재도입(pin 8f8716c·f959b65, import-sources 미수정 파일 갱신), Agent heartbeat 관리, module-as-user write --mode 600, 개발 지시 v0.4.0(Oz)·docs/HAL-SETTINGS.md·VERSION_HISTORY 0.4.0 — test/hal-040.test.mjs RED → GREEN 5/5, agent 11/11, 전체 31파일 통과
2026-10-29 06:46:45 +00:00
const vault = require ( './lib/vault' );
const halManaged = require ( './lib/hal-managed' ); // v1.9.0 (Ariweb Suite HAL 0.4.0 대응) HAL 통합 설정 관제 — 관리 항목 적용·잠금 // v1.3.1 외부 연동 접속 정보 암호화 보관소
2026-10-26 11:02:58 +00:00
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' );
2026-10-28 15:07:35 +00:00
const visualEditor = require ( './lib/visual-editor' ); // v1.8.0 직접 편집(시각 편집기): 캔버스 문서 변환·저장 검증(순수 로직)
2026-10-26 11:56:38 +00:00
const ariweb = require ( './lib/ariweb' ); // [Ariweb Suite 변경] 포털 연동(SSO 티켓·내부 API·프로젝트↔사이트 연결)
2026-10-26 11:02:58 +00:00
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 } 로 돌려준다. */
2026-10-28 15:07:35 +00:00
/** 요청 본문(JSON 또는 multipart)을 한 번만 읽고 메모한다(1.8.0 검토 수정 3차) — 사이트 라우터가 잠금 밖에서 먼저 읽고, 처리기가 다시 불러도 같은 결과(파싱 오류도 같은 오류)를 받는다 */
async function readBody ( req ) { if ( ! req . __wmBody ) req . __wmBody = readBodyOnce ( req ); return req . __wmBody ; }
async function readBodyOnce ( req ) {
2026-10-26 11:02:58 +00:00
const ct = req . headers [ 'content-type' ] || '' ;
2026-10-28 15:07:35 +00:00
2026-10-26 11:02:58 +00:00
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 } /` ; }
2026-10-28 15:07:35 +00:00
/**
* v1.8.0 직접 편집 캔버스 런타임: public/visual-editor-core.js(공용 핵심) + public/wm-ve-canvas.js(캔버스 안 동작)를 이어 붙인 코드.
* 캔버스 문서(srcdoc, 출처 null) 안에 nonce 가 있는 인라인 스크립트로 들어가므로 네트워크 요청 없이 실행된다. 파일이 바뀌면(mtime) 다시 읽는다.
*/
let veRuntimeCache = null ;
function veRuntime () {
const files = [ 'visual-editor-core.js' , 'wm-ve-canvas.js' ]. map (( f ) => path . join ( PUBLIC_DIR , f ));
const stamp = files . map (( f ) => { try { return fs . statSync ( f ). mtimeMs ; } catch ( _ ) { return 0 ; } }). join ( '|' );
if ( ! veRuntimeCache || veRuntimeCache . stamp !== stamp ) veRuntimeCache = { stamp , code : files . map (( f ) => fs . readFileSync ( f , 'utf8' )). join ( '\n;\n' ) };
return veRuntimeCache . code ;
}
/** 요청을 보낸 빌더 화면의 출처(scheme://host) — 캔버스가 받아들일 부모 창 출처. 앞단 프록시 헤더를 따르고, 없으면 공개 주소 */
function requestOrigin ( req ) {
const host = String ( req . headers [ 'x-forwarded-host' ] || req . headers . host || '' ). split ( ',' )[ 0 ]. trim ();
if ( ! host ) { try { return new URL ( PUBLIC_BASE_URL ). origin ; } catch ( _ ) { return PUBLIC_BASE_URL ; } }
return ` ${ isSecureReq ( req ) ? 'https' : 'http' } :// ${ host } ` ;
}
2026-10-26 11:02:58 +00:00
/**
* 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 };
}
2026-10-28 15:07:35 +00:00
// ---------- 배경 작업의 저장(1.8.0 검토 수정 3차 — 같은 잎 동시 변경은 덮어쓰지 않는다) ----------
/**
* 배경 작업(AI 생성/수정·기획 단계·가져오기·복사)은 자기 사이트 객체를 오래 들고 있다가 저장한다. 저장소(lib/store.js)는 읽은 뒤 바뀐 잎만 디스크 최신 기록 위에 합치고,
* 같은 잎을 둘 다 다르게 바꾼 진짜 동시 변경은 site_save_conflict 로 거절한다(디스크의 먼저 쓴 값 보존). 그때 이 함수가 최신 기록을 다시 읽어(store.update) 작업이 가진 항목만(reapply)
* 그 위에 적용해 저장하고 작업 객체를 그 결과로 맞춘다(store.adopt) — 화면이 먼저 저장한 값은 남고 작업의 결과(명령 기록·상태·버전)도 남는다. 다른 오류는 그대로 던진다.
*/
function saveJob ( site , reapply ) {
try { return store . save ( site ); }
catch ( e ) {
if ( ! ( e && e . code === 'site_save_conflict' )) throw e ;
broadcast ( site . id , { type : 'log' , level : 'debug' , text : `사이트 기록 저장 충돌( ${ ( e . fields || []). join ( ', ' ) } ) — 최신 기록 위에 작업 결과만 다시 적용합니다` });
const fresh = store . update ( site . id , ( f ) => reapply ( f , site ));
if ( fresh ) store . adopt ( site , fresh );
return site ;
}
}
/** AI 작업(runCommand·runPlanStep)이 가진 항목: 이 작업의 명령 기록(번호로 교체/추가)·상태·세션·캐시 대기·기획 단계·검증·경고 — 화면이 바꾸는 기본 사항·등급·첨부·도메인 등은 건드리지 않는다 */
function reapplyCommand ( cmd ) {
return ( f , s ) => {
f . commands = f . commands || []; const i = f . commands . findIndex (( c ) => c . no === cmd . no ); if ( i >= 0 ) f . commands [ i ] = cmd ; else f . commands . push ( cmd );
f . status = s . status ; if ( s . sessionId ) f . sessionId = s . sessionId ;
if ( Object . prototype . hasOwnProperty . call ( s , 'creditWait' )) f . creditWait = s . creditWait ;
if ( s . plan && f . plan ) { f . plan . stage = s . plan . stage ; if ( s . plan . verification !== undefined ) f . plan . verification = s . plan . verification ; if ( s . plan . warnings !== undefined ) f . plan . warnings = s . plan . warnings ; }
};
}
/** 가져오기·복사 작업이 가진 항목(제목은 화면이 직접 정한 제목(titleCustom)을 이기지 않는다) */
function reapplyFields ( keys ) {
return ( f , s ) => { for ( const k of keys ) { if ( k === 'title' ) { if ( ! f . titleCustom && s . title ) f . title = s . title ; } else if ( k === 'plan' ) { if ( s . plan && f . plan ) { f . plan . stage = s . plan . stage ; if ( s . plan . brief && f . plan . brief && s . plan . brief . business ) f . plan . brief . business = s . plan . brief . business ; } } else if ( Object . prototype . hasOwnProperty . call ( s , k )) f [ k ] = s [ k ]; } };
}
2026-10-26 11:02:58 +00:00
// ---------- git 보관 ----------
function gitAuthor ( site ) { return { name : site . ownerName || site . ownerId || 'webhosting-agent' , email : ` ${ site . ownerId || 'agent' } @webhosting.local` }; }
2026-10-28 15:07:35 +00:00
/**
* 변경을 커밋·푸시하고 site.git 에 기록한다. 실패해도 예외를 던지지 않는다(AI 작업 등은 버전 기록 실패가 작업 결과를 취소할 이유가 아니다).
* 직접 편집 저장은 이 함수를 쓰지 않고 gitstore.recordChangeStrict 트랜잭션(파일 → 로컬 커밋 → 기록 → 원자적 푸시, 실패하면 검증된 되돌리기)을 쓴다 — PUT /visual 참고.
*/
2026-10-26 11:02:58 +00:00
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 ));
2026-10-28 15:07:35 +00:00
site . git . seq = no ; site . git . changes . push ( rec );
saveJob ( site , ( f ) => { f . git = f . git || { seq : 0 , changes : [] }; if ( ! f . git . changes . some (( c ) => c . no === rec . no )) f . git . changes . push ( rec ); f . git . seq = Math . max ( Number ( f . git . seq ) || 0 , no ); }); // 같은 git.changes 를 화면(다시 푸시 등)이 먼저 바꿨으면 그 위에 이 버전만 더한다
2026-10-26 11:02:58 +00:00
broadcast ( site . id , { type : 'log' , level : 'info' , text : `버전 # ${ no } 저장됨 ( ${ message } )` });
const failed = site . git . changes . filter (( c ) => ! c . pushed && c !== rec );
2026-10-28 15:07:35 +00:00
if ( failed . length ) detached (() => setImmediate (() => reconcileSite ( site . id ))); // 옛 실패분 재시도는 잠금 안에서 최신 기록을 읽는 조정으로(작업 객체를 저장하지 않는다)
2026-10-26 11:02:58 +00:00
return rec ;
} catch ( e ) {
broadcast ( site . id , { type : 'log' , level : 'debug' , text : `버전 저장 실패: ${ e . message } ` });
return null ;
}
}
2026-10-28 15:07:35 +00:00
/**
* 원격 반영 조정(1.8.0 검토 수정 2차): 직접 편집 저장은 기록을 푸시 **전** 에 `pushed:false, remote:'pending'` 으로 남기고 푸시로 끝난다(푸시 뒤 상태 변경 없음).
* 이 함수가 잠금 안에서 기록을 다시 읽어 pushed 가 아닌 변경을 gitstore.reconcilePushes 로 원격 ref 에서 확인(필요하면 원자적 푸시)하고, 바뀐 것이 있으면 기록만 저장한다
* (`{ keepRev: true }` — 그 사이 다른 저장과 합치되 내용 변경이 아니므로 Suite 의 rev 를 올리지 않는다). 저장 트랜잭션 밖의 별도 단계라 실패해도 기록은 보수적(pending)으로 남을 뿐이다. 호출: 저장 응답 뒤·기동 때.
*/
/** 원격 반영 조정 결과(pushed·remote·verifiedAt·error — 원격 ref 에서 얻은 값)를 최신 기록의 같은 커밋 항목에 적용한다(merge-safe 경로: 배경 작업이 그 사이 저장했어도 그 위에 적용) */
function applyPushState ( f , recs ) {
if ( ! f . git || ! Array . isArray ( f . git . changes )) return ;
for ( const r of recs ) { const t = f . git . changes . find (( c ) => c && c . commit === r . commit && c . no === r . no ); if ( ! t ) continue ; t . pushed = r . pushed ; t . error = r . error ; if ( r . remote !== undefined ) t . remote = r . remote ; if ( r . verifiedAt !== undefined ) t . verifiedAt = r . verifiedAt ; }
}
function reconcileSite ( siteId ) {
return withSiteLock ( siteId , async () => {
const site = store . load ( siteId ); if ( ! site || ! site . git || ! Array . isArray ( site . git . changes )) return ;
const todo = site . git . changes . filter (( c ) => c && ! c . pushed && c . commit ); if ( ! todo . length ) return ;
const before = JSON . stringify ( todo );
await gitstore . reconcilePushes ( siteId , path . join ( SITES_ROOT , siteId ), todo );
if ( JSON . stringify ( todo ) === before ) return ;
store . update ( siteId , ( f ) => applyPushState ( f , todo ), { keepRev : true }); // 최신 기록 위에 조정 결과만 적용(그 사이 배경 작업이 저장했어도 덮어쓰지 않는다); 내용 변경이 아니므로 Suite rev 는 올리지 않는다
const ok = todo . filter (( c ) => c . pushed ). length ; const bad = todo . filter (( c ) => ! c . pushed && c . error );
if ( ok ) broadcast ( siteId , { type : 'log' , level : 'debug' , text : `소스 저장소(Gitea) 반영 확인: 버전 ${ todo . filter (( c ) => c . pushed ). map (( c ) => '#' + c . no ). join ( ' ' ) } ` });
if ( bad . length ) broadcast ( siteId , { type : 'log' , level : 'debug' , text : `소스 저장소(Gitea) 반영 미확인 — 다음 변경 때 다시 시도합니다: ${ bad [ 0 ]. error } ` });
}). catch (( e ) => { try { broadcast ( siteId , { type : 'log' , level : 'debug' , text : `원격 반영 조정 실패: ${ e . message } ` }); } catch ( _ ) {} });
}
// ---------- 사이트별 변경 잠금(1.8.0 검토 수정) ----------
// 한 사이트의 파일·기록을 바꾸는 저장(소스 편집기 PUT·직접 편집 PUT·버전 복원)과 작업 시작(AI 생성/수정·기획 단계·가져오기·복사·다국어·배포)은 이 잠금 안에서
// "다시 검사 → 쓰기(또는 작업 표식 세우기)" 를 끝낸다. 느린 요청이 사전 검사만 통과한 뒤 다른 변경 도중에 쓰거나, 두 저장이 서로의 검사 결과를 무효로 만드는 일이 없다.
// 잠금은 프로세스 안의 약속 사슬(Promise chain)이다 — 서버는 한 프로세스이고 파일·기록은 이 프로세스만 바꾼다. 요청 본문 읽기는 잠금 **밖** 에서 끝내고(느린 클라이언트가 잠금을 붙잡지 않게)
// 잠금 안에서 실행 중·배포 중·파일 hash·사이트 기록을 다시 읽어 검사한다.
const siteLocks = new Map (); // siteId -> 마지막 작업의 Promise
// 재진입(1.8.0 검토 수정 3차): 잠금을 쥔 비동기 맥락(AsyncLocalStorage)이 같은 사이트의 잠금을 다시 요청하면 기다리지 않고 바로 실행한다 — 라우터가 처리기 전체를 잠금 안에서 돌릴 때
// 처리기 안의 withSiteLock/claimJob 이 자기 자신을 기다리는 교착이 없다. 잠금 안에서 띄우는 배경 작업은 detached() 로 맥락을 끊어 "쥐지 않은 잠금을 쥔 것처럼" 보이지 않게 한다.
const { AsyncLocalStorage } = require ( 'async_hooks' ); const lockHeld = new AsyncLocalStorage ();
function withSiteLock ( siteId , fn ) {
const held = lockHeld . getStore ();
if ( held && held . has ( siteId )) return Promise . resolve (). then ( fn );
const next = new Set ( held || []); next . add ( siteId );
const prev = siteLocks . get ( siteId ) || Promise . resolve ();
const go = () => lockHeld . run ( next , fn );
const run = prev . then ( go , go );
siteLocks . set ( siteId , run . then (() => undefined , () => undefined ));
return run ;
}
/** 잠금 맥락 밖에서 fn 을 실행한다(배경 작업 예약용) — 그 안에서 거는 withSiteLock 은 정상적으로 줄을 선다 */
function detached ( fn ) { return lockHeld . exit ( fn ); }
/** 작업 시작: 잠금 안에서 check(실행 중·배포 중·동시 작업 상한 — 어긋나면 던진다) 뒤 set(표식 세우기)을 원자적으로 한다 */
function claimJob ( siteId , check , set ) { return withSiteLock ( siteId , () => { if ( jobOf ( siteId ). deleting ) throw Object . assign ( new Error ( '사이트 폐기가 진행 중이라 작업을 시작할 수 없습니다' ), { status : 409 }); check (); set (); }); }
2026-10-26 11:02:58 +00:00
// ---------- 작업 실행 ----------
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 );
2026-10-28 15:07:35 +00:00
// 사이트 변경 잠금 안에서 검사 → 표식(1.8.0 검토 수정): 진행 중인 직접 편집·소스 저장이 끝난 뒤에 작업이 시작되고, 그 뒤의 저장은 잠금 안에서 running 을 다시 보아 409 를 받는다
await claimJob ( 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 ; });
2026-10-26 11:02:58 +00:00
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 ;
2026-10-28 15:07:35 +00:00
site . commands . push ( cmd ); site . status = 'running' ; saveJob ( site , reapplyCommand ( cmd ));
2026-10-26 11:02:58 +00:00
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 } ` }); } }
2026-10-28 15:07:35 +00:00
saveJob ( site , reapplyCommand ( cmd ));
2026-10-26 11:02:58 +00:00
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 };
2026-10-28 15:07:35 +00:00
saveJob ( site , reapplyCommand ( cmd ));
2026-10-26 11:02:58 +00:00
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' ;
2026-10-28 15:07:35 +00:00
saveJob ( site , reapplyCommand ( cmd ));
2026-10-26 11:02:58 +00:00
broadcast ( site . id , { type : 'log' , level : 'warn' , text : '작업이 중단되었습니다. 지금까지 만들어진 내용은 유지됩니다.' });
} else {
Object . assign ( cmd , { finishedAt : new Date (). toISOString (), status : 'error' , error : err . message });
2026-10-28 15:07:35 +00:00
site . status = 'error' ; saveJob ( site , reapplyCommand ( cmd )); // 실패 기록도 최신 기록을 덮어쓰지 않고 합친다
2026-10-26 11:02:58 +00:00
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 );
2026-10-28 15:07:35 +00:00
saveJob ( site , reapplyCommand ( cmd ));
2026-10-26 11:02:58 +00:00
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 ) {
2026-10-28 15:07:35 +00:00
await claimJob ( site . id , () => { if ( job . running || job . deploying ) throw Object . assign ( new Error ( '다른 작업이 진행 중입니다. 끝난 뒤 하세요' ), { status : 409 }); }, () => { job . running = true ; }); // 잠금 안 검사→표식을 **먼저**(1.8.0 검토 수정 3차): 거절되면 폴더·기록을 건드리지 않는다
2026-10-26 11:02:58 +00:00
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 );
( 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 없음 등)는 경고로만 돌려준다(파일이 지워지면 그 도메인은 더 이상 사이트를 보여 주지 않음).
*/
2026-10-28 15:07:35 +00:00
/**
* 사이트 폐기(1.8.0 검토 수정 4차 — 잠금·표식 규약). ① 잠금 안에서 기록을 다시 읽어 검사(소유자·실행 중·배포 중·이미 폐기 중·확인 문구)하고 폐기 표식(jobOf(id).deleting = {token})을 원자적으로 잡는다 — 그 뒤의 모든 변경 요청(라우터 재적재)·작업 시작(claimJob)은 409.
* ② 잠금 **밖** 에서 배포 서버 정리(네트워크)를 기다린다. ③ 다시 잠금을 잡고 같은 표식(token)인지·기록이 아직 있는지 확인한 뒤에만 파일·기록을 지운다(되돌릴 수 없는 단계).
* 되돌릴 수 없는 삭제 전에 사이트 폴더·기록을 임시 이름으로 옮겨 두고(rename, 되돌릴 수 있음), opts.beforeIrreversible(Suite: 포털 연결 해제)이 실패하면 되돌린다. 되돌릴 수 없는 단계는 기록 파일 unlink 한 번이며,
* 그 실패는 되돌리기 + opts.compensate(Suite: 연결 되살리기)로 보상하고 실패로 알린다(성공으로 꾸미지 않음). 그 뒤의 뒷정리(폴더·git·통계 등)는 최선이고 실패는 warnings 와 감사 기록으로 운영자에게 보인다.
* 실패(정리 실패·표식 불일치·준비 실패)면 표식을 풀고(같은 표식일 때만) deleteFailed{detail, recoveryRequired} 를 최신 기록 위에 남긴다(store.update — 덮어쓰기 없음) — 사이트는 그대로 쓸 수 있다.
* @param {string} siteId
* @param {string} by
* @param {{ownerId?: string, confirm?: string, beforeIrreversible?: Function, compensate?: Function}} opts ownerId 가 있으면 소유자 확인, confirm 이 있으면 제목 일치 확인(사용자 경로); 훅은 Ariweb Suite 가 쓴다
*/
async function destroySite ( siteId , by , opts = {}) {
const job = jobOf ( siteId ); const token = crypto . randomBytes ( 8 ). toString ( 'hex' );
const s1 = await withSiteLock ( siteId , () => {
const s = store . load ( siteId ); if ( ! s || ( opts . ownerId && s . ownerId !== opts . ownerId )) throw Object . assign ( new Error ( '사이트를 찾을 수 없습니다' ), { status : 404 });
if ( job . deleting ) throw Object . assign ( new Error ( '사이트 폐기가 이미 진행 중입니다' ), { status : 409 });
if ( job . running || job . deploying ) throw Object . assign ( new Error ( '작업이 진행 중입니다. 끝난 뒤 폐기하세요' ), { status : 409 });
if ( opts . confirm !== undefined && String ( opts . confirm || '' ). trim () !== String ( s . title || '' ). trim ()) throw Object . assign ( new Error ( '확인을 위해 사이트 제목을 정확히 입력하세요' ), { status : 400 });
job . deleting = { token , at : new Date (). toISOString (), by : by || '' };
return s ;
});
try {
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 ) throw Object . assign ( new Error ( `배포 서버에서 사이트 파일을 지우지 못해 폐기를 멈췄습니다(운영 주소에 내용이 남아 있을 수 있음). 배포 서버 접속을 확인한 뒤 다시 시도하세요: ${ failed . join ( ' / ' ) } ` ), { status : 502 , failed });
return await withSiteLock ( siteId , async () => {
if ( ! job . deleting || job . deleting . token !== token ) throw Object . assign ( new Error ( '폐기 표식이 바뀌어 폐기를 중단했습니다. 다시 시도하세요' ), { status : 409 });
if ( ! store . load ( siteId )) throw Object . assign ( new Error ( '사이트를 찾을 수 없습니다' ), { status : 404 });
// ① 되돌릴 수 있는 준비: 사이트 폴더·기록 파일을 임시 이름으로 옮긴다(rename — 원자적·되돌릴 수 있음). 이 뒤로 사이트는 보이지 않는다
const siteDir = path . join ( SITES_ROOT , s1 . id ); const recordFile = path . join ( store . DATA_DIR , ` ${ s1 . id } .json` );
const stagedDir = path . join ( SITES_ROOT , `.deleting- ${ s1 . id } - ${ token } ` ); const stagedRecord = ` ${ recordFile } .deleting- ${ token } ` ;
const staged = { dir : false , record : false };
const restoreStaged = () => { const problems = []; if ( staged . record ) { try { fs . renameSync ( stagedRecord , recordFile ); staged . record = false ; } catch ( e ) { problems . push ( `기록 복원 실패: ${ e . message } ` ); } } if ( staged . dir ) { try { fs . renameSync ( stagedDir , siteDir ); staged . dir = false ; } catch ( e ) { problems . push ( `사이트 폴더 복원 실패: ${ e . message } ` ); } } return problems ; };
try { if ( fs . existsSync ( siteDir )) { fs . renameSync ( siteDir , stagedDir ); staged . dir = true ; } fs . renameSync ( recordFile , stagedRecord ); staged . record = true ; }
catch ( e ) { const p = restoreStaged (); throw Object . assign ( new Error ( `폐기 준비(파일 옮기기)에 실패해 폐기를 중단했습니다: ${ e . message } ` ), { status : 500 , failed : [ e . message , ... p ], recoveryRequired : p . length > 0 }); }
// ② 외부 연결 해제(Ariweb Suite: 포털 연결 등록부) — 되돌릴 수 없는 삭제 **전**. 실패하면 옮긴 것을 되돌리고 중단한다(연결·사이트 모두 그대로)
if ( typeof opts . beforeIrreversible === 'function' ) {
try { await opts . beforeIrreversible ( s1 ); }
catch ( e ) { const p = restoreStaged (); throw Object . assign ( new Error ( e . message ), { status : e . status || 502 , failed : [ e . message , ... p ], recoveryRequired : p . length > 0 }); }
}
// ③ 되돌릴 수 없는 단계 — 기록 파일 삭제(작고 확실한 한 번의 unlink). 실패하면 옮긴 것을 되돌리고 외부 연결을 보상(compensate)한 뒤 실패로 알린다(성공으로 꾸미지 않는다)
try { fs . unlinkSync ( stagedRecord ); staged . record = false ; }
catch ( e ) {
const problems = restoreStaged (); let compensated = true ;
if ( typeof opts . compensate === 'function' ) { try { await opts . compensate ( s1 ); } catch ( e2 ) { compensated = false ; problems . push ( `외부 연결 복원 실패: ${ e2 . message } ` ); } }
throw Object . assign ( new Error ( `사이트 기록을 지우지 못해 폐기를 중단했습니다: ${ e . message }${ problems . length ? ' / 되돌리기 문제: ' + problems . join ( '; ' ) : '' } ` ), { status : 500 , failed : [ e . message , ... problems ], recoveryRequired : problems . length > 0 || ! compensated });
}
// ④ 뒷정리(최선): 옮겨 둔 사이트 폴더·git·문의·통계·모듈·메일·다국어. 실패해도 폐기는 끝난 것이며(사이트·기록·연결 모두 없음) 남은 것은 경고로 운영자에게 보인다 — 조용히 삼키지 않는다
if ( s1 . source && s1 . source . keyId ) { try { remote . deleteKey ( s1 . source . keyId ); } catch ( e ) { warnings . push ( `원본 서버 키 삭제 실패: ${ e . message } ` ); } }
try { fs . rmSync ( stagedDir , { recursive : true , force : true }); staged . dir = false ; } catch ( e ) { warnings . push ( `사이트 폴더 정리 실패 — 운영자가 ${ stagedDir } 를 지워야 합니다 ( ${ e . message } )` ); }
for ( const [ label , fn ] of [[ '통계' , () => stats . remove ( s1 . id )], [ '모듈' , () => modules . remove ( s1 . id )], [ '메일' , () => mailsys . removeSite ( s1 . id )], [ '다국어' , () => multilang . removeSite ( s1 . id )]]) { try { fn (); } catch ( e ) { warnings . push ( ` ${ label } 기록 정리 실패: ${ e . message } ` ); } }
try { fs . rmSync ( path . join ( path . dirname ( store . DATA_DIR ), 'git' , s1 . id ), { recursive : true , force : true }); } catch ( e ) { warnings . push ( `git 저장소 정리 실패: ${ e . message } ` ); }
try { fs . unlinkSync ( path . join ( path . dirname ( store . DATA_DIR ), 'inquiries' , ` ${ s1 . id } .json` )); } catch ( _ ) {}
jobs . delete ( s1 . id );
if ( warnings . length ) audit . log ({ by : by || '' , action : 'site.destroy.cleanup' , targetType : 'site' , target : s1 . id , siteId : s1 . id , userId : s1 . ownerId , ok : false , detail : { warnings } });
return { ok : true , deleted : s1 . id , warnings };
});
} catch ( e ) {
if ( job . deleting && job . deleting . token === token ) job . deleting = null ; // 표식 해제(같은 표식일 때만)
if ( e && e . failed ) { try { store . update ( siteId , ( f ) => { f . deleteFailed = { at : new Date (). toISOString (), detail : e . failed , recoveryRequired : !! e . recoveryRequired }; }); } catch ( _ ) {} } // 실패 기록은 최신 기록 위에(덮어쓰기 없음)
if ( e && e . recoveryRequired ) { try { audit . log ({ by : by || '' , action : 'site.destroy.failed' , targetType : 'site' , target : siteId , siteId , ok : false , detail : { failed : e . failed , recoveryRequired : true } }); } catch ( _ ) {} console . error ( `[site] 폐기 실패 뒤 되돌리기 미완료 — 운영자 확인 필요 ( ${ siteId } ): ${ ( e . failed || []). join ( ' / ' ) } ` ); }
throw e ;
2026-10-26 11:02:58 +00:00
}
}
/** 발행 취소 안내 페이지 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 );
const label = site . source ? '원본 서버 반영' : '배포' ;
2026-10-28 15:07:35 +00:00
// 잠금 안에서 검사 → 배포 표식(1.8.0 검토 수정): 진행 중인 저장이 파일을 다 쓴 뒤에 배포가 파일을 읽기 시작하고, 그 뒤의 저장은 deploying 을 보고 409
await claimJob ( site . id , () => {
if ( job . running ) throw Object . assign ( new Error ( '작업 실행 중에는 배포할 수 없습니다' ), { status : 409 });
if ( job . deploying ) throw Object . assign ( new Error ( '이미 배포가 진행 중입니다' ), { status : 409 });
}, () => { job . deploying = { startedAt : new Date (). toISOString (), label , by }; });
2026-10-26 11:02:58 +00:00
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 );
2026-10-28 15:07:35 +00:00
await claimJob ( 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 ; });
2026-10-26 11:02:58 +00:00
const siteDir = path . join ( SITES_ROOT , site . id ); const src = site . source ;
2026-10-28 15:07:35 +00:00
site . status = 'importing' ; site . importError = null ; saveJob ( site , reapplyFields ([ 'status' , 'importError' ]));
2026-10-26 11:02:58 +00:00
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 };
2026-10-28 15:07:35 +00:00
site . status = 'idle' ; saveJob ( site , reapplyFields ([ 'status' , 'importError' , 'importInfo' , 'title' ]));
2026-10-26 11:02:58 +00:00
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 ) {
2026-10-28 15:07:35 +00:00
site . status = 'error' ; site . importError = err . message ; saveJob ( site , reapplyFields ([ 'status' , 'importError' ]));
2026-10-26 11:02:58 +00:00
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 ) {
2026-10-28 15:07:35 +00:00
const job = jobOf ( site . id );
await claimJob ( 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 ; });
2026-10-26 11:02:58 +00:00
const siteDir = path . join ( SITES_ROOT , site . id );
2026-10-28 15:07:35 +00:00
site . status = 'importing' ; site . importError = null ; saveJob ( site , reapplyFields ([ 'status' , 'importError' ]));
2026-10-26 11:02:58 +00:00
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 ; }
2026-10-28 15:07:35 +00:00
site . status = 'idle' ; saveJob ( site , reapplyFields ([ 'status' , 'importError' , 'copyInfo' , 'title' , 'plan' ]));
2026-10-26 11:02:58 +00:00
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 ) {
2026-10-28 15:07:35 +00:00
site . status = 'error' ; site . importError = err . message ; if ( site . plan && site . plan . stage === 'importing' ) site . plan . stage = 'generated' ; saveJob ( site , reapplyFields ([ 'status' , 'importError' , 'plan' ]));
2026-10-26 11:02:58 +00:00
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 );
2026-10-28 15:07:35 +00:00
await claimJob ( 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 ; });
2026-10-26 11:02:58 +00:00
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 ;
2026-10-28 15:07:35 +00:00
site . commands . push ( cmd ); site . status = 'running' ; saveJob ( site , reapplyCommand ( cmd ));
2026-10-26 11:02:58 +00:00
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 );
2026-10-28 15:07:35 +00:00
site . status = 'idle' ; saveJob ( site , reapplyCommand ( cmd ));
2026-10-26 11:02:58 +00:00
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: 원인 분석용 원문 보관
2026-10-28 15:07:35 +00:00
saveJob ( site , reapplyCommand ( cmd )); // 실패·중단 기록도 최신 기록 위에 합친다
2026-10-26 11:02:58 +00:00
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 }); }
2026-10-26 11:56:38 +00:00
/**
* [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 와 같은 기준
2026-10-26 11:02:58 +00:00
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 );
2026-10-26 11:56:38 +00:00
// ---------- [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 }); }
}
2026-10-26 11:02:58 +00:00
// ---------- 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 });
HAL 0.4.0: 통합 설정 관제 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리(설정 목록·범위 global/server·비밀값 Vault·유효값 hash, /ops/api/settings·재인증, settings.apply → settings.apply.target → Agent module.configure(HMAC 설정 받기·hash 확인·hal-managed.json 0600·재시작·모듈 적용 확인), /ops/settings 화면·운영 현황 "설정 적용 필요", AI 도구 hal_settings_get/set/apply(비밀값 거부)), 도입 모듈 Webmaker 1.9.0·AriMail 1.1.0(HAL 0.4.0 대응 halCompat, hal-managed 적용·관리 항목 409 managed_by_hal 잠금·"HAL 관리" 표시) 재도입(pin 8f8716c·f959b65, import-sources 미수정 파일 갱신), Agent heartbeat 관리, module-as-user write --mode 600, 개발 지시 v0.4.0(Oz)·docs/HAL-SETTINGS.md·VERSION_HISTORY 0.4.0 — test/hal-040.test.mjs RED → GREEN 5/5, agent 11/11, 전체 31파일 통과
2026-10-29 06:46:45 +00:00
if ( req . method === 'GET' ) return send ( res , 200 , { ... settings . publicView (), halManaged : halManaged . publicInfo () /* v1.9.0 */ , availableModels : providers . availableModels ( null ), unavailableProviders : providers . unavailableProviders (), serverLogin : settings . serverLoginInfo (), claude : claudeInfo (), version : VERSION , welcomeCandidates : welcomeCandidates (), domainProviders : domainsLib . publicCatalog () /* v1.5.36 */ });
2026-10-26 11:02:58 +00:00
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)가 없습니다. 생성을 마친 뒤 고르세요' }); }
HAL 0.4.0: 통합 설정 관제 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리(설정 목록·범위 global/server·비밀값 Vault·유효값 hash, /ops/api/settings·재인증, settings.apply → settings.apply.target → Agent module.configure(HMAC 설정 받기·hash 확인·hal-managed.json 0600·재시작·모듈 적용 확인), /ops/settings 화면·운영 현황 "설정 적용 필요", AI 도구 hal_settings_get/set/apply(비밀값 거부)), 도입 모듈 Webmaker 1.9.0·AriMail 1.1.0(HAL 0.4.0 대응 halCompat, hal-managed 적용·관리 항목 409 managed_by_hal 잠금·"HAL 관리" 표시) 재도입(pin 8f8716c·f959b65, import-sources 미수정 파일 갱신), Agent heartbeat 관리, module-as-user write --mode 600, 개발 지시 v0.4.0(Oz)·docs/HAL-SETTINGS.md·VERSION_HISTORY 0.4.0 — test/hal-040.test.mjs RED → GREEN 5/5, agent 11/11, 전체 31파일 통과
2026-10-29 06:46:45 +00:00
halManaged . assertNotManaged ( halManaged . lockedKeys ( b )); // v1.9.0: HAL 이 관리하는 항목은 409 managed_by_hal
2026-10-26 11:02:58 +00:00
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 });
HAL 0.4.0: 통합 설정 관제 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리(설정 목록·범위 global/server·비밀값 Vault·유효값 hash, /ops/api/settings·재인증, settings.apply → settings.apply.target → Agent module.configure(HMAC 설정 받기·hash 확인·hal-managed.json 0600·재시작·모듈 적용 확인), /ops/settings 화면·운영 현황 "설정 적용 필요", AI 도구 hal_settings_get/set/apply(비밀값 거부)), 도입 모듈 Webmaker 1.9.0·AriMail 1.1.0(HAL 0.4.0 대응 halCompat, hal-managed 적용·관리 항목 409 managed_by_hal 잠금·"HAL 관리" 표시) 재도입(pin 8f8716c·f959b65, import-sources 미수정 파일 갱신), Agent heartbeat 관리, module-as-user write --mode 600, 개발 지시 v0.4.0(Oz)·docs/HAL-SETTINGS.md·VERSION_HISTORY 0.4.0 — test/hal-040.test.mjs RED → GREEN 5/5, agent 11/11, 전체 31파일 통과
2026-10-29 06:46:45 +00:00
// v1.9.0: HAL 이 관리하는 비밀값의 보관소 종류는 여기서 바꾸거나 지울 수 없다(HAL 통합 설정에서만)
const vaultLocked = ( kind ) => { if ( kind && halManaged . lockedVaultKinds (). includes ( String ( kind ))) throw halManaged . managedError ( Object . entries ( halManaged . VAULT_KIND ). filter (([, v ]) => v === String ( kind )). map (([ k ]) => k )); };
if ( req . method === 'POST' && ! arg ) { const { fields : b } = await readBody ( req ); vaultLocked ( b . kind ); 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 ); }
2026-10-26 11:02:58 +00:00
// v1.3.2: .key 파일 자체를 올려 가져오기 (multipart: kind, id?, label?, file) — 파일은 저장하지 않고 메모리에서 읽는다
HAL 0.4.0: 통합 설정 관제 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리(설정 목록·범위 global/server·비밀값 Vault·유효값 hash, /ops/api/settings·재인증, settings.apply → settings.apply.target → Agent module.configure(HMAC 설정 받기·hash 확인·hal-managed.json 0600·재시작·모듈 적용 확인), /ops/settings 화면·운영 현황 "설정 적용 필요", AI 도구 hal_settings_get/set/apply(비밀값 거부)), 도입 모듈 Webmaker 1.9.0·AriMail 1.1.0(HAL 0.4.0 대응 halCompat, hal-managed 적용·관리 항목 409 managed_by_hal 잠금·"HAL 관리" 표시) 재도입(pin 8f8716c·f959b65, import-sources 미수정 파일 갱신), Agent heartbeat 관리, module-as-user write --mode 600, 개발 지시 v0.4.0(Oz)·docs/HAL-SETTINGS.md·VERSION_HISTORY 0.4.0 — test/hal-040.test.mjs RED → GREEN 5/5, agent 11/11, 전체 31파일 통과
2026-10-29 06:46:45 +00:00
if ( req . method === 'POST' && arg === 'upload' ) { const { fields : b , files } = await readBody ( req ); vaultLocked ( b . kind ); 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 ); vaultLocked ( b . kind ); 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 ); }
2026-10-26 11:02:58 +00:00
if ( req . method === 'POST' && arg && sub2 === 'test' ) return send ( res , 200 , await vault . test ( arg ));
HAL 0.4.0: 통합 설정 관제 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리(설정 목록·범위 global/server·비밀값 Vault·유효값 hash, /ops/api/settings·재인증, settings.apply → settings.apply.target → Agent module.configure(HMAC 설정 받기·hash 확인·hal-managed.json 0600·재시작·모듈 적용 확인), /ops/settings 화면·운영 현황 "설정 적용 필요", AI 도구 hal_settings_get/set/apply(비밀값 거부)), 도입 모듈 Webmaker 1.9.0·AriMail 1.1.0(HAL 0.4.0 대응 halCompat, hal-managed 적용·관리 항목 409 managed_by_hal 잠금·"HAL 관리" 표시) 재도입(pin 8f8716c·f959b65, import-sources 미수정 파일 갱신), Agent heartbeat 관리, module-as-user write --mode 600, 개발 지시 v0.4.0(Oz)·docs/HAL-SETTINGS.md·VERSION_HISTORY 0.4.0 — test/hal-040.test.mjs RED → GREEN 5/5, agent 11/11, 전체 31파일 통과
2026-10-29 06:46:45 +00:00
if ( req . method === 'DELETE' && arg ) { const it0 = vault . list (). find (( x ) => x . id === arg ); vaultLocked ( it0 && it0 . kind ); vault . remove ( arg ); audit . log ({ by : user . id , action : 'vault.remove' , targetType : 'vault' , target : arg }); return send ( res , 200 , { ok : true }); }
2026-10-26 11:02:58 +00:00
}
// ---- 메일 시스템 (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 ) {
2026-10-28 15:07:35 +00:00
let out ; try { out = await destroySite ( s1 . id , user . id , {}); } catch ( e ) { return send ( res , e . status || 500 , { error : e . status === 409 ? '작업 실행 중·폐기 진행 중에는 삭제할 수 없습니다: ' + e . message : e . message }); } // 잠금 안 재검사·폐기 표식 → 정리 → 같은 표식 확인 뒤 삭제
return send ( res , 200 , out );
2026-10-26 11:02:58 +00:00
}
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' });
}
2026-10-26 11:56:38 +00:00
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 })) });
2026-10-26 11:02:58 +00:00
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 ) {
2026-10-28 15:07:35 +00:00
const site0 = store . load ( m [ 1 ]);
if ( ! site0 || site0 . ownerId !== user . id ) return send ( res , 404 , { error : '사이트를 찾을 수 없습니다' });
const handle = async ( site ) => {
2026-10-26 11:02:58 +00:00
const sub = m [ 2 ] || '' ;
2026-10-26 11:56:38 +00:00
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 ) });
2026-10-26 11:02:58 +00:00
if ( req . method === 'PATCH' && sub === '' ) {
const { fields : b } = await readBody ( req );
2026-10-26 11:56:38 +00:00
{ const c = revConflict ( site , b , req ); if ( c ) return send ( res , 409 , c ); } // [Ariweb Suite 변경] 동시 편집 충돌(§9.3)
2026-10-26 11:02:58 +00:00
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 : '파일이 너무 큽니다' });
2026-10-28 15:07:35 +00:00
const content = fs . readFileSync ( abs , 'utf8' );
return send ( res , 200 , { path : rel , content , size : st . size , mtime : st . mtime . toISOString (), hash : visualEditor . contentHash ( content ) }); // v1.8.0 hash: 직접 편집과 같은 저장 기준
2026-10-26 11:02:58 +00:00
}
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 가 필요합니다' });
if ( Buffer . byteLength ( b . content ) > MAX_TEXT_BYTES ) return send ( res , 413 , { error : '내용이 너무 큽니다' });
2026-10-28 15:07:35 +00:00
// v1.8.0 검토 수정: 선택적 baseHash(GET /files 의 hash) — 보냈으면 잠금 안에서 현재 파일과 비교해 다르면 409(stale). 보내지 않는 옛 화면은 예전처럼 저장한다
if ( b . baseHash !== undefined && ( typeof b . baseHash !== 'string' || ! /^[a-f0-9]{64}$/ . test ( b . baseHash ))) return send ( res , 400 , { error : '저장 기준(baseHash) 형식이 올바르지 않습니다. 화면을 새로 고친 뒤 다시 저장하세요' });
// 사이트 변경 잠금 안에서 다시 검사하고 쓴다(본문 읽기는 잠금 밖에서 끝냈다)
return withSiteLock ( site . id , async () => {
if ( jobOf ( site . id ). running ) return send ( res , 409 , { error : 'AI 작업 실행 중에는 저장할 수 없습니다' });
if ( jobOf ( site . id ). deploying ) return send ( res , 409 , { error : '배포가 진행 중입니다. 배포가 끝난 뒤 다시 저장하세요' });
const liveSite = store . load ( site . id ) || site ;
{ const c = revConflict ( liveSite , b , req ); if ( c ) return send ( res , 409 , c ); } // [Ariweb Suite 변경] 동시 편집 충돌(§9.3) — 잠금 안에서 다시 읽은 최신 rev 로 비교
if ( typeof b . baseHash === 'string' ) {
let cur = null ; try { cur = visualEditor . contentHash ( fs . readFileSync ( abs , 'utf8' )); } catch ( _ ) { cur = null ; } // 새 파일이면 비교할 원본이 없다
if ( cur !== null && cur !== b . baseHash ) return send ( res , 409 , { error : '다른 창(또는 직접 편집·AI 수정·버전 복원)이 이 파일을 먼저 저장해 내용이 달라졌습니다. 최신 내용을 다시 불러온 뒤 저장하세요' , stale : true , currentHash : cur });
}
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 );
liveSite . manualEdits = ( liveSite . manualEdits || []). concat ({ path : rel , at : new Date (). toISOString () }). slice ( - 200 ); store . save ( liveSite );
broadcast ( liveSite . id , { type : 'log' , level : 'info' , text : `소스 편집기에서 ${ rel } 저장 ( ${ st . size . toLocaleString () } bytes)` });
const rec = await gitRecord ( liveSite , 'manual-edit' , `수동 편집: ${ rel } ` );
return send ( res , 200 , { path : rel , size : st . size , mtime : st . mtime . toISOString (), hash : visualEditor . contentHash ( b . content ), files : listFiles ( siteDir ), git : publicView ( liveSite ). git , change : rec });
});
2026-10-26 11:02:58 +00:00
}
}
2026-10-28 15:07:35 +00:00
// ---------- v1.8.0 직접 편집(시각 편집기) ----------
// GET /api/sites/<id>/visual/<페이지> 저장된 HTML 을 "편집 캔버스 문서" 로 바꿔 돌려준다(스크립트·핸들러 비활성화 + CSP nonce + 편집 런타임). 화면은 이를 sandbox iframe 의 srcdoc 에 넣는다.
// PUT /api/sites/<id>/visual/<페이지> { body, baseHash, rev } — 캔버스 본문을 검증해 원본의 머리·꼬리를 그대로 둔 채 본문만 바꿔 저장하고 버전(visual-edit)을 기록한다.
// 규칙: 소유자만(위의 404), HTML 페이지만, AI 작업·배포 중 저장 불가, baseHash 가 현재 파일과 다르면 409(다른 창·AI·소스 편집기가 먼저 저장),
// 새 스크립트·이벤트 속성·javascript:/data:text/html 주소·iframe/object/embed/meta/link/base 추가 거절(lib/visual-editor.validateVisualSave).
// 캐시(credits)를 쓰지 않고 AI 명령·수정 횟수에도 세지 않는다 — 사용자가 직접 고친 것이므로.
// [Ariweb Suite 변경] 여러 사용자가 같은 프로젝트를 편집할 때의 저장본 rev 충돌(§9.3)도 소스 편집기와 같이 검사한다(revConflict → 409 + 양쪽 요약).
if ( sub === '/visual' && m [ 3 ]) {
const { abs , rel } = resolveTextFile ( siteDir , m [ 3 ]);
if ( ! visualEditor . isHtmlPath ( rel )) return send ( res , 400 , { error : '직접 편집은 HTML 페이지(.html/.htm)에서만 쓸 수 있습니다. 다른 파일은 소스 편집기에서 수정하세요' });
let st ; try { st = fs . statSync ( abs ); } catch ( _ ) { return send ( res , 404 , { error : '파일이 없습니다. 페이지가 지워졌거나 이름이 바뀌었으면 페이지 선택을 새로 고치세요' }); }
if ( ! st . isFile ()) return send ( res , 400 , { error : '잘못된 파일 경로입니다' });
if ( st . size > MAX_TEXT_BYTES ) return send ( res , 413 , { error : '파일이 너무 커서 직접 편집으로 열 수 없습니다. 소스 편집기를 사용하세요' });
if ( req . method === 'GET' ) {
const original = fs . readFileSync ( abs , 'utf8' );
const session = crypto . randomBytes ( 16 ). toString ( 'hex' ); const nonce = crypto . randomBytes ( 16 ). toString ( 'base64url' );
const dir = rel . includes ( '/' ) ? rel . slice ( 0 , rel . lastIndexOf ( '/' ) + 1 ) : '' ; // en/about.html → en/ : 상대 경로 자원이 저장된 페이지와 같게 풀린다
const baseHref = siteUrl ( site . id ) + dir . split ( '/' ). map ( encodeURIComponent ). join ( '/' );
const canvas = visualEditor . prepareCanvas ( original , { baseHref , session , nonce , parentOrigin : requestOrigin ( req ), runtime : veRuntime () });
return send ( res , 200 , { path : rel , hash : visualEditor . contentHash ( original ), rev : Number ( site . rev ) || 0 , session , canvas , size : st . size , mtime : st . mtime . toISOString (), running : !! jobOf ( site . id ). running });
}
if ( req . method === 'PUT' ) {
if ( jobOf ( site . id ). running ) return send ( res , 409 , { error : 'AI 작업 실행 중에는 직접 편집을 저장할 수 없습니다. 작업이 끝난 뒤 다시 저장하세요(편집 내용은 화면에 그대로 있습니다)' });
if ( jobOf ( site . id ). deploying ) return send ( res , 409 , { error : '배포가 진행 중입니다. 배포가 끝난 뒤 다시 저장하세요' });
let b ; try { b = ( await readBody ( req )). fields ; } catch ( e ) { return send ( res , e . status || 400 , { error : `요청 본문을 읽을 수 없습니다 ( ${ e . message } ). 화면을 새로 고친 뒤 다시 저장하세요` }); }
if ( ! b || typeof b !== 'object' || Array . isArray ( b )) return send ( res , 400 , { error : '요청 본문이 올바르지 않습니다. 화면을 새로 고친 뒤 다시 저장하세요' });
if ( typeof b . baseHash !== 'string' || ! /^[a-f0-9]{64}$/ . test ( b . baseHash )) return send ( res , 400 , { error : '저장 기준(baseHash)이 없습니다. 직접 편집을 다시 열어 주세요' });
// [Ariweb Suite 변경] 직접 편집 저장은 유한한 숫자 rev 가 **필수**(fail-closed). 없거나 문자열·NaN·null 이면 400 — rev 없는 저장을 "충돌 없음" 으로 통과시키지 않는다(소스 편집기의 옛 화면 호환 규칙은 이 경로에 적용하지 않는다).
if ( typeof b . rev !== 'number' || ! Number . isFinite ( b . rev )) return send ( res , 400 , { error : '저장본 개정 번호(rev)가 없거나 올바르지 않습니다. 직접 편집을 다시 열어 주세요' , revRequired : true });
// 사이트 변경 잠금(1.8.0 검토 수정): 본문을 다 읽은 뒤 잠금 안에서 실행 중·배포 중·사이트 기록(rev)·파일 hash 를 **다시** 검사하고 쓰기·기록·버전까지 끝낸다.
// 사전 검사만 통과한 느린 요청이 AI 작업·배포·다른 저장 도중에 쓰는 일이 없고, 두 요청이 나란히 들어와도 뒤의 요청은 hash(또는 rev)가 달라 409 를 받는다 — 첫 저장이 조용히 덮어써지지 않는다.
return withSiteLock ( site . id , async () => {
if ( jobOf ( site . id ). running ) return send ( res , 409 , { error : 'AI 작업 실행 중에는 직접 편집을 저장할 수 없습니다. 작업이 끝난 뒤 다시 저장하세요(편집 내용은 화면에 그대로 있습니다)' });
if ( jobOf ( site . id ). deploying ) return send ( res , 409 , { error : '배포가 진행 중입니다. 배포가 끝난 뒤 다시 저장하세요' });
const liveSite = store . load ( site . id ) || site ;
{ const c = revConflict ( liveSite , b , req ); if ( c ) return send ( res , 409 , c ); } // [Ariweb Suite 변경] 동시 편집 충돌(§9.3) — 잠금 안에서 다시 읽은 최신 rev 로 비교(다른 파일의 저장으로 오른 rev 도 충돌)
let original ; try { original = fs . readFileSync ( abs , 'utf8' ); } catch ( _ ) { return send ( res , 404 , { error : '파일이 없습니다. 페이지가 지워졌거나 이름이 바뀌었으면 페이지 선택을 새로 고치세요' }); }
const currentHash = visualEditor . contentHash ( original );
if ( b . baseHash !== currentHash ) return send ( res , 409 , { error : '다른 창(또는 AI 수정·소스 편집기·버전 복원)이 이 페이지를 먼저 저장해 내용이 달라졌습니다. 직접 편집을 다시 열어 최신 내용 위에서 작업하세요' , stale : true , currentHash });
let r ; try { r = visualEditor . validateVisualSave ({ original , body : b . body , maxBytes : MAX_TEXT_BYTES }); } catch ( e ) { return send ( res , e . status || 400 , { error : e . message }); }
// ---- 트랜잭션(1.8.0 검토 수정 2차): 파일 → 로컬 커밋·변경 브랜치 → 사이트 기록 → 원격 원자적 푸시(마지막 단계, main + 변경 브랜치 함께). 원격은 페이지·기록·로컬 커밋이 모두 남은 뒤에만 바뀌고,
// 어느 단계가 실패해도 gitstore.recordChangeStrict 가 HEAD·인덱스·변경 브랜치와(아래 onRollback 으로) 페이지·사이트 기록을 되돌린 뒤 **검증** 한다 — rolledBack:true 는 검증을 통과했을 때만 ----
const prevRecord = JSON . stringify ( liveSite ); // 되돌릴 사이트 기록(manualEdits·git 목록·Suite rev)
// 원자적 저장(같은 폴더 임시 파일 → rename): 쓰다가 실패해도 마지막 저장본이 잘리지 않는다
try { visualEditor . writeTextAtomic ( abs , r . html ); } catch ( e ) { return send ( res , 500 , { error : `페이지를 저장하지 못했습니다 ( ${ e . code || e . message } ). 서버의 디스크 공간·사이트 폴더 권한을 확인한 뒤 다시 저장하세요. 기존 페이지는 그대로 있습니다` }); }
liveSite . git = liveSite . git || { seq : 0 , changes : [] }; const no = liveSite . git . seq + 1 ;
let rec = null ;
try {
rec = await gitstore . recordChangeStrict ({
siteId : site . id , siteDir , no , kind : 'visual-edit' , message : `직접 편집: ${ rel } ` , author : gitAuthor ( liveSite ),
onCommitted : ( r2 ) => { // 로컬 커밋 뒤·원격 푸시 전: 사이트 기록을 저장한다(실패하면 던져 전부 되돌린다). 변경이 없으면(r2=null) manualEdits 만
if ( r2 ) { if ( liveSite . plan && liveSite . plan . brief ) r2 . briefSnapshot = JSON . parse ( JSON . stringify ( liveSite . plan . brief )); liveSite . git . seq = no ; liveSite . git . changes . push ( r2 ); }
liveSite . manualEdits = ( liveSite . manualEdits || []). concat ({ path : rel , at : new Date (). toISOString (), kind : 'visual' }). slice ( - 200 );
store . save ( liveSite );
},
onRollback : () => { // 페이지 원본·사이트 기록(manualEdits·버전 목록·rev)을 저장 전으로 되돌리고 **실제 상태** 로 검증한 문제 목록을 돌려준다(빈 목록 = 성공)
const problems = []; let pageErr = null ; let recErr = null ;
try { visualEditor . writeTextAtomic ( abs , original ); } catch ( e ) { pageErr = e ; }
try { if ( fs . readFileSync ( abs , 'utf8' ) !== original ) problems . push ( `페이지가 원본으로 돌아가지 않았습니다 ${ pageErr ? ` ( ${ pageErr . code || pageErr . message } )` : '' } ` ); } catch ( e ) { problems . push ( `페이지를 확인할 수 없습니다 ( ${ e . code || e . message } )` ); }
try { store . save ( JSON . parse ( prevRecord ), { restore : true }); } catch ( e ) { recErr = e ; } // [Ariweb Suite 변경] restore 는 rev 를 올리지 않는다 — 실패한 저장이 rev 를 바꾸지 않는다
try { const back = store . load ( site . id ); const strip = ( o ) => JSON . stringify ({ ... o , updatedAt : undefined , saveId : undefined }); /* 저장이 관리하는 표식만 제외하고 비교 */ if ( ! back || strip ( back ) !== strip ( JSON . parse ( prevRecord ))) problems . push ( `사이트 기록이 저장 전과 다릅니다 ${ recErr ? ` ( ${ recErr . code || recErr . message } )` : '' } ` ); } catch ( e ) { problems . push ( `사이트 기록을 확인할 수 없습니다 ( ${ e . code || e . message } )` ); }
return problems ;
},
});
} catch ( e ) {
const rb = e . rollback || { ok : false , problems : [ '되돌리기를 수행하지 못했습니다' ] };
const what = e . stage === 'push' ? '원격 소스 보관소(Git)에 올리지 못해' : e . stage === 'metadata' ? '사이트 기록을 저장하지 못해' : '버전 기록(Git 커밋)을 남기지 못해' ;
broadcast ( site . id , { type : 'log' , level : 'warn' , text : `직접 편집 저장 취소: ${ what } ( ${ e . message } ) ${ rb . ok ? ' — 페이지·기록을 저장 전으로 되돌렸습니다' : ` — 되돌리기 검증 실패: ${ rb . problems . join ( ' / ' ) } ` } ` });
return send ( res , 500 , { error : ` ${ what } 저장을 취소했습니다 ( ${ e . code || e . message } ). ${ rb . ok ? '페이지와 기록은 저장 전으로 되돌렸습니다. 잠시 뒤 같은 편집 내용으로 다시 저장하세요(편집 내용은 화면에 그대로 있습니다)' : `되돌리기 일부가 실패해 상태를 확신할 수 없습니다 — 관리자에게 알리세요: ${ rb . problems . join ( ' / ' ) } ` } ` , rolledBack : rb . ok , stage : e . stage || null , ...( rb . ok ? {} : { rollbackProblems : rb . problems }) });
}
const st2 = fs . statSync ( abs );
broadcast ( liveSite . id , { type : 'log' , level : 'info' , text : `직접 편집(시각 편집기)에서 ${ rel } 저장 ( ${ st2 . size . toLocaleString () } bytes)` });
const pv = publicView ( liveSite );
if ( rec && rec . remote === 'pending' ) detached (() => setImmediate (() => reconcileSite ( site . id ))); // 트랜잭션·잠금 맥락 밖: 원격 ref 로 확인한 뒤에만 pushed 로 표시(응답은 pending 그대로)
// 원자적 푸시의 모호한 클라이언트 오류(원격은 받았는데 오류·브랜치만 올라감·확인 불통): 로컬 저장은 끝났고 기록은 pending 이므로 200 이되 remoteState 로 알린다 — 되돌리지 않았고 뒤의 조정이 해결한다
const ambiguous = !! ( rec && rec . remoteOutcome === 'ambiguous' );
if ( ambiguous ) broadcast ( site . id , { type : 'log' , level : 'warn' , text : `직접 편집은 저장되었지만 원격 소스 보관소 반영을 확인하지 못했습니다 ( ${ rec . error } ) — 기록은 '확인 중' 으로 두고 뒤에 다시 확인합니다` });
return send ( res , 200 , { path : rel , size : st2 . size , mtime : st2 . mtime . toISOString (), hash : r . hash , rev : Number ( liveSite . rev ) || 0 , files : listFiles ( siteDir ), git : pv . git , versions : pv . versions , change : rec , ...( ambiguous ? { remoteState : 'ambiguous' } : {}) });
});
}
return send ( res , 405 , { error : 'method not allowed' });
}
2026-10-26 11:02:58 +00:00
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 : '확인을 위해 사이트 제목을 정확히 입력하세요' });
2026-10-26 11:56:38 +00:00
// [Ariweb Suite 변경] 포털에 연결된 프로젝트: 운영 사이트·배포·도메인에 미치는 영향을 포털에 물어 보이고, 사용자가 영향을 확인(acknowledgeImpact)했을 때만 프로젝트를 폐기한다.
// 포털의 운영 사이트·도메인·Source 는 여기서 절대 지우지 않는다(§9.1). 포털에 닿지 못하면 영향을 모르므로 폐기를 보류한다.
2026-10-28 15:07:35 +00:00
let link = null ;
2026-10-26 11:56:38 +00:00
{ 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 } });
2026-10-28 15:07:35 +00:00
link = l ; } }
// [Ariweb Suite 변경] 포털 연결 해제는 되돌릴 수 없는 삭제 **직전**(배포 서버 정리 성공·사이트 파일/기록을 임시 이름으로 옮긴 뒤)에만 하고, 그 뒤 기록 삭제가 실패하면 연결을 되살린다(readback 확인) — 정리 실패로 연결만 풀리는 일이 없다
let out ; try {
out = await destroySite ( site . id , user . id , { ownerId : user . id , confirm : b . confirm ,
beforeIrreversible : () => { if ( ! link ) return ; ariweb . unlink ( site . id ); if ( ariweb . linkOf ( site . id )) throw Object . assign ( new Error ( '포털 연결 해제를 확인하지 못했습니다(연결 등록부 readback)' ), { status : 502 }); audit . log ({ by : user . id , action : 'ariweb.unlink' , targetType : 'site' , target : site . id , detail : { siteId : link . siteId , reason : 'project destroyed' } }); },
compensate : () => { if ( ! link ) return ; ariweb . link ( site . id , link ); const back = ariweb . linkOf ( site . id ); if ( ! back || back . siteId !== link . siteId || back . groupId !== link . groupId ) throw new Error ( '포털 연결 되살리기 readback 실패' ); audit . log ({ by : user . id , action : 'ariweb.relink' , targetType : 'site' , target : site . id , detail : { siteId : link . siteId , reason : 'destroy failed after unlink — compensated' } }); } });
} catch ( e ) { return send ( res , e . status || 500 , { error : e . message , ...( e . recoveryRequired ? { recoveryRequired : true } : {}) }); } // 잠금 안 재검사·폐기 표식 → 배포 서버 정리(바깥) → 같은 표식 확인 뒤 옮기기 → 연결 해제 → 삭제
return send ( res , 200 , out );
2026-10-26 11:02:58 +00:00
}
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 : '작업 실행 중에는 되돌릴 수 없습니다' });
2026-10-28 15:07:35 +00:00
// 사이트 변경 잠금 안에서(1.8.0 검토 수정): 진행 중인 직접 편집·소스 저장과 파일을 동시에 바꾸지 않는다
return withSiteLock ( site . id , async () => {
if ( jobOf ( site . id ). running || jobOf ( site . id ). deploying ) 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 ));
});
2026-10-26 11:02:58 +00:00
}
if ( req . method === 'POST' && sub === '/git' && m [ 3 ] === 'retry' ) {
site . git = site . git || { seq : 0 , changes : [] };
2026-10-28 15:07:35 +00:00
await gitstore . retryPush ( site . id , siteDir , site . git . changes );
const fresh = store . update ( site . id , ( f ) => applyPushState ( f , site . git . changes )) || site ; // 조정 결과(원격 ref 기준)를 최신 기록 위에 적용 — 그 사이 배경 작업이 같은 git.changes 에 버전을 더했어도 덮어쓰지 않는다
return send ( res , 200 , publicView ( fresh ). git );
2026-10-26 11:02:58 +00:00
}
// ---------- 기획(플랜) 라우트 ----------
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' ) {
2026-10-26 11:56:38 +00:00
// [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` } }); }
2026-10-26 11:02:58 +00:00
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 ;
}
2026-10-28 15:07:35 +00:00
};
// ---------- 사이트 변경 요청의 잠금·재적재(1.8.0 검토 수정 3차) ----------
// 변경 요청(POST/PUT/PATCH/DELETE)은 본문을 잠금 **밖** 에서 먼저 읽고(메모 — 처리기가 다시 읽어도 같은 결과), 잠금 안에서 사이트 기록을 **다시 읽은 뒤** 그 기록으로 처리한다.
// 본문을 기다리는 동안 끝난 다른 저장(직접 편집·소스·버전·기록)을 뒤늦게 저장하는 처리기가 덮어쓰지 않는다. 짧은 변경(파일·기록만 바꾸는 저장·설정·복원)은 처리기 전체를 잠금 안에서 돌린다.
// 긴 작업을 시작하거나 끝까지 기다리는 경로(AI 작업·기획 단계·배포·가져오기·폐기·검색 등록·다국어 전환)는 잠금을 쥔 채 기다리면 다른 저장이 409 대신 멈추므로, 잠금 안에서 기록만 다시 읽어
// 그 기록으로 처리기를 돌린다 — 처리기는 시작 표식(claimJob, 잠금 안 검사→표식)까지 await 없이 이어지므로 그 사이 다른 변경이 끼지 못하고, 작업 중의 다른 저장은 running 검사로 409 를 받는다.
const mutating = ! [ 'GET' , 'HEAD' , 'OPTIONS' ]. includes ( req . method );
if ( ! mutating ) { await handle ( site0 ); if ( res . headersSent || res . writableEnded ) return ; }
else {
const sub0 = m [ 2 ] || '' ; const act0 = ( m [ 3 ] || '' ). split ( '/' )[ 0 ]; const key = ` ${ req . method } ${ sub0 } ` ;
const SHORT = new Set ([ 'PATCH ' , 'PUT /files' , 'PUT /visual' , 'POST /cancel' , 'POST /versions' , 'POST /git' , 'PUT /langs:settings' , 'POST /langs:review' , 'POST /optimize:apply' , 'POST /optimize:remove' , 'PUT /model' , 'PUT /domains' , 'POST /uploads' , 'DELETE /uploads' , 'PUT /modules' , 'PUT /plan' ]);
const short = SHORT . has ( key ) || SHORT . has ( ` ${ key } : ${ act0 } ` );
await readBody ( req ). catch (() => {}); // 본문은 잠금 밖에서(느린 클라이언트가 잠금을 붙잡지 않게); 파싱 오류는 처리기가 같은 오류로 받아 지금처럼 응답한다
// 잠금 안 재적재. 읽기 요청(GET/HEAD/OPTIONS)은 부작용이 없다 — 생성 코드 주석 언어(x-ui-lang)도 변경 요청의 이 지점(잠금 안)에서만 저장한다(1.8.0 검토 수정 4차). 폐기 표식이 잡힌 사이트는 모든 변경을 409 로 거절한다
const fresh = () => {
const s1 = store . load ( m [ 1 ]); if ( ! ( s1 && s1 . ownerId === user . id )) return null ;
if ( jobOf ( m [ 1 ]). deleting ) return 'deleting' ;
const ul = String ( req . headers [ 'x-ui-lang' ] || '' ); if ( /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/ . test ( ul ) && s1 . uiLang !== ul ) { s1 . uiLang = ul ; store . save ( s1 ); }
return s1 ;
};
const run = async ( s1 ) => { if ( ! s1 ) return send ( res , 404 , { error : '사이트를 찾을 수 없습니다' }); if ( s1 === 'deleting' ) return send ( res , 409 , { error : '사이트 폐기가 진행 중입니다. 폐기가 끝나면 이 사이트는 더 이상 쓸 수 없고, 폐기가 실패하면 다시 쓸 수 있습니다' }); return handle ( s1 ); };
if ( short ) await withSiteLock ( m [ 1 ], () => run ( fresh ()));
else await run ( await withSiteLock ( m [ 1 ], () => fresh ()));
if ( res . headersSent || res . writableEnded ) return ;
}
2026-10-26 11:02:58 +00:00
}
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 ) => {
HAL 0.4.0: 통합 설정 관제 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리(설정 목록·범위 global/server·비밀값 Vault·유효값 hash, /ops/api/settings·재인증, settings.apply → settings.apply.target → Agent module.configure(HMAC 설정 받기·hash 확인·hal-managed.json 0600·재시작·모듈 적용 확인), /ops/settings 화면·운영 현황 "설정 적용 필요", AI 도구 hal_settings_get/set/apply(비밀값 거부)), 도입 모듈 Webmaker 1.9.0·AriMail 1.1.0(HAL 0.4.0 대응 halCompat, hal-managed 적용·관리 항목 409 managed_by_hal 잠금·"HAL 관리" 표시) 재도입(pin 8f8716c·f959b65, import-sources 미수정 파일 갱신), Agent heartbeat 관리, module-as-user write --mode 600, 개발 지시 v0.4.0(Oz)·docs/HAL-SETTINGS.md·VERSION_HISTORY 0.4.0 — test/hal-040.test.mjs RED → GREEN 5/5, agent 11/11, 전체 31파일 통과
2026-10-29 06:46:45 +00:00
if ( ! res . headersSent ) send ( res , err . status || 500 , { error : err . message , ...( err . code === 'managed_by_hal' ? { code : err . code , keys : err . keys } : {}) }); // v1.9.0 HAL 관리 항목 잠금은 코드·키를 함께 알린다
2026-10-26 11:02:58 +00:00
else res . end ();
});
}). listen ( PORT , HOST , () => {
HAL 0.4.0: 통합 설정 관제 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리(설정 목록·범위 global/server·비밀값 Vault·유효값 hash, /ops/api/settings·재인증, settings.apply → settings.apply.target → Agent module.configure(HMAC 설정 받기·hash 확인·hal-managed.json 0600·재시작·모듈 적용 확인), /ops/settings 화면·운영 현황 "설정 적용 필요", AI 도구 hal_settings_get/set/apply(비밀값 거부)), 도입 모듈 Webmaker 1.9.0·AriMail 1.1.0(HAL 0.4.0 대응 halCompat, hal-managed 적용·관리 항목 409 managed_by_hal 잠금·"HAL 관리" 표시) 재도입(pin 8f8716c·f959b65, import-sources 미수정 파일 갱신), Agent heartbeat 관리, module-as-user write --mode 600, 개발 지시 v0.4.0(Oz)·docs/HAL-SETTINGS.md·VERSION_HISTORY 0.4.0 — test/hal-040.test.mjs RED → GREEN 5/5, agent 11/11, 전체 31파일 통과
2026-10-29 06:46:45 +00:00
// v1.9.0 (Ariweb Suite HAL 0.4.0 대응): HAL 이 Agent 로 내려준 통합 설정(DATA_ROOT/hal-managed.json)을 적용하고, 파일이 바뀌면 다시 적용한다
try { halManaged . start (); } catch ( e ) { console . error ( `[hal-managed] 시작 실패: ${ e . message } ` ); }
2026-10-26 11:02:58 +00:00
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 );
});
2026-10-28 15:07:35 +00:00
// v1.8.0 (검토 수정 2차): 기동 때 원격 반영 조정 — 기록 저장과 푸시 사이에 프로세스가 죽어 'pending' 으로 남은 직접 편집 기록을 찾아 로컬 커밋·변경 브랜치를 원격 ref 로 확인하거나 원자적으로 올린다(확인된 뒤에만 pushed).
setImmediate (() => { try { for ( const s0 of store . list ()) { const s1 = store . load ( s0 . id ); if ( s1 && s1 . git && Array . isArray ( s1 . git . changes ) && s1 . git . changes . some (( c ) => c && ! c . pushed && c . remote === 'pending' )) reconcileSite ( s1 . id ); } } catch ( _ ) {} });
2026-10-26 11:02:58 +00:00
// 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 ); } } });