[verified] feat: add Webmaker visual editor v0.2.0
This commit is contained in:
@@ -83,8 +83,8 @@ function saveLinks(d) { js.writeJsonAtomic(LINKS_FILE, d, { mode: 0o600, keepBac
|
||||
/** 프로젝트의 포털 연결 정보(없으면 null) */
|
||||
function linkOf(projectId) { return loadLinks().links[projectId] || null; }
|
||||
/** 연결 등록(포털이 사이트를 만들거나 기존 프로젝트를 연결할 때) */
|
||||
function link(projectId, { siteId, groupId, portalUrl: pu, siteName, groupName }) {
|
||||
const d = loadLinks(); d.links[projectId] = { siteId: String(siteId), groupId: String(groupId), portalUrl: String(pu || portalUrl()), siteName: String(siteName || ''), groupName: String(groupName || ''), linkedAt: new Date().toISOString() }; saveLinks(d); return d.links[projectId];
|
||||
function link(projectId, { siteId, groupId, portalUrl: pu, siteName, groupName, linkedAt }) { // linkedAt: 폐기 실패 보상(되살리기) 때 원래 연결 시각 보존
|
||||
const d = loadLinks(); d.links[projectId] = { siteId: String(siteId), groupId: String(groupId), portalUrl: String(pu || portalUrl()), siteName: String(siteName || ''), groupName: String(groupName || ''), linkedAt: linkedAt || new Date().toISOString() }; saveLinks(d); return d.links[projectId];
|
||||
}
|
||||
function unlink(projectId) { const d = loadLinks(); const had = !!d.links[projectId]; delete d.links[projectId]; saveLinks(d); return had; }
|
||||
/** 연결된 프로젝트 전체(포털 사이트 id → 프로젝트 id 역참조용) */
|
||||
|
||||
@@ -140,22 +140,41 @@ function squashToCurrent(siteId, siteDir, message) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 푸시 실패분 재시도 */
|
||||
function retryPush(siteId, siteDir, recs) {
|
||||
/**
|
||||
* 원격 반영 조정(푸시 실패분 재시도의 후속, 1.8.0 검토 수정 2차). pushed 가 아닌 기록(직접 편집 저장의 'pending' — 기록은 푸시 **전** 에 저장되므로 원격 반영을 주장하지 않는다 — 과 예전 실패분)을
|
||||
* 1) `ls-remote` 로 **원격 ref 에서 확인** 해 변경 브랜치 = 기록의 커밋이고 main 이 그 커밋을 포함하면 푸시 없이 pushed:true·remote:'verified' 로 표시하고,
|
||||
* 2) 아니면 로컬 변경 브랜치가 기록의 커밋을 가리키는지 확인한 뒤 그 브랜치들 + main(+HEAD — main 은 늘 지금 HEAD, v1.3.17 B07) 을 **한 번의 원자적 푸시** 로 올리고 다시 1) 로 확인한다.
|
||||
* 확인되지 않은 기록은 pushed:false 로 남고 error 에 사유가 적힌다(거짓 성공 없음). 원격이 없으면 그대로 돌려준다. 던지지 않는다. retryPush 라는 옛 이름으로도 부른다.
|
||||
*/
|
||||
function reconcilePushes(siteId, siteDir, recs) {
|
||||
return enqueue(siteId, async () => {
|
||||
if (!conn().remote) return recs;
|
||||
const { remote, prefix } = conn(); if (!remote) return recs;
|
||||
await initSite(siteId, siteDir);
|
||||
// v1.3.17 (B07): 예전에는 실패했던 옛 기록마다 그 커밋을 main 에 강제로 올려, 새 버전이 이미 올라간 뒤에 main 이 옛 내용으로
|
||||
// 되감길 수 있었다. 이제 옛 기록은 자기 변경 브랜치에만 올리고, main 은 늘 지금 HEAD(가장 새 버전)로 맞춘다.
|
||||
for (const r of recs) {
|
||||
if (r.pushed) continue;
|
||||
try { if (r.kind !== 'deploy' && r.branch) await git(siteId, siteDir, ['push', '-q', 'origin', `${r.commit}:refs/heads/${r.branch}`], { timeout: 120000 }); r.pushed = true; r.error = null; }
|
||||
catch (e) { r.error = e.message; }
|
||||
const main = `refs/heads/${mainBranch(siteId)}`; const sh = (args, opts) => git(siteId, siteDir, args, opts);
|
||||
const pending = (recs || []).filter((r) => r && !r.pushed && r.commit);
|
||||
if (!pending.length) return recs;
|
||||
const readRemote = () => readRemoteRefs(siteId, siteDir, prefix);
|
||||
const isAncestor = async (c, sha) => { if (!sha) return false; if (c === sha) return true; try { await sh(['merge-base', '--is-ancestor', c, sha]); return true; } catch (_) { return false; } };
|
||||
const verified = async (r, refs) => ((r.kind === 'deploy' || !r.branch) ? true : refs[`refs/heads/${r.branch}`] === r.commit) && await isAncestor(r.commit, refs[main]);
|
||||
const mark = (r) => { r.pushed = true; r.error = null; r.remote = 'verified'; r.verifiedAt = new Date().toISOString(); };
|
||||
let refs; try { refs = await readRemote(); } catch (e) { for (const r of pending) r.error = `원격 확인 실패: ${e.message}`; return recs; }
|
||||
const toPush = []; for (const r of pending) { if (await verified(r, refs)) mark(r); else toPush.push(r); }
|
||||
if (!toPush.length) return recs;
|
||||
const specs = []; const pushing = [];
|
||||
for (const r of toPush) {
|
||||
if (r.kind === 'deploy' || !r.branch) { pushing.push(r); continue; } // 배포 정리 기록은 main 만
|
||||
const local = (await sh(['rev-parse', '--verify', '-q', `refs/heads/${r.branch}`]).catch(() => '')) || null;
|
||||
if (local !== r.commit) { r.error = `로컬 변경 브랜치 ${r.branch} 가 기록의 커밋을 가리키지 않아 올릴 수 없습니다 (${local ? local.slice(0, 7) : '없음'})`; continue; }
|
||||
specs.push(`${r.commit}:refs/heads/${r.branch}`); pushing.push(r);
|
||||
}
|
||||
try { await git(siteId, siteDir, ['push', '-q', 'origin', `+HEAD:refs/heads/${mainBranch(siteId)}`], { timeout: 120000 }); } catch (e) { for (const r of recs) if (r.pushed && !r.error) { r.pushed = false; r.error = e.message; } }
|
||||
if (!pushing.length) return recs;
|
||||
try { await sh(['push', '--atomic', '-q', 'origin', ...specs, `+HEAD:${main}`], { timeout: 120000 }); } catch (e) { for (const r of pushing) r.error = e.message; return recs; }
|
||||
let refs2; try { refs2 = await readRemote(); } catch (e) { for (const r of pushing) r.error = `푸시 뒤 원격 확인 실패: ${e.message}`; return recs; }
|
||||
for (const r of pushing) { if (await verified(r, refs2)) mark(r); else r.error = '푸시 뒤 원격 ref 가 기록과 다릅니다'; }
|
||||
return recs;
|
||||
});
|
||||
}
|
||||
const retryPush = reconcilePushes;
|
||||
|
||||
/** 작업 트리를 지정 커밋의 내용으로 되돌린다(추적 파일 기준). 이후 recordChange 로 새 변경(restore)을 기록해야 한다. */
|
||||
function restoreTo(siteId, siteDir, commit) {
|
||||
@@ -166,9 +185,134 @@ function restoreTo(siteId, siteDir, commit) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 직접 편집 저장 트랜잭션(1.8.0 검토 수정 2차·3차) ----------
|
||||
/** status --porcelain 출력 → 경로 집합(되돌리기 검증: 저장 전에 없던 변경이 남지 않았는지) */
|
||||
function statusPaths(porcelain) { return new Set(String(porcelain || '').split('\n').filter(Boolean).map((l) => l.slice(3).replace(/^.* -> /, ''))); }
|
||||
/** 원격의 이 사이트 ref → { refname: sha } (ls-remote). 실패하면 던진다 */
|
||||
async function readRemoteRefs(siteId, siteDir, prefix) {
|
||||
const out = await git(siteId, siteDir, ['ls-remote', '--heads', 'origin', `refs/heads/${prefix}/${siteId}/*`], { timeout: 60000 });
|
||||
return Object.fromEntries(out.split('\n').filter(Boolean).map((l) => { const [sha, ref] = l.split('\t'); return [ref, sha]; }));
|
||||
}
|
||||
/**
|
||||
* 되돌리기 기준 스냅샷: 저장소가 있었는지·HEAD(첫 커밋 전이면 null)·인덱스가 HEAD 와 같았는지·변경 브랜치의 이전 값(없으면 null)·작업 트리 상태(경로 집합). add 하기 **전** 에 찍는다.
|
||||
* @returns {Promise<{repoExisted: boolean, head: string|null, indexClean: boolean, branch: string|null, branchWas: string|null, paths: Set<string>|null}>}
|
||||
*/
|
||||
async function snapshotState(siteId, siteDir, branch, repoExisted) {
|
||||
const head = (await git(siteId, siteDir, ['rev-parse', '--verify', '-q', 'HEAD']).catch(() => '')) || null;
|
||||
let indexClean = false; try { await git(siteId, siteDir, ['diff', '--cached', '--quiet']); indexClean = true; } catch (_) {}
|
||||
const branchWas = branch ? ((await git(siteId, siteDir, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`]).catch(() => '')) || null) : null;
|
||||
const status = await git(siteId, siteDir, ['status', '--porcelain']).catch(() => null);
|
||||
return { repoExisted: repoExisted !== false, head, indexClean, branch: branch || null, branchWas, paths: status === null ? null : statusPaths(status) };
|
||||
}
|
||||
/**
|
||||
* 로컬 Git 상태를 스냅샷으로 되돌리고 **검증한 결과** 를 돌려준다(큐 밖, recordChangeStrict 안에서 쓴다).
|
||||
* 0) 이 트랜잭션이 저장소를 새로 만들었으면(repoExisted=false) 그 git 폴더를 통째로 지운다 — init·인덱스·브랜치 효과가 모두 사라진다. 스냅샷을 찍기 전에 실패했으면(snap 없음) 그 밖의 Git 상태는 건드리지 않는다.
|
||||
* 1) HEAD·인덱스: `reset --mixed <이전 HEAD>` — 커밋이 만들어졌으면 되감고, add 로 올라간 인덱스도 비운다. 작업 트리의 다른 파일은 건드리지 않는다(--hard 는 저장과 무관한
|
||||
* 커밋되지 않은 파일을 지울 수 있다). 첫 커밋 전이었으면 만들어진 브랜치 참조를 지우고 인덱스를 비운다(read-tree --empty).
|
||||
* 2) 변경 브랜치: 새로 만든 것은 지우고, 원래 있던 것은 이전 값으로.
|
||||
* 3) hooks.onRollback(): 호출자가 페이지 원본·사이트 기록을 되돌리고 문제 목록(빈 목록 = 성공)을 돌려준다. 어느 단계에서 실패했든 **항상** 부른다(페이지는 트랜잭션 전에 이미 바뀌었다).
|
||||
* 4) 검증: 저장소 유무 = 스냅샷, HEAD = 스냅샷, 인덱스 = HEAD(저장 전에 깨끗했다면), 변경 브랜치 = 이전 값, 작업 트리 변경 경로 ⊆ 저장 전 경로. 판정은 **실제 상태** 로만 한다 — 되돌리기 명령이 실패해도
|
||||
* 상태가 저장 전과 같으면 ok 이고 명령 오류는 notes 에 남긴다. 스냅샷이 없어 검증할 수 없으면(저장소가 있었는데 스냅샷 전에 실패) ok 가 아니다 — 되돌렸다고 주장하지 않는다.
|
||||
* @returns {Promise<{ok: boolean, problems: string[], notes: string[]}>}
|
||||
*/
|
||||
async function rollbackLocal(siteId, siteDir, snap, hooks) {
|
||||
const problems = []; const notes = []; const sh = (args) => git(siteId, siteDir, args); const gitDir = path.join(GIT_ROOT, siteId);
|
||||
const repoExisted = !!(snap && snap.repoExisted);
|
||||
if (!repoExisted) { try { fs.rmSync(gitDir, { recursive: true, force: true }); } catch (e) { notes.push(`새로 만든 git 폴더 삭제 실패: ${e.message}`); } }
|
||||
else if (snap && !snap.unknown) {
|
||||
try {
|
||||
const cur = (await sh(['rev-parse', '--verify', '-q', 'HEAD']).catch(() => '')) || null;
|
||||
if (snap.head) await sh(['reset', '-q', '--mixed', snap.head]);
|
||||
else { if (cur) { const b = (await sh(['symbolic-ref', '--short', '-q', 'HEAD']).catch(() => '')) || 'main'; await sh(['update-ref', '-d', `refs/heads/${b}`]); } await sh(['read-tree', '--empty']); }
|
||||
} catch (e) { notes.push(`HEAD·인덱스 되돌리기 명령 실패: ${e.message}`); }
|
||||
if (snap.branch) { try { if (snap.branchWas) await sh(['update-ref', `refs/heads/${snap.branch}`, snap.branchWas]); else if ((await sh(['rev-parse', '--verify', '-q', `refs/heads/${snap.branch}`]).catch(() => ''))) await sh(['update-ref', '-d', `refs/heads/${snap.branch}`]); } catch (e) { notes.push(`변경 브랜치 되돌리기 명령 실패: ${e.message}`); } }
|
||||
}
|
||||
if (hooks && typeof hooks.onRollback === 'function') { try { for (const p of ((await hooks.onRollback()) || [])) problems.push(String(p)); } catch (e) { problems.push(`페이지·기록 되돌리기 실패: ${e.message}`); } }
|
||||
if (!repoExisted) { if (fs.existsSync(gitDir)) problems.push('이 트랜잭션이 만든 git 폴더가 남아 있습니다'); return { ok: problems.length === 0, problems, notes }; }
|
||||
if (!snap || snap.unknown) { problems.push('저장 전 Git 상태를 찍기 전에 실패해 되돌리기를 검증할 수 없습니다'); return { ok: false, problems, notes }; }
|
||||
try {
|
||||
const cur = (await sh(['rev-parse', '--verify', '-q', 'HEAD']).catch(() => '')) || null;
|
||||
if (cur !== snap.head) problems.push(`HEAD 가 저장 전과 다릅니다 (${cur || '없음'} ≠ ${snap.head || '없음'})`);
|
||||
let indexClean = false; try { await sh(['diff', '--cached', '--quiet']); indexClean = true; } catch (_) { /* 차이 있음 또는 실패 */ }
|
||||
if (!indexClean) problems.push('인덱스에 커밋되지 않은 항목이 남아 있습니다'); else if (!snap.indexClean) notes.push('저장 전에 인덱스에 있던 항목은 비워졌습니다(커밋되지 않은 채 작업 트리에 남음)');
|
||||
if (snap.branch) { const b = (await sh(['rev-parse', '--verify', '-q', `refs/heads/${snap.branch}`]).catch(() => '')) || null; if (b !== snap.branchWas) problems.push(`변경 브랜치 ${snap.branch} 가 저장 전과 다릅니다`); }
|
||||
if (snap.paths) { for (const p of statusPaths(await sh(['status', '--porcelain']))) if (!snap.paths.has(p)) problems.push(`작업 트리에 저장 전에 없던 변경이 남아 있습니다: ${p}`); }
|
||||
else problems.push('저장 전 작업 트리 상태를 알 수 없어 검증하지 못했습니다');
|
||||
} catch (e) { problems.push(`되돌리기 검증 실패: ${e.message}`); }
|
||||
return { ok: problems.length === 0, problems, notes };
|
||||
}
|
||||
/** rollbackLocal 을 사이트 큐 안에서(외부 호출용). 결과는 검증된 {ok, problems, notes} */
|
||||
function rollbackTo(siteId, siteDir, snap, hooks) { return enqueue(siteId, () => rollbackLocal(siteId, siteDir, snap, hooks)); }
|
||||
/**
|
||||
* 직접 편집 저장 트랜잭션. 한 큐 항목 안에서 순서대로(모두 try 안 — 어느 단계에서 실패해도 onRollback 이 불린다. 페이지는 호출 전에 이미 바뀌었기 때문):
|
||||
* [prepare] 저장소 유무 확인 → initSite → 원격 설정 → 스냅샷(add 전) → [commit] add·commit·변경 브랜치(로컬) → [metadata] onCommitted(rec)(호출자가 사이트 기록을 **원격 푸시 전** 에 저장; 던지면 실패)
|
||||
* → [push] 원격이 있으면 푸시 전 원격 ref 를 읽고(실패하면 푸시를 시도하지 않은 것이므로 되돌림), main + 변경 브랜치를 **한 번의 원자적 푸시**(`push --atomic`)로 올린다.
|
||||
* 푸시가 마지막 단계다 — 성공한 푸시 뒤에는 실패할 수 있는 상태 변경을 하지 않는다. 푸시 **전** 에 저장되는 기록은 원격 반영을 주장하지 않는다(`pushed:false, remote:'pending'`).
|
||||
* 푸시 명령이 오류를 돌려주면(클라이언트 오류는 모호하다 — 원격이 받았을 수도 있다) 원격 ref 를 다시 읽어 판정한다:
|
||||
* · main·변경 브랜치가 모두 의도한 커밋 → 성공으로 본다(rec.remoteOutcome='accepted', 되돌리지 않음; 조정이 verified 로 바꾼다)
|
||||
* · 둘 다 푸시 전 값 그대로 → 원격이 바뀌지 않았음이 증명됨 → 되돌리고 로컬·원격을 다시 검증한다(throw, stage 'push')
|
||||
* · 혼합이거나 원격을 읽을 수 없음 → 되돌리지 **않고** 로컬 커밋·페이지·기록을 pending 으로 남긴 채 rec.remoteOutcome='ambiguous'(+error) 로 돌려준다 — 호출자는 rolledBack 을 주장하지 않고, 조정(기동·저장 뒤·다시 푸시)이 안전하게 해결한다.
|
||||
* 던지는 오류에 { stage, rollback: {ok, problems, notes} } 를 붙인다. 원격이 없으면 로컬 커밋으로 끝난다(pushed:false, error:NO_REMOTE). 변경이 없으면 rec=null 로 onCommitted 만 부른다. 강제 푸시를 되돌리기에 쓰지 않는다.
|
||||
* @returns {Promise<object|null>} rec(변경 없음이면 null)
|
||||
*/
|
||||
function recordChangeStrict({ siteId, siteDir, no, kind, message, author, onCommitted, onRollback }) {
|
||||
return enqueue(siteId, async () => {
|
||||
const repoExisted = fs.existsSync(path.join(GIT_ROOT, siteId, 'HEAD'));
|
||||
let stage = 'prepare'; let snap = null; let rec = null; let remote = ''; let prefix = 'sites'; let branch = null; let remoteBefore = null;
|
||||
const sh = (args, opts) => git(siteId, siteDir, args, opts);
|
||||
try {
|
||||
await initSite(siteId, siteDir);
|
||||
({ remote, prefix } = conn());
|
||||
branch = changeBranch(siteId, no, kind);
|
||||
snap = await snapshotState(siteId, siteDir, branch, repoExisted);
|
||||
stage = 'commit';
|
||||
await sh(['add', '-A']);
|
||||
const status = await sh(['status', '--porcelain']);
|
||||
if (status) {
|
||||
const authorArgs = ['-c', `user.name=${(author && author.name) || 'webhosting-agent'}`, '-c', `user.email=${(author && author.email) || 'webhosting-agent@local'}`];
|
||||
await sh([...authorArgs, 'commit', '-q', '-m', message]);
|
||||
const commit = await sh(['rev-parse', 'HEAD']);
|
||||
await sh(['branch', '-f', branch, 'HEAD']);
|
||||
let files = []; let stat = '';
|
||||
try { files = (await sh(['diff-tree', '--no-commit-id', '--name-status', '-r', '--root', 'HEAD'])).split('\n').filter(Boolean).slice(0, 100).map((l) => { const [st, ...rest] = l.split('\t'); return { status: st[0], path: rest[rest.length - 1] }; }); } catch (_) {}
|
||||
try { stat = (await sh(['show', '--shortstat', '--format=', 'HEAD'])).trim(); } catch (_) {}
|
||||
rec = { no, kind, branch, commit, message, pushed: false, error: remote ? null : NO_REMOTE, at: new Date().toISOString(), url: branchUrl(branch), commitUrl: commitUrl(commit), files, stat };
|
||||
if (remote) rec.remote = 'pending'; // 원격 반영은 아직 확인되지 않음 — reconcilePushes 가 ref 로 확인한 뒤에만 pushed:true
|
||||
}
|
||||
stage = 'metadata';
|
||||
if (typeof onCommitted === 'function') await onCommitted(rec);
|
||||
if (rec && remote) {
|
||||
stage = 'push';
|
||||
const mainRef = `refs/heads/${mainBranch(siteId)}`; const branchRef = `refs/heads/${branch}`;
|
||||
remoteBefore = await readRemoteRefs(siteId, siteDir, prefix); // 실패하면 푸시를 시도하지 않는다 → 원격 불변 → 아래에서 되돌림
|
||||
try { await sh(['push', '--atomic', '-q', 'origin', `+HEAD:${mainRef}`, `HEAD:${branchRef}`], { timeout: 120000 }); }
|
||||
catch (e) {
|
||||
let after = null; try { after = await readRemoteRefs(siteId, siteDir, prefix); } catch (_) { after = null; }
|
||||
if (after && after[mainRef] === rec.commit && after[branchRef] === rec.commit) { rec.remoteOutcome = 'accepted'; return rec; } // 원격은 받았다 — 클라이언트 쪽 오류였을 뿐
|
||||
if (after && after[mainRef] === remoteBefore[mainRef] && after[branchRef] === remoteBefore[branchRef]) { e.remoteUnchanged = true; throw e; } // 원격 불변 증명 → 되돌림
|
||||
rec.remoteOutcome = 'ambiguous';
|
||||
rec.error = after ? `원격 반영이 혼합 상태라 확인이 필요합니다 (main ${after[mainRef] ? after[mainRef].slice(0, 7) : '없음'} / 변경 브랜치 ${after[branchRef] ? after[branchRef].slice(0, 7) : '없음'}; 푸시 오류: ${e.message})` : `원격 반영을 확인할 수 없습니다 (푸시 오류: ${e.message})`;
|
||||
return rec;
|
||||
}
|
||||
}
|
||||
return rec;
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
err.stage = stage;
|
||||
err.rollback = await rollbackLocal(siteId, siteDir, snap || { repoExisted, unknown: true, head: null, branch, branchWas: null, paths: null }, { onRollback });
|
||||
if (err.remoteUnchanged && remoteBefore) { // 원격이 바뀌지 않았음을 되돌리기 뒤에 다시 확인한다(확인하지 못하면 되돌렸다고 주장하지 않는다)
|
||||
try { const again = await readRemoteRefs(siteId, siteDir, prefix); const mainRef = `refs/heads/${mainBranch(siteId)}`; const branchRef = `refs/heads/${branch}`; if (again[mainRef] !== remoteBefore[mainRef] || again[branchRef] !== remoteBefore[branchRef]) err.rollback.problems.push('되돌리기 뒤 원격 ref 가 푸시 전과 다릅니다'); }
|
||||
catch (e2) { err.rollback.problems.push(`되돌리기 뒤 원격 확인 실패: ${e2.message}`); }
|
||||
err.rollback.ok = err.rollback.problems.length === 0;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function info(siteId) {
|
||||
const { remote, module } = conn();
|
||||
return { remote: remote ? remote.replace(/^.*[:/]([^/]+\/[^/]+?)(\.git)?$/, '$1') : null, provider: module ? module.id : 'none', mainBranch: mainBranch(siteId), mainUrl: branchUrl(mainBranch(siteId)), enabled: !!remote };
|
||||
}
|
||||
|
||||
module.exports = { recordChange, retryPush, restoreTo, squashToCurrent, info, mainBranch, remoteStatus, gitHint };
|
||||
module.exports = { recordChange, recordChangeStrict, snapshotState, rollbackTo, reconcilePushes, retryPush, restoreTo, squashToCurrent, info, mainBranch, remoteStatus, gitHint, NO_REMOTE };
|
||||
|
||||
@@ -29,18 +29,87 @@ function recomputeTotals(site) {
|
||||
|
||||
// v1.6.31 (요구 10): 원자적 쓰기(임시 파일 + fsync + rename) + 직전 정상본 .bak 보존. 읽기 실패는 손상으로 기록해 관리자 운영 복구 화면에 보인다(빈 값으로 숨기지 않음)
|
||||
const js = require('./jsonstore');
|
||||
// [Ariweb Suite 변경] rev: 저장할 때마다 1씩 오르는 개정 번호. 여러 사용자가 같은 프로젝트를 편집할 때 화면이 알고 있던 rev 와 다르면 조용히 덮어쓰지 않고 409 로 알린다(server.js PATCH·PUT /files, Ariweb Suite §9.3).
|
||||
function save(site) {
|
||||
const crypto = require('crypto');
|
||||
/** 저장이 관리하는 항목 — 변경 감지에서 제외한다(rev 는 디스크 값을 기준으로 올린다) */
|
||||
const META_KEYS = new Set(['updatedAt', 'saveId', 'rev']);
|
||||
/**
|
||||
* 읽었을 때의 상태(항목별 JSON·저장 표식)를 객체에 숨겨 둔다(열거되지 않아 JSON.stringify·spread·publicView 에 나오지 않는다). save 가 "이 객체가 바꾼 항목" 을 알아내는 기준.
|
||||
* v1.8.0 검토 수정 3차: 배경 AI 작업은 자기 사이트 객체를 오래 들고 있다가 저장하므로, 그 사이 화면(기본 사항·모델 등급 등)이 다른 객체로 저장한 변경을 덮어쓸 수 있었다.
|
||||
*/
|
||||
function remember(site) {
|
||||
if (!site || typeof site !== 'object') return site;
|
||||
const base = { saveId: site.saveId || null, keys: {} }; for (const k of Object.keys(site)) base.keys[k] = JSON.stringify(site[k]);
|
||||
Object.defineProperty(site, '__base', { value: base, enumerable: false, writable: true, configurable: true }); return site;
|
||||
}
|
||||
/**
|
||||
* 디스크 기록이 이 객체가 읽은 뒤 다른 객체로 저장되었으면(saveId 가 다름), 이 객체가 읽은 뒤 **바꾼 잎만** 디스크의 최신 기록 위에 합친다(3자 병합 — 일반 객체는 안으로 들어가 잎 단위로, 배열·기본값은 잎).
|
||||
* · 이 객체만 바꾼 잎 → 이 객체 값 / 디스크만 바뀐 잎 → 디스크 값 / 둘 다 같은 값으로 바꿈 → 그 값
|
||||
* · 둘 다 **다르게** 바꾼 잎(진짜 동시 변경) → 합치지 않고 SaveConflict(code site_save_conflict, fields=잎 경로 목록, status 409)를 던진다 — 디스크의 먼저 쓴 값을 보존하고 늦은 저장자(배경 작업)에게 알린다.
|
||||
* (1.8.0 검토 수정 3차: 같은 항목을 "마지막 저장자가 이김" 으로 두면 먼저 쓴 쪽이 조용히 사라진다. 늦은 쪽은 update() 로 최신 기록을 다시 읽어 자기 결과만 다시 적용한다.)
|
||||
* · 저장이 관리하는 항목(updatedAt·saveId·rev)만 비교에서 뺀다. 호출자의 객체는 합친 결과로 바뀐다(충돌이면 바뀌지 않는다). 읽은 적 없는 객체(새 사이트·JSON.parse 스냅샷)·restore 저장은 합치지 않는다.
|
||||
* @returns {object|null} 합치는 데 쓴 디스크 기록(없으면 null)
|
||||
*/
|
||||
function mergeOntoDisk(site) {
|
||||
const base = site.__base; if (!base) return null;
|
||||
let disk = null; try { disk = js.readJson(fileOf(site.id), null, {}); } catch (_) { disk = null; }
|
||||
if (!disk || typeof disk !== 'object' || (disk.saveId || null) === base.saveId) return null;
|
||||
const conflicts = []; const merged = {};
|
||||
const keys = new Set([...Object.keys(site), ...Object.keys(disk), ...Object.keys(base.keys)]);
|
||||
for (const k of keys) {
|
||||
if (META_KEYS.has(k)) continue;
|
||||
const has = Object.prototype.hasOwnProperty.call(site, k);
|
||||
const r = mergeValue(has ? site[k] : undefined, Object.prototype.hasOwnProperty.call(disk, k) ? disk[k] : undefined, base.keys[k] === undefined ? undefined : JSON.parse(base.keys[k]), k, conflicts);
|
||||
if (r.present) merged[k] = r.value;
|
||||
}
|
||||
if (conflicts.length) throw new SaveConflict(site.id, conflicts);
|
||||
for (const k of Object.keys(site)) if (!META_KEYS.has(k) && !Object.prototype.hasOwnProperty.call(merged, k)) delete site[k];
|
||||
for (const k of Object.keys(merged)) site[k] = merged[k];
|
||||
return disk;
|
||||
}
|
||||
const isPlain = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||
/** 잎 하나(또는 객체는 안으로 들어가며)의 3자 병합. mine/disk/base 는 값(없으면 undefined). 결과 {present, value}; 충돌은 conflicts 에 경로를 더한다 */
|
||||
function mergeValue(mine, disk, base, pathKey, conflicts) {
|
||||
const sm = JSON.stringify(mine === undefined ? null : mine), sd = JSON.stringify(disk === undefined ? null : disk), sb = JSON.stringify(base === undefined ? null : base);
|
||||
const mineChanged = sm !== sb || (mine === undefined) !== (base === undefined); const diskChanged = sd !== sb || (disk === undefined) !== (base === undefined);
|
||||
if (!mineChanged) return { present: disk !== undefined, value: disk };
|
||||
if (!diskChanged) return { present: mine !== undefined, value: mine };
|
||||
if (sm === sd && (mine === undefined) === (disk === undefined)) return { present: mine !== undefined, value: mine };
|
||||
if (isPlain(mine) && isPlain(disk) && isPlain(base)) { // 둘 다 바꾼 객체: 안으로 들어가 잎 단위로 가른다
|
||||
const out = {}; const ks = new Set([...Object.keys(mine), ...Object.keys(disk), ...Object.keys(base)]);
|
||||
for (const k of ks) { const r = mergeValue(Object.prototype.hasOwnProperty.call(mine, k) ? mine[k] : undefined, Object.prototype.hasOwnProperty.call(disk, k) ? disk[k] : undefined, Object.prototype.hasOwnProperty.call(base, k) ? base[k] : undefined, `${pathKey}.${k}`, conflicts); if (r.present) out[k] = r.value; }
|
||||
return { present: true, value: out };
|
||||
}
|
||||
conflicts.push(pathKey); return { present: mine !== undefined, value: mine };
|
||||
}
|
||||
/** 같은 잎을 둘 다 다르게 바꾼 저장 — 디스크는 그대로이고 호출자가 최신 기록 위에 다시 적용해야 한다(update) */
|
||||
class SaveConflict extends Error {
|
||||
constructor(id, fields) { super(`사이트 기록 저장 충돌: 다른 저장이 먼저 바꾼 항목을 이 저장도 다르게 바꿨습니다 (${fields.join(', ')}). 최신 기록을 다시 읽어 적용하세요`); this.code = 'site_save_conflict'; this.status = 409; this.siteId = id; this.fields = fields; }
|
||||
}
|
||||
/**
|
||||
* 안전 경로: 최신 기록을 다시 읽어 mutate 를 적용한 뒤 저장한다(읽기 직후라 합칠 것이 없고, 그 사이 또 바뀌었으면 같은 규칙으로 충돌). 배경 작업이 충돌을 받았을 때 자기 결과만 다시 적용하는 데 쓴다.
|
||||
* @returns {object|null} 저장된 최신 기록(없으면 null)
|
||||
*/
|
||||
function update(id, mutate, opts) { const fresh = load(id); if (!fresh) return null; mutate(fresh); return save(fresh, opts); }
|
||||
/** 오래 들고 있던 객체를 최신 기록(fresh)으로 맞춘다 — 모든 열거 항목을 바꾸고 읽은 시점을 갱신한다(충돌 뒤 배경 작업이 같은 객체를 계속 쓸 수 있게) */
|
||||
function adopt(site, fresh) { for (const k of Object.keys(site)) delete site[k]; Object.assign(site, fresh); return remember(site); }
|
||||
/**
|
||||
* 사이트 기록 저장. opts.restore=true 는 "실패한 저장을 되돌리는" 저장이다(server.js 직접 편집 저장 트랜잭션): 저장 전 스냅샷을 그대로 다시 쓴다.
|
||||
* (Ariweb Suite 의 store.js 는 저장마다 개정 번호 rev 를 올리는데, restore 때는 올리지 않아 rev 도 저장 전으로 돌아간다. 단독 Webmaker 에는 rev 가 없어 opts 를 쓰지 않는다.)
|
||||
*/
|
||||
// [Ariweb Suite 변경] rev: 저장할 때마다 1씩 오르는 개정 번호. 여러 사용자가 같은 프로젝트를 편집할 때 화면이 알고 있던 rev 와 다르면 조용히 덮어쓰지 않고 409 로 알린다(server.js PATCH·PUT /files·PUT /visual, Ariweb Suite §9.3).
|
||||
// restore(되돌리기 저장)에서는 rev 를 올리지 않는다 — 스냅샷의 rev 가 그대로 남아 실패한 직접 편집 저장이 rev 를 바꾸지 않는다.
|
||||
function save(site, opts) {
|
||||
if (!(opts && opts.restore)) { const disk = mergeOntoDisk(site); if (disk && disk.rev !== undefined) site.rev = disk.rev; } // [Ariweb Suite 변경] 합칠 때 rev 는 디스크(최신) 값을 기준으로 올린다
|
||||
recomputeTotals(site);
|
||||
site.rev = (Number(site.rev) || 0) + 1;
|
||||
site.updatedAt = new Date().toISOString();
|
||||
if (!(opts && (opts.restore || opts.keepRev))) site.rev = (Number(site.rev) || 0) + 1; // [Ariweb Suite 변경] keepRev: 원격 반영 조정처럼 내용 변경이 아닌 저장은 rev 를 올리지 않는다(합치기는 한다)
|
||||
site.updatedAt = new Date().toISOString(); site.saveId = crypto.randomBytes(6).toString('hex');
|
||||
js.writeJsonAtomic(fileOf(site.id), JSON.stringify(site, null, 2), { mode: 0o600, keepBackup: true });
|
||||
return site;
|
||||
return remember(site);
|
||||
}
|
||||
/** 사이트 기록 읽기. 없으면 null. 깨졌으면 .bak 으로 복구를 시도하고 손상 목록에 남긴다(복구도 안 되면 null — 화면에는 "손상" 으로 보인다) */
|
||||
function load(id) {
|
||||
if (!ID_RE.test(id)) return null;
|
||||
return js.readJson(fileOf(id), null, {});
|
||||
return remember(js.readJson(fileOf(id), null, {}));
|
||||
}
|
||||
/** 손상된 사이트 파일 목록 (운영 복구 화면) */
|
||||
function health() { return js.health().filter((h) => h.file.startsWith(DATA_DIR)); }
|
||||
@@ -72,4 +141,4 @@ function create(fields) {
|
||||
return save(site);
|
||||
}
|
||||
|
||||
module.exports = { health, ID_RE, create, save, load, list, DATA_DIR };
|
||||
module.exports = { health, ID_RE, create, save, update, adopt, SaveConflict, load, list, DATA_DIR };
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
'use strict';
|
||||
/**
|
||||
* lib/visual-editor.js — v1.8.0 직접 편집(시각 편집기)의 서버 쪽 순수 로직. 브라우저·파일 시스템·네트워크에 의존하지 않아 Node 테스트로 바로 검증한다.
|
||||
*
|
||||
* 하는 일
|
||||
* 1) prepareCanvas(html, opts) — 저장된 페이지를 "편집 캔버스 문서" 로 바꾼다. 캔버스는 빌더 미리보기 iframe(sandbox, allow-same-origin 없음 → 출처 null) 에
|
||||
* srcdoc 으로 들어간다. 이때 페이지의 스크립트·인라인 이벤트 핸들러·javascript: 링크·iframe/object/embed·meta http-equiv 는 모두
|
||||
* "실행되지 않는 보존 형태" 로 바꾸고(지우지 않는다), CSP(nonce 가 있는 편집 런타임만 실행)·<base href>(상대 경로 자원이 사이트 주소 기준으로
|
||||
* 풀리도록)·편집 런타임(설정 + public/visual-editor-core.js + public/wm-ve-canvas.js)을 넣는다.
|
||||
* 2) restoreFromCanvas(bodyHtml) — 캔버스가 돌려준 본문(body innerHTML)에서 편집기 표식(data-wm-ve-*)·덧씌운 UI(data-wm-ve-ui)·contenteditable 을 지우고
|
||||
* 1) 에서 바꿔 둔 보존 속성을 원래 이름으로 되돌린다. 원래 페이지에 있던 코드는 그대로 남는다.
|
||||
* 3) validateVisualSave(...) — 저장 요청을 검증하고 원본의 doctype·<head>·<body …> 여는 태그·꼬리(</body></html>)를 그대로 둔 채 본문만 바꿔 끼운 완성 HTML 을 돌려준다.
|
||||
* 직접 편집 경로가 "임의 HTML 실행 API" 가 되지 않도록, 원본에 없던 스크립트·이벤트 속성·javascript:/data:text/html 주소·
|
||||
* iframe/object/embed/meta/link/base 요소·스타일 표현식을 거절한다(원본에 있던 것은 그 수까지만 허용 — 삭제는 가능, 추가·변경은 불가).
|
||||
*
|
||||
* 설계 메모
|
||||
* - HTML 파서 의존성이 없어 작은 태그 스캐너(scanTags)로 처리한다. 주석(<!-- -->)·doctype·<script>/<style> 의 원문(raw text)·따옴표 안의 '>' 를 올바르게 건너뛴다.
|
||||
* - "보존 속성" 규칙(둘 다 같은 표를 쓴다): onX → data-wm-ve-on-X, 위험 href → data-wm-ve-href, iframe/frame/embed src·srcdoc → data-wm-ve-src/-srcdoc,
|
||||
* object data → data-wm-ve-data, meta http-equiv → data-wm-ve-http-equiv, base href → data-wm-ve-href, script type → type="text/x-wm-ve-disabled" (+ data-wm-ve-orig-type).
|
||||
* - 본문 비교(inventory)는 다중집합(문자열 → 개수)으로 한다. 편집본의 개수가 원본보다 많은 항목이 하나라도 있으면 거절.
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const core = require('../public/visual-editor-core'); // 주소(URL) 정규화·위험 스킴 판별은 화면·캔버스와 **같은 구현** 을 쓴다(한 곳에서만 고친다)
|
||||
|
||||
const DISABLED_TYPE = 'text/x-wm-ve-disabled'; // 캔버스 안에서 실행되지 않도록 바꿔 두는 script type
|
||||
const MARK = 'data-wm-ve-'; // 편집기 표식 속성 접두어
|
||||
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
||||
/** 캔버스에서 "불러오거나 실행" 될 수 있어 원본에 있던 수를 넘기지 못하는 요소 */
|
||||
const RISKY_TAGS = new Set(['iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'meta', 'link', 'base']);
|
||||
/** 본문 안에 있어서는 안 되는 문서 구조 태그 */
|
||||
const STRUCT_TAGS = new Set(['html', 'head', 'body']);
|
||||
/** 주소를 담는 속성(위험 스킴 검사 대상) */
|
||||
const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'xlink:href', 'poster', 'data', 'srcset', 'background', 'ping', 'cite', 'longdesc', 'srcdoc']);
|
||||
/** 캔버스에서 네트워크 요청·실행으로 이어지는 src 를 보존 속성으로 바꾸는 요소 */
|
||||
const SRC_NEUTRALIZE = new Set(['iframe', 'frame', 'embed']);
|
||||
/**
|
||||
* SVG SMIL 변이·참조 요소(1.8.0 검토 수정 2차 — 저장형 XSS 우회 차단). <animate attributeName="href" values="javascript:…">·<set …to="javascript:…"> 는 정적 주소 속성에는
|
||||
* 위험 스킴이 없어 주소 목록 비교를 지나지만, 문서가 열리면 href 를 javascript: 로 바꿔 실행된다(Chromium 확인). 속성을 하나씩 좇는 대신 요소 자체를 묶음으로 다룬다:
|
||||
* 저장 검증은 원본에 있던 수까지만(삭제 허용, 추가·변경 거절), 캔버스에서는 실행되지 않는 이름(wm-ve-x-…)으로 바꿨다가 복원 때 표준 이름으로 되돌린다.
|
||||
* 범위: HTML 파서가 SVG 로 읽는 SMIL 애니메이션 요소 전부(animate·set·animateMotion·animateTransform·animateColor(폐기되었지만 파서는 받는다)·discard(SVG2, 요소 제거)·mpath(animateMotion 의 경로 참조)).
|
||||
* 이름 공간 접두어(svg:animate)·대소문자 형태는 HTML 문서에서는 실행되지 않지만 fail-closed 로 같은 묶음에 넣는다(키는 소문자 로컬 이름).
|
||||
*/
|
||||
const SMIL_TAGS = { animate: 'animate', set: 'set', animatemotion: 'animateMotion', animatetransform: 'animateTransform', animatecolor: 'animateColor', discard: 'discard', mpath: 'mpath' };
|
||||
const SMIL_PREFIX = 'wm-ve-x-'; // 캔버스용 비활성 요소 이름 접두어(MARK 'data-wm-ve-' 와 겹치지 않는다)
|
||||
/** 소문자 태그 이름이 SMIL 요소(접두어 포함)면 표준 이름(animateMotion 등)을, 아니면 null */
|
||||
function smilName(lowerName) { const i = lowerName.lastIndexOf(':'); const local = i >= 0 ? lowerName.slice(i + 1) : lowerName; return SMIL_TAGS[local] || null; }
|
||||
/** 캔버스 이름(wm-ve-x-<원래 이름>, 브라우저가 소문자로 직렬화했을 수 있다) → 표준 이름(접두어가 있었으면 그대로 붙인다) */
|
||||
function smilRestoreName(lowerCanvasName) { const n = lowerCanvasName.slice(SMIL_PREFIX.length); const i = n.lastIndexOf(':'); const local = i >= 0 ? n.slice(i + 1) : n; const canon = SMIL_TAGS[local] || local; return i >= 0 ? n.slice(0, i + 1) + canon : canon; }
|
||||
/** 여는·닫는 태그 원문에서 이름을 대소문자 그대로 */
|
||||
function rawTagName(t) { const m = /^<\/?([^\s/>]+)/.exec(t.raw); return m ? m[1] : t.name; }
|
||||
/** 내용이 HTML 로 해석되지 않는 원문 요소(브라우저의 raw text·RCDATA) — HTML 문맥에서만. <svg>·<math> 안(외래 콘텐츠)에서는 자식이 요소로 해석되므로 적용하지 않는다(scanTags) */
|
||||
const RAW_TEXT = new Set(['script', 'style', 'title', 'textarea']);
|
||||
|
||||
// ---------- 태그 스캐너 ----------
|
||||
/**
|
||||
* 속성 문자열을 {name, value} 배열로. value 가 null 이면 값 없는 속성(예: disabled). 값은 HTML 원문 그대로(엔티티를 풀지 않는다).
|
||||
* @param {string} s 여는 태그 안의 속성 부분
|
||||
*/
|
||||
function parseAttrs(s) {
|
||||
const out = []; const re = /([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g; let m;
|
||||
while ((m = re.exec(s))) { if (!m[1]) continue; out.push({ name: m[1], value: m[2] !== undefined ? m[2] : (m[3] !== undefined ? m[3] : (m[4] !== undefined ? m[4] : null)) }); }
|
||||
return out;
|
||||
}
|
||||
/** 속성 배열 → 여는 태그 문자열. 값은 항상 큰따옴표로 감싸고 값 안의 큰따옴표는 " 로 */
|
||||
function buildTag(name, attrs, selfClosing) {
|
||||
const a = attrs.map((x) => x.value === null ? x.name : `${x.name}="${String(x.value).replace(/"/g, '"')}"`).join(' ');
|
||||
return `<${name}${a ? ' ' + a : ''}${selfClosing ? ' /' : ''}>`;
|
||||
}
|
||||
/**
|
||||
* HTML 을 한 번 훑으며 태그마다 visit(tag) 를 부른다. visit 가 문자열을 돌려주면 그 태그(원문 요소면 내용·닫는 태그까지)를 그 문자열로 바꾸고, null 이면 그대로 둔다.
|
||||
* tag = { closing, name, attrs, selfClosing, raw, inner(script/style 의 내용), closeRaw, start, end }
|
||||
* @param {string} html
|
||||
* @param {(tag: object) => string|null} visit
|
||||
*/
|
||||
function scanTags(html, visit) {
|
||||
let i = 0; const n = html.length; let out = '';
|
||||
// 외래 콘텐츠(<svg>·<math> 안) 깊이(1.8.0 검토 수정 4차 — 파서 차이). HTML 파서는 외래 콘텐츠 안에서 title·textarea·style·script 를 원문(RCDATA/raw text)으로 읽지 않고
|
||||
// 자식을 요소로 해석한다(<svg><title><script>…</script></title></svg> 의 script 가 실행된다). 그래서 외래 콘텐츠 안에서는 원문 규칙을 쓰지 않는다 — HTML 통합 지점(foreignObject·desc·mtext·annotation-xml)
|
||||
// 안에서는 파서가 다시 HTML 로 읽어 title 이 원문이 되지만, 그 경우도 요소로 보아 세는 쪽(fail-closed)이 안전하다. 자기 닫힘 <svg/> 은 내용이 없다.
|
||||
let foreign = 0;
|
||||
while (i < n) {
|
||||
const lt = html.indexOf('<', i);
|
||||
if (lt < 0) { out += html.slice(i); break; }
|
||||
out += html.slice(i, lt);
|
||||
if (html.startsWith('<!--', lt)) { const e = html.indexOf('-->', lt + 4); const end = e < 0 ? n : e + 3; out += html.slice(lt, end); i = end; continue; } // 주석
|
||||
if (html[lt + 1] === '!' || html[lt + 1] === '?') { const e = html.indexOf('>', lt); const end = e < 0 ? n : e + 1; out += html.slice(lt, end); i = end; continue; } // doctype·처리 명령
|
||||
const m = /^<(\/?)([a-zA-Z][a-zA-Z0-9:_-]*)/.exec(html.slice(lt, lt + 80));
|
||||
if (!m) { out += '<'; i = lt + 1; continue; }
|
||||
// 태그 끝('>')을 따옴표를 존중하며 찾는다 — 속성값 안의 '>' 에 끊기지 않는다
|
||||
let j = lt + m[0].length; let q = null;
|
||||
while (j < n) { const c = html[j]; if (q) { if (c === q) q = null; } else if (c === '"' || c === "'") q = c; else if (c === '>') break; j++; }
|
||||
const end = Math.min(j + 1, n);
|
||||
let attrText = html.slice(lt + m[0].length, j); const selfClosing = /\/\s*$/.test(attrText); if (selfClosing) attrText = attrText.replace(/\/\s*$/, '');
|
||||
const tag = { closing: m[1] === '/', name: m[2].toLowerCase(), attrs: m[1] ? [] : parseAttrs(attrText), selfClosing, raw: html.slice(lt, end), inner: null, closeRaw: '', start: lt, end };
|
||||
let after = end;
|
||||
if (tag.name === 'svg' || tag.name === 'math') { if (tag.closing) { if (foreign > 0) foreign--; } else if (!tag.selfClosing) foreign++; }
|
||||
if (!tag.closing && foreign === 0 && RAW_TEXT.has(tag.name)) { // 원문 요소(script·style 과 RCDATA 인 title·textarea): 닫는 태그까지의 내용은 HTML 이 아니다 — 안의 "<head>" 같은 글자는 태그가 아니다
|
||||
const re = new RegExp(`</${tag.name}\\s*>`, 'ig'); re.lastIndex = end; const mm = re.exec(html);
|
||||
tag.inner = html.slice(end, mm ? mm.index : n); tag.closeRaw = mm ? mm[0] : ''; after = mm ? mm.index + mm[0].length : n; tag.end = after;
|
||||
}
|
||||
const rep = visit(tag);
|
||||
out += rep == null ? html.slice(lt, after) : rep;
|
||||
i = after;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- 문서 나누기 ----------
|
||||
/**
|
||||
* 문서를 머리(doctype + <html> + <head>…</head> + <body …> 여는 태그까지)·본문·꼬리(</body> 부터 끝)로 나눈다.
|
||||
* <body> 가 없는 조각은 전체가 본문이다(hasBody=false). <head> 는 있는데 <body> 가 없는 문서는 호출자가 거절한다.
|
||||
* @param {string} html
|
||||
* @returns {{prefix: string, body: string, suffix: string, hasBody: boolean, hasHead: boolean}}
|
||||
*/
|
||||
function splitDocument(html) {
|
||||
html = String(html || '');
|
||||
let open = null; let close = null;
|
||||
scanTags(html, (t) => { if (t.name === 'body' && !t.closing && !open) open = t; else if (t.name === 'body' && t.closing && open && !close) close = t; return null; });
|
||||
const hasHead = /<head[\s>]/i.test(html);
|
||||
if (!open) return { prefix: '', body: html, suffix: '', hasBody: false, hasHead };
|
||||
const bodyStart = open.end; const bodyEnd = close ? close.start : html.length;
|
||||
return { prefix: html.slice(0, bodyStart), body: html.slice(bodyStart, bodyEnd), suffix: html.slice(bodyEnd), hasBody: true, hasHead };
|
||||
}
|
||||
|
||||
// ---------- 주소 검사 ----------
|
||||
/**
|
||||
* javascript:/vbscript:/data:text/html 처럼 실행·문서 주입이 가능한 주소인지. 판별은 public/visual-editor-core.js 의 urlScheme 과 같다(한 구현):
|
||||
* 이름 엔티티(: 	 
 / …)·숫자 엔티티(10/16진, 세미콜론 생략·앞자리 0 포함)를 두 겹까지 풀고, 제어·공백·폭 없는 문자를 걷어낸 뒤 스킴을 본다.
|
||||
* 스킴 자리에 풀리지 않은(모르는) 엔티티가 남아 있으면 "흐린 스킴" 으로 보아 거절한다(fail-closed). 1.8.0 검토 수정: 예전에는 숫자 엔티티·&tab;·&newline; 만 풀어 : 를 놓쳤다.
|
||||
*/
|
||||
function isDangerousUrl(v) { return core.isDangerousUrl(v); }
|
||||
/** style 속성·<style> 내용에 실행 표현식이 있는지 */
|
||||
function hasStyleHazard(css) { return /expression\s*\(|javascript\s*:|vbscript\s*:|behavior\s*:|-moz-binding|@import/i.test(String(css || '')); }
|
||||
|
||||
// ---------- 캔버스 변환 ----------
|
||||
/**
|
||||
* 여는 태그 하나를 "실행되지 않는 보존 형태" 로 바꾼다(캔버스 입력용). 바뀐 것이 없으면 null.
|
||||
* @param {object} t scanTags 의 tag
|
||||
*/
|
||||
function neutralizeTag(t) {
|
||||
if (t.closing) return smilName(t.name) ? `</${SMIL_PREFIX}${rawTagName(t)}>` : null; // SMIL 요소의 닫는 태그도 같이 바꾼다
|
||||
let changed = false; const attrs = t.attrs.map((a) => ({ ...a }));
|
||||
let name = t.name;
|
||||
if (smilName(t.name)) { name = SMIL_PREFIX + rawTagName(t); changed = true; } // SMIL 요소 → 실행되지 않는 이름(속성은 그대로 보존, 복원 때 되돌린다)
|
||||
for (const a of attrs) {
|
||||
const n = a.name.toLowerCase();
|
||||
if (/^on[a-z]/.test(n)) { a.name = MARK + 'on-' + n.slice(2); changed = true; continue; }
|
||||
if (n === 'href' && (t.name === 'base' || isDangerousUrl(a.value))) { a.name = MARK + 'href'; changed = true; continue; }
|
||||
if ((n === 'src' || n === 'srcdoc') && SRC_NEUTRALIZE.has(t.name)) { a.name = MARK + n; changed = true; continue; }
|
||||
if (n === 'data' && t.name === 'object') { a.name = MARK + 'data'; changed = true; continue; }
|
||||
if (n === 'http-equiv' && t.name === 'meta') { a.name = MARK + 'http-equiv'; changed = true; continue; }
|
||||
if (URL_ATTRS.has(n) && n !== 'href' && isDangerousUrl(a.value)) { a.name = MARK + n; changed = true; continue; }
|
||||
}
|
||||
if (t.name === 'script') {
|
||||
const typeIdx = attrs.findIndex((a) => a.name.toLowerCase() === 'type');
|
||||
if (typeIdx >= 0 && attrs[typeIdx].value !== DISABLED_TYPE) { attrs.push({ name: MARK + 'orig-type', value: attrs[typeIdx].value === null ? '' : attrs[typeIdx].value }); attrs[typeIdx].value = DISABLED_TYPE; }
|
||||
else if (typeIdx < 0) attrs.unshift({ name: 'type', value: DISABLED_TYPE });
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return null;
|
||||
return buildTag(name, attrs, t.selfClosing) + (t.inner !== null ? t.inner + t.closeRaw : '');
|
||||
}
|
||||
/** 런타임 코드를 <script> 안에 넣어도 문서가 끊기지 않게 */
|
||||
function inlineScriptSafe(code) { return String(code || '').replace(/<\/(script)/gi, '<\\/$1').replace(/<!--/g, '<\\!--'); }
|
||||
/**
|
||||
* 저장된 페이지 → 편집 캔버스 문서.
|
||||
* @param {string} html 저장된 페이지 원문
|
||||
* @param {object} opts
|
||||
* @param {string} opts.baseHref 상대 경로 기준(페이지가 있는 폴더의 사이트 주소, 예: https://host/<id>/en/)
|
||||
* @param {string} opts.session 이 편집 세션의 식별자 — 부모 창과의 postMessage 는 이 값이 맞아야만 처리된다
|
||||
* @param {string} opts.nonce CSP nonce — 편집 런타임 스크립트만 실행된다
|
||||
* @param {string} opts.parentOrigin 부모(빌더) 창의 출처 — 캔버스는 이 출처가 보낸 메시지만 받는다
|
||||
* @param {string} opts.runtime 캔버스 런타임 코드(visual-editor-core.js + wm-ve-canvas.js 를 이어 붙인 것)
|
||||
* @returns {string}
|
||||
*/
|
||||
function prepareCanvas(html, opts) {
|
||||
const o = opts || {}; const out = scanTags(String(html || ''), neutralizeTag);
|
||||
const csp = `default-src * data: blob: 'unsafe-inline'; script-src 'nonce-${o.nonce}'; object-src 'none'; frame-src 'none'; child-src 'none'; form-action 'none'`;
|
||||
const config = { session: String(o.session || ''), parentOrigin: String(o.parentOrigin || ''), baseHref: String(o.baseHref || '') };
|
||||
// base 의 target=_blank: 캔버스 안에서 어떤 활성화(링크·area·폼)가 런타임의 차단을 지나쳐도 "새 창" 으로 가려 하고, sandbox(allow-popups 없음)는 새 창을 열지 않으므로 iframe 문서가 바뀌지 않는다(심층 방어)
|
||||
const head = `<meta http-equiv="Content-Security-Policy" content="${csp}">`
|
||||
+ `<base href="${String(o.baseHref || '').replace(/"/g, '"')}" target="_blank" ${MARK}ui>`
|
||||
+ `<script nonce="${o.nonce}" ${MARK}ui>window.__WM_VE__=${JSON.stringify(config).replace(/</g, '\\u003c')};</script>`
|
||||
+ `<script nonce="${o.nonce}" ${MARK}ui>${inlineScriptSafe(o.runtime)}</script>`;
|
||||
// 진짜 <head>/<html>/<body> 여는 태그를 **구조 스캐너(scanTags)** 로 찾는다 — 주석(<!--<head>-->)·script/style/title/textarea 원문 안의 글자·<header> 같은 비슷한 이름에 속지 않는다.
|
||||
// (1.8.0 검토 수정: 예전 정규식 /<head[^>]*>/ 은 주석 속 <head> 와 <header> 에도 걸려 CSP·런타임이 주석 안이나 본문 요소 안으로 들어갈 수 있었다.)
|
||||
let headTag = null; let htmlTag = null; let bodyTag = null;
|
||||
scanTags(out, (t) => { if (t.closing) return null; if (t.name === 'head' && !headTag) headTag = t; else if (t.name === 'html' && !htmlTag) htmlTag = t; else if (t.name === 'body' && !bodyTag) bodyTag = t; return null; });
|
||||
if (headTag) return out.slice(0, headTag.end) + head + out.slice(headTag.end);
|
||||
if (htmlTag) return out.slice(0, htmlTag.end) + '<head>' + head + '</head>' + out.slice(htmlTag.end); // <head> 가 없으면 진짜 <head> 를 만든다
|
||||
if (bodyTag) return out.slice(0, bodyTag.start) + '<head>' + head + '</head>' + out.slice(bodyTag.start); // <html> 도 없으면 <body> 앞에
|
||||
return '<!doctype html><html><head>' + head + '</head><body>' + out + '</body></html>'; // 구조가 없는 조각은 문서로 감싼다
|
||||
}
|
||||
|
||||
// ---------- 캔버스 결과 복원 ----------
|
||||
/**
|
||||
* 캔버스가 돌려준 본문에서 편집기 흔적을 지우고 보존 속성을 되돌린다. 원래 페이지 코드는 그대로 남는다.
|
||||
* - data-wm-ve-ui 요소(선택 테두리·도구 막대 등 편집기가 덧씌운 것)는 내용까지 통째로 제거
|
||||
* - data-wm-ve-editable 이 붙은 요소의 contenteditable/spellcheck 만 제거(원래 페이지의 contenteditable 은 건드리지 않는다)
|
||||
* - class 의 wm-ve-* 토큰 제거, 나머지 data-wm-ve-* 속성 제거
|
||||
* @param {string} bodyHtml
|
||||
* @returns {string}
|
||||
*/
|
||||
/**
|
||||
* 편집기가 덧씌운 요소(data-wm-ve-ui)를 내용·닫는 태그까지 통째로 지운다. 같은 이름 요소의 여닫음 깊이를 세어 범위를 정한다.
|
||||
* @param {string} html
|
||||
*/
|
||||
function stripUiElements(html) {
|
||||
const ranges = []; let skip = null; // skip = 제거 중인 요소 { name, depth, start }
|
||||
scanTags(html, (t) => {
|
||||
if (skip) { if (t.name === skip.name && t.inner === null) { if (t.closing) { skip.depth--; if (skip.depth === 0) { ranges.push([skip.start, t.end]); skip = null; } } else if (!t.selfClosing && !VOID.has(t.name)) skip.depth++; } return null; }
|
||||
if (t.closing || !t.attrs.some((a) => a.name.toLowerCase() === MARK + 'ui')) return null;
|
||||
if (t.selfClosing || VOID.has(t.name) || t.inner !== null) ranges.push([t.start, t.end]); else skip = { name: t.name, depth: 1, start: t.start };
|
||||
return null;
|
||||
});
|
||||
if (skip) ranges.push([skip.start, html.length]); // 닫히지 않은 UI 요소는 끝까지
|
||||
let out = ''; let pos = 0; for (const [s, e] of ranges) { out += html.slice(pos, s); pos = e; } return out + html.slice(pos);
|
||||
}
|
||||
function restoreFromCanvas(bodyHtml) {
|
||||
return scanTags(stripUiElements(String(bodyHtml || '')), (t) => {
|
||||
if (t.closing) return t.name.startsWith(SMIL_PREFIX) ? `</${smilRestoreName(t.name)}>` : null;
|
||||
const has = (n) => t.attrs.some((a) => a.name.toLowerCase() === n);
|
||||
const editable = has(MARK + 'editable'); const spell = has(MARK + 'spellcheck');
|
||||
let changed = false; const attrs = [];
|
||||
let name = t.name;
|
||||
if (name.startsWith(SMIL_PREFIX)) { name = smilRestoreName(name); changed = true; } // 캔버스용 비활성 이름 → 표준 SMIL 이름(저장 검증이 원본의 수와 비교한다)
|
||||
for (const a0 of t.attrs) {
|
||||
const a = { ...a0 }; const n = a.name.toLowerCase();
|
||||
if (n.startsWith(MARK)) {
|
||||
const key = n.slice(MARK.length); changed = true;
|
||||
if (key.startsWith('on-')) { a.name = 'on' + key.slice(3); attrs.push(a); }
|
||||
else if (['href', 'src', 'srcdoc', 'data', 'http-equiv', 'action', 'formaction', 'poster', 'srcset', 'background', 'xlink:href', 'ping', 'cite', 'longdesc'].includes(key)) { a.name = key; attrs.push(a); }
|
||||
continue; // orig-type·editable·id·spellcheck 등 표식은 버린다
|
||||
}
|
||||
if (editable && (n === 'contenteditable' || (spell && n === 'spellcheck'))) { changed = true; continue; }
|
||||
if (n === 'class' && /(^|\s)wm-ve-/.test(a.value || '')) { const v = String(a.value).split(/\s+/).filter((c) => c && !c.startsWith('wm-ve-')).join(' '); changed = true; if (!v) continue; a.value = v; }
|
||||
attrs.push(a);
|
||||
}
|
||||
if (t.name === 'script') {
|
||||
const ti = attrs.findIndex((a) => a.name.toLowerCase() === 'type');
|
||||
if (ti >= 0 && attrs[ti].value === DISABLED_TYPE) {
|
||||
const orig = t.attrs.find((a) => a.name.toLowerCase() === MARK + 'orig-type');
|
||||
if (orig) attrs[ti].value = orig.value; else attrs.splice(ti, 1);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) return null;
|
||||
return buildTag(name, attrs, t.selfClosing) + (t.inner !== null ? t.inner + t.closeRaw : '');
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 저장 검증 ----------
|
||||
/** 비교용 목록: 범주별 다중집합(문자열 → 개수) */
|
||||
function inventory(html) {
|
||||
const inv = { script: new Map(), handler: new Map(), url: new Map(), risky: new Map(), style: new Map(), struct: 0 };
|
||||
const add = (m, k) => m.set(k, (m.get(k) || 0) + 1);
|
||||
const norm = (t) => t.attrs.map((a) => `${a.name.toLowerCase()}=${a.value === null ? '' : String(a.value).trim()}`).sort().join('|');
|
||||
scanTags(String(html || ''), (t) => {
|
||||
if (t.closing) return null;
|
||||
if (t.name === 'script') add(inv.script, norm(t) + '#' + String(t.inner || '').trim());
|
||||
if (t.name === 'style' && hasStyleHazard(t.inner)) add(inv.style, 'style#' + String(t.inner || '').trim());
|
||||
if (RISKY_TAGS.has(t.name) || smilName(t.name)) add(inv.risky, t.name + '|' + norm(t)); // SMIL 요소는 이름·속성 묶음으로 센다(속성 변경 = 새 항목 = 거절)
|
||||
if (STRUCT_TAGS.has(t.name)) inv.struct++;
|
||||
for (const a of t.attrs) {
|
||||
const n = a.name.toLowerCase();
|
||||
if (/^on[a-z]/.test(n)) add(inv.handler, `${n}=${a.value}`);
|
||||
else if (URL_ATTRS.has(n) && isDangerousUrl(a.value)) add(inv.url, `${n}=${String(a.value).trim()}`);
|
||||
else if (n === 'style' && hasStyleHazard(a.value)) add(inv.style, `style=${a.value}`);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return inv;
|
||||
}
|
||||
/** 편집본(b)의 어떤 항목이 원본(a)보다 많은지 — 첫 번째 초과 항목을 돌려준다(없으면 null) */
|
||||
function exceeds(a, b) { for (const [k, n] of b) if (n > (a.get(k) || 0)) return k; return null; }
|
||||
/** status 를 가진 Error */
|
||||
function fail(status, message) { return Object.assign(new Error(message), { status }); }
|
||||
/** 문자열의 sha256 (hex) — 낙관적 동시성(baseHash)에 쓴다 */
|
||||
function contentHash(s) { return crypto.createHash('sha256').update(String(s), 'utf8').digest('hex'); }
|
||||
/** 직접 편집할 수 있는 파일인지(.html/.htm) */
|
||||
function isHtmlPath(rel) { return /\.(html?|HTML?)$/.test(String(rel || '')) && !/\.(html?)\//.test(String(rel || '')); }
|
||||
|
||||
/**
|
||||
* 저장 요청 검증 + 합치기.
|
||||
* @param {object} p
|
||||
* @param {string} p.original 저장된 원본 페이지 전체
|
||||
* @param {string} p.body 캔버스가 보낸 본문(body innerHTML)
|
||||
* @param {number} p.maxBytes 본문 크기 상한
|
||||
* @returns {{html: string, body: string, hash: string}} html = 완성 문서, body = 복원된 본문, hash = 완성 문서의 sha256
|
||||
* @throws {Error & {status: number}} 400/413 와 한국어 안내
|
||||
*/
|
||||
function validateVisualSave(p) {
|
||||
const { original, body, maxBytes } = p || {};
|
||||
if (typeof body !== 'string') throw fail(400, '편집한 본문(body)이 문자열이어야 합니다. 화면을 새로 고친 뒤 다시 시도하세요');
|
||||
if (Buffer.byteLength(body, 'utf8') > (maxBytes || 5 * 1024 * 1024)) throw fail(413, `편집한 내용이 너무 큽니다 (최대 ${Math.round((maxBytes || 5 * 1024 * 1024) / 1024 / 1024)}MB). 큰 이미지는 첨부 파일로 올리고 주소로 넣으세요`);
|
||||
const src = splitDocument(String(original || ''));
|
||||
if (!src.hasBody && src.hasHead) throw fail(400, '이 페이지에는 <body> 가 없어 직접 편집으로 저장할 수 없습니다. 소스 편집기에서 수정하세요');
|
||||
const restored = restoreFromCanvas(body);
|
||||
if (/data-wm-ve-|text\/x-wm-ve-disabled/.test(restored)) throw fail(400, '편집기 표식이 남아 있어 저장할 수 없습니다. 화면을 새로 고친 뒤 다시 시도하세요');
|
||||
if (inventory(restored).struct > 0) throw fail(400, '본문 안에 html·head·body 태그를 넣을 수 없습니다');
|
||||
// 합치기. 브라우저가 </body> 뒤의 스크립트를 본문 안으로 옮겨 두었을 수 있으므로 꼬리의 같은 스크립트는 중복되지 않게 뺀다.
|
||||
let suffix = src.suffix;
|
||||
if (src.hasBody && /<script/i.test(suffix)) suffix = scanTags(suffix, (t) => (t.name === 'script' && !t.closing && restored.includes(t.raw + (t.inner || '') + t.closeRaw)) ? '' : null);
|
||||
const html = src.hasBody ? src.prefix + restored + suffix : restored;
|
||||
// 위험 요소 비교는 **완성 문서 전체 ↔ 원본 전체** 로 한다(1.8.0 검토 수정). 예전에는 원본 전체의 목록과 "본문만" 을 비교해, 머리(head)·<body …> 여는 태그·꼬리에만 있던
|
||||
// 스크립트·핸들러·위험 주소·iframe/object 를 본문으로 복사해 넣어도 개수가 같아 통과했다. 머리·꼬리는 글자 그대로 보존되므로 전체 비교에서 본문에 더해진 것만 초과로 드러난다.
|
||||
const a = inventory(String(original || '')); const b = inventory(html);
|
||||
let k;
|
||||
if ((k = exceeds(a.script, b.script))) throw fail(400, '직접 편집에서는 스크립트를 추가하거나 바꿀 수 없습니다. 스크립트는 소스 편집기에서 수정하세요');
|
||||
if ((k = exceeds(a.handler, b.handler))) throw fail(400, `인라인 이벤트 속성(${k.split('=')[0]})은 직접 편집으로 넣을 수 없습니다. 필요하면 소스 편집기를 사용하세요`);
|
||||
if ((k = exceeds(a.url, b.url))) throw fail(400, '허용되지 않는 링크 주소(javascript:·data:text/html 등)가 있습니다. http(s)·mailto·tel·상대 경로·#앵커만 쓸 수 있습니다');
|
||||
if ((k = exceeds(a.risky, b.risky))) { const nm = k.split('|')[0]; throw fail(400, smilName(nm) ? `직접 편집에서는 SVG 애니메이션 요소(${nm} — animate·set·animateMotion·animateTransform·mpath 등)를 추가하거나 바꿀 수 없습니다. 소스 편집기를 사용하세요` : `직접 편집에서는 ${nm} 요소(iframe·object·embed·meta·link·base)를 추가하거나 바꿀 수 없습니다. 소스 편집기를 사용하세요`); }
|
||||
if ((k = exceeds(a.style, b.style))) throw fail(400, '허용되지 않는 스타일 표현식(expression·javascript:·behavior 등)이 있습니다');
|
||||
return { html, body: restored, hash: contentHash(html) };
|
||||
}
|
||||
|
||||
// ---------- 원자적 저장 ----------
|
||||
/**
|
||||
* 텍스트 파일을 "같은 폴더의 임시 파일에 쓴 뒤 rename" 으로 바꿔 끼운다(1.8.0 직접 편집 저장용).
|
||||
* 쓰는 도중 실패(디스크 가득·권한·프로세스 종료)해도 원래 파일은 마지막 저장본 그대로 남고 잘린 페이지가 생기지 않는다.
|
||||
* rename 은 같은 파일 시스템 안에서 원자적이므로 임시 파일을 반드시 같은 폴더에 둔다. 실패하면 임시 파일을 지우고(정리 실패는 무시) 오류를 그대로 던진다.
|
||||
* @param {string} absPath 바꿔 끼울 파일의 절대 경로
|
||||
* @param {string} content 새 내용(문자열)
|
||||
* @param {object} [opts]
|
||||
* @param {object} [opts.fs] 주입할 파일 시스템 함수 묶음 { writeFileSync, renameSync, unlinkSync } — 테스트에서 실패 경로를 결정적으로 재현하는 데 쓴다
|
||||
* @param {number} [opts.mode] 파일 모드(기본 0o644 — nginx 가 읽을 수 있게)
|
||||
* @returns {string} 쓰였던 임시 파일 경로(진단용)
|
||||
*/
|
||||
function writeTextAtomic(absPath, content, opts) {
|
||||
const o = opts || {}; const f = o.fs || fs; const mode = o.mode || 0o644;
|
||||
if (typeof absPath !== 'string' || !absPath) throw fail(400, '저장할 파일 경로가 없습니다');
|
||||
if (typeof content !== 'string') throw fail(400, '저장할 내용이 문자열이어야 합니다');
|
||||
const tmp = path.join(path.dirname(absPath), `.${path.basename(absPath)}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
||||
try {
|
||||
f.writeFileSync(tmp, content, { mode });
|
||||
if (typeof f.chmodSync === 'function') f.chmodSync(tmp, mode); // umask 와 무관하게 nginx 가 읽을 수 있는 모드로(테스트·CLI 는 umask 077 일 수 있다)
|
||||
f.renameSync(tmp, absPath);
|
||||
} catch (e) {
|
||||
try { f.unlinkSync(tmp); } catch (_) { /* 임시 파일 정리 실패는 원래 오류를 가리지 않는다 */ }
|
||||
throw e;
|
||||
}
|
||||
return tmp;
|
||||
}
|
||||
|
||||
module.exports = { DISABLED_TYPE, MARK, splitDocument, prepareCanvas, restoreFromCanvas, validateVisualSave, contentHash, isHtmlPath, isDangerousUrl, scanTags, parseAttrs, writeTextAtomic };
|
||||
Reference in New Issue
Block a user