[verified] feat: add Webmaker visual editor v0.2.0

This commit is contained in:
oz
2026-10-28 15:07:35 +00:00
parent 4b3714e37c
commit 0a56d4612c
40 changed files with 4770 additions and 191 deletions
+378 -85
View File
@@ -65,6 +65,7 @@ const i18n = require('./lib/i18n');
const feedback = require('./lib/feedback');
const costcal = require('./lib/costcal');
const crawler = require('./lib/crawler');
const visualEditor = require('./lib/visual-editor'); // v1.8.0 직접 편집(시각 편집기): 캔버스 문서 변환·저장 검증(순수 로직)
const ariweb = require('./lib/ariweb'); // [Ariweb Suite 변경] 포털 연동(SSO 티켓·내부 API·프로젝트↔사이트 연결)
process.umask(0o022); // 생성 파일을 nginx(www-data)가 읽을 수 있도록
@@ -232,8 +233,11 @@ const MAX_BODY = 200 * 1024 * 1024;
// v1.6.31 (검토 10장): 공통 보안 헤더. HSTS 는 공개 주소가 https 일 때만(평문 설치에서 켜면 접속이 막힌다)
const SEC_HEADERS = { 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'X-Frame-Options': 'SAMEORIGIN', ...(/^https:/i.test(process.env.PUBLIC_BASE_URL || '') ? { 'Strict-Transport-Security': 'max-age=15552000' } : {}) };
/** JSON 또는 multipart/form-data 본문을 읽어 { fields, files } 로 돌려준다. */
async function readBody(req) {
/** 요청 본문(JSON 또는 multipart)을 한 번만 읽고 메모한다(1.8.0 검토 수정 3차) — 사이트 라우터가 잠금 밖에서 먼저 읽고, 처리기가 다시 불러도 같은 결과(파싱 오류도 같은 오류)를 받는다 */
async function readBody(req) { if (!req.__wmBody) req.__wmBody = readBodyOnce(req); return req.__wmBody; }
async function readBodyOnce(req) {
const ct = req.headers['content-type'] || '';
const len = parseInt(req.headers['content-length'] || '0', 10);
if (len > MAX_BODY) throw Object.assign(new Error('본문이 너무 큽니다'), { status: 413 });
if (ct.startsWith('multipart/form-data')) {
@@ -292,6 +296,23 @@ function resolveTextFile(siteDir, rel) {
return { abs, rel };
}
function siteUrl(id) { return `${PUBLIC_BASE_URL}/${id}/`; }
/**
* 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}`;
}
/**
* v1.4.5 사용자 첫 화면 페이지 후보: 관리자 계정이 만든 사이트 (관리자도 빌더(/builder)에서 사용자처럼 만들고 배포한다).
* url 은 배포된 운영 주소가 있으면 그것, 없으면 서비스의 사이트 주소(/<사이트ID>/).
@@ -447,9 +468,42 @@ function editLimitOf(site) {
return { max, used, locked: max > 0 && used >= max };
}
// ---------- 배경 작업의 저장(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]; } };
}
// ---------- git 보관 ----------
function gitAuthor(site) { return { name: site.ownerName || site.ownerId || 'webhosting-agent', email: `${site.ownerId || 'agent'}@webhosting.local` }; }
/** 변경을 커밋·푸시하고 site.git 에 기록한다. 실패해도 예외를 던지지 않는다. */
/**
* 변경을 커밋·푸시하고 site.git 에 기록한다. 실패해도 예외를 던지지 않는다(AI 작업 등은 버전 기록 실패가 작업 결과를 취소할 이유가 아니다).
* 직접 편집 저장은 이 함수를 쓰지 않고 gitstore.recordChangeStrict 트랜잭션(파일 → 로컬 커밋 → 기록 → 원자적 푸시, 실패하면 검증된 되돌리기)을 쓴다 — PUT /visual 참고.
*/
async function gitRecord(site, kind, message) {
site.git = site.git || { seq: 0, changes: [] };
const no = site.git.seq + 1;
@@ -457,10 +511,11 @@ async function gitRecord(site, kind, message) {
const rec = await gitstore.recordChange({ siteId: site.id, siteDir: path.join(SITES_ROOT, site.id), no, kind, message, author: gitAuthor(site) });
if (!rec) return null;
if (site.plan && site.plan.brief) rec.briefSnapshot = JSON.parse(JSON.stringify(site.plan.brief));
site.git.seq = no; site.git.changes.push(rec); store.save(site);
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 를 화면(다시 푸시 등)이 먼저 바꿨으면 그 위에 이 버전만 더한다
broadcast(site.id, { type: 'log', level: 'info', text: `버전 #${no} 저장됨 (${message})` });
const failed = site.git.changes.filter((c) => !c.pushed && c !== rec);
if (failed.length) gitstore.retryPush(site.id, path.join(SITES_ROOT, site.id), failed).then(() => store.save(site)).catch(() => {});
if (failed.length) detached(() => setImmediate(() => reconcileSite(site.id))); // 옛 실패분 재시도는 잠금 안에서 최신 기록을 읽는 조정으로(작업 객체를 저장하지 않는다)
return rec;
} catch (e) {
broadcast(site.id, { type: 'log', level: 'debug', text: `버전 저장 실패: ${e.message}` });
@@ -468,6 +523,54 @@ async function gitRecord(site, kind, message) {
}
}
/**
* 원격 반영 조정(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(); }); }
// ---------- 작업 실행 ----------
async function runCommand(site, kind, instruction, prompt, attachedNames = [], opts = {}) {
// v1.4.14 (요구 7): 이번 작업 모델. 직전 작업과 다른 모델(특히 다른 제공자)이면 그 사이 다른 모델이 한 작업을 프롬프트에 넣어 맥락을 잇는다
@@ -478,10 +581,12 @@ async function runCommand(site, kind, instruction, prompt, attachedNames = [], o
if (!opts.continuation) { const ul = site.uiLang || 'ko'; const ex = site.mode === 'import' || site.mode === 'copy'; prompt = `${commentRule(ul, ex)}\n${multilang.isMulti(site) ? multilang.promptRules(site) + '\n' : ''}${switchNote ? switchNote + '\n' : ''}${prompt}\n\n${commentReminder(ul, ex)}`; }
else if (switchNote) prompt = `${switchNote}\n${prompt}`; // 캐시 충전 후 이어하기도 그 사이 모델이 바뀌었을 수 있다
const job = jobOf(site.id);
if (job.running) throw Object.assign(new Error('이미 실행 중인 작업이 있습니다'), { status: 409 });
if (job.deploying) throw Object.assign(new Error('배포가 진행 중입니다. 완료 후 다시 시도하세요'), { status: 409 });
assertJobCapacity(); // v1.6.31 동시 작업 상한
job.running = true; job.log = []; job.cancelRequested = false;
// 사이트 변경 잠금 안에서 검사 → 표식(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; });
const ownerAdmin = isAdminUser(site.ownerId);
const startBalance = credits.balance(site.ownerId);
@@ -496,7 +601,7 @@ async function runCommand(site, kind, instruction, prompt, attachedNames = [], o
};
for (const a of site.attachments || []) if (!a.usedInCommands.length || attachedNames.includes(a.name)) a.usedInCommands.push(cmd.no);
cmd.log = []; job.currentCmd = cmd; cmd.userIdForEstimate = site.ownerId;
site.commands.push(cmd); site.status = 'running'; store.save(site);
site.commands.push(cmd); site.status = 'running'; saveJob(site, reapplyCommand(cmd));
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)} — 그 사이 다른 모델이 한 작업을 맥락으로 함께 전달했습니다 (세션 기억에 의존하지 않음)` }); }
@@ -539,7 +644,7 @@ async function runCommand(site, kind, instruction, prompt, attachedNames = [], o
if (r.sessionId) site.sessionId = r.sessionId;
site.status = r.ok ? 'idle' : 'error';
if (opts.onResult) { try { await opts.onResult(r, cmd); } catch (e) { broadcast(site.id, { type: 'log', level: 'debug', text: `후처리 오류: ${e.message}` }); } }
store.save(site);
saveJob(site, reapplyCommand(cmd));
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) {
@@ -551,7 +656,7 @@ async function runCommand(site, kind, instruction, prompt, attachedNames = [], o
deductCredit(site, cmd, `${kind === 'create' ? '생성' : '수정'} #${cmd.no} (일시 중지)`);
site.status = 'waiting-credit';
site.creditWait = { at: new Date().toISOString(), kind, instruction: opts.continuation ? instruction.replace(/^\(이어서\) /, '') : instruction, prompt: opts.originalPrompt || prompt, cmdNo: cmd.no };
store.save(site);
saveJob(site, reapplyCommand(cmd));
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) });
@@ -559,11 +664,11 @@ async function runCommand(site, kind, instruction, prompt, attachedNames = [], o
deductCredit(site, cmd, `${kind === 'create' ? '생성' : '수정'} #${cmd.no} (중단)`);
site.status = 'idle';
if (site.plan && site.plan.stage === 'generating') site.plan.stage = site.commands.some((c) => c.kind === 'create' && c.status === 'done') ? 'generated' : 'confirmed';
store.save(site);
saveJob(site, reapplyCommand(cmd));
broadcast(site.id, { type: 'log', level: 'warn', text: '작업이 중단되었습니다. 지금까지 만들어진 내용은 유지됩니다.' });
} else {
Object.assign(cmd, { finishedAt: new Date().toISOString(), status: 'error', error: err.message });
site.status = 'error'; store.save(site);
site.status = 'error'; saveJob(site, reapplyCommand(cmd)); // 실패 기록도 최신 기록을 덮어쓰지 않고 합친다
userWarn(site.id, '작업을 완료하지 못했습니다. 잠시 후 다시 시도해 주세요.', `작업 #${cmd.no} 오류: ${err.message}`);
}
}
@@ -574,7 +679,7 @@ async function runCommand(site, kind, instruction, prompt, attachedNames = [], o
broadcast(site.id, { type: 'progress', text: '버전을 저장하고 있습니다' });
try { stats.ensureBeacon(siteDir, site.id, formBase()); } catch (_) {} // 접속 통계 비컨(모든 HTML)
cmd.git = await gitRecord(site, kind === 'create' ? 'create' : 'ai-update', gmsg);
store.save(site);
saveJob(site, reapplyCommand(cmd));
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' ? '페이지 생성' : '수정 반영' });
@@ -661,9 +766,9 @@ async function changeLanguages(site, b, user) {
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) {
await claimJob(site.id, () => { if (job.running || job.deploying) throw Object.assign(new Error('다른 작업이 진행 중입니다. 끝난 뒤 하세요'), { status: 409 }); }, () => { job.running = true; }); // 잠금 안 검사→표식을 **먼저**(1.8.0 검토 수정 3차): 거절되면 폴더·기록을 건드리지 않는다
for (const c of was.list) if (!next.list.includes(c)) { try { fs.rmSync(path.join(siteDir, c), { recursive: true, force: true }); } catch (_) {} } // 뺀 언어 폴더
site.langs = { ...was, list: next.list, xDefault: b.xDefault !== undefined ? next.xDefault : (next.list.includes(was.xDefault) || was.xDefault === 'root' ? was.xDefault : next.default) }; store.save(site);
job.running = true;
(async () => { try { await syncLanguages(site); await gitRecord(site, 'ai-update', `다국어 언어 변경: ${next.list.join(', ')}`); } catch (e) { userWarn(site.id, `다른 언어 페이지를 만들지 못했습니다: ${e.message}`, `다국어 맞추기 실패: ${e.message}`); } finally { job.running = false; broadcast(site.id, { type: 'done', site: publicView(site), command: null }); } })();
return { ok: true, mode: 'sync', langs: site.langs };
}
@@ -817,25 +922,72 @@ async function rejudgeAccess(site, targetId) {
* 로컬 기록을 지우지 않고 실패를 알린다(502). vhost(도메인 설정) 삭제와 파일 삭제는 서로 독립적으로 시도하고,
* vhost 삭제 실패(sudo 없음 등)는 경고로만 돌려준다(파일이 지워지면 그 도메인은 더 이상 사이트를 보여 주지 않음).
*/
async function destroySite(s1, by) {
const warnings = []; const failed = [];
audit.log({ by: by || '', action: 'site.destroy', targetType: 'site', target: s1.id, siteId: s1.id, userId: s1.ownerId, detail: { title: s1.title, domains: s1.domains || [] } }); // v1.6.31 (요구 21)
for (const tid of [...new Set((s1.deployments || []).filter((d) => d.ok && d.targetId !== 'source').map((d) => d.targetId))]) {
if ((s1.domains || []).length) { try { const v = await deploy.removeVhost(s1.id, tid); if (!v.ok) warnings.push(`${tid}: 도메인 설정 삭제 실패 — ${String(v.detail || '').slice(0, 160)}`); } catch (e) { warnings.push(`${tid}: 도메인 설정 삭제 실패 — ${e.message}`); } }
try { const r = await deploy.removeSite(s1.id, tid); if (!r.ok) failed.push(`${tid}: ${String(r.detail || '삭제 실패').slice(0, 200)}`); } catch (e) { failed.push(`${tid}: ${e.message}`); }
/**
* 사이트 폐기(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;
}
if (failed.length) {
s1.deleteFailed = { at: new Date().toISOString(), detail: failed }; store.save(s1);
throw Object.assign(new Error(`배포 서버에서 사이트 파일을 지우지 못해 폐기를 멈췄습니다(운영 주소에 내용이 남아 있을 수 있음). 배포 서버 접속을 확인한 뒤 다시 시도하세요: ${failed.join(' / ')}`), { status: 502 });
}
fs.rmSync(path.join(SITES_ROOT, s1.id), { recursive: true, force: true });
if (s1.source && s1.source.keyId) remote.deleteKey(s1.source.keyId);
stats.remove(s1.id); modules.remove(s1.id); mailsys.removeSite(s1.id); multilang.removeSite(s1.id);
try { fs.rmSync(path.join(path.dirname(store.DATA_DIR), 'git', s1.id), { recursive: true, force: true }); } catch (_) {}
try { fs.unlinkSync(path.join(path.dirname(store.DATA_DIR), 'inquiries', `${s1.id}.json`)); } catch (_) {}
try { fs.unlinkSync(path.join(store.DATA_DIR, `${s1.id}.json`)); } catch (_) {}
jobs.delete(s1.id);
return { ok: true, deleted: s1.id, warnings };
}
/** 발행 취소 안내 페이지 HTML (서버 정체·사이트 내용 비노출) */
function maintenanceHtml(site) { return require('./lib/maintpage').html({ title: site.title }); } // v1.5.27 (요구 5): 영어 제목 + 한·일·중·서 안내 + "supported by <도메인>" (lib/maintpage.js)
@@ -1016,10 +1168,12 @@ async function autoIndexNow(site) {
}
async function deploySiteTo(site, targetId, by) {
const job = jobOf(site.id);
if (job.running) throw Object.assign(new Error('작업 실행 중에는 배포할 수 없습니다'), { status: 409 });
if (job.deploying) throw Object.assign(new Error('이미 배포가 진행 중입니다'), { status: 409 });
const label = site.source ? '원본 서버 반영' : '배포';
job.deploying = { startedAt: new Date().toISOString(), label, by };
// 잠금 안에서 검사 → 배포 표식(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 }; });
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 의 절대 주소) 함께 올린다
@@ -1167,12 +1321,13 @@ async function deployToSource(site, by) {
/** 기존 사이트 가져오기 작업(비동기): 파일 내려받기 → 버전 기록 → 대기 상태 */
async function runImport(site) {
const job = jobOf(site.id);
if (job.running) throw Object.assign(new Error('이미 실행 중인 작업이 있습니다'), { status: 409 });
if (job.deploying) throw Object.assign(new Error('배포가 진행 중입니다. 완료 후 다시 시도하세요'), { status: 409 });
assertJobCapacity(); // v1.6.31 동시 작업 상한
job.running = true; job.log = []; job.cancelRequested = false;
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; });
const siteDir = path.join(SITES_ROOT, site.id); const src = site.source;
site.status = 'importing'; site.importError = null; store.save(site);
site.status = 'importing'; site.importError = null; saveJob(site, reapplyFields(['status', 'importError']));
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;
@@ -1181,14 +1336,14 @@ async function runImport(site) {
chmodTree(siteDir);
if (!site.titleCustom && r.title) site.title = r.title.slice(0, 80);
site.importInfo = { at: src.importedAt, fileCount: r.fileCount, bytes: r.bytes, index: r.index, dynamic: r.dynamic };
site.status = 'idle'; store.save(site);
site.status = 'idle'; saveJob(site, reapplyFields(['status', 'importError', 'importInfo', 'title']));
broadcast(site.id, { type: 'log', level: 'info', text: `가져오기 완료 — 파일 ${r.fileCount}개, ${(r.bytes / 1024 / 1024).toFixed(1)}MB${r.index ? ', 첫 페이지 ' + r.index : ', 첫 페이지 파일(index.html) 없음'}${r.dynamic ? ' · 서버 스크립트(PHP 등) 포함 — 이 도구는 파일 수정만 지원하며 서버 실행 결과는 미리보기에 나타나지 않습니다' : ''}` });
if (!r.index) broadcast(site.id, { type: 'log', level: 'warn', text: '첫 페이지 파일(index.html)을 찾지 못했습니다. 사이트 경로가 맞는지 확인하세요.' });
if (r.dynamic) broadcast(site.id, { type: 'log', level: 'warn', text: '이 사이트에는 서버에서 실행되는 스크립트(PHP 등)가 있습니다. 미리보기는 정적 파일만 보여 주며, 수정은 원본 서버에 반영한 뒤 실제 주소에서 확인하세요.' });
await gitRecord(site, 'import', `기존 사이트 가져옴: ${src.host}${src.docroot}`);
ok = true;
} catch (err) {
site.status = 'error'; site.importError = err.message; store.save(site);
site.status = 'error'; site.importError = err.message; saveJob(site, reapplyFields(['status', 'importError']));
userWarn(site.id, `사이트를 가져오지 못했습니다: ${err.message}`, `가져오기 실패(${src.user}@${src.host}): ${err.message}`);
} finally {
job.running = false; job.cancel = null;
@@ -1229,11 +1384,14 @@ async function analyzeSite(site) {
}
/** 사이트 복사하기 작업 (v1.2.0): 웹 주소에서 HTML·CSS·JS·이미지를 내려받아 사이트 폴더로 */
async function runCopy(site, allowPrivate) {
const job = jobOf(site.id); if (job.running) throw Object.assign(new Error('이미 실행 중인 작업이 있습니다'), { status: 409 });
assertJobCapacity(); // v1.6.31 동시 작업 상한
job.running = true; job.log = []; job.cancelRequested = false;
const 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; });
const siteDir = path.join(SITES_ROOT, site.id);
site.status = 'importing'; site.importError = null; store.save(site);
site.status = 'importing'; site.importError = null; saveJob(site, reapplyFields(['status', 'importError']));
broadcast(site.id, { type: 'status', status: 'running', label: '사이트 복사하기' });
broadcast(site.id, { type: 'log', level: 'info', text: `사이트 복사 시작 — ${site.copyFrom}` });
let ok = false;
@@ -1243,13 +1401,13 @@ async function runCopy(site, allowPrivate) {
if (!site.titleCustom && r.title) site.title = r.title.slice(0, 80);
site.copyInfo = { at: new Date().toISOString(), from: site.copyFrom, pages: r.pages, assets: r.assets, bytes: r.bytes, skipped: r.skipped, errors: r.errors.length, dynamic: r.dynamic };
if (site.plan) { site.plan.stage = 'generated'; site.plan.brief.business.name = site.title; }
site.status = 'idle'; store.save(site);
site.status = 'idle'; saveJob(site, reapplyFields(['status', 'importError', 'copyInfo', 'title', 'plan']));
broadcast(site.id, { type: 'log', level: 'info', text: `복사 완료 — 페이지 ${r.pages}개, 파일 ${r.assets}개, ${(r.bytes / 1048576).toFixed(1)}MB${r.skipped ? ` (상한으로 ${r.skipped}개 건너뜀)` : ''}${r.errors.length ? ` · 가져오지 못한 주소 ${r.errors.length}개` : ''}` });
if (r.dynamic) broadcast(site.id, { type: 'log', level: 'warn', text: '서버에서 실행되는 페이지(PHP 등)는 결과 화면(HTML)만 복사되었습니다. 문의·로그인 같은 서버 기능은 동작하지 않을 수 있습니다.' });
await gitRecord(site, 'import', `사이트 복사: ${site.copyFrom}`);
ok = true;
} catch (err) {
site.status = 'error'; site.importError = err.message; if (site.plan && site.plan.stage === 'importing') site.plan.stage = 'generated'; store.save(site);
site.status = 'error'; site.importError = err.message; if (site.plan && site.plan.stage === 'importing') site.plan.stage = 'generated'; saveJob(site, reapplyFields(['status', 'importError', 'plan']));
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 초안
@@ -1281,10 +1439,11 @@ const PLAN_LABEL = { 'site-translate': '다국어 번역', 'plan-analyze': 'AI
/** 기획 단계 AI 호출: 명령 이력/토큰 기록, 잠금, JSON 반환 */
async function runPlanStep(site, kind, prompt, opts = {}) {
const job = jobOf(site.id);
if (job.running) throw Object.assign(new Error('이미 실행 중인 작업이 있습니다'), { status: 409 });
if (job.deploying) throw Object.assign(new Error('배포가 진행 중입니다. 완료 후 다시 시도하세요'), { status: 409 });
assertJobCapacity(); // v1.6.31 동시 작업 상한
job.running = true; job.log = []; job.cancelRequested = false;
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; });
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;
@@ -1292,7 +1451,7 @@ async function runPlanStep(site, kind, prompt, opts = {}) {
try { await assertEngine(stepRef); } catch (e) { job.running = false; if (e.adminDetail) broadcast(site.id, { type: 'log', level: 'debug', text: `엔진 준비 실패: ${e.adminDetail}` }); throw e; }
const cmd = { no: site.commands.length + 1, kind, instruction: PLAN_LABEL[kind] || kind, prompt, model: stepRef, tier: settings.validTier(opts.tier || site.tier), startedAt: new Date().toISOString(), finishedAt: null, status: 'running', usage: null, costUsd: 0, numTurns: 0, durationMs: 0, resultText: '', error: null, attachments: [] };
cmd.log = []; job.currentCmd = cmd; cmd.userIdForEstimate = site.ownerId;
site.commands.push(cmd); site.status = 'running'; store.save(site);
site.commands.push(cmd); site.status = 'running'; saveJob(site, reapplyCommand(cmd));
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);
@@ -1304,7 +1463,7 @@ async function runPlanStep(site, kind, prompt, opts = {}) {
Object.assign(cmd, await chargeFor(r.costUsd, cmd.startedAt, cmd.userKey));
costcal.observe(cmd.model, estimateUsdFor(r.usage, cmd.model), r.costUsd);
deductCredit(site, cmd, PLAN_LABEL[kind] || kind);
site.status = 'idle'; store.save(site);
site.status = 'idle'; saveJob(site, reapplyCommand(cmd));
broadcast(site.id, { type: 'log', level: 'info', text: `${PLAN_LABEL[kind] || kind} 완료 — 토큰 ${r.usage.total.toLocaleString()}, 비용 $${r.costUsd.toFixed(4)}` });
return data;
} catch (err) {
@@ -1312,7 +1471,7 @@ async function runPlanStep(site, kind, prompt, opts = {}) {
if (err.cancelled && job.creditStop) { err.creditStop = true; if (!pipelineSites.has(site.id) && ['plan-analyze', 'plan-verify'].includes(kind)) { site.creditWait = { at: new Date().toISOString(), kind: 'plan', step: kind, instruction: PLAN_LABEL[kind] || kind, cmdNo: cmd.no }; } }
if (err.cancelled) { Object.assign(cmd, { finishedAt: new Date().toISOString(), status: job.creditStop ? 'paused-credit' : 'cancelled', error: job.creditStop ? '캐시 소진으로 중단' : err.message, usage: err.usage, usageEstimated: true, costUsd: estimateUsdCal(err.usage, cmd.model) }); Object.assign(cmd, await chargeFor(cmd.costUsd, cmd.startedAt)); deductCredit(site, cmd, `${PLAN_LABEL[kind] || kind} (중단)`); site.status = 'idle'; if (job.creditStop) { err.message = '캐시가 소진되어 중단되었습니다. 충전되면 자동으로 다시 진행합니다.'; if (site.creditWait || pipelineSites.has(site.id)) site.status = 'waiting-credit'; } }
else { Object.assign(cmd, { finishedAt: new Date().toISOString(), status: 'error', error: err.message, usage: r ? r.usage : null, costUsd: r ? r.costUsd : 0, resultText: err.rawText ? err.rawText.slice(0, 20000) : '' }); site.status = 'error'; if (err.rawText) cmd.log.push({ ts: Date.now(), level: 'debug', text: `파싱하지 못한 AI 응답 (끝부분): …${err.rawText.slice(-1500)}` }); } // v1.3.10: 원인 분석용 원문 보관
store.save(site);
saveJob(site, reapplyCommand(cmd)); // 실패·중단 기록도 최신 기록 위에 합친다
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) });
@@ -2298,8 +2457,8 @@ async function handle(req, res) {
if (req.method === 'POST' && sub2 === 'target') { const { fields: b } = await readBody(req); s1.deployTargetId = b.targetId || null; store.save(s1); return send(res, 200, publicView(s1).deploy); }
if (req.method === 'POST' && sub2 === 'cancel') { const job = jobOf(s1.id); if (!job.running) return send(res, 409, { error: '실행 중인 작업이 없습니다' }); job.cancelRequested = true; if (job.cancel) job.cancel(); return send(res, 202, { ok: true }); }
if (req.method === 'DELETE' && !sub2) {
if (jobOf(s1.id).running || jobOf(s1.id).deploying) return send(res, 409, { error: '작업 실행 중에는 삭제할 수 없습니다' });
return send(res, 200, await destroySite(s1, user.id));
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);
}
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' });
@@ -2461,9 +2620,9 @@ async function handle(req, res) {
}
const m = p.match(/^\/api\/sites\/([a-f0-9]{32})(\/[a-z]+)?(?:\/(.+))?$/);
if (m) {
const site = store.load(m[1]);
if (!site || site.ownerId !== user.id) return send(res, 404, { error: '사이트를 찾을 수 없습니다' });
{ const ul = String(req.headers['x-ui-lang'] || ''); if (/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(ul) && site.uiLang !== ul) { site.uiLang = ul; store.save(site); } } // 생성 코드 주석 언어
const site0 = store.load(m[1]);
if (!site0 || site0.ownerId !== user.id) return send(res, 404, { error: '사이트를 찾을 수 없습니다' });
const handle = async (site) => {
const sub = m[2] || '';
if (req.method === 'GET' && sub === '') return send(res, 200, { ...publicView(site), ariweb: ariweb.linkOf(site.id), running: jobOf(site.id).running, deploying: jobOf(site.id).deploying || null, status: jobOf(site.id).running ? 'running' : (site.status === 'running' ? 'idle' : site.status) });
if (req.method === 'PATCH' && sub === '') {
@@ -2526,24 +2685,119 @@ async function handle(req, res) {
if (req.method === 'GET') {
let st; try { st = fs.statSync(abs); } catch (_) { return send(res, 404, { error: '파일이 없습니다' }); }
if (st.size > MAX_TEXT_BYTES) return send(res, 413, { error: '파일이 너무 큽니다' });
return send(res, 200, { path: rel, content: fs.readFileSync(abs, 'utf8'), size: st.size, mtime: st.mtime.toISOString() });
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: 직접 편집과 같은 저장 기준
}
if (req.method === 'PUT') {
if (jobOf(site.id).running) return send(res, 409, { error: 'AI 작업 실행 중에는 저장할 수 없습니다' });
const { fields: b } = await readBody(req);
if (typeof b.content !== 'string') return send(res, 400, { error: 'content 가 필요합니다' });
{ const c = revConflict(site, b, req); if (c) return send(res, 409, c); } // [Ariweb Suite 변경] 동시 편집 충돌(§9.3)
if (Buffer.byteLength(b.content) > MAX_TEXT_BYTES) return send(res, 413, { error: '내용이 너무 큽니다' });
fs.mkdirSync(path.dirname(abs), { recursive: true, mode: 0o755 });
fs.writeFileSync(abs, b.content, { mode: 0o644 });
try { fs.chmodSync(abs, 0o644); } catch (_) {}
const st = fs.statSync(abs);
site.manualEdits = (site.manualEdits || []).concat({ path: rel, at: new Date().toISOString() }).slice(-200); store.save(site);
broadcast(site.id, { type: 'log', level: 'info', text: `소스 편집기에서 ${rel} 저장 (${st.size.toLocaleString()} bytes)` });
const rec = await gitRecord(site, 'manual-edit', `수동 편집: ${rel}`);
return send(res, 200, { path: rel, size: st.size, mtime: st.mtime.toISOString(), files: listFiles(siteDir), git: publicView(site).git, change: rec });
// 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 });
});
}
}
// ---------- 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' });
}
if (req.method === 'DELETE' && sub === '') {
// v1.1.0: 사용자(소유자)의 사이트 폐기. 확인 문자열(사이트 제목)을 함께 받아 실수를 막는다.
const { fields: b } = await readBody(req);
@@ -2551,11 +2805,18 @@ async function handle(req, res) {
if (String(b.confirm || '').trim() !== String(site.title || '').trim()) return send(res, 400, { error: '확인을 위해 사이트 제목을 정확히 입력하세요' });
// [Ariweb Suite 변경] 포털에 연결된 프로젝트: 운영 사이트·배포·도메인에 미치는 영향을 포털에 물어 보이고, 사용자가 영향을 확인(acknowledgeImpact)했을 때만 프로젝트를 폐기한다.
// 포털의 운영 사이트·도메인·Source 는 여기서 절대 지우지 않는다(§9.1). 포털에 닿지 못하면 영향을 모르므로 폐기를 보류한다.
let link = null;
{ const l = ariweb.linkOf(site.id); if (l) { const r = await ariweb.impactFromPortal(site.id); if (!r.reachable) return send(res, 503, { error: '포털에서 운영 사이트 영향을 확인하지 못해 폐기를 보류합니다. 잠시 후 다시 시도하세요', ariweb: { reason: r.reason } });
if (r.impact && r.impact.blocked) return send(res, 409, { error: r.impact.message || '운영 중인 사이트가 이 프로젝트를 쓰고 있어 폐기할 수 없습니다. 포털에서 사이트를 먼저 중지·해지하세요', ariweb: { impact: r.impact } });
if (!b.acknowledgeImpact) return send(res, 409, { error: '이 프로젝트는 Ariweb Suite 사이트에 연결되어 있습니다. 아래 영향을 확인한 뒤 다시 요청하세요', ariweb: { impact: r.impact, needAcknowledge: true } });
ariweb.unlink(site.id); audit.log({ by: user.id, action: 'ariweb.unlink', targetType: 'site', target: site.id, detail: { siteId: l.siteId, reason: 'project destroyed' } }); } }
return send(res, 200, await destroySite(site, user.id));
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);
}
if (site.source && req.method === 'POST' && sub === '/deploy') {
if (jobOf(site.id).running) return send(res, 409, { error: '작업 실행 중에는 반영할 수 없습니다' });
@@ -2594,21 +2855,26 @@ async function handle(req, res) {
const rec = ((site.git || {}).changes || []).find((c) => c.no === no);
if (!rec) return send(res, 404, { error: '해당 버전이 없습니다' });
if (jobOf(site.id).running) return send(res, 409, { error: '작업 실행 중에는 되돌릴 수 없습니다' });
await gitstore.restoreTo(site.id, siteDir, rec.commit);
chmodTree(siteDir);
if (rec.briefSnapshot && site.plan) { site.plan.brief = JSON.parse(JSON.stringify(rec.briefSnapshot)); site.plan.warnings = briefLib.checkConsistency(site.plan.brief); }
store.save(site);
broadcast(site.id, { type: 'log', level: 'info', text: `버전 #${no} (${rec.message}) 로 되돌렸습니다` });
await gitRecord(site, 'restore', `버전 #${no} 복원: ${rec.message}`);
if (site.plan && ['generated', 'published'].includes(site.plan.stage)) { site.plan.verification = { at: new Date().toISOString(), rules: briefLib.verifyFiles(siteDir, site.plan.brief), ai: null, stale: true }; }
store.save(site);
broadcast(site.id, { type: 'done', site: publicView(site), command: null });
return send(res, 200, publicView(site));
// 사이트 변경 잠금 안에서(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));
});
}
if (req.method === 'POST' && sub === '/git' && m[3] === 'retry') {
site.git = site.git || { seq: 0, changes: [] };
await gitstore.retryPush(site.id, siteDir, site.git.changes); store.save(site);
return send(res, 200, publicView(site).git);
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);
}
// ---------- 기획(플랜) 라우트 ----------
if (sub === '/plan') {
@@ -2881,6 +3147,31 @@ async function handle(req, res) {
req.on('close', () => { clearInterval(ping); job.subscribers.delete(res); });
return;
}
};
// ---------- 사이트 변경 요청의 잠금·재적재(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;
}
}
if (p.startsWith('/api/')) return send(res, 404, { error: 'not found' });
return staticFile(res, '404.html', 404);
@@ -2939,5 +3230,7 @@ http.createServer((req, res) => {
// v1.5.17 (요구 1): 기동 20초 뒤 — 서버에 와일드카드 인증서(설치 때 미리 받아 둔 것 포함)가 있으면 HTTP 로 배포돼 있던 서브 도메인 사이트의 nginx 설정을 HTTPS 로 다시 쓴다
setTimeout(() => { sslLib.autoApply().then((r) => { console.log(`[ssl] 와일드카드 인증서: ${r.certName ? r.certName + (r.external ? ' (서버에 미리 발급됨)' : '') : (r.reason || '없음')}${r.reconfigured ? ` — 서브 도메인 사이트 ${r.reconfigured}개 설정을 HTTPS 로 갱신` : ''}`); }).catch((e) => console.log('[ssl] 와일드카드 인증서 확인 실패: ' + e.message)); }, 20000);
});
// v1.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 (_) {} });
// 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); } } });