Files
AriWeb-Suite/modules/webmaker/lib/visual-editor.js
T

348 lines
29 KiB
JavaScript
Raw Normal View History

'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;
}
/** 속성 배열 → 여는 태그 문자열. 값은 항상 큰따옴표로 감싸고 값 안의 큰따옴표는 &quot; 로 */
function buildTag(name, attrs, selfClosing) {
const a = attrs.map((x) => x.value === null ? x.name : `${x.name}="${String(x.value).replace(/"/g, '&quot;')}"`).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 과 같다(한 구현):
* 이름 엔티티(&colon; &Tab; &NewLine; &sol; …)·숫자 엔티티(10/16진, 세미콜론 생략·앞자리 0 포함)를 두 겹까지 풀고, 제어·공백·폭 없는 문자를 걷어낸 뒤 스킴을 본다.
* 스킴 자리에 풀리지 않은(모르는) 엔티티가 남아 있으면 "흐린 스킴" 으로 보아 거절한다(fail-closed). 1.8.0 검토 수정: 예전에는 숫자 엔티티·&tab;·&newline; 만 풀어 &colon; 를 놓쳤다.
*/
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, '&quot;')}" 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 };