[verified] feat: add Webmaker visual editor v0.2.0
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
# AI Webmaker (WebHoting AI Agent) — v1.7.3
|
||||
# AI Webmaker (WebHoting AI Agent) — v1.8.0
|
||||
|
||||
AI 로 웹페이지를 자동 생성하고 같은 서버(nginx)에서 바로 호스팅하는 에이전트.
|
||||
|
||||
## 변경 이력
|
||||
전체 변경 기록은 [CHANGELOG.md](CHANGELOG.md), 오류 점검 목록은 [ERRORS.md](ERRORS.md) 에 있다.
|
||||
- **1.8.0** **직접 편집(시각 편집기)** — 초안이 만들어져 미리보기가 보이면 오른쪽 도구 줄의 "직접 편집" 으로 글을 바로 고치고, 블록을 끌어 옮기고, 구성 요소(섹션·제목·문단·버튼·이미지·구분선·여백)를 팔레트에서 끌어다 놓는다(버튼·키보드 대안 포함). 속성 창(글·링크·이미지·정렬·색·배경·여백), 실행 취소/다시 실행, 저장 상태 표시, Ctrl+S 저장, 페이지·사이트 전환·창 닫기 전 저장하지 않은 변경 보호. 저장은 선택한 HTML 파일을 갱신하고 버전 `visual-edit` 로 남기며 AI 캐시를 쓰지 않는다. 캔버스는 sandbox(출처 null)+CSP nonce 로 페이지 스크립트를 실행하지 않고, 서버는 원본에 없던 스크립트·이벤트 속성·위험 주소·iframe 등을 거절하며 머리·꼬리는 글자 그대로 보존한다(`lib/visual-editor.js`, `public/visual-editor-core.js`, `public/wm-ve-canvas.js`, `public/visual-editor-ui.js`). 독립 검토 차단 항목 12건 수정(캔버스 MessagePort 연결 규약, 이름 엔티티 주소 정규화, 구조 스캐너 기반 CSP 삽입, 완성 문서 전체 비교, 사이트별 변경 잠금, 저장 트랜잭션·되돌리기, 전환 보호, 복제 id, 끌기 정리 — CHANGELOG §8) + 종결 검토 3건(SVG SMIL 애니메이션 요소 거절, Git 저장 트랜잭션 재설계 — 로컬 커밋·기록 뒤 main + 변경 브랜치 원자적 푸시·검증된 되돌리기, Suite 릴리스 승인 규칙 — CHANGELOG §9) + 최종 검토 5건(Suite 승인 불변, 사이트 변경 요청의 잠금 안 재적재, 트랜잭션 준비 단계·모호한 푸시 판정, 문서, 배경 작업 저장이 짧은 변경을 덮어쓰지 않는 잎 단위 저장소 합치기와 같은 잎 동시 변경의 fail-closed 거절 — CHANGELOG §10) + 4차 검토 4건(Suite artifact 설치 불변, 읽기 요청 부작용 제거, 폐기 잠금·표식 규약, svg/math 안 title 파서 차이 — CHANGELOG §11). 테스트 115개(원본 52 + 직접 편집 63). 실제 브라우저 게이트(Playwright, Chrome) 61/61.
|
||||
- **1.7.3** 도메인 연결 오류 수정(배포 서버에 `log_format wmstats` 가 이미 있으면 중복 정의하지 않고, 중복으로 nginx -t 가 실패하면 우리 파일을 지워 자가 복구 · nginx 1.19.4 미만은 ssl_reject_handshake 대신 자체 서명 인증서 기본 서버) + **메일 사용량 연동**(AriMail 1.0.9 사용량 API: 관리자 콘솔에서 키 발급·보관(보관소 `arimail-usage`), 도메인별 메일함 용량·메일 수·발송/수신 표, 사용자 메일 화면 사용량 행, 사이트 정보 "메일 데이터" = 서버 메일함 용량 + 메일 사용량 구역(30일 막대)).
|
||||
- **1.7.2** 도메인 관리 역할 분리 — 도메인 화면은 구매·갱신·**네임서버 이전 4단계 띠**, DNS 화면은 **사이트 ↔ 도메인 연결 표**(하위 이름 선택·연결 끊기)가 먼저이고 레코드 관리는 그 아래, 메일 화면은 계정 형식·접속 주소·초기 관리자를 한눈에; **사이트 정보 페이지**를 오른쪽 캔버스로 옮겨 방문자 막대·송수신 선 그래프·7일/월간(3개월) 막대·디스크(웹/DB/메일) 그래프를 SVG 로 표시. 두 서버 로그 0건. bear 배포 중단.
|
||||
- **1.6.40** 사용자 화면 전면 재구성 — 작업 흐름 중심의 세 칸 구조(레일: 홈 / 만들기·수정·배포·운영 / 도메인·DNS·메일 / 캐시·충전·설정, 상태 점·배지), **홈**(단계별 상태·다음 할 일·의존 관계 지도), **배포** 화면(준비 점검표 + 발행·원본 서버 반영·검색 등록), **DNS**·**메일** 화면 분리, **캐시·충전** 화면(선불 충전 상품 → 결제 진행은 PG 미연동이라 결제 대기 주문만 → 관리자 "입금 확인 → 적립", 적립 = 결제 금액 + 인센티브), 관리자 콘솔 선불 충전 상품·주문 관리(`lib/prepaid.js`). 로그 점검 두 서버 0건.
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
# 직접 편집(시각 편집기) 1.8.0 — TDD RED→GREEN 기록
|
||||
|
||||
- 작업 트리: `/home/master/work/WebHoting_AI_Agent-visual-editor-v180`, 브랜치 `feature/visual-editor-v1.8.0`, 기준 `origin/main 3b10859884ef2fd0f2af11934ab6c16370fd64b8`
|
||||
- 날짜: 2026-10-28 · Node v24.22.1 · 커밋·push·배포 없음(Neo 검토 전 구현 인도)
|
||||
- 아래 출력은 실제 실행 결과에서 그대로 옮겼다(요약을 위해 스택 줄만 줄임). 실행 때 `CLAUDE_BIN` 은 바로 실패하는 가짜 실행 파일로 두어 개발 PC 의 진짜 `claude` 가 사이트 생성 작업으로 실행되지 않게 했다(첫 RED 실행에서는 그 설정이 없어 진짜 claude 프로세스가 하나 시작되었고 즉시 종료시켰다 — 이 때문에 두 테스트 파일 모두 가짜 실행 파일을 쓰도록 고쳤다).
|
||||
|
||||
## 조각 1·2 — 서버 순수 모듈(lib/visual-editor.js)·공용 핵심(public/visual-editor-core.js)
|
||||
|
||||
RED — `node --test test/visual-editor.test.js` (모듈이 없음)
|
||||
```
|
||||
Error: Cannot find module '/home/master/work/WebHoting_AI_Agent-visual-editor-v180/lib/visual-editor'
|
||||
Require stack:
|
||||
- /home/master/work/WebHoting_AI_Agent-visual-editor-v180/test/visual-editor.test.js
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
```
|
||||
중간(모듈 구현 뒤 첫 실행) — 12/15 통과, 3 실패: ① 테스트의 정규식이 보존 속성 `data-wm-ve-href="javascript:…"` 까지 잡아 `/\shref="javascript:/` 로 고침, ② `restoreFromCanvas` 가 덧씌운 UI 요소 안의 **글자**("삭제")를 남겨 범위 제거(`stripUiElements`)로 구현 수정, ③ 런타임 파일(`public/wm-ve-canvas.js`·`public/visual-editor-ui.js`) 미존재(조각 4 에서 해결).
|
||||
|
||||
GREEN — `node --test test/visual-editor.test.js`
|
||||
```
|
||||
✔ splitDocument: 머리(doctype·head·body 여는 태그)·본문·꼬리로 나눈다
|
||||
✔ prepareCanvas: 페이지 스크립트·핸들러·javascript: 링크·iframe 을 모두 비활성화하고 CSP nonce·base·런타임을 넣는다
|
||||
✔ restoreFromCanvas: 편집기 표식·덧씌운 요소를 지우고 보존 속성을 되돌린다
|
||||
✔ validateVisualSave: 글만 고친 본문은 머리·doctype·스타일·스크립트를 그대로 둔 채 합쳐진다
|
||||
✔ validateVisualSave: 새 스크립트·이벤트 속성·위험 주소·금지 요소·스타일 표현식·구조 태그·크기·형식 오류를 거절한다
|
||||
✔ validateVisualSave: 원래 있던 스크립트·iframe 을 지우는 것은 허용(구성 요소 삭제)되고 숫자가 늘면 거절
|
||||
✔ validateVisualSave: body 가 없는 문서는 거절하고 머리 없는 조각은 본문만 저장한다
|
||||
✔ contentHash·isHtmlPath
|
||||
✔ core: 브라우저 전역·Node 모듈 양쪽에서 로드되고 런타임 파일은 문법 검사를 통과한다
|
||||
✔ core.createHistory: 실행 취소·다시 실행·새 변경 뒤 다시 실행 지움·한도
|
||||
✔ core.createSaveState: 저장 안 됨 → 저장 중 → 저장됨/오류 전이와 한국어 표시
|
||||
✔ core.validateMessage: 보낸 창·출처·세션·형식·종류를 모두 확인해야 통과한다
|
||||
✔ core.COMPONENTS: 필수 구성 요소가 있고 각 HTML 은 스크립트·핸들러·위험 주소가 없다
|
||||
✔ core.moveIndex·dropIndexFor: 재배치 계산
|
||||
✔ core.safeUrlInput·propsFor·labelFor: 속성 편집 규칙
|
||||
ℹ tests 15
|
||||
ℹ pass 15
|
||||
ℹ fail 0
|
||||
```
|
||||
|
||||
## 조각 3·4 — 서버 경로(server.js)·화면(index.html·style.css·app.js·visual-editor-ui.js·wm-ve-canvas.js)
|
||||
|
||||
RED — `node --test test/visual-editor-server.test.js` (경로가 없어 404, 화면에 버튼 없음)
|
||||
```
|
||||
✖ 권한·출처: 소유자만, 다른 사용자는 404, 로그인 없이는 401, 출처(Referer)·CSRF 없는 fetch 는 거절
|
||||
✖ 허용 페이지: HTML 만, 경로 탈출·링크·없는 파일 거절
|
||||
✖ 캔버스 문서 계약: 페이지 스크립트·핸들러는 실행되지 않는 형태, CSP nonce·편집 런타임·base·세션이 있고 GET /files 는 hash 를 준다
|
||||
✖ 저장: 파일 갱신·버전 기록(visual-edit)·미리보기 목록·캐시 미차감·AI 수정 횟수·명령 기록 미증가·표식 제거·머리 보존
|
||||
✖ 오래된 저장(다른 창이 먼저 저장) 은 409 로 거절하고 최신 hash 를 알려 준다 · 잘못된 본문은 400
|
||||
✖ AI 작업 실행 중에는 저장이 409 로 거절된다 (가짜 claude 실행 파일로 작업을 멈춰 둔 서버)
|
||||
✖ 화면: 직접 편집 전환·저장 상태·저장/실행 취소/다시 실행/버리기·팔레트·속성 창이 접근성 속성과 함께 있고 런타임 파일이 서빙된다
|
||||
ℹ pass 0
|
||||
ℹ fail 7
|
||||
actual: 404,
|
||||
expected: 200,
|
||||
```
|
||||
중간 1 — 4/7 통과: 저장·오래된 저장 테스트가 "AI 작업 실행 중" 409 를 받음 → 사이트 생성이 띄운 AI 작업이 **진짜 claude** 로 실행되고 있었다(개발 PC PATH). 테스트를 가짜 실행 파일(`CLAUDE_BIN`, 즉시 exit 1)로 바꾸고 작업 종료를 기다리게 수정. 화면 테스트의 반응형 정규식(`[^}]*` → `[^@]*`) 수정.
|
||||
중간 2 — 6/7: 오래된 저장 테스트가 `baseHash:'deadbeef'`(형식 오류 400)를 보냄 → 64자리 16진으로 수정. 그 뒤 JSON 파싱 실패가 500 으로 나와 **서버 수정**(직접 편집 PUT 은 본문 읽기 실패를 400 한국어 안내로).
|
||||
|
||||
GREEN — `node --test test/visual-editor-server.test.js`
|
||||
```
|
||||
✔ 권한·출처: 소유자만, 다른 사용자는 404, 로그인 없이는 401, 출처(Referer)·CSRF 없는 fetch 는 거절 (1266ms)
|
||||
✔ 허용 페이지: HTML 만, 경로 탈출·링크·없는 파일 거절 (24ms)
|
||||
✔ 캔버스 문서 계약: 페이지 스크립트·핸들러는 실행되지 않는 형태, CSP nonce·편집 런타임·base·세션이 있고 GET /files 는 hash 를 준다 (11ms)
|
||||
✔ 저장: 파일 갱신·버전 기록(visual-edit)·미리보기 목록·캐시 미차감·AI 수정 횟수·명령 기록 미증가·표식 제거·머리 보존 (138ms)
|
||||
✔ 오래된 저장(다른 창이 먼저 저장) 은 409 로 거절하고 최신 hash 를 알려 준다 · 잘못된 본문은 400 (147ms)
|
||||
✔ AI 작업 실행 중에는 저장이 409 로 거절된다 (가짜 claude 실행 파일로 작업을 멈춰 둔 서버) (510ms)
|
||||
✔ 화면: 직접 편집 전환·저장 상태·저장/실행 취소/다시 실행/버리기·팔레트·속성 창이 접근성 속성과 함께 있고 런타임 파일이 서빙된다 (22ms)
|
||||
ℹ pass 7
|
||||
ℹ fail 0
|
||||
ℹ duration_ms 2255.904911
|
||||
```
|
||||
|
||||
## 전체 묶음 — `CLAUDE_BIN=<가짜 exit 1> npm test` (node --test test/*.test.js)
|
||||
```
|
||||
ℹ tests 74
|
||||
ℹ pass 74
|
||||
ℹ fail 0
|
||||
ℹ duration_ms 90188.973409
|
||||
```
|
||||
(원본 1.7.3 의 52개 + 새 22개. 기존 테스트 변경 없음.)
|
||||
|
||||
## 요구 ↔ 테스트 대응
|
||||
| 요구 | 테스트 |
|
||||
|---|---|
|
||||
| 권한(소유자만)·다른 사용자 404·로그인 401 | server: 권한·출처 |
|
||||
| CSRF/출처(Referer 없는 fetch·생성 사이트 페이지 출처 거절) | server: 권한·출처 |
|
||||
| 허용 페이지(HTML 만·경로 탈출·링크·없는 파일) | server: 허용 페이지 · unit: isHtmlPath |
|
||||
| 낙관적 동시성/오래된 저장 409 + currentHash | server: 오래된 저장 |
|
||||
| 안전한 정화·규약(스크립트·핸들러·위험 주소·금지 요소·스타일 표현식·구조 태그·표식 제거·머리 보존) | unit: prepareCanvas·restoreFromCanvas·validateVisualSave ×4 · server: 캔버스 계약·저장 |
|
||||
| 스크립트 비실행 계약(disabled type·CSP nonce·보존 속성·세션·부모 출처) | unit: prepareCanvas · server: 캔버스 계약 |
|
||||
| 저장이 버전 이력(visual-edit)을 만들고 캐시·AI 명령에 세지 않음 | server: 저장 |
|
||||
| 실행 취소/다시 실행 핵심 | unit: core.createHistory |
|
||||
| 끌어 옮기기/구성 요소 삽입 핵심(재배치 계산·구성 요소 안전) | unit: core.moveIndex·dropIndexFor·COMPONENTS |
|
||||
| 저장 상태/이동 보호 | unit: core.createSaveState · server: 화면(상태 aria-live·버튼) · app.js 연결(정적) |
|
||||
| 필수 접근성 조작 | server: 화면 |
|
||||
| AI 작업 중 거절 | server: AI 작업 실행 중 |
|
||||
| 잘못된 payload·크기 | server: 오래된 저장(400/413) · unit: validateVisualSave |
|
||||
|
||||
## 검토 수정(Neo 독립 검토, 2026-10-28) — 동시 저장 경쟁(출시 차단) · 원자적 저장(안전 강화)
|
||||
|
||||
**지적 ①(차단)**: 직접 편집 PUT 이 `await readBody(req)` **앞**에서 파일을 읽어 hash 를 잡아 두었다. 두 요청이 본문을 읽는 동안 나란히 들어오면 둘 다 같은 hash 를 통과해 둘째가 첫째를 조용히 덮어쓴다. **지적 ②(강화)**: `fs.writeFileSync` 로 운영 HTML 을 직접 덮어써 쓰기 실패·중단 때 마지막 정상 페이지가 잘릴 수 있다.
|
||||
|
||||
RED — 새 테스트 3건 추가 뒤 실행
|
||||
- `node --test test/visual-editor.test.js` (helper 없음)
|
||||
```
|
||||
✖ writeTextAtomic: 같은 폴더의 임시 파일(0644)에 쓴 뒤 rename 하고, 실패하면 임시 파일을 지우고 원본을 건드리지 않는다
|
||||
TypeError [Error]: ve.writeTextAtomic is not a function
|
||||
ℹ pass 15
|
||||
ℹ fail 1
|
||||
```
|
||||
- `node --test test/visual-editor-server.test.js` — 경쟁 테스트는 chunked PUT 두 개의 머리+본문 앞 12바이트만 보내 둔 채 400ms 기다려(두 처리기가 본문 읽기에서 멈춤) A 를 끝내고 200 을 받은 **뒤** B 를 끝낸다. 원자성 테스트는 사이트 폴더를 0555 로 바꿔 임시 파일을 만들 수 없게 한다(기존 파일 직접 덮어쓰기는 이 조건에서도 "성공" 한다).
|
||||
```
|
||||
✖ 동시 저장 경쟁: 두 PUT 이 본문을 다 보내기 전에 함께 들어와도 둘째는 409 — 첫 저장이 조용히 덮어써지지 않는다
|
||||
AssertionError [ERR_ASSERTION]: 둘째는 오래된 저장: {"path":"index.html", … "change":{"no":2,"kind":"visual-edit", …}}
|
||||
actual: 200,
|
||||
expected: 409,
|
||||
✖ 원자적 저장: 임시 파일을 만들 수 없는 폴더(읽기 전용)에서는 500 과 한국어 안내, 기존 페이지는 그대로이고 임시 파일도 남지 않는다
|
||||
actual: 200,
|
||||
expected: 500,
|
||||
ℹ pass 7
|
||||
ℹ fail 2
|
||||
```
|
||||
(둘째 요청이 200 을 받고 파일에 "둘째-B" 가 남아 첫 저장이 사라졐음을 확인 — 재현 성공.)
|
||||
|
||||
수정
|
||||
- `lib/visual-editor.js` `writeTextAtomic(absPath, content, {fs, mode})`: 같은 폴더의 `.<이름>.<pid>.<무작위>.tmp` 에 쓰고(0644, umask 와 무관하게 chmod) rename. 실패하면 임시 파일을 지우고(정리 실패는 무시) 오류를 그대로 던진다. 파일 시스템 함수를 주입할 수 있어 rename 실패·쓰기 실패 경로를 결정적으로 검증한다.
|
||||
- `server.js` 직접 편집 PUT: 본문을 다 읽은 **뒤** `store.load` 로 사이트 기록을, `fs.readFileSync` 로 파일을 다시 읽어 hash 를 비교하고, 검증·원자적 쓰기까지 await 없이 이어 간다. 쓰기 실패는 500 + "페이지를 저장하지 못했습니다 (…). … 기존 페이지는 그대로 있습니다". GET 은 바뀌지 않았다. 소스 편집기(`PUT /files`)의 기존 쓰기는 손대지 않았다.
|
||||
- 중간 실행: helper 가 0644 를 돌려주지 않아(테스트 프로세스 umask 077 → 0600) chmod 를 추가.
|
||||
|
||||
GREEN
|
||||
```
|
||||
node --test test/visual-editor.test.js
|
||||
ℹ tests 16
|
||||
ℹ pass 16
|
||||
ℹ fail 0
|
||||
node --test test/visual-editor-server.test.js
|
||||
✔ 동시 저장 경쟁: 두 PUT 이 본문을 다 보내기 전에 함께 들어와도 둘째는 409 — 첫 저장이 조용히 덮어써지지 않는다 (655ms)
|
||||
✔ 원자적 저장: 임시 파일을 만들 수 없는 폴더(읽기 전용)에서는 500 과 한국어 안내, 기존 페이지는 그대로이고 임시 파일도 남지 않는다 (243ms)
|
||||
ℹ tests 9
|
||||
ℹ pass 9
|
||||
ℹ fail 0
|
||||
```
|
||||
전체 묶음(수정 뒤): 아래 "전체 묶음 — 검토 수정 뒤" 절.
|
||||
|
||||
## 전체 묶음 — 검토 수정 뒤
|
||||
`CLAUDE_BIN=<가짜 exit 1> npm test` (node --test test/*.test.js)
|
||||
```
|
||||
ℹ tests 77
|
||||
ℹ pass 77
|
||||
ℹ fail 0
|
||||
ℹ duration_ms 90193.018088
|
||||
```
|
||||
(원본 52 + 직접 편집 25. Suite 체크아웃의 `modules/webmaker` 에서 같은 명령: tests 84 · pass 84 · fail 0 — 0.1.1 의 ariweb.test.js 7건 포함.)
|
||||
|
||||
## 실제 브라우저 시각 검증 수정(Neo 의 브라우저 게이트, 2026-10-28) — 직접 편집 중 배치(출시 차단)
|
||||
|
||||
**발견(실제 브라우저, 독립 Webmaker 코드)**: 1280×633 에서 직접 편집을 켜도 `.app` 이 레일 232px + 작업 칸 `.left` 460px + 캔버스 세 칸 그대로였고 `.ve-palette`·`.ve-inspector` 가 각 240px 이라 **iframe 실제 폭이 114.625px**, 한국어 제목이 한 글자씩 줄바꿈되었다. 390×844 에서는 `.left` 가 그대로 보여 캔버스가 y≈2228 로 밀려 옛 작업 폼을 다 지나야 편집기가 나왔다.
|
||||
**원인**: `visual-editor-ui.js` 는 켤 때 `body.ve-on` 을 넣고 끌 때 빼지만, `style.css` 에 그 class 로 작업 칸을 숨기거나 `.app` 격자를 다시 짜는 규칙이 없었다(`.ve-on` 은 전환 버튼 색에만 쓰였다).
|
||||
|
||||
RED — `node --test test/visual-editor.test.js` (새 계약 테스트 3건 추가 뒤; CSS 파서가 선택자·속성값을 읽는다 — 주석 문자열은 보지 않는다)
|
||||
```
|
||||
✖ style.css 계약: body.ve-on 이면 옛 작업 칸(.left)을 숨기고 .app 을 레일 + 전체 폭 캔버스로 바꾼다 — 1280px 에서 캔버스 폭 ≥ 500px
|
||||
AssertionError [ERR_ASSERTION]: 직접 편집 중 옛 작업 칸 숨김
|
||||
✖ style.css 계약: 900px 이하에서 body.ve-on 은 레일 바로 아래에 캔버스를 두고(옛 작업 칸 행 없음) 팔레트·속성 창은 캔버스 아래에 쌓인다
|
||||
AssertionError [ERR_ASSERTION]: 좁은 화면 body.ve-on .app 규칙
|
||||
✔ visual-editor-ui.js 동작: open() 이 body.ve-on 을 켜고 패널·도구 줄을 보이며, close() 가 모두 되돌리고 미리보기를 다시 읽는다
|
||||
ℹ tests 19
|
||||
ℹ pass 17
|
||||
ℹ fail 2
|
||||
```
|
||||
세 번째 테스트(가짜 DOM 위에서 `visual-editor-ui.js` 를 실행해 `open()`/`close()` 가 `body.ve-on`·패널·`aria-pressed`·srcdoc 을 켜고 되돌리는지)는 RED 단계에서 이미 통과 — JS 전환은 맞고 **CSS 규칙만 빠졌다**는 진단이 테스트로 확인되었다.
|
||||
|
||||
수정(`public/style.css`, 두 저장소 동일)
|
||||
```
|
||||
body.ve-on .app { grid-template-columns: var(--rail-w) minmax(0, 1fr); }
|
||||
body.ve-on .left { display: none; }
|
||||
@media (max-width: 1180px) { body.ve-on .ve-side { flex-basis: 190px; } }
|
||||
@media (max-width: 900px) { … body.ve-on .app { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); height: 100vh; } body.ve-on .right { min-height: 0; } }
|
||||
```
|
||||
계산: 1280px → 캔버스 1280 − 232 − 240×2 = **568px**(양쪽 패널 펼침). 1100px(레일 204px) → 1100 − 204 − 190×2 = 516px. 390px → 레일(가로 띠) 다음 행이 바로 캔버스(`frame-wrap` order 1, min-height 40vh), 팔레트·속성 창은 아래에 쌓이고 기본 접힘. 끝내면 `body.ve-on` 이 빠져 원래 `var(--rail-w) var(--left-w, 460px) 1fr` 세 칸이 그대로 돌아온다(작업 칸 DOM 은 건드리지 않는다).
|
||||
|
||||
GREEN — `node --test test/visual-editor.test.js` (독립 저장소 / Suite `modules/webmaker` 모두)
|
||||
```
|
||||
ℹ tests 19
|
||||
ℹ pass 19
|
||||
ℹ fail 0
|
||||
```
|
||||
전체 묶음(수정 뒤): 아래 "전체 묶음 — 배치 수정 뒤".
|
||||
|
||||
## 전체 묶음 — 배치 수정 뒤
|
||||
`CLAUDE_BIN=<가짜 exit 1> npm test` (node --test test/*.test.js)
|
||||
```
|
||||
ℹ tests 80
|
||||
ℹ pass 80
|
||||
ℹ fail 0
|
||||
```
|
||||
(원본 52 + 직접 편집 28. Suite 체크아웃의 `modules/webmaker` 에서 같은 명령: tests 87 · pass 87 · fail 0. Suite 전체 `node test/run.mjs` 결과는 Suite 의 `docs/verification/webmaker-visual-editor-0.2.0.md`.)
|
||||
|
||||
## 실제 브라우저 시각 검증 수정 2(Neo 의 배치 수정 뒤 게이트, 2026-10-28) — 선택 전 도구 줄 노출(출시 차단)
|
||||
|
||||
**발견(실제 브라우저)**: 직접 편집을 켜고 아무 요소도 고르지 않았는데 어두운 선택 도구 줄 `.wm-ve-tools` 가 페이지 위에 이미 그려져 있었다. 속성 창은 "선택한 요소 없음", DOM 은 `tools.hidden = true` 였다.
|
||||
**원인**: 캔버스 런타임(`wm-ve-canvas.js`)이 끼워 넣는 작성자 규칙 `.wm-ve-tools{…display:flex…}` 가 사용자 에이전트 규칙 `[hidden]{display:none}` 을 덮어쓴다(작성자 origin 은 명시도와 무관하게 UA 를 이긴다). 그래서 `hidden` 속성이 있어도 flex 로 보였다. `renderOverlay()` 가 `hidden=false` 로 바꾸기 전까지 보이지 않아야 하고, 선택 해제·미리보기·`applyHtml` 뒤 다시 숨겨져야 한다.
|
||||
|
||||
RED — `node --test test/visual-editor.test.js` (새 계약 테스트 1건 추가 뒤). 테스트는 런타임 소스의 CSS 블록(`st.textContent`)을 꺼내 파싱하고, `hidden` 인 `.wm-ve-tools`·`.wm-ve-drop` 요소의 최종 display 를 cascade 모형(!important > `[hidden]` 명시도 > 나중 선언, 작성자 규칙이 없으면 UA 의 none)으로 계산한다 — 주석 문자열은 보지 않는다.
|
||||
```
|
||||
✖ wm-ve-canvas.js CSS 계약: hidden 인 선택 도구 줄(.wm-ve-tools)·놓기 표시(.wm-ve-drop)는 작성자 CSS 가 다시 보이게 만들 수 없다
|
||||
AssertionError [ERR_ASSERTION]: hidden 인 .wm-ve-tools 의 최종 display
|
||||
actual: 'flex',
|
||||
expected: 'none',
|
||||
ℹ tests 20
|
||||
ℹ pass 19
|
||||
ℹ fail 1
|
||||
```
|
||||
|
||||
수정(`public/wm-ve-canvas.js` 런타임 CSS, 두 저장소 byte-identical) — 한 줄 추가, 다른 동작 변경 없음
|
||||
```
|
||||
.wm-ve-tools[hidden],.wm-ve-drop[hidden]{display:none !important}
|
||||
```
|
||||
`hidden` 을 켜고 끄는 JS(`renderOverlay`·끌기 시작·`showDrop`·dragleave)는 그대로이며, 보일 때의 `display:flex` 도 그대로다(테스트가 함께 확인).
|
||||
|
||||
GREEN — `node --test test/visual-editor.test.js` (독립 저장소 / Suite `modules/webmaker` 모두)
|
||||
```
|
||||
ℹ tests 20
|
||||
ℹ pass 20
|
||||
ℹ fail 0
|
||||
```
|
||||
|
||||
## 전체 묶음 — 도구 줄 수정 뒤
|
||||
`CLAUDE_BIN=<가짜 exit 1> npm test` (node --test test/*.test.js)
|
||||
```
|
||||
ℹ tests 81
|
||||
ℹ pass 81
|
||||
ℹ fail 0
|
||||
```
|
||||
(원본 52 + 직접 편집 29. Suite 체크아웃의 `modules/webmaker` 에서 같은 명령: tests 88 · pass 88 · fail 0. Suite 전체 `node test/run.mjs` 결과는 Suite 의 `docs/verification/webmaker-visual-editor-0.2.0.md`.)
|
||||
|
||||
## 실제 브라우저 게이트 3(Playwright 자동 게이트, 2026-10-28) — 팔레트 → 캔버스 끌어다 놓기 실패(출시 차단)
|
||||
|
||||
**발견(실제 Chrome, Playwright frameLocator 로 캔버스 iframe 을 직접 조작)**: 선택·contenteditable 글 편집·실행 취소/다시 실행·Alt+↑/↓ 이동·팔레트 표시까지 19/19 확인 통과. 그러나 부모 팔레트의 "문단" 을 캔버스에 실제 HTML5 끌어다 놓기 하면 아무것도 들어가지 않았다(`시간 안에 되지 않음: 끌어다 놓기 뒤 요소 수 증가`).
|
||||
**원인**: 부모 dragstart 는 전용 MIME `text/wm-ve-component` 만 넣었고, 캔버스는 `sandbox="allow-scripts"`(allow-same-origin 없음) 의 **출처 'null' 문서**다. Chromium 은 다른 출처가 넣은 전용 MIME 끌기 자료를 이 경계 너머로 넘겨주지 않는다(종류 목록에서도 사라진다) → 캔버스의 dragover/drop 이 끌기를 인식하지 못했다. 하네스 한계가 아니라 제품 결함.
|
||||
|
||||
설계(요구대로): 전용 MIME 은 유지하고, 부모가 표준 `text/plain` 에 이름 공간 값 `wm-ve-component:<검증된 id>` 를 **함께** 넣는다. 캔버스는 안전한 추출을 한곳(`core.dragComponentId`)에 모아 전용 MIME 을 우선 읽고, 없으면 text/plain 이 그 형식에 **정확히** 맞고 `core.COMPONENTS` 의 알려진 id 일 때만 받는다. dragover 때는 브라우저가 getData 를 막으므로 종류에 전용 MIME 또는 text/plain 이 있으면 끌기로 인식해 놓기 표시를 보인다(`core.isComponentDrag`). drop 때 id 가 틀리거나 임의 외부 글이면 아무것도 넣지 않고 표식도 남기지 않는다. sandbox(allow-same-origin 추가 없음)·CSP·메시지 검증·저장 검증·"추가" 버튼 대안은 그대로.
|
||||
|
||||
RED — `node --test test/visual-editor.test.js` (새 테스트 3건 추가 뒤)
|
||||
```
|
||||
✖ core.dragComponentId·isComponentDrag·componentDragText: 전용 MIME 이 없어도 이름 공간이 붙은 text/plain 만 정확히 받고, 임의 글·모르는 id 는 거절한다
|
||||
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: actual: undefined, expected: 'text/wm-ve-component'
|
||||
✖ visual-editor-ui.js 팔레트 dragstart: 전용 MIME 과 함께 이름 공간이 붙은 text/plain 을 넣는다(sandbox iframe 으로 넘어가는 표준 종류)
|
||||
TypeError [Error]: core.componentDragText is not a function
|
||||
✖ wm-ve-canvas.js 동작: 전용 MIME 이 sandbox 경계에서 사라져도 이름 공간이 붙은 text/plain 으로 구성 요소 하나를 넣고, 임의 text/plain·모르는 id 는 아무것도 넣지 않는다
|
||||
AssertionError [ERR_ASSERTION]: dragover preventDefault(놓기 허용) actual: false, expected: true
|
||||
ℹ tests 23
|
||||
ℹ pass 20
|
||||
ℹ fail 3
|
||||
```
|
||||
세 번째는 **동작 테스트**다: 작은 가짜 DOM(요소 트리·속성·classList·innerHTML 파서/직렬화·querySelectorAll/closest·getBoundingClientRect·template.content·elementFromPoint·parent.postMessage)을 만들어 실제 `wm-ve-canvas.js` 를 vm 에서 부트하고, 전용 MIME 이 **없는** DataTransfer(`types: ['text/plain']`, drop 때 `text/plain = 'wm-ve-component:paragraph'`)로 dragover/drop 이벤트를 쏜다. 확인: dragover 가 기본 동작을 막고 놓기 표시를 보임 → drop 뒤 본문에 문단 **하나**가 h1 앞에 들어가고 선택되며 부모에 `changed` 본문(표식 없음)이 한 번 감 → 임의 글(`https://…`, `<img … onerror>`)·모르는 id(`wm-ve-component:evil`)·정확히 맞지 않는 값(`'…paragraph '`, `'paragraph'`)은 본문 불변·표식 없음·changed/error 없음(브라우저 기본 drop 은 막음) → 전용 MIME 이 남아 있으면 그 값 우선(heading → h2) → 파일/HTML 만 있는 끌기는 dragover/drop 모두 건드리지 않음. (가짜 DOM 의 innerHTML 파서가 자식 목록 참조를 그대로 돌려줘 둘째 요소가 빠지던 테스트 자체 결함을 먼저 고쳤다 — RED 가 "진짜 이유" 로 나도록.)
|
||||
|
||||
수정(두 저장소 byte-identical)
|
||||
- `public/visual-editor-core.js`: `COMPONENT_MIME`, `componentDragText(id)`, `isComponentDrag(types)`, `dragComponentId(dt)` 추가·export(한국어 주석에 이유).
|
||||
- `public/visual-editor-ui.js` 팔레트 dragstart: `setData(core.COMPONENT_MIME, id)` + `setData('text/plain', core.componentDragText(id))`.
|
||||
- `public/wm-ve-canvas.js` dragover/drop: `core.isComponentDrag(e.dataTransfer.types)` 로 인식, drop 에서 `core.dragComponentId` 가 null 이면 놓기 표시만 숨기고 반환(기본 drop 차단, 삽입 없음). drop 도 `mode === 'edit'` 에서만.
|
||||
|
||||
GREEN — `node --test test/visual-editor.test.js` (독립 저장소 / Suite `modules/webmaker` 모두)
|
||||
```
|
||||
ℹ tests 23
|
||||
ℹ pass 23
|
||||
ℹ fail 0
|
||||
```
|
||||
|
||||
## 전체 묶음 — 끌어다 놓기 수정 뒤
|
||||
`CLAUDE_BIN=<가짜 exit 1> npm test` (node --test test/*.test.js)
|
||||
```
|
||||
ℹ tests 84
|
||||
ℹ pass 84
|
||||
ℹ fail 0
|
||||
```
|
||||
(원본 52 + 직접 편집 32. Suite 체크아웃의 `modules/webmaker` 에서 같은 명령: tests 91 · pass 91 · fail 0. Suite 전체 `node test/run.mjs` 결과는 Suite 의 `docs/verification/webmaker-visual-editor-0.2.0.md`.)
|
||||
|
||||
## 실제 브라우저 게이트 4(살균 DnD 진단, 2026-10-28) — HTML5 끌기가 sandbox iframe 으로 넘어가지 않음 → 포인터 끌기 다리(출시 차단)
|
||||
|
||||
**발견**: MIME 보완(게이트 3 수정) 뒤 같은 게이트를 다시 돌렸는데 여전히 끌어다 놓기 단계에서 실패. 부모·캔버스 양쪽 window 에 살균 리스너(종류·types·defaultPrevented·text/plain·전용 MIME 존재 여부·이름 공간 일치 여부만, 실제 자료는 기록하지 않음)를 붙여 확인한 결과: **부모 dragstart 2건(text/plain·전용 MIME 모두 존재·형식 유효), 캔버스 dragenter 0·dragover 0·drop 0**. 즉 Chromium 은 HTML5 끌기 자체를 부모 문서에서 출처 'null' sandbox iframe 으로 넘기지 않는다 — dataTransfer 종류 보완으로는 해결할 수 없는 제품 결함.
|
||||
**설계(요구대로, sandbox 는 그대로 — allow-same-origin 추가 없음)**: 부모(`visual-editor-ui.js`)가 팔레트 항목의 **포인터**(마우스·펜)를 직접 추적한다. pointerdown("추가" 버튼 위는 제외, 터치·보조 버튼 제외) → 포인터 캡처 → `core.POINTER_DRAG_THRESHOLD`(4px) 넘게 움직이면 끌기 시작(표시 class) → iframe 위에서도 캡처로 pointermove 를 받아 `#frame.getBoundingClientRect()`(테두리·스크롤바 보정 `clientLeft/Top/Width/Height`)와 비교 → 안쪽이면 검증된 명령 `paletteDragAt { component, x, y }`(iframe 상대 좌표), 밖으로 나가면·Escape·pointercancel·끝내기면 `paletteDragCancel` → 안에서 pointerup 이면 `paletteDropAt { component, x, y }`. 캔버스(`wm-ve-canvas.js`)는 메시지 규약(보낸 창·출처·세션·종류)을 통과한 뒤 `core.isComponentId`·`core.canvasPoint`(유한 숫자·자기 뷰포트 안)·편집 모드를 다시 검사하고, 스스로 `dropTargetAt` → `showDrop` / `insertComponent` 를 한다(부모는 DOM 위치를 지정하지 않는다). 잘못된 id·좌표·모드는 아무것도 넣지 않고 놓기 표시를 숨긴다. HTML5 DnD 는 포인터 이벤트가 없는 환경의 대체 경로로 남기되, 포인터 끌기 중에는 네이티브 dragstart 를 막아 pointercancel 로 끊기지 않게 한다. CSS 는 `.ve-comp { user-select:none; touch-action:pan-y }`·끌기 중 class 만 추가(터치 스크롤·"추가" 버튼·키보드·메시지·CSP·저장 검증 불변).
|
||||
|
||||
RED — `node --test test/visual-editor.test.js` (새 테스트 3건 추가 뒤)
|
||||
```
|
||||
✖ core 포인터 끌기 규약: 메시지 종류·문턱·iframe 상대 좌표 계산(테두리 보정)·캔버스 좌표 검증
|
||||
AssertionError [ERR_ASSERTION]: paletteDragAt 는 부모→캔버스 종류
|
||||
✖ visual-editor-ui.js 포인터 끌기 다리: 팔레트 항목 pointerdown → 문턱 뒤 이동 추적 → iframe 안에서만 상대 좌표 paletteDragAt, 밖으로 나가면 paletteDragCancel, 안에서 pointerup 이면 paletteDropAt; "추가" 버튼·Escape·pointercancel 처리
|
||||
AssertionError [ERR_ASSERTION]: pointerdown/move/up 처리기
|
||||
✖ wm-ve-canvas.js 동작: paletteDragAt 이 놓기 표시를 보이고, paletteDropAt 이 계산한 자리에 구성 요소를 넣어 표식 없는 changed 를 한 번 보내며, 잘못된 id·좌표·미리보기 모드는 아무것도 하지 않고 표시를 숨긴다
|
||||
AssertionError [ERR_ASSERTION]: 놓기 표시 보임
|
||||
ℹ tests 26
|
||||
ℹ pass 23
|
||||
ℹ fail 3
|
||||
```
|
||||
동작 테스트 내용 — 부모(가짜 DOM 위 실제 `visual-editor-ui.js`): 바깥 pointerdown → 문턱 아래 이동(메시지 없음) → 문턱 넘어 바깥(표시 class 만) → iframe 안(400,200 → `paletteDragAt {x:100,y:100}`, 봉투 wmve·세션 그대로) → 밖(`paletteDragCancel` 한 번, 계속 밖이어도 한 번) → 안 → pointerup(`paletteDropAt {x:160,y:160}`, 캡처·class 해제, 이후 이벤트 무시) · "추가" 버튼 위 pointerdown 은 끌기 아님 · 짧은 클릭 메시지 없음 · 터치·보조 버튼 무시 · Escape → 취소 뒤 up 은 놓기 아님 · pointercancel → 취소 정리 · 포인터 끌기 중 네이티브 dragstart 차단, 평소엔 HTML5 자료 유지 · 편집 끝낸 뒤 메시지 없음. 캔버스(가짜 DOM 위 실제 `wm-ve-canvas.js`, 부모 창에서 온 것처럼 message 이벤트): `paletteDragAt` → 놓기 표시(h1 위쪽 절반 → h1 앞 선, p 아래쪽 절반 → p 아래 선) · 취소 → 숨김 · 모르는 id·뷰포트 밖·문자열 좌표 → 숨김 · `paletteDropAt` → h1 앞에 문단 하나, 표식 없는 `changed` 한 번, 새 요소 선택, 표시 숨김 · 빈 곳 → 본문 끝 · 모르는 id·좌표 누락/범위 밖/문자열·빈 payload·미리보기 모드 → 본문 불변·changed/error 없음 · 다른 세션·다른 보낸 창 → 무시.
|
||||
|
||||
GREEN — `node --test test/visual-editor.test.js` (독립 저장소 / Suite `modules/webmaker` 모두)
|
||||
```
|
||||
ℹ tests 26
|
||||
ℹ pass 26
|
||||
ℹ fail 0
|
||||
```
|
||||
|
||||
## 전체 묶음 — 포인터 끌기 다리 뒤
|
||||
`CLAUDE_BIN=<가짜 exit 1> npm test` (node --test test/*.test.js)
|
||||
```
|
||||
ℹ tests 87
|
||||
ℹ pass 87
|
||||
ℹ fail 0
|
||||
```
|
||||
(원본 52 + 직접 편집 35. Suite 체크아웃의 `modules/webmaker` 에서 같은 명령: tests 94 · pass 94 · fail 0. Suite 전체 `node test/run.mjs` 결과는 Suite 의 `docs/verification/webmaker-visual-editor-0.2.0.md`.)
|
||||
|
||||
## 독립 검토(읽기 전용) 차단 항목 12건 수정(2026-10-28) — 보안 7 · 규격/회귀 5
|
||||
검토는 참조 저장소·Suite 양쪽 작업 트리를 읽고 아래 항목을 출시 차단으로 보고했다. 항목마다 행동·적대 테스트를 먼저 써 RED 를 잡고 최소 수정으로 GREEN 을 만들었다. 공유 파일(`lib/visual-editor.js`·`lib/gitstore.js`·`public/visual-editor-core.js`·`visual-editor-ui.js`·`wm-ve-canvas.js`·`test/visual-editor*.test.js`)은 두 저장소에서 byte-identical(`cmp`). `server.js`·`public/app.js`·`lib/store.js` 는 참조 저장소 변경을 Suite 블록을 보존한 채 패치했다(Suite 전용 항목 7·11 은 Suite 쪽 기록 `docs/verification/webmaker-visual-editor-0.2.0.md`).
|
||||
|
||||
| # | 결함 | 수정 | 테스트(RED 증거) |
|
||||
|---|---|---|---|
|
||||
| 1 | iframe 안 문서가 바뀌면(a/area href 등) 새 문서가 같은 contentWindow·출처 null 로 부모의 `*` 명령에서 세션을 배워 changed/hotkey 를 꾸밀 수 있음 | 캔버스가 `MessageChannel` 포트를 첫 ready 에 동봉, 이후 포트 전용(`validatePortMessage`); 부모는 첫 ready 한 번만 연결·연결 뒤 창 메시지 전면 무시·끝내기 때 포트 닫음; 캔버스는 창 메시지를 듣지 않음; a/area/제출/`input[type=image]` 클릭·submit 차단(두 모드), `<base target="_blank">`, CSP form-action none, meta refresh 비활성화 | `visual-editor-ui.js 연결 규약`(RED: `ready 와 함께 포트가 넘어온다` 실패 — 포트 없음) · `wm-ve-canvas.js 연결 규약` · `캔버스 안의 이동·제출 활성화 … 막힌다`(RED: area 클릭 defaultPrevented false) · `prepareCanvas: 이동 활성화 방어`(RED: base 에 target 없음) |
|
||||
| 2 | `javascript:…` 등 이름 엔티티로 감춘 스킴 통과 | `core.urlScheme`/`isDangerousUrl`(서버 lib 가 core 를 require — 한 구현): 이름·숫자 엔티티 두 겹, 제어·폭 없는 문자 제거, 모르는 엔티티가 스킴 자리에 남으면 거절 | `isDangerousUrl·neutralizeTag·validateVisualSave: 이름 있는 엔티티…`(RED: `거절: "javascript:alert(1)"` 등) · `applyProps·core.safeUrlInput`(RED: `javascript:alert(1)` 통과) |
|
||||
| 3 | `/<head[^>]*>/` 가 주석·`<header>`·원문의 `<head>` 에 걸려 CSP·런타임이 엉뚱한 곳에 들어감 | `scanTags`(title·textarea 도 원문)로 진짜 head/html/body 를 찾아 넣고 없으면 진짜 `<head>` 생성 | `prepareCanvas: 주석·원문… 진짜 <head> 뒤에만`(RED: `주석은 그대로` 실패 — CSP 가 주석 안에 들어감) |
|
||||
| 4 | 원본 전체 ↔ 본문만 비교 → 머리의 스크립트·핸들러·위험 주소·iframe/object 를 본문에 복사해도 통과 | 완성 문서 전체 ↔ 원본 전체 비교(꼬리 스크립트 중복 제거 뒤) | `validateVisualSave: 머리(head)·body 여는 태그·꼬리에만 있던 … 거절`(RED: `<p>본문</p><script src="/head.js"></script>` 통과) |
|
||||
| 5 | 사전 검사만 지난 느린 요청이 다른 변경 도중에 씀; 소스 편집기 저장은 hash 검사 없음 | `withSiteLock`: 소스 PUT·직접 편집 PUT·버전 복원은 잠금 안에서 재검사→쓰기; AI/기획/가져오기/복사/다국어/배포 시작은 `claimJob`; 소스 PUT 선택적 `baseHash`(app.js 가 보냄) + 응답 `hash` | `사이트 변경 잠금: 소스 편집기 PUT(baseHash) 과 직접 편집 PUT …`(RED: 소스 저장 200 — 직접 편집 저장이 덮어써짐) · `… AI 작업이 시작되면 … 409`(RED: 200, AI 작업 중 파일 변경) |
|
||||
| 6 | 파일 쓴 뒤 기록/Git 실패를 삼키고 200 | 스냅샷(기록·HEAD) → 파일/기록 되돌리기(`store.save(prev,{restore:true})`)·`gitstore.rollbackTo` → 500; `gitRecord(...,{strict:true})` | `직접 편집 저장 트랜잭션: 사이트 기록(store) 저장이 실패하면 …`(RED: 500 이지만 파일은 바뀐 채) · `… Git 버전 기록이 실패하면 …`(RED: 200, change null) |
|
||||
| 8 | 홈 탭·openSite·HTML 없는 갱신이 묻지 않고 편집기를 끝냄 | `openSite` 전환 전 `close(false)`·취소 시 현재 사이트 반환, `showTab('home')` 질문, `onSiteUpdated` 가 다른 사이트/사라진 페이지에서 묻고 취소 시 유지, `renderPreview` 가 캔버스를 숨기지 않음 | `visual-editor-ui.js onSiteUpdated`(RED: 다른 사이트 갱신에 즉시 종료) · `test/visual-editor-app.test.js`(실제 app.js — RED: `VE.isActive()` actual false expected true, 취소했는데 끝남) |
|
||||
| 9 | 복제가 뿌리 id 만 지움 | 자손 `[id]` 모두 제거 | `duplicate: 복제본은 … 자손의 id 도 지우고`(RED: 복제본 자손 id 1개 남음) |
|
||||
| 10 | 블록 끌기에 캡처·pointercancel/blur/Escape 정리 없음 | `endBlockDrag`: 리스너·캡처·클래스·표시 정리; 손잡이 `setPointerCapture` | `기존 블록 끌기: … pointercancel·창 blur·Escape …`(RED: `setPointerCapture` 미호출) |
|
||||
|
||||
### RED — `node --test test/visual-editor.test.js`(수정 전 코드, 새 테스트 포함)
|
||||
```
|
||||
ℹ tests 37
|
||||
ℹ pass 24
|
||||
ℹ fail 13
|
||||
```
|
||||
실패 13건: 주소 엔티티 / prepareCanvas 구조 / 완성 문서 비교 / 부모 연결 규약 / onSiteUpdated / 캔버스 연결 규약 / 이동 활성화 차단 / base target·meta refresh / 복제 id / 끌기 정리 / applyProps 엔티티 / (포트 규약으로 옮긴) 포인터 다리 부모 / 캔버스 paletteDragAt — 대표 메시지 `Error: 포트가 연결되지 않음`, `AssertionError: 거절: "javascript:alert(1)"`, `AssertionError: 주석은 그대로`, `AssertionError: 거절되어야 함: <p>본문</p><script src="/head.js"></script>`.
|
||||
|
||||
### RED — `CLAUDE_BIN=<가짜 exit 1> node --test test/visual-editor-server.test.js`
|
||||
```
|
||||
ℹ tests 13
|
||||
ℹ pass 9
|
||||
ℹ fail 4
|
||||
```
|
||||
`actual: 200, expected: 409`(소스↔직접 편집 잠금) · `actual: "EACCES …sites/<id>.json….tmp", expected: /저장하지 못했습니다|되돌렸습니다/`(store 실패가 그대로 500 으로 새고 파일은 바뀜) · `actual: 200, expected: 500`(Git 실패를 삼킴) · `actual: 200, expected: 409`(AI 작업 시작과 겹친 저장).
|
||||
|
||||
### RED — `node --test test/visual-editor-app.test.js`(수정 전 `public/app.js` 스냅샷으로 실행)
|
||||
```
|
||||
ℹ pass 0
|
||||
ℹ fail 1
|
||||
AssertionError: Expected values to be strictly equal: actual: false, expected: true ← openSite(다른 사이트) 취소 뒤 VE.isActive()
|
||||
```
|
||||
|
||||
### GREEN — 수정 뒤
|
||||
```
|
||||
node --test test/visual-editor.test.js ℹ tests 37 · pass 37 · fail 0
|
||||
node --test test/visual-editor-server.test.js ℹ tests 13 · pass 13 · fail 0
|
||||
node --test test/visual-editor-app.test.js ℹ tests 1 · pass 1 · fail 0
|
||||
```
|
||||
|
||||
## 전체 묶음 — 독립 검토(1차) 차단 항목 수정 뒤
|
||||
`CLAUDE_BIN=<가짜 exit 1> npm test` (node --test test/*.test.js)
|
||||
```
|
||||
ℹ tests 103
|
||||
ℹ pass 103
|
||||
ℹ fail 0
|
||||
```
|
||||
(원본 52 + 직접 편집 51 = 단위 37 + 서비스 13 + 화면 전환 1. Suite 체크아웃의 `modules/webmaker` 에서 같은 명령: tests 110 · pass 110 · fail 0. Suite 전체 `node test/run.mjs`: 테스트 파일 24개 중 실패 0 — Suite 쪽 기록 `docs/verification/webmaker-visual-editor-0.2.0.md`.) 최종 수치는 아래 "종결 검토 뒤" 절.
|
||||
문법: `node --check` — `lib/visual-editor.js`·`lib/gitstore.js`·`lib/store.js`·`server.js`·`public/visual-editor-core.js`·`visual-editor-ui.js`·`wm-ve-canvas.js`·`public/app.js` 두 저장소 모두 통과. 공유 파일 두 저장소 byte-identical(`cmp`). `git diff --check` 깨끗함.
|
||||
|
||||
## 수정 뒤 브라우저 게이트 재실행 실패의 원인 조사(2026-10-28) — 게이트 멱등성 결함 + auxclick 틈
|
||||
- 재실행 출력: `step: "edit-text"`, 11/11 통과 뒤 `시간 안에 되지 않음: 저장 상태 dirty`. 선택·속성 창이 동작했으므로 MessagePort 연결은 정상.
|
||||
- 가설 (a) **같은 저장 폴더 재사용** — 이전 37/37 실행이 이미 같은 고정 제목을 저장했다. 로컬 재현(참조 저장소 서버 + Chrome for Testing 156 + playwright-core 1.63, 고정 페이지의 h1 을 미리 같은 제목으로 둠): 게이트 진단 `editDiag.afterTyping = { events:{ input:16, beforeinput:16, keydown:5, focusout:0 }, canvas:{ tag:"h1", editable:true, hasFocus:true }, h1EqualsTyped:true }`, Escape 뒤 `veState:"ve-state clean", isDirtyApi:false, undoEnabled:false`. 즉 입력은 캔버스 h1 에 정확히 들어갔고 `changed` 도 갔지만 본문이 저장본과 같아 `history.push` 가 false → clean 유지. **제품의 dirty 규칙은 옳고(변경 없음 = clean) 게이트가 멱등하지 않았다.** 게이트는 실행마다 `브라우저에서 직접 수정한 제목 <실행 표식>` 을 쓰고 편집 전에 현재 제목과 다른지 단언한다(`editTargetDiffersFromCurrent`). 제품 변경 없음.
|
||||
- 가설 (b) **가운데 클릭** — 같은 로컬 실행에서 게이트의 새 단언 `canvasNoNavigate_linkMiddleClick` 이 `popups=1`(새 페이지가 앱 출처로 열림)로 실패. 가운데 버튼은 `click` 이 아니라 `auxclick` 으로 오고 런타임은 `click` 만 막고 있었다 → 실제 틈. RED(단위, 가짜 DOM `fire('auxclick')`: `AssertionError: edit: AREA 가운데 클릭(auxclick) 차단`) → `wm-ve-canvas.js` 에 `auxclick` 캡처 리스너(이동 활성화 요소만 preventDefault) → GREEN. 실제 Chrome: `popups=0`.
|
||||
- 결과(로컬 게이트, 같은 저장 폴더로 연속 2회): `status: pass`, **61/61**, 두 번째도 61/61. `handshake:{ windowMessagesFromFrame:1, readyWithPort:1, otherTypesViaWindow:[], origins:["null"] }`, `forged:{ putSeen:false, elemsBefore:28, elemsAfter:28 }`, 이동 활성화 6종 모두 `alive:true, popups:0`.
|
||||
|
||||
## 독립 검토 2차(종결 검토) 차단 항목 3건 수정(2026-10-28) — SVG SMIL 저장형 XSS · Git 저장 트랜잭션 재설계 · (Suite) 릴리스 승인 fail-open
|
||||
| # | 결함(검토자 발견) | 수정 | 테스트 |
|
||||
|---|---|---|---|
|
||||
| 1 | `validateVisualSave` 가 `<svg><a href="#x"><animate attributeName="href" values="javascript:…">`·`<set … to="javascript:…">` 를 받았다 — 주소 목록은 정적 주소 속성만 보고 위험 요소 목록에 SMIL 변이 요소가 없었다(Chromium 에서 저장 페이지를 열면 href 가 javascript: 로 바뀌어 실행) | `lib/visual-editor.js`: SMIL 묶음 `animate·set·animateMotion·animateTransform·animateColor·discard·mpath`(HTML 파서가 SVG 로 읽는 SMIL 요소 전부; 대소문자·`svg:` 접두어 형태도 같은 묶음, fail-closed)을 위험 요소로 센다(원본 수까지만 — 삭제 허용, 추가·속성 변경 거절, 전용 안내문). `prepareCanvas` 는 SMIL 요소를 `wm-ve-x-<이름>`(여는·닫는 태그, 속성 보존)으로 바꾸고 `restoreFromCanvas` 가 표준 이름으로 되돌린다(브라우저가 소문자로 직렬화해도 `animateMotion` 등으로). `core.isSafeComponentHtml` 도 같은 묶음 거절 | 단위 +1(`validateVisualSave·prepareCanvas·restoreFromCanvas·core.isSafeComponentHtml: SVG SMIL …`) |
|
||||
| 2 | `recordChange` 가 커밋·브랜치·**원격 푸시 뒤** 사이트 기록을 저장; `rollbackTo` 는 soft reset(커밋 실패 뒤 인덱스 그대로); 경로가 되돌리기 boolean 무시; 성공한 푸시 뒤 기록 저장 실패가 원격만 바꾼 채 남을 수 있음 | `lib/gitstore.js` 새 `recordChangeStrict`(큐 한 항목): 스냅샷(add 전) → add·commit·변경 브랜치 → `onCommitted`(기록 저장) → `git push --atomic` 으로 main + 변경 브랜치 함께(마지막 단계, 그 뒤 상태 변경 없음) / 실패 → `rollbackLocal`: `reset --mixed <이전 HEAD>`(커밋 되감기 + 인덱스 비움), 첫 커밋 전이면 브랜치 삭제 + `read-tree --empty`, 변경 브랜치 삭제/복원, `onRollback`(페이지 원본·`store.save(prev,{restore:true})`), **검증**(HEAD·인덱스·브랜치·작업 트리 경로 ⊆ 저장 전·페이지 바이트·기록) → `{ok, problems, notes}`. `server.js` PUT /visual 은 `rolledBack` 을 검증 결과로만 보내고 실패 때 `rolledBack:false`+`rollbackProblems`+"상태를 확신할 수 없습니다". 강제 푸시 되돌리기 없음. 푸시 전에 저장되는 기록은 `pushed:false, remote:'pending'`(원격 반영 미주장) 이고 `reconcilePushes`(저장 뒤·기동 때·다음 기록 때)가 `ls-remote` 로 확인한 뒤에만 `verified`. `gitRecord` strict 옵션·`headCommit`·`changeBranchName` 제거(다른 호출자는 `recordChange` 그대로) | 서비스 +2(진짜 bare 원격 + pre-receive 거부 훅/pre-commit 훅/기록 폴더 읽기 전용; gitstore 직접 호출 — 첫 커밋 전 되감기·문제 보고 때 ok:false) + 기존 store/Git 실패 테스트에 `rolledBack:true`·인덱스·브랜치 단언 |
|
||||
| 3 | (Suite) `hal/core.approveRelease` 가 명시적 false 만 거절 — checks 없음·testsPassed null(--skip-tests)·dirty 미기록도 승인 | Suite `hal/core.releaseApprovalProblem`: `testsPassed === true`·`dirty === false`·`provenanceClean === true` 모두 필수(approveRelease·registerRelease approve:true·운영자 API·`release.mjs register --approve` 가 같은 규칙) | Suite `test/release-provenance.test.mjs` +3 |
|
||||
|
||||
### RED — `node --test test/visual-editor.test.js`(수정 전 코드, 새 테스트 포함)
|
||||
```
|
||||
✖ validateVisualSave·prepareCanvas·restoreFromCanvas·core.isSafeComponentHtml: SVG SMIL 변이 요소(…)는 … 거절하고 …
|
||||
AssertionError [ERR_ASSERTION]: 거절되어야 함: …<svg><a href="#x"><text>hi</text></a><animate attributeName="href" values="javascript:alert(1)" begin="0s" xlink:href="#a"/></svg>
|
||||
```
|
||||
### RED — `CLAUDE_BIN=<가짜 exit 1> node --test test/visual-editor-server.test.js`(수정 전 코드)
|
||||
```
|
||||
ℹ tests 14 · pass 11 · fail 3
|
||||
AssertionError: 검증된 되돌리기: {"error":"버전 기록(Git)·사이트 기록을 남기지 못해 저장을 취소했습니다 (EACCES). … 일부 되돌리기에 실패했으니 관리자에게 알리세요 …","rolledBack":false} ← Git 실패 테스트: 옛 rollbackTo 가 false 를 돌려주는데 상태는 멀쩡(판정 근거 없음)
|
||||
AssertionError: {"refs/heads/sites/<id>/changes/0001-create":…,"…/0002-visual-edit":…,"…/main":…} ← 원격 트랜잭션 테스트(첫 단언에서 멈춤; 옛 코드는 pre-receive 거부를 200 + rec.error 로 삼켰다)
|
||||
```
|
||||
### RED — Suite `node test/release-provenance.test.mjs`(수정 전 hal/core·release.mjs)
|
||||
```
|
||||
✘ hal/core.approveRelease·registerRelease(approve:true): … → Error: checks 없음 (approveRelease) (승인됨)
|
||||
✘ HAL 운영자 API POST /ops/api/releases/:version/approve … → Error: 7.7.0: 거절 200 {"ok":true,"release":{…}}
|
||||
✘ release.mjs register --approve: testsPassed … → Error: tests-null: HAL 릴리스 등록: 0.2.0 (approved) … 기대 1 실제 0
|
||||
✘ 통과 4 · 실패 3
|
||||
```
|
||||
### GREEN — 수정 뒤
|
||||
```
|
||||
node --test test/visual-editor.test.js ℹ tests 38 · pass 38 · fail 0
|
||||
node --test test/visual-editor-server.test.js ℹ tests 16 · pass 16 · fail 0 (원격 트랜잭션: 성공 때 main·변경 브랜치 함께 갱신·기록은 pending → ref 확인 뒤 verified, 거부/커밋 실패/기록 실패 세 경로 모두 원격 ref 불변·rolledBack:true·재시도 200, 기동 조정)
|
||||
Suite: node test/release-provenance.test.mjs ✔ 통과 7 · 실패 0
|
||||
```
|
||||
### 원격 반영 기록의 보수성(종결 검토 후속) — RED → GREEN
|
||||
- 검사: 푸시가 원격에 닿는 순간(pre-receive 훅)에 사이트 기록 파일을 복사해 보니, 1차 구현은 푸시 **전** 에 저장한 기록이 이미 `pushed:true` 였다(RED: `AssertionError: 푸시 전 기록은 원격 반영을 주장하지 않는다: {"no":2,"kind":"visual-edit",…,"pushed":true,"error":null,…}`). 프로세스가 그 사이에 죽으면 "원격에 있다" 는 거짓 기록이 남는다.
|
||||
- 수정: 기록은 `pushed:false, remote:'pending'` 으로 저장·응답하고(푸시 뒤 어떤 저장도 하지 않는다), 트랜잭션 밖의 `reconcilePushes` 가 잠금 안에서 기록을 다시 읽어 `ls-remote` 로 **원격 ref 에서 확인** 한 뒤에만 `pushed:true, remote:'verified', verifiedAt` 로 바꾼다. 원격에 없으면(크래시 창) 로컬 변경 브랜치가 기록의 커밋을 가리키는지 확인하고 변경 브랜치들 + main 을 한 번의 원자적 푸시로 올린 뒤 다시 확인한다. 호출: 저장 응답 뒤(setImmediate)·서버 기동 때(pending 이 있는 사이트)·다음 기록 때(옛 retryPush 자리). 조정의 저장은 `{restore:true}` 라 Suite rev 를 올리지 않는다.
|
||||
- GREEN(서비스 테스트): 훅이 복사한 푸시 순간의 기록 `pushed:false·remote:'pending'`, 응답도 pending, 조정 뒤 `verified`(푸시 1회 — 확인만 하고 다시 푸시하지 않음); **기동 조정**: 기록을 pending 으로 되돌리고 원격 변경 브랜치 삭제·main 을 한 커밋 전으로 되돌린 뒤 같은 데이터로 재기동 → 원자적 푸시 한 번으로 브랜치·main 복구, ref 확인 뒤 `verified`, 기록 저장되고 rev 불변; gitstore 직접 호출: 거부(REJECT)면 pending + 사유, 해제 뒤 푸시·확인, 이미 올라간 기록은 확인만(푸시 로그 2건 = 거부 1 + 성공 1).
|
||||
|
||||
설계 메모: 되돌리기에 `reset --hard` 를 쓰지 않은 이유 — 작업 트리는 사이트 폴더 전체이고 저장과 무관한 커밋되지 않은 파일(이전 비엄격 기록 실패 등)이 있을 수 있어 `--hard` 는 그것을 지운다. 저장이 바꾼 것은 페이지 한 파일(원본 바이트로 복원)과 커밋·인덱스·브랜치뿐이므로 `--mixed` + 페이지 복원 + "작업 트리 변경 경로 ⊆ 저장 전 경로" 검증이 같은 보장을 준다. 판정은 명령 성공 여부가 아니라 실제 상태로 한다(읽기 전용 저장소에서 add 가 실패하면 reset 도 실패하지만 상태는 저장 전과 같아 ok — notes 에 명령 오류). 성공한 푸시 뒤에는 트랜잭션 안에서 어떤 저장도 하지 않는다 — 기록은 보수적(pending)으로 남고, 별도 조정 단계가 원격 ref 로 확인한 뒤에만 verified 로 바꾼다. 크래시 창(기록 저장과 푸시 사이에 프로세스 종료)에는 기록이 pending·로컬 커밋과 변경 브랜치가 남아 기동 조정이 원자적으로 올린다(거짓 "원격 반영" 기록 없음).
|
||||
|
||||
## 독립 검토 3차(최종 검토) 차단 항목 4건 수정(2026-10-28) — (Suite) 승인 불변 · 사이트 변경 잠금 전면 감사 · 트랜잭션 완결성·모호한 푸시 판정 · 문서 수치
|
||||
| # | 결함(검토자 발견) | 수정 | 테스트 |
|
||||
|---|---|---|---|
|
||||
| 1 | (Suite) `registerRelease` upsert 가 승인된 버전의 checks·artifact 를 바꾸면서 approved 유지 | 승인된 버전은 같은 내용 재등록만 멱등, 다르면 `release_immutable`(변경 없음); 미승인 버전의 변경 재등록은 registered·승인 미승계; CLI 거부 종료 1 | Suite provenance +2(core 8가지 × approve true/false readback 불변, CLI) |
|
||||
| 2 | 라우터가 요청 시작 때 읽은 기록으로 본문을 기다린 뒤 처리·저장(claimJob·다국어 설정·언어 폴더·복원이 기다리기 전 기록 사용) | 변경 요청은 본문을 잠금 밖에서 메모 읽기 → 잠금 안에서 기록 재적재 → 짧은 경로는 처리기 전체 잠금, 긴 경로는 재적재한 기록으로 처리(claimJob 까지 await 없음); 재진입 잠금(AsyncLocalStorage)·`detached()`; changeLanguages 는 claimJob 먼저 | 서비스 +1(설정 저장·복원·작업 시작 vs 직접 편집 저장의 결정적 경쟁) |
|
||||
| 3 | `recordChangeStrict` 의 initSite·스냅샷·원격 설정이 try 밖(실패해도 onRollback 없음); 푸시 클라이언트 오류를 무조건 실패로 보고 되돌림(원격이 받았을 수 있음) | 모두 트랜잭션 안(`prepare`), 스냅샷에 repoExisted·indexClean, 새로 만든 저장소는 되돌릴 때 삭제, 스냅샷 전 실패는 ok:false; 푸시 전 원격 ref 를 읽고 오류 뒤 다시 읽어 accepted(성공)/unchanged(되돌림 + 원격 재검증)/ambiguous(pending 유지, 200 + `remoteState`) | 서비스 +1(git 래퍼: FAIL_AFTER·MIXED·FLAKY·DOWN) + 직접 호출 준비 단계 실패 |
|
||||
| 4 | 문서 합계 모순(106/113·107/114) | 현재 증거로 통일 | — |
|
||||
| 5 | 배경 AI 작업이 자기 사이트 객체를 나중에 저장해 작업 중 허용되는 짧은 변경(기본 사항 PATCH·모델 등급·도메인·첨부·검수·모듈 — 실행 중 검사 없음)을 지움 | `lib/store.js` 저장소 불변식: 읽은 객체마다 `__base`(항목별 JSON + `saveId`)를 기억, 저장 때 디스크 `saveId` 가 다르면 이 객체가 바꾼 최상위 항목만 디스크 최신 기록 위에 합침(restore 제외, 조정은 keepRev); 취소·상태 경로 불변, 새 잠금 없음 | 서비스 +1(작업 중 PATCH 제목·PUT 등급 → 중단 → 종료 저장 뒤 유지) |
|
||||
| 6 | 같은 최상위 항목을 둘 다 바꾸면 마지막 저장자가 이겨 먼저 쓴 값이 사라짐(작업 중 다시 푸시의 git.changes 반영 상태 vs 작업 종료의 새 버전 추가) | `lib/store.js` 잎 단위 3자 병합 + 같은 잎을 다르게 바꾼 경우 `SaveConflict`(site_save_conflict, 409, fields)로 거절·디스크 보존; `store.update`·`store.adopt`; 서버 `saveJob(site, reapply)`(배경 작업의 모든 저장, 실패 기록 포함)·`reapplyCommand`/`reapplyFields`; `gitRecord` 충돌 때 버전만 추가; 다시 푸시·조정은 `applyPushState` 를 `store.update` 로 적용 | 서비스 +2(저장소 규칙 6가지; 실제 서버 — git 래퍼로 commit 2초 지연 → 작업 종료의 버전 기록 창에 다시 푸시) |
|
||||
|
||||
### RED(수정 전 코드)
|
||||
```
|
||||
Suite: ✘ hal/core.registerRelease 불변 승인 … Error: 승인자·시각도 그대로: 기대 {"status":"approved","by":"op",…} 실제 {"status":"approved","by":"op2",…} ← 재등록이 승인된 행을 바꿈
|
||||
Suite: ✘ HAL CLI register-release … 기대 approved_by "test" 실제 "test2"
|
||||
W: ✖ 사이트 변경 잠금(전체 감사) … AssertionError: 설정 저장이 직접 편집 버전을 덮어쓰지 않는다: ["1:create"] ← 설정 저장이 본문을 기다리는 사이 끝난 직접 편집 버전을 지움
|
||||
W: ✖ 원자적 푸시의 모호한 클라이언트 오류 … AssertionError: 원격이 받았으면 성공: {"error":"원격 소스 보관소(Git)에 올리지 못해 저장을 취소했습니다 (fatal: the remote end hung up unexpectedly (test)) …","rolledBack":true,"stage":"push"} ← 원격이 받았는데 되돌림
|
||||
W: ✖ gitstore.recordChangeStrict(직접 호출) … AssertionError: 준비 단계 실패도 트랜잭션 안: {"message":"…/git/s1: Permission denied","gitDirExists":false,"page":"<p>b</p>"} ← stage·rollback 없음, 페이지 미복원
|
||||
W: ✖ 배경 AI 작업이 나중에 자기 사이트 객체를 저장해도 … AssertionError: 작업의 종료 저장이 화면의 제목 변경을 덮어쓰지 않는다: {"title":"VE site","status":"idle"} ← 작업 중 바꾼 제목이 종료 저장에 지워짐
|
||||
W: ✖ 배경 AI 작업과 작업 중 허용되는 경로가 같은 잎을 바꾸는 진짜 동시 변경 … AssertionError: 다시 푸시가 쓴 확인 시각이 그대로다(…): {"kept":"…46.004Z","retry":"…45.975Z"} ← 작업 종료가 덮어쓰고 배경 재확인이 다른 시각을 씀(최상위 항목 합치기만 있던 코드)
|
||||
W: ✖ store.save 합치기 규칙 … TypeError: store.update is not a function / 같은 잎을 둘 다 다르게 바꿔도 거절되지 않음
|
||||
중간(저장소를 fail-closed 로 바꾼 직후): ✖ … AssertionError: {"error":"사이트 기록 저장 충돌: … (git.changes) …"} ← 충돌이 "다시 푸시" 경로에 드러남(덮어쓰기 없음) → 다시 푸시·조정을 store.update 경로로 바꿈
|
||||
```
|
||||
### GREEN — 수정 뒤
|
||||
```
|
||||
node --test test/visual-editor-server.test.js ℹ tests 21 · pass 21 · fail 0 (저장소 합치기 뒤: 되돌리기 검증은 저장 표식 saveId 도 제외하고 비교; 같은 잎 동시 변경은 거절·디스크 보존·작업은 최신 기록 위에 재적용)
|
||||
Suite: node test/release-provenance.test.mjs ✔ 통과 9 · 실패 0
|
||||
```
|
||||
|
||||
## 독립 검토 4차 차단 항목 4건 수정(2026-10-28) — (Suite) artifact 설치 불변 · 읽기 요청 부작용 · 폐기 잠금·표식 · svg/math 안 title 파서 차이
|
||||
| # | 결함(검토자 발견) | 수정 | 테스트 |
|
||||
|---|---|---|---|
|
||||
| 1 | (Suite) 두 CLI 가 DB 불변 검사 전에 버전 폴더에 복사해 승인된 artifact 바이트가 덮임; HAL CLI 는 해시 미검증 | `hal/core.installArtifacts`: 바이트 해시·크기 검증 → 승인된 버전은 메타데이터·설치 파일 해시가 같을 때만 멱등(복사 없음) / 다르면 `release_immutable`(파일·DB 불변) → 새 설치는 임시 파일 → 해시 재확인 → rename; 두 CLI 모두 설치 → 등록 | Suite provenance +2(HAL CLI·release.mjs) |
|
||||
| 2 | GET/HEAD/OPTIONS 가 x-ui-lang 으로 잠금 밖 store.save | 읽기 메서드는 저장소를 건드리지 않음; 언어는 변경 요청의 잠금 안 재적재에서만 저장 | 서비스 +1 |
|
||||
| 3 | 폐기가 잠금·표식 없이 긴 정리를 기다림; 실패 기록을 오래된 객체로 저장 | 잠금 안 재검사 + 표식(token) 원자적 claim → 잠금 밖 정리 → 다시 잠금·같은 표식 확인 뒤 **임시 이름으로 옮김(되돌릴 수 있음) → `beforeIrreversible` 훅(Suite 포털 연결 해제) → 기록 unlink(되돌릴 수 없는 한 단계, 실패면 되돌리기 + `compensate`) → 최선의 뒷정리(실패는 warnings·감사)**; 표식 중 변경 요청·claimJob 409; 실패는 표식 해제 + `store.update` 로 deleteFailed{recoveryRequired} | 서비스 +1(가짜 ssh 2초: 5종 409, 성공·실패·다른 사이트); Suite 모듈 `test/ariweb.test.js` +3(정리 실패 → 연결 유지, 연결 해제 실패 → 옮긴 것 되돌림, 뒷정리 실패 → 경고·남은 폴더) |
|
||||
| 4 | 스캐너가 svg/math 안 `<title>` 을 RCDATA 로 읽어 `<svg><title><script>` 통과(브라우저는 실행) | `scanTags` 외래 콘텐츠 깊이 — svg/math 안에서는 원문 규칙 없음(통합 지점도 요소로, fail-closed) | 단위 +1(15 변형·닫히지 않은 title·정상 SVG·캔버스 출력) |
|
||||
|
||||
### RED(수정 전 코드)
|
||||
```
|
||||
W 단위: ✖ scanTags 파서 차이(저장형 XSS) … AssertionError: 거절되어야 함: …<svg><title><script>alert(1)</script></title></svg>
|
||||
W 서비스: ✖ 읽기 요청은 부작용이 없다 … AssertionError: 읽기 요청은 기록 파일을 바꾸지 않는다(uiLang·saveId·rev 포함)
|
||||
W 서비스: ✖ 사이트 폐기는 잠금 안에서 … AssertionError: #0 폐기 안내 ← 폐기 중 직접 편집 저장이 "폐기" 로 거절되지 않음
|
||||
Suite: ✘ artifact 설치 불변·fail-closed(HAL CLI register-release) … 기대 "artifact-A-bytes" 실제 "artifact-B-bytes-different" ← 승인된 artifact 가 덮임
|
||||
Suite: ✘ artifact 설치 불변·fail-closed(scripts/release.mjs register) … 기대 "rel-A" 실제 "rel-B-other"
|
||||
```
|
||||
### GREEN — 수정 뒤
|
||||
```
|
||||
node --test test/visual-editor.test.js ℹ tests 39 · pass 39 · fail 0
|
||||
node --test test/visual-editor-server.test.js ℹ tests 23 · pass 23 · fail 0
|
||||
Suite: node test/release-provenance.test.mjs ✔ 통과 11 · 실패 0
|
||||
```
|
||||
|
||||
## 전체 묶음 — 독립 검토 4차 뒤(현재 증거)
|
||||
`CLAUDE_BIN=<가짜 exit 1> npm test`
|
||||
```
|
||||
ℹ tests 115
|
||||
ℹ pass 115
|
||||
ℹ fail 0
|
||||
```
|
||||
(원본 52 + 직접 편집 63 = 단위 39 + 서비스 23 + 화면 전환 1. Suite `modules/webmaker` 같은 명령: tests 125 · pass 125 · fail 0(Suite 전용 `test/ariweb.test.js` 10 포함). Suite 전체 24개 파일 실패 0 — release-provenance 11.) 실제 Chrome 게이트는 61/61 그대로(이번 4건은 게이트가 보는 화면 동작을 바꾸지 않는다).
|
||||
|
||||
## 전체 묶음 — 최종 검토(3차) + 잎 단위 저장소 합치기 뒤
|
||||
`CLAUDE_BIN=<가짜 exit 1> npm test`
|
||||
```
|
||||
ℹ tests 112
|
||||
ℹ pass 112
|
||||
ℹ fail 0
|
||||
```
|
||||
(원본 52 + 직접 편집 60 = 단위 38 + 서비스 21 + 화면 전환 1. Suite `modules/webmaker` 같은 명령: tests 119 · pass 119 · fail 0. Suite 전체 `CLAUDE_BIN=<가짜 exit 1> node test/run.mjs`: 테스트 파일 24개 중 실패 0 — release-provenance 7·webmaker-visual-editor 5 포함, 생성된 `local-e2e-2026-10-28.{md,json}` 은 삭제.) 문법 `node --check`(server.js·lib/gitstore.js·lib/visual-editor.js·core·테스트) 두 저장소 통과, 공유 파일 byte-identical(`cmp`), Suite `server.js` 차이 115줄(Suite 블록만), `git diff --check` 깨끗함. Suite 도입 기록 재생성: 184 파일·수정 8·workingTreeDirty=true(의도 — Neo 가 참조 저장소를 커밋한 뒤 다시 가져온다).
|
||||
|
||||
## 브라우저 검증 상태
|
||||
- **실제 브라우저 검증은 수행되었다**(Neo 의 게이트 — 1·2차 시각 게이트 1280×633·390×844, 3·4차 Playwright 자동 게이트, 5차 네이티브 포인터 순서 게이트 **37/37 통과**: 직접 편집 열림·선택·글 편집·실행 취소/다시 실행·Alt+↑/↓·팔레트 포인터 끌어다 놓기(놓기 표시 → 문단 삽입)·저장 PUT 2xx·저장 파일(편집 제목·추가 문단·원본 스크립트 보존·표식 없음)·390×844 배치). 이 37/37 은 **독립 검토(1차) 차단 항목 수정 전** 의 결과다.
|
||||
- **수정 뒤 브라우저 재실행 — 완료**: Neo 의 첫 재실행은 게이트 멱등성 결함으로 `edit-text` 에서 멈췄다(위 절). 고친 게이트(실행마다 다른 제목, 연결 규약·이동 활성화 6종·위조 창 메시지 단언 추가)는 이 PC 의 Chrome 156 에서 61/61(연속 2회), **Neo 호스트의 실제 Chrome 에서도 61/61 통과**(2026-10-28): 직접 편집 열림(ready + 포트 → 본문 수신)·선택·글 편집·실행 취소/다시 실행·이동·팔레트 끌어다 놓기·저장·파일·390×844 + 연결 규약·이동 활성화 6종·위조 창 메시지. 종결 검토 3건(서버 검증·Git·HAL)은 브라우저 동작을 바꾸지 않아 추가 브라우저 재실행은 필요 없다.
|
||||
- 자동화하지 않은 것: 포털 SSO 로 들어간 실제 화면, 운영 서버 배포, Chrome 외 브라우저. Node 에서는 런타임 파일의 문법 검사·`eval`/`new Function` 부재·정적 구조·CSS 배치 계약·가짜 DOM 위 실제 런타임/app.js 실행(연결·전환·끌기·놓기)을 확인한다.
|
||||
@@ -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 };
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "webhosting-ai-agent",
|
||||
"version": "1.7.3",
|
||||
"version": "1.8.0",
|
||||
"private": true,
|
||||
"description": "AI 기반 자동 웹페이지 생성·호스팅 에이전트 (웹메이커)",
|
||||
"main": "server.js",
|
||||
|
||||
@@ -9,10 +9,10 @@ const KIND = { create: '생성', update: '수정', 'plan-analyze': '이해 확
|
||||
const MODE = { ai: 'AI에게 맡기기', manual: '내가 고르기', quick: '기본', import: '사이트 관리(가져옴)', copy: '사이트 복사' };
|
||||
const TIER = { basic: '기본', standard: '중급', premium: '고급' };
|
||||
const STATUS = { running: '실행 중', deploying: '배포 중', 'waiting-credit': '캐시 대기', done: '완료', error: '오류', cancelled: '중단', interrupted: '끊김', idle: '대기' };
|
||||
const VER = { create: '생성', 'ai-update': 'AI 수정', 'manual-edit': '직접 편집', restore: '복원' };
|
||||
const VER = { create: '생성', 'ai-update': 'AI 수정', 'manual-edit': '소스 편집', 'visual-edit': '직접 편집', restore: '복원' }; // v1.8.0 visual-edit
|
||||
let overview = null, es = null, me = null, curSite = null, billingData = null, curBillUser = null, settingsData = null, siteFilter = 'all';
|
||||
const DS = { pending: '배포 대기중', deployed: '발행 완료', working: '작업 중', deploying: '배포 중', empty: '완성 전', transferred: '전송 완료', 'vhost-wait': '웹서버 설정 대기', 'dns-wait': 'DNS 설정 대기', 'cert-wait': '인증서 준비 중(HTTP)', 'check-failed': '접속 확인 실패', 'vhost-failed': '도메인 연결 실패', failed: '배포 실패' }; // v1.6.31 (요구 14) 단계 구분
|
||||
const CHG = { create: '생성', 'ai-update': 'AI 수정', 'manual-edit': '직접 편집', restore: '복원', deploy: '배포본' };
|
||||
const CHG = { create: '생성', 'ai-update': 'AI 수정', 'manual-edit': '소스 편집', 'visual-edit': '직접 편집', restore: '복원', deploy: '배포본' };
|
||||
|
||||
/**
|
||||
* 관리자 API 호출.
|
||||
|
||||
@@ -119,7 +119,10 @@ let switching = false;
|
||||
* @param {string|null} targetId 열려는 사이트 (null = 새 사이트 만들기 화면)
|
||||
*/
|
||||
function discardOk(targetId) {
|
||||
if (!ed.dirty || (current && targetId === current.id)) return true;
|
||||
if (current && targetId === current.id) return true;
|
||||
// v1.8.0: 직접 편집(시각 편집기)의 저장하지 않은 변경도 같은 자리에서 묻는다
|
||||
if (window.VisualEditor && VisualEditor.isDirty() && !VisualEditor.discardOk(T('직접 편집에서 저장하지 않은 변경이 있습니다. 버리고 다른 사이트로 바꿀까요?'))) return false;
|
||||
if (!ed.dirty) return true;
|
||||
if (!confirm(T('저장하지 않은 소스 변경이 있습니다. 버리고 다른 사이트로 바꿀까요?'))) return false;
|
||||
ed.dirty = false; return true;
|
||||
}
|
||||
@@ -141,6 +144,8 @@ function applyTabView(name) {
|
||||
async function showTab(name) {
|
||||
if (!TABS.includes(name)) name = 'create';
|
||||
{ const target = name === 'create' ? createId : SITE_TABS.includes(name) ? manageId : (current && current.id); if (!['settings', 'domains', 'dns', 'mail', 'credits', 'home'].includes(name) && !discardOk(target || null)) return; }
|
||||
// v1.8.0 검토 수정: 홈 화면도 고른 사이트(manageId)가 지금 사이트와 다르면 그 사이트를 여므로, 직접 편집의 저장하지 않은 변경을 먼저 묻는다(취소하면 화면을 바꾸지 않는다)
|
||||
if (name === 'home' && manageId && current && current.id !== manageId && !discardOk(manageId)) return;
|
||||
applyTabView(name);
|
||||
try { localStorage.setItem('activeTab', name); } catch (_) {}
|
||||
if (switching) return; switching = true;
|
||||
@@ -756,7 +761,7 @@ async function renderInquiries() {
|
||||
$('btnInquiryReload').onclick = renderInquiries;
|
||||
|
||||
// ---------- 버전(되돌리기) ----------
|
||||
const VER_KIND = { create: '생성', 'ai-update': 'AI 수정', 'manual-edit': '직접 편집', restore: '복원', deploy: '배포본', import: '가져옴' };
|
||||
const VER_KIND = { create: '생성', 'ai-update': 'AI 수정', 'manual-edit': '소스 편집', 'visual-edit': '직접 편집', restore: '복원', deploy: '배포본', import: '가져옴' }; // v1.8.0 visual-edit: 시각 편집기 저장
|
||||
const CMD_STATUS = { running: '실행 중', done: '완료', error: '오류', cancelled: '중단', interrupted: '끊김' };
|
||||
function renderGit(site) {
|
||||
const list = site.versions || []; $('versionsCard').classList.remove('hidden');
|
||||
@@ -791,9 +796,14 @@ function renderPreview(site, reload) {
|
||||
sel.classList.toggle('hidden', htmls.length < 2);
|
||||
const hasIndex = htmls.length > 0;
|
||||
renderEmptyReason(site, hasIndex);
|
||||
$('emptyState').classList.toggle('hidden', hasIndex);
|
||||
$('frame').classList.toggle('hidden', !hasIndex);
|
||||
if (hasIndex && reload) loadFrame();
|
||||
// v1.8.0: 직접 편집 중이면 미리보기를 덮어쓰지 않고 편집기에 알린다(저장하지 않은 변경 보호). 아니면 예전처럼 다시 읽는다.
|
||||
// v1.8.0 검토 수정: HTML 페이지가 없어진 갱신도 편집기가 스스로 판단한다(onSiteUpdated — 변경이 있으면 묻고, 취소하면 캔버스를 그대로 둔다). 강제로 끝내지 않는다.
|
||||
const veActive = !!(window.VisualEditor && VisualEditor.isActive());
|
||||
if (veActive && (reload || !hasIndex)) VisualEditor.onSiteUpdated(site); else if (hasIndex && reload) loadFrame();
|
||||
const veStill = !!(window.VisualEditor && VisualEditor.isActive());
|
||||
$('emptyState').classList.toggle('hidden', hasIndex || veStill);
|
||||
$('frame').classList.toggle('hidden', !hasIndex && !veStill);
|
||||
$('btnVisual').disabled = !hasIndex && !veStill; // v1.8.0 직접 편집은 HTML 페이지가 있을 때만(편집 중이면 끝내기 버튼으로 남는다)
|
||||
}
|
||||
/**
|
||||
* 미리보기할 HTML 이 없을 때의 안내 (v1.3.3). 1.3.2 까지는 가져오기·복사가 실패했거나 진행 중이어도 "새 사이트를 만들면…"
|
||||
@@ -861,7 +871,7 @@ async function loadEditFile(rel) {
|
||||
if (ed.dirty && !confirm('저장하지 않은 변경이 있습니다. 버리고 다른 파일을 열까요?')) { $('editFile').value = ed.file; return; }
|
||||
try {
|
||||
const r = await api(`/api/sites/${current.id}/files/${encodeURIComponent(rel)}`);
|
||||
ed.file = rel; ed.saved = r.content; ed.dirty = false;
|
||||
ed.file = rel; ed.saved = r.content; ed.dirty = false; ed.hash = r.hash || null; // v1.8.0 검토 수정: 저장 기준 hash — 다른 곳이 먼저 저장했으면 서버가 409 로 알린다
|
||||
$('editor').value = r.content; $('editFile').value = rel; updateLineNos(); setDirty(false);
|
||||
} catch (e) { alert('파일 열기 실패: ' + e.message); }
|
||||
}
|
||||
@@ -893,15 +903,20 @@ async function saveEditFile() {
|
||||
if (!current || !ed.file || !ed.dirty) return;
|
||||
$('btnSave').disabled = true;
|
||||
// v1.3.17 (B22): 보낸 내용·사이트·파일을 붙잡아 두고, 응답 뒤에는 그 내용만 "저장됨" 으로 본다 (저장 중에 더 친 글자는 저장 안 됨으로 남김)
|
||||
const sent = { siteId: current.id, file: ed.file, content: $('editor').value };
|
||||
const sent = { siteId: current.id, file: ed.file, content: $('editor').value, baseHash: ed.hash || undefined };
|
||||
try {
|
||||
const r = await api(`/api/sites/${sent.siteId}/files/${encodeURIComponent(sent.file)}`, { method: 'PUT', body: JSON.stringify({ content: sent.content }) });
|
||||
// v1.8.0 검토 수정: baseHash(파일을 열 때 받은 hash)를 함께 보낸다 — 직접 편집·AI 수정·다른 창이 먼저 저장했으면 서버가 409(stale) 로 거절해 조용히 덮어쓰지 않는다
|
||||
const r = await api(`/api/sites/${sent.siteId}/files/${encodeURIComponent(sent.file)}`, { method: 'PUT', body: JSON.stringify({ content: sent.content, baseHash: sent.baseHash }) });
|
||||
if (!current || current.id !== sent.siteId || ed.file !== sent.file) return; // 그 사이 다른 사이트·파일로 바뀜
|
||||
ed.saved = sent.content; setDirty($('editor').value !== sent.content); if (ed.dirty) $('btnSave').disabled = false;
|
||||
ed.saved = sent.content; if (r.hash) ed.hash = r.hash; setDirty($('editor').value !== sent.content); if (ed.dirty) $('btnSave').disabled = false;
|
||||
if (r.files) { current.files = r.files; fillEditFiles(); }
|
||||
if (r.git) { current.git = r.git; renderGit(current); }
|
||||
$('frame').removeAttribute('srcdoc'); loadFrame();
|
||||
} catch (e) { alert('저장 실패: ' + e.message); $('btnSave').disabled = false; }
|
||||
} catch (e) {
|
||||
$('btnSave').disabled = false;
|
||||
if (e.status === 409 && e.body && e.body.stale) { if (confirm(T('다른 곳(직접 편집·AI 수정·다른 창)에서 이 파일을 먼저 저장했습니다. 최신 내용을 다시 불러올까요? (지금 편집기의 내용은 사라집니다. "취소" 를 누르면 편집기에 남겨 둡니다)'))) { ed.dirty = false; loadEditFile(sent.file); } return; }
|
||||
alert('저장 실패: ' + e.message);
|
||||
}
|
||||
}
|
||||
$('editor').addEventListener('input', () => {
|
||||
setDirty($('editor').value !== ed.saved); updateLineNos();
|
||||
@@ -915,7 +930,7 @@ $('editor').addEventListener('keydown', (e) => {
|
||||
$('btnSave').onclick = saveEditFile;
|
||||
$('btnRevert').onclick = () => { if (!ed.dirty || confirm('변경 내용을 버리고 저장된 내용으로 되돌릴까요?')) { $('editor').value = ed.saved; setDirty(false); updateLineNos(); $('frame').removeAttribute('srcdoc'); loadFrame(); } };
|
||||
$('editFile').onchange = (e) => loadEditFile(e.target.value);
|
||||
$('btnToggleSource').onclick = () => setEditorOpen(!ed.open);
|
||||
$('btnToggleSource').onclick = () => { if (!ed.open && window.VisualEditor && VisualEditor.isActive() && !VisualEditor.close(false)) return; setEditorOpen(!ed.open); }; // v1.8.0 소스 편집기와 직접 편집은 한 번에 하나만 (미리보기 iframe 을 함께 쓴다)
|
||||
$('liveApply').onchange = () => { if ($('liveApply').checked) applyLivePreview(); else { $('frame').removeAttribute('srcdoc'); loadFrame(); } };
|
||||
// 분할 크기 드래그
|
||||
(() => {
|
||||
@@ -925,7 +940,7 @@ $('liveApply').onchange = () => { if ($('liveApply').checked) applyLivePreview()
|
||||
window.addEventListener('mousemove', (e) => { if (!dragging) return; const box = $('frame').parentElement.getBoundingClientRect(); const h = Math.min(Math.max(box.bottom - e.clientY, 120), box.height - 120); pane.style.height = h + 'px'; });
|
||||
window.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; document.body.style.cursor = ''; $('frame').style.pointerEvents = ''; });
|
||||
})();
|
||||
window.addEventListener('beforeunload', (e) => { if (ed.dirty) { e.preventDefault(); e.returnValue = ''; } });
|
||||
window.addEventListener('beforeunload', (e) => { if (ed.dirty || (window.VisualEditor && VisualEditor.isDirty())) { e.preventDefault(); e.returnValue = ''; } }); // v1.8.0 직접 편집 변경도 보호
|
||||
|
||||
// ---------- 사이트 로드 / 이벤트 ----------
|
||||
function connectEvents(id) {
|
||||
@@ -960,6 +975,9 @@ function connectEvents(id) {
|
||||
es.onerror = () => { /* 브라우저가 자동 재접속 */ };
|
||||
}
|
||||
async function openSite(id, opts = {}) {
|
||||
// v1.8.0 검토 수정: 다른 사이트로 바꾸기 **전에** 직접 편집의 저장하지 않은 변경을 묻는다(여기가 모든 호출 경로의 마지막 방어선 — 홈·탭·선택 상자·다른 모듈의 openSite 포함).
|
||||
// 취소하면 아무것도 바꾸지 않고(현재 사이트·편집기 유지) 지금 사이트를 돌려준다. 승낙하면 편집기를 끝낸 뒤 진행한다.
|
||||
if (window.VisualEditor && VisualEditor.isActive() && VisualEditor.state().siteId !== id) { if (!VisualEditor.close(false)) { syncPicker(); return current; } }
|
||||
const site = await api(`/api/sites/${id}`);
|
||||
current = site;
|
||||
const prog_ = inProgress(site);
|
||||
@@ -995,6 +1013,8 @@ async function openSite(id, opts = {}) {
|
||||
ed.file = null; ed.dirty = false; $('editor').value = ''; setDirty(false); updateLineNos();
|
||||
let wantOpen = false; try { wantOpen = localStorage.getItem('sourceOpen') === '1'; } catch (_) {}
|
||||
setEditorOpen(wantOpen);
|
||||
// v1.8.0: 사이트가 바뀌면 직접 편집은 끝낸다(저장하지 않은 변경은 위에서 close(false) 로 이미 물었다 — 여기 남아 있으면 변경이 없는 경우뿐). 같은 사이트를 다시 열 때는 그대로 둔다
|
||||
if (window.VisualEditor && VisualEditor.isActive() && VisualEditor.state().siteId !== site.id) VisualEditor.close(false);
|
||||
}
|
||||
/** 위쪽 사이트 선택 아래: 사이트 상태 배지와 운영 주소 (모든 탭 공통, 높이 고정) */
|
||||
function renderSiteMeta(site) {
|
||||
@@ -1043,6 +1063,7 @@ function resetToNew(clearManage = true) {
|
||||
renderUsage({ totals: {}, commands: [] }); $('attachCard').classList.add('hidden'); $('versionsCard').classList.add('hidden'); $('inquiryCard').classList.add('hidden'); $('siteInfoCard').classList.add('hidden');
|
||||
$('siteUrl').classList.remove('live'); $('btnOpen').textContent = '새 탭에서 열기';
|
||||
ed.file = null; ed.dirty = false; ed.open = false; $('editorPane').classList.add('hidden'); $('splitter').classList.add('hidden'); $('btnToggleSource').disabled = true; $('btnToggleSource').classList.remove('active'); $('btnToggleSource').textContent = '소스 보기';
|
||||
if (window.VisualEditor && VisualEditor.isActive()) VisualEditor.close(true); $('btnVisual').disabled = true; // v1.8.0
|
||||
$('siteUrl').textContent = '아직 생성된 페이지가 없습니다'; $('btnOpen').classList.add('disabled'); $('btnReload').disabled = true; $('btnCopy').disabled = true;
|
||||
$('fileSelect').classList.add('hidden'); renderEmptyReason(null, false); $('emptyState').classList.remove('hidden'); $('frame').classList.add('hidden'); $('frame').src = 'about:blank';
|
||||
$('siteSelect').value = '';
|
||||
@@ -1102,8 +1123,8 @@ $('btnCancel').onclick = async () => {
|
||||
$('btnCancel').disabled = false;
|
||||
};
|
||||
$('fAttachNow').onchange = (e) => { uploadNow([...e.target.files]); e.target.value = ''; };
|
||||
$('btnReload').onclick = loadFrame;
|
||||
$('fileSelect').onchange = loadFrame;
|
||||
$('btnReload').onclick = () => { if (window.VisualEditor && VisualEditor.isActive()) { VisualEditor.reload(false); return; } loadFrame(); }; // v1.8.0 직접 편집 중 새로고침 = 저장된 페이지 다시 불러오기(변경이 있으면 묻는다)
|
||||
$('fileSelect').onchange = () => { if (window.VisualEditor && VisualEditor.isActive() && VisualEditor.onPageChange($('fileSelect').value)) return; loadFrame(); }; // v1.8.0 페이지 전환 때 저장하지 않은 직접 편집 보호
|
||||
/**
|
||||
* v1.4.17 (요구 1) 미리보기 안에서 페이지를 옮겨 다닐 때 위 도구 줄의 페이지 선택(#fileSelect)과 소스 편집기를 따라가게 한다.
|
||||
* 미리보기 iframe 은 sandbox(allow-same-origin 없음)라 빌더가 iframe 의 주소를 읽을 수 없다. 대신 모든 생성 페이지에 들어 있는
|
||||
|
||||
@@ -429,8 +429,21 @@
|
||||
<a id="btnOpen" class="btn ghost disabled" target="_blank" rel="noopener">새 탭에서 열기</a>
|
||||
<button id="btnCopy" class="ghost" disabled>URL 복사</button>
|
||||
<button id="btnToggleSource" class="ghost" disabled title="우측 화면을 위(미리보기)/아래(소스) 로 분할">소스 보기</button>
|
||||
<!-- v1.8.0 직접 편집(시각 편집기): 미리보기 화면에서 글을 바로 고치고 블록을 끌어 옮기고 구성 요소를 놓는다. 사이트와 HTML 페이지가 골라져 있을 때만 켜진다 (public/visual-editor-ui.js) -->
|
||||
<button id="btnVisual" class="ghost" disabled aria-pressed="false" title="미리보기 화면에서 글을 바로 고치고 블록을 끌어 옮기는 직접 편집 모드 (AI 캐시를 쓰지 않습니다)">직접 편집</button>
|
||||
</div>
|
||||
<div class="preview" id="previewArea">
|
||||
<!-- v1.8.0 직접 편집 도구 줄: 저장 상태(aria-live) · 실행 취소 · 다시 실행 · 버리고 다시 불러오기 · 저장(Ctrl+S) · 편집 끝내기 -->
|
||||
<div id="veBar" class="ve-bar hidden" role="toolbar" aria-label="직접 편집 도구">
|
||||
<span class="ve-title">직접 편집</span>
|
||||
<span id="veState" class="ve-state clean" aria-live="polite">변경 없음</span>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" id="veUndo" class="ghost small-btn" disabled title="실행 취소 (Ctrl+Z)" aria-keyshortcuts="Control+Z">실행 취소</button>
|
||||
<button type="button" id="veRedo" class="ghost small-btn" disabled title="다시 실행 (Ctrl+Shift+Z)" aria-keyshortcuts="Control+Shift+Z">다시 실행</button>
|
||||
<button type="button" id="veDiscard" class="ghost small-btn" disabled title="저장하지 않은 변경을 버리고 저장된 페이지를 다시 불러옵니다">버리고 다시 불러오기</button>
|
||||
<button type="button" id="veSave" class="primary small-btn" disabled title="선택한 HTML 파일에 저장하고 버전으로 남깁니다 (Ctrl+S)" aria-keyshortcuts="Control+S">저장 (Ctrl+S)</button>
|
||||
<button type="button" id="veClose" class="ghost small-btn" title="직접 편집을 끝내고 미리보기로 돌아갑니다">편집 끝내기</button>
|
||||
</div>
|
||||
<div id="emptyState" class="empty">
|
||||
<div class="empty-inner">
|
||||
<h3>미리보기</h3>
|
||||
@@ -439,7 +452,18 @@
|
||||
</div>
|
||||
<!-- v1.4.15 사용자 첫 화면 페이지: 사이트를 고르기 전 오른쪽 창에만 보이는 관리자 지정 페이지 (app.js showWelcome) -->
|
||||
<div id="welcomeBox" class="welcome-box hidden"><iframe id="welcomeFrame" class="welcome-frame" title="첫 화면" sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"></iframe></div>
|
||||
<div class="frame-wrap" id="frameWrap"><iframe id="frame" class="hidden" title="생성된 페이지 미리보기" sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads"></iframe><!-- v1.3.17 (S02): 미리보기는 별도 출처(sandbox, allow-same-origin 없음) — 사이트 스크립트가 빌더 세션·저장소를 쓰지 못한다 --></div>
|
||||
<!-- v1.8.0 직접 편집 무대: 왼쪽 구성 요소 팔레트 · 가운데 미리보기(캔버스) · 오른쪽 속성 창. 팔레트·속성 창은 캔버스를 가리지 않고 옆(좁은 화면에서는 아래)에 놓이며 접을 수 있다 -->
|
||||
<div class="ve-stage" id="veStage">
|
||||
<aside id="vePalette" class="ve-side ve-palette hidden" role="region" aria-label="구성 요소 팔레트">
|
||||
<div class="ve-side-head"><button type="button" id="vePaletteToggle" class="ve-toggle" aria-expanded="true" aria-controls="vePaletteList" title="팔레트 접기/펼치기">▾</button><b>구성 요소</b> <button type="button" class="help" data-tip="항목을 미리보기로 끌어다 놓으면 그 자리에 들어갑니다. 마우스를 쓰기 어려우면 '추가' 버튼으로 선택한 요소 뒤(선택이 없으면 페이지 끝)에 넣을 수 있습니다.">?</button></div>
|
||||
<ul id="vePaletteList" class="ve-palette-list" aria-label="구성 요소 목록"></ul>
|
||||
</aside>
|
||||
<div class="frame-wrap" id="frameWrap"><iframe id="frame" class="hidden" title="생성된 페이지 미리보기" sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads"></iframe><!-- v1.3.17 (S02): 미리보기는 별도 출처(sandbox, allow-same-origin 없음) — 사이트 스크립트가 빌더 세션·저장소를 쓰지 못한다. v1.8.0 직접 편집 캔버스도 같은 iframe(srcdoc)을 쓴다 --></div>
|
||||
<aside id="veInspector" class="ve-side ve-inspector hidden" role="region" aria-label="선택한 요소 속성">
|
||||
<div class="ve-side-head"><button type="button" id="veInspectorToggle" class="ve-toggle" aria-expanded="true" aria-controls="veInspectorBody" title="속성 창 접기/펼치기">▾</button><b>선택한 요소</b></div>
|
||||
<div id="veInspectorBody" class="ve-inspector-body"></div>
|
||||
</aside>
|
||||
</div>
|
||||
<div id="splitter" class="splitter hidden" title="드래그하여 크기 조절"></div>
|
||||
<div id="editorPane" class="editor hidden">
|
||||
<div class="editor-bar">
|
||||
@@ -502,5 +526,7 @@
|
||||
<script src="/flow-ui.js"></script><!-- v1.6.40 홈(작업 흐름 지도)·레일 상태 배지·배포 점검표 -->
|
||||
<script src="/credits-ui.js"></script><!-- v1.6.40 캐시·충전 화면(선불 충전 상품·주문) -->
|
||||
<script src="/site-info.js"></script><!-- v1.7.2 오른쪽 캔버스 사이트 정보 페이지(그래프) -->
|
||||
<script src="/visual-editor-core.js"></script><!-- v1.8.0 직접 편집 공용 핵심(실행 취소·저장 상태·메시지 규약·구성 요소) -->
|
||||
<script src="/visual-editor-ui.js"></script><!-- v1.8.0 직접 편집 빌더 쪽 제어(팔레트·속성 창·저장) -->
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -752,6 +752,39 @@ details.dom-rec-details > summary { cursor: pointer; font-size: 13px; font-weigh
|
||||
.si-disk-total { font-size: 22px; font-weight: 700; letter-spacing: -.01em; }
|
||||
.si-table th, .si-table td { white-space: nowrap; }
|
||||
|
||||
/* ===== v1.8.0: 직접 편집(시각 편집기) — 도구 줄(.ve-bar) · 무대(.ve-stage: 팔레트 | 캔버스 | 속성 창) ===== */
|
||||
.ve-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--line); background: #f8fafc; flex-wrap: wrap; }
|
||||
.ve-bar .ve-title { font-weight: 700; font-size: 13px; color: #1e3a8a; }
|
||||
.ve-bar .spacer { flex: 1; }
|
||||
.ve-state { font-size: 12px; padding: 2px 8px; border-radius: 999px; background: #e5e7eb; color: #374151; }
|
||||
.ve-state.dirty, .ve-state.error { background: #fee2e2; color: #991b1b; font-weight: 600; } .ve-state.saving { background: #fef3c7; color: var(--run); } .ve-state.saved { background: #d1fae5; color: var(--ok); }
|
||||
.ve-stage { flex: 1; min-height: 0; display: flex; }
|
||||
.ve-stage .frame-wrap { flex: 1; min-width: 0; }
|
||||
.ve-side { flex: 0 0 240px; max-width: 40%; border-left: 1px solid var(--line); background: #fff; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
.ve-side.ve-palette { border-left: 0; border-right: 1px solid var(--line); }
|
||||
.ve-side.collapsed { flex-basis: 34px; } .ve-side.collapsed .ve-side-head b, .ve-side.collapsed .ve-side-head .help { display: none; } .ve-side.collapsed > *:not(.ve-side-head) { display: none; }
|
||||
.ve-side-head { display: flex; align-items: center; gap: 6px; padding: 8px; border-bottom: 1px solid var(--line); font-size: 13px; }
|
||||
.ve-toggle { all: unset; cursor: pointer; width: 22px; height: 22px; border-radius: 6px; text-align: center; line-height: 22px; color: var(--muted); } .ve-toggle:hover, .ve-toggle:focus-visible { background: #eef2ff; outline: 2px solid #c7d7ff; }
|
||||
.ve-palette-list { list-style: none; margin: 0; padding: 8px; overflow: auto; display: flex; flex-direction: column; gap: 6px; }
|
||||
.ve-comp { border: 1px dashed #c9d1dc; border-radius: 8px; padding: 8px; background: #f8fafc; cursor: grab; display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; align-items: center; }
|
||||
.ve-comp:hover { border-color: var(--accent); background: #eef2ff; } .ve-comp .ve-comp-name { font-weight: 600; font-size: 13px; } .ve-comp .ve-comp-hint { grid-column: 1; } .ve-comp button { grid-column: 2; grid-row: 1 / span 2; }
|
||||
/* 포인터 끌기 다리(visual-editor-ui.js): 끌기 중 글 선택 방지·잡는 손 모양. 터치는 다리를 쓰지 않으므로 세로 스크롤(pan-y)은 그대로 */
|
||||
.ve-comp { user-select: none; touch-action: pan-y; } .ve-comp.ve-comp-dragging { opacity: .6; cursor: grabbing; border-style: solid; border-color: var(--accent); } body.ve-palette-dragging, body.ve-palette-dragging * { cursor: grabbing !important; user-select: none; }
|
||||
.ve-inspector-body { padding: 10px; overflow: auto; font-size: 13px; }
|
||||
.ve-sel-head { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
|
||||
.ve-actions { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; } .ve-actions .danger { color: #991b1b; }
|
||||
.ve-field { display: block; margin-bottom: 8px; } .ve-field textarea { resize: vertical; }
|
||||
body.ve-on .toolbar #btnVisual.active { background: #dbeafe; border-color: #93c5fd; color: #1e3a8a; }
|
||||
/* 직접 편집 중(body.ve-on, visual-editor-ui.js 가 켜고 끈다): 옛 작업 칸(.left)을 숨기고 .app 을 "레일 + 전체 폭 캔버스" 두 칸으로 다시 짠다.
|
||||
실제 브라우저 검증(1280×633)에서 세 칸 그대로면 iframe 이 115px 로 줄어 글자가 한 자씩 떨어졌다 — 이제 1280px 에서 캔버스 = 1280 − 레일 232 − 패널 240×2 = 568px.
|
||||
편집을 끝내면 class 가 빠져 원래 세 칸 배치가 그대로 돌아온다(작업 칸 내용은 숨겨질 뿐 바뀌지 않는다). */
|
||||
body.ve-on .app { grid-template-columns: var(--rail-w) minmax(0, 1fr); }
|
||||
body.ve-on .left { display: none; }
|
||||
@media (max-width: 1180px) { body.ve-on .ve-side { flex-basis: 190px; } } /* 레일 204px 구간: 1100px 에서도 캔버스 ≥ 500px */
|
||||
@media (max-width: 900px) { .ve-stage { flex-direction: column; } .ve-side { flex: 0 0 auto; max-width: none; max-height: 32vh; border-left: 0; border-top: 1px solid var(--line); } .ve-side.ve-palette { order: 2; border-right: 0; } .ve-inspector { order: 3; } .ve-stage .frame-wrap { order: 1; min-height: 40vh; } .ve-side.collapsed { max-height: 36px; }
|
||||
/* 390px 등 좁은 화면: 레일(가로 띠) 바로 아래에 캔버스. 옛 작업 칸 행이 없으므로 캔버스가 y≈2200 으로 밀리지 않는다 */
|
||||
body.ve-on .app { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); height: 100vh; } body.ve-on .right { min-height: 0; } }
|
||||
|
||||
/* ===== v1.7.3: 메일 사용량 (메일 화면 한눈에 보기 · 사이트 정보) ===== */
|
||||
.mg-usage .git-list { margin: 6px 0 4px; } .mg-usage .git-list .st { white-space: normal; text-align: right; }
|
||||
.mg-usage > div + div { margin-top: 3px; }
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* public/visual-editor-core.js — v1.8.0 직접 편집(시각 편집기)의 공용 핵심. 브라우저(빌더 화면 · 캔버스 iframe)와 Node(테스트) 양쪽에서 같은 코드를 쓴다.
|
||||
* DOM 에 의존하지 않는 순수 로직만 둔다: 실행 취소/다시 실행 기록, 저장 상태 기계, postMessage 규약 검증, 구성 요소(팔레트) 목록,
|
||||
* 재배치 계산(순서 이동·놓을 위치), 속성 편집 규칙(요소별 편집 가능 속성·주소 입력 검사·한국어 이름).
|
||||
* 브라우저: window.WMVisualCore / Node: module.exports
|
||||
*/
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory();
|
||||
else root.WMVisualCore = factory();
|
||||
}(typeof window !== 'undefined' ? window : this, function () {
|
||||
'use strict';
|
||||
const VERSION = '1.8.0';
|
||||
/** 메시지 종류: 부모(빌더) → 캔버스 / 캔버스 → 부모. 이 목록에 없는 종류는 어느 쪽도 처리하지 않는다 */
|
||||
const MESSAGE_TYPES = {
|
||||
toCanvas: ['init', 'select', 'move', 'duplicate', 'delete', 'insert', 'setProps', 'applyHtml', 'setMode', 'requestBody', 'paletteDragAt', 'paletteDragCancel', 'paletteDropAt'],
|
||||
fromCanvas: ['ready', 'selected', 'changed', 'error', 'body', 'hotkey'],
|
||||
};
|
||||
/** 보내는 메시지 봉투. wmve 판별값·세션·종류·payload(객체) 네 칸뿐이다 */
|
||||
function makeMessage(session, type, payload) { return { wmve: 1, session: String(session || ''), type: String(type || ''), payload: payload && typeof payload === 'object' ? payload : {} }; }
|
||||
/** 봉투 검사(공통): wmve 판별값·세션·종류(방향별 목록)·payload(배열 아닌 객체) */
|
||||
function validateEnvelope(d, o) {
|
||||
if (!d || typeof d !== 'object' || d.wmve !== 1) return false;
|
||||
if (typeof d.session !== 'string' || d.session !== o.session) return false;
|
||||
if (typeof d.type !== 'string' || !(MESSAGE_TYPES[o.direction] || []).includes(d.type)) return false;
|
||||
if (!d.payload || typeof d.payload !== 'object' || Array.isArray(d.payload)) return false;
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* 받은 **창(window) message** 이벤트가 우리 규약에 맞는지. 보낸 창(source)·출처(origin)·세션·형식·종류를 모두 확인한다. 하나라도 어긋나면 false.
|
||||
* 캔버스는 sandbox(allow-same-origin 없음) 라 출처가 'null' 이다 — 부모는 expectedOrigin:'null' 과 iframe 의 contentWindow 를 넘긴다.
|
||||
* 연결 규약(1.8.0 검토 수정)에서는 창 메시지는 캔버스 → 부모의 첫 'ready'(MessagePort 동봉) 한 번만 쓰고, 그 뒤 모든 명령·이벤트는 포트로 오간다(validatePortMessage).
|
||||
* @param {{data:any, source:any, origin:string}} ev
|
||||
* @param {{expectedSource:any, expectedOrigin:string, session:string, direction:'fromCanvas'|'toCanvas'}} o
|
||||
*/
|
||||
function validateMessage(ev, o) {
|
||||
if (!ev || !o || !o.session) return false;
|
||||
if (!o.expectedSource || ev.source !== o.expectedSource) return false;
|
||||
if (String(ev.origin) !== String(o.expectedOrigin)) return false;
|
||||
return validateEnvelope(ev.data, o);
|
||||
}
|
||||
/**
|
||||
* **MessagePort** 로 받은 message 이벤트 검사. 포트 메시지에는 보낸 창·출처가 없다(포트 자체가 그것을 만든 문서에 묶여 있어 다른 문서가 이어받을 수 없다).
|
||||
* 그래서 봉투(세션·종류·payload)만 검사한다. 세션은 이중 확인용이다.
|
||||
* @param {{data:any}} ev
|
||||
* @param {{session:string, direction:'fromCanvas'|'toCanvas'}} o
|
||||
*/
|
||||
function validatePortMessage(ev, o) { if (!ev || !o || !o.session) return false; return validateEnvelope(ev.data, o); }
|
||||
|
||||
// ---------- 주소(URL) 정규화 — 서버(lib/visual-editor.js)와 화면·캔버스가 같은 규칙을 쓴다 ----------
|
||||
/**
|
||||
* 브라우저가 속성값에서 풀어 읽는 HTML 이름 엔티티 중 주소 스킴을 감추는 데 쓰일 만한 것. 숫자 엔티티(&#NN; &#xHH; 세미콜론 생략 포함)는 모두 푼다.
|
||||
* 여기 없는 이름 엔티티는 풀지 않고 그대로 두며, 그것이 스킴 자리에 남아 있으면 urlScheme 이 ambiguous(흐린 스킴)로 보아 거절한다 — 모르는 조합을 허용하지 않는다(fail-closed).
|
||||
*/
|
||||
const URL_ENTITIES = { colon: ':', Tab: '\t', NewLine: '\n', sol: '/', bsol: '\\', num: '#', period: '.', quest: '?', excl: '!', lpar: '(', rpar: ')', quot: '"', QUOT: '"', apos: "'", amp: '&', AMP: '&', lt: '<', LT: '<', gt: '>', GT: '>', nbsp: '\u00a0', ensp: '\u2002', emsp: '\u2003', thinsp: '\u2009', zwnj: '\u200c', zwj: '\u200d', lrm: '\u200e', rlm: '\u200f', ZeroWidthSpace: '\u200b', NoBreak: '\u2060', NegativeMediumSpace: '\u200b', equals: '=', semi: ';', plus: '+', comma: ',', verbar: '|', vert: '|', grave: '`', Hat: '^', lowbar: '_', dollar: '$', percnt: '%', ast: '*', midast: '*', commat: '@', lbrack: '[', lsqb: '[', rbrack: ']', rsqb: ']', lbrace: '{', lcub: '{', rbrace: '}', rcub: '}', tilde: '~' };
|
||||
const ENTITY_RE = /&(#[xX][0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]*);?/g;
|
||||
/** 엔티티 한 번 풀기. 모르는 이름 엔티티는 원문 그대로 남긴다 */
|
||||
function decodeEntitiesOnce(s) {
|
||||
return String(s).replace(ENTITY_RE, (m, body) => {
|
||||
if (body[0] === '#') { const hex = body[1] === 'x' || body[1] === 'X'; const n = parseInt(body.slice(hex ? 2 : 1), hex ? 16 : 10); if (!Number.isFinite(n) || n < 0 || n > 0x10ffff) return ''; try { return String.fromCodePoint(n); } catch (_) { return ''; } }
|
||||
if (Object.prototype.hasOwnProperty.call(URL_ENTITIES, body)) return URL_ENTITIES[body];
|
||||
const low = body.toLowerCase(); for (const k of Object.keys(URL_ENTITIES)) if (k.toLowerCase() === low) return URL_ENTITIES[k];
|
||||
return m;
|
||||
});
|
||||
}
|
||||
/** 스킴 판별용 정규형: 엔티티(두 겹까지) 풀기 → 제어·공백·폭 없는 문자 제거 → 소문자. 저장되는 값은 바꾸지 않고 판별에만 쓴다 */
|
||||
function normalizeUrl(v) {
|
||||
let s = String(v == null ? '' : v); for (let i = 0; i < 2 && s.includes('&'); i++) s = decodeEntitiesOnce(s);
|
||||
return s.replace(/[\u0000-\u0020\u007f-\u00a0\u1680\u180e\u2000-\u200f\u2028-\u202f\u205f\u2060-\u2064\u3000\ufeff]/g, '').toLowerCase();
|
||||
}
|
||||
/**
|
||||
* 주소의 스킴 분석. { scheme: 'javascript' 등(없으면 ''), rest: 스킴 뒤, ambiguous: 스킴 자리(첫 ':' '/' '?' '#' 앞)에 풀리지 않은 '&' 가 남아 있고 뒤에 ':' 가 있는 경우 }
|
||||
* 예) 'java:script:x' → javascript · 'javascript:x' → javascript · 'java&bogus;script:x' → ambiguous · 'about.html?x:y' → 스킴 없음(상대 경로)
|
||||
*/
|
||||
function urlScheme(v) {
|
||||
const s = normalizeUrl(v); const head = s.split(/[/?#:]/)[0];
|
||||
const ambiguous = head.includes('&') && s.slice(head.length).includes(':');
|
||||
const m = /^([a-z][a-z0-9+.\-]*):/.exec(s); if (!m) return { scheme: '', rest: s, ambiguous };
|
||||
return { scheme: m[1], rest: s.slice(m[0].length), ambiguous };
|
||||
}
|
||||
const SCRIPT_SCHEMES = ['javascript', 'vbscript', 'livescript', 'mocha', 'ecmascript'];
|
||||
/** 실행·문서 주입이 가능한 주소인지(javascript: 류, data: 는 이미지만 허용, 흐린 스킴은 거절) */
|
||||
function isDangerousUrl(v) {
|
||||
const u = urlScheme(v); if (u.ambiguous) return true;
|
||||
if (SCRIPT_SCHEMES.includes(u.scheme)) return true;
|
||||
if (u.scheme === 'data') return !/^image\//.test(u.rest) || /script/.test(u.rest);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------- 실행 취소 / 다시 실행 ----------
|
||||
/**
|
||||
* 본문 HTML 스냅샷 기록. reset(초기값) 뒤 push(새 스냅샷) 으로 쌓고 undo()/redo() 가 돌아갈 스냅샷을 돌려준다(없으면 null).
|
||||
* 같은 내용을 연달아 push 하면 기록하지 않는다. limit 를 넘으면 가장 오래된 것부터 버린다.
|
||||
* @param {number} limit
|
||||
*/
|
||||
function createHistory(limit) {
|
||||
const max = Math.max(2, Number(limit) || 100); let items = []; let idx = -1;
|
||||
return {
|
||||
reset(initial) { items = [initial]; idx = 0; },
|
||||
push(snap) { if (idx >= 0 && items[idx] === snap) return false; items = items.slice(0, idx + 1); items.push(snap); if (items.length > max) items.splice(0, items.length - max); idx = items.length - 1; return true; },
|
||||
undo() { if (idx <= 0) return null; idx--; return items[idx]; },
|
||||
redo() { if (idx >= items.length - 1) return null; idx++; return items[idx]; },
|
||||
canUndo() { return idx > 0; }, canRedo() { return idx < items.length - 1; },
|
||||
current() { return idx >= 0 ? items[idx] : null; }, size() { return items.length; },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 저장 상태 ----------
|
||||
const SAVE_LABELS = { clean: '변경 없음', dirty: '저장되지 않은 변경이 있습니다', saving: '저장 중…', saved: '저장됨', error: '저장 실패' };
|
||||
/**
|
||||
* 저장 상태 기계: clean → dirty → saving → saved | error. saving 중의 변경은 저장이 끝나면 dirty 로 남는다(잃어버리지 않는다).
|
||||
* label() 은 화면·aria-live 에 바로 쓸 한국어 문구.
|
||||
*/
|
||||
function createSaveState() {
|
||||
let st = 'clean'; let pending = false; let err = '';
|
||||
return {
|
||||
state() { return st; }, isDirty() { return st === 'dirty' || st === 'error' || (st === 'saving') || pending; },
|
||||
label() { return st === 'error' && err ? `${SAVE_LABELS.error} — ${err}` : SAVE_LABELS[st]; },
|
||||
markDirty() { if (st === 'saving') { pending = true; return; } st = 'dirty'; err = ''; },
|
||||
beginSave() { if (st !== 'dirty' && st !== 'error') return false; st = 'saving'; pending = false; err = ''; return true; },
|
||||
saveOk() { if (st !== 'saving') return; st = pending ? 'dirty' : 'saved'; pending = false; },
|
||||
saveFail(message) { if (st !== 'saving') return; st = 'error'; err = String(message || ''); pending = false; },
|
||||
reset() { st = 'clean'; pending = false; err = ''; },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 구성 요소(팔레트) ----------
|
||||
/** 팔레트에 보이는 구성 요소. html 은 스크립트·이벤트 속성·위험 주소가 없는 정적 HTML 이어야 하며 isSafeComponentHtml 로 검사된다 */
|
||||
const COMPONENTS = [
|
||||
{ id: 'section', label: '섹션(컨테이너)', hint: '안에 다른 요소를 담는 구역', html: '<section style="padding:40px 20px"><h2>새 섹션</h2><p>여기에 내용을 입력하세요.</p></section>' },
|
||||
{ id: 'heading', label: '제목', hint: '큰 글씨 제목(h2)', html: '<h2>새 제목</h2>' },
|
||||
{ id: 'paragraph', label: '문단', hint: '본문 글', html: '<p>여기에 내용을 입력하세요.</p>' },
|
||||
{ id: 'button', label: '버튼(링크)', hint: '눌러서 이동하는 링크 버튼', html: '<a href="#" style="display:inline-block;padding:12px 24px;background:#2f6fed;color:#fff;border-radius:8px;text-decoration:none">버튼</a>' },
|
||||
{ id: 'image', label: '이미지', hint: '이미지(주소·대체 글은 속성에서)', html: '<img src="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23e5e7eb%22/%3E%3Ctext x=%22320%22 y=%22190%22 text-anchor=%22middle%22 font-size=%2228%22 fill=%22%236b7684%22 font-family=%22sans-serif%22%3E%EC%9D%B4%EB%AF%B8%EC%A7%80%3C/text%3E%3C/svg%3E" alt="이미지 설명" style="max-width:100%;height:auto">' },
|
||||
{ id: 'divider', label: '구분선', hint: '가로 선', html: '<hr>' },
|
||||
{ id: 'spacer', label: '여백', hint: '세로 빈 공간', html: '<div style="height:40px" aria-hidden="true"></div>' },
|
||||
];
|
||||
/** 구성 요소 HTML (없는 id 면 null) */
|
||||
function componentHtml(id) { const c = COMPONENTS.find((x) => x.id === id); return c ? c.html : null; }
|
||||
|
||||
/**
|
||||
* 팔레트 → 캔버스 끌어다 놓기의 dataTransfer 규약.
|
||||
* 캔버스는 출처 'null' 인 sandbox iframe(allow-same-origin 없음)이라 Chromium 은 다른 출처에서 넣은 전용 MIME('text/wm-ve-component')
|
||||
* 끌기 자료를 넘겨주지 않는다(종류 목록에서도 사라진다). 그래서 부모는 전용 MIME 과 **함께** 표준 'text/plain' 에 이름 공간을 붙인
|
||||
* 값 'wm-ve-component:<id>' 를 넣고, 캔버스는 전용 MIME 을 우선 읽되 없으면 text/plain 이 이 형식에 **정확히** 맞고 id 가 알려진
|
||||
* 구성 요소일 때만 받는다. 임의 글·주소·HTML·모르는 id 는 null → 캔버스는 아무것도 넣지 않는다.
|
||||
* dragover 때는 브라우저가 getData 를 막으므로 isComponentDrag 로 종류만 보고 끌기를 인식하고, drop 때 dragComponentId 로 검증한다.
|
||||
*/
|
||||
const COMPONENT_MIME = 'text/wm-ve-component';
|
||||
const COMPONENT_TEXT_PREFIX = 'wm-ve-component:';
|
||||
/** 부모 dragstart 가 text/plain 에 넣는 값 */
|
||||
function componentDragText(id) { return COMPONENT_TEXT_PREFIX + String(id || ''); }
|
||||
/** 끌기 종류 목록(dataTransfer.types)에 구성 요소 끌기일 수 있는 종류가 있는가 */
|
||||
function isComponentDrag(types) { let t = []; try { t = Array.from(types || []); } catch (_) { return false; } return t.includes(COMPONENT_MIME) || t.includes('text/plain'); }
|
||||
/** 알려진 구성 요소 id 인가 */
|
||||
function isComponentId(id) { return typeof id === 'string' && COMPONENTS.some((c) => c.id === id); }
|
||||
|
||||
/**
|
||||
* 팔레트 → 캔버스 **포인터 끌기 다리**(주 경로).
|
||||
* 실제 브라우저에서 HTML5 끌어다 놓기는 부모 문서에서 sandbox(출처 null) iframe 으로 넘어가지 않는다(dragenter/dragover/drop 이 캔버스에 한 번도 오지 않음 — 진단으로 확인).
|
||||
* 그래서 부모가 팔레트 항목의 포인터(마우스·펜)를 직접 추적한다: pointerdown 뒤 POINTER_DRAG_THRESHOLD px 넘게 움직이면 끌기 시작, 포인터 캡처로 iframe 위에서도
|
||||
* pointermove 를 계속 받고, 좌표를 #frame 의 화면 사각형과 비교해 **안쪽일 때만** 검증된 메시지 paletteDragAt(iframe 상대 x·y)·paletteDropAt 을 보낸다.
|
||||
* 캔버스는 좌표를 canvasPoint 로 다시 검증해 dropTargetAt → showDrop / insertComponent 를 스스로 수행한다(부모가 DOM 위치를 지정하지 않는다).
|
||||
*/
|
||||
const POINTER_DRAG_THRESHOLD = 4;
|
||||
/**
|
||||
* 화면 좌표 → iframe 안쪽(콘텐츠 상자) 상대 좌표. rect 는 iframe 요소의 getBoundingClientRect(), box 는 { left: clientLeft, top: clientTop, width: clientWidth, height: clientHeight }
|
||||
* (테두리·스크롤바 보정, 생략하면 테두리 0·rect 크기). 안쪽이 아니면 inside:false.
|
||||
*/
|
||||
function pointInFrame(clientX, clientY, rect, box) {
|
||||
const b = box || {}; const bl = Number(b.left) || 0; const bt = Number(b.top) || 0; const w = Number(b.width) || Number(rect.width) || 0; const h = Number(b.height) || Number(rect.height) || 0;
|
||||
const x = Math.round(clientX - rect.left - bl); const y = Math.round(clientY - rect.top - bt);
|
||||
return { inside: x >= 0 && y >= 0 && x < w && y < h, x, y };
|
||||
}
|
||||
/** 캔버스가 받은 좌표 검증: 유한한 숫자이고 자기 뷰포트(0..w, 0..h) 안일 때만 {x, y}, 아니면 null */
|
||||
function canvasPoint(x, y, w, h) {
|
||||
if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return null;
|
||||
if (x < 0 || y < 0 || x > w || y > h) return null;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/** drop 의 dataTransfer 에서 검증된 구성 요소 id 를 꺼낸다(없거나 틀리면 null). 전용 MIME 값이 있으면 그 값만 믿는다 */
|
||||
function dragComponentId(dt) {
|
||||
if (!dt || typeof dt.getData !== 'function') return null;
|
||||
const known = (v) => (COMPONENTS.some((c) => c.id === v) ? v : null);
|
||||
let own = ''; try { own = dt.getData(COMPONENT_MIME) || ''; } catch (_) { own = ''; }
|
||||
if (own) return known(String(own));
|
||||
let text = ''; try { text = dt.getData('text/plain') || ''; } catch (_) { return null; }
|
||||
text = String(text); if (!text.startsWith(COMPONENT_TEXT_PREFIX)) return null;
|
||||
const id = text.slice(COMPONENT_TEXT_PREFIX.length); if (!/^[a-z]+$/.test(id)) return null;
|
||||
return known(id);
|
||||
}
|
||||
/** 구성 요소 HTML 안전 규칙: script/iframe/object/embed/style 요소 없음, on* 속성 없음, javascript:/vbscript:/data:text 주소 없음 */
|
||||
function isSafeComponentHtml(html) {
|
||||
const s = String(html || '');
|
||||
if (/<\s*(script|iframe|frame|object|embed|style|link|meta|base|form)\b/i.test(s)) return false;
|
||||
if (/<\s*(?:[a-z0-9_-]+:)?(animate|set|animateMotion|animateTransform|animateColor|discard|mpath)\b/i.test(s)) return false; // SVG SMIL 변이 요소(href 를 javascript: 로 바꿀 수 있다) — 서버 저장 검증과 같은 묶음, 접두어 형태 포함
|
||||
if (/\son[a-z]+\s*=/i.test(s)) return false;
|
||||
if (/(href|src|action|srcset|poster)\s*=\s*["']?\s*(javascript|vbscript|data:text)/i.test(s.replace(/\s+/g, ' '))) return false;
|
||||
const re = /\s(href|src|action|formaction|srcset|poster)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi; let m;
|
||||
while ((m = re.exec(s))) if (isDangerousUrl(m[2] !== undefined ? m[2] : (m[3] !== undefined ? m[3] : m[4]))) return false; // 엔티티·제어 문자로 감춘 스킴까지(서버와 같은 규칙)
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------- 재배치 계산 ----------
|
||||
/** 배열의 from 위치 항목을 to 위치로 옮긴 새 배열(범위를 벗어나면 그대로) */
|
||||
function moveIndex(arr, from, to) { const a = arr.slice(); if (from < 0 || from >= a.length || to < 0 || to >= a.length) return a; const [x] = a.splice(from, 1); a.splice(to, 0, x); return a; }
|
||||
/**
|
||||
* 세로로 늘어선 형제 요소들의 경계 상자(top/bottom)와 포인터 y 로 "몇 번째 앞에 놓을지" 를 정한다. 각 요소의 세로 중간선을 넘으면 그 다음 자리.
|
||||
* @param {{top:number,bottom:number}[]} rects
|
||||
* @param {number} y
|
||||
* @returns {number} 0..rects.length
|
||||
*/
|
||||
function dropIndexFor(rects, y) { let idx = 0; for (const r of rects) { if (y > (r.top + r.bottom) / 2) idx++; else break; } return idx; }
|
||||
|
||||
// ---------- 속성 편집 규칙 ----------
|
||||
const TEXT_TAGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'button', 'span', 'li', 'td', 'th', 'blockquote', 'label', 'small', 'strong', 'em', 'b', 'i', 'figcaption', 'summary', 'dt', 'dd', 'address', 'cite', 'legend'];
|
||||
const BLOCK_TAGS = ['section', 'div', 'article', 'aside', 'header', 'footer', 'nav', 'main', 'ul', 'ol', 'figure', 'form', 'table', 'details', 'hr', 'img', 'picture', 'video', 'audio', 'blockquote', 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li'];
|
||||
const LABELS = { h1: '제목 1', h2: '제목 2', h3: '제목 3', h4: '제목 4', h5: '제목 5', h6: '제목 6', p: '문단', a: '링크', button: '버튼', img: '이미지', section: '섹션', div: '블록', article: '글', aside: '보조 영역', header: '머리글', footer: '바닥글', nav: '메뉴', main: '본문 영역', ul: '목록', ol: '번호 목록', li: '목록 항목', span: '글 조각', hr: '구분선', figure: '그림', figcaption: '그림 설명', table: '표', form: '폼', input: '입력칸', textarea: '여러 줄 입력', select: '선택', label: '라벨', strong: '강조', em: '강조(기울임)', blockquote: '인용', video: '동영상', iframe: '삽입 프레임', svg: '벡터 그림' };
|
||||
/** 요소 이름의 한국어 표시 */
|
||||
function labelFor(tag) { const t = String(tag || '').toLowerCase(); return LABELS[t] || `요소(${t || '?'})`; }
|
||||
/**
|
||||
* 요소 종류별로 속성 창에 보일 항목: text(글) · href(링크 주소) · src/alt(이미지) · align(정렬) · color(글자색) · background(배경색) · padding/margin(여백)
|
||||
* @param {string} tag
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function propsFor(tag) {
|
||||
const t = String(tag || '').toLowerCase(); const out = [];
|
||||
if (TEXT_TAGS.includes(t)) out.push('text');
|
||||
if (t === 'a' || t === 'button') out.push('href');
|
||||
if (t === 'img') out.push('src', 'alt');
|
||||
if (t !== 'hr' && t !== 'br' && t !== 'img') out.push('align', 'color');
|
||||
out.push('background', 'padding', 'margin');
|
||||
return out;
|
||||
}
|
||||
/**
|
||||
* 사용자가 속성 창에 넣은 주소 검사. kind 'link' 는 http(s)·mailto·tel·sms·상대 경로·#앵커만, 'image' 는 거기에 data:image/* 를 더 허용한다.
|
||||
* @returns {{ok: boolean, value: string, error?: string}}
|
||||
*/
|
||||
function safeUrlInput(value, kind) {
|
||||
const v = String(value == null ? '' : value).trim();
|
||||
const u = urlScheme(v); const scheme = u.scheme; // 엔티티(: j …)·제어 문자·폭 없는 문자로 감춘 스킴도 서버와 같은 규칙으로 판별
|
||||
if (u.ambiguous) return { ok: false, value: v, error: '주소에 알 수 없는 문자 참조(&…;)가 섞여 있어 넣을 수 없습니다. 주소를 그대로 입력하세요' };
|
||||
if (SCRIPT_SCHEMES.includes(scheme)) return { ok: false, value: v, error: '스크립트 주소(javascript: 등)는 넣을 수 없습니다' };
|
||||
if (scheme === 'data') { if (kind === 'image' && /^image\/(png|jpe?g|gif|webp|svg\+xml|avif)[;,]/.test(u.rest) && !/script/.test(u.rest)) return { ok: true, value: v }; return { ok: false, value: v, error: 'data: 주소는 이미지(data:image/…)에만 쓸 수 있습니다' }; }
|
||||
if (scheme && !['http', 'https', 'mailto', 'tel', 'sms'].includes(scheme)) return { ok: false, value: v, error: `허용되지 않는 주소 형식(${scheme}:)입니다. http(s)·mailto·tel 또는 상대 경로를 쓰세요` };
|
||||
return { ok: true, value: v };
|
||||
}
|
||||
|
||||
return { VERSION, MESSAGE_TYPES, makeMessage, validateMessage, validatePortMessage, createHistory, createSaveState, SAVE_LABELS, COMPONENTS, componentHtml, isSafeComponentHtml, isComponentId, COMPONENT_MIME, componentDragText, isComponentDrag, dragComponentId, POINTER_DRAG_THRESHOLD, pointInFrame, canvasPoint, moveIndex, dropIndexFor, TEXT_TAGS, BLOCK_TAGS, propsFor, labelFor, safeUrlInput, normalizeUrl, urlScheme, isDangerousUrl };
|
||||
}));
|
||||
@@ -0,0 +1,247 @@
|
||||
'use strict';
|
||||
/**
|
||||
* public/visual-editor-ui.js — v1.8.0 직접 편집(시각 편집기) 빌더 쪽 제어. 오른쪽 미리보기 도구 줄의 "직접 편집" 버튼으로 켜고 끈다.
|
||||
* 역할
|
||||
* - 켜기: GET /api/sites/<id>/visual/<페이지> 로 캔버스 문서를 받아 미리보기 iframe(sandbox, 출처 null)의 srcdoc 에 넣는다. 세션·baseHash·rev 를 기억한다.
|
||||
* - 캔버스와의 연결 규약: 캔버스의 첫 'ready' 창 메시지(iframe 창·출처 'null'·세션·종류 확인, MessagePort 동봉)로 한 번 연결한 뒤 모든 명령·이벤트는 그 포트로만(창 메시지는 더 받지 않는다).
|
||||
* 캔버스가 보낸 변경(본문)을 실행 취소 기록에 쌓고 저장 상태를 바꾼다.
|
||||
* - 저장(버튼·Ctrl/Cmd+S): PUT { body, baseHash, rev } → 파일 갱신·버전 기록. 409(다른 창이 먼저 저장) 는 안내와 함께 "다시 불러오기" 로 이어진다.
|
||||
* - 실행 취소/다시 실행(스냅샷 기록), 버리고 다시 불러오기, 편집 끝내기. 저장하지 않은 변경은 페이지·사이트·탭 전환과 창 닫기 전에 묻는다(app.js 의 discardOk·beforeunload 가 이 모듈에 묻는다).
|
||||
* - 팔레트(구성 요소: 끌어다 놓기 + 버튼으로 "선택한 요소 뒤에 추가")와 속성 창(글·링크·이미지·정렬·색·배경·여백 + 상위 선택·이동·복제·삭제 버튼).
|
||||
* app.js 의 전역(current·api·ed·setEditorOpen·renderGit·loadFrame·logLine·pushMsg·$)을 쓴다. 직접 편집은 AI 캐시를 쓰지 않고 AI 수정으로 세지 않는다(서버 규칙).
|
||||
*/
|
||||
window.VisualEditor = (() => {
|
||||
const core = window.WMVisualCore; if (!core) return null;
|
||||
const st = { active: false, siteId: null, file: null, session: null, port: null, baseHash: null, rev: null, history: core.createHistory(200), save: core.createSaveState(), savedBody: null, selected: null, loading: false };
|
||||
const frame = () => $('frame');
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
// ---------- 캔버스와의 통신(연결 규약, 1.8.0 검토 수정) ----------
|
||||
// 캔버스 런타임은 부팅 때 **한 번** 창 메시지 'ready' 에 MessagePort 를 동봉해 보낸다. 여기서는 보낸 창(iframe contentWindow)·출처('null')·세션·종류를 검사한 뒤 그 포트에 연결하고,
|
||||
// 그 뒤 모든 명령은 포트로만 보낸다(contentWindow.postMessage(…, '*') 를 쓰지 않는다 — 세션이 든 명령이 '*' 로 나가는 일이 없다). 연결 뒤에 오는 창 메시지는 어떤 것이든 받지 않는다:
|
||||
// iframe 안의 문서가 바뀌어(링크·area·폼 이동 등) 새 문서가 같은 contentWindow·출처 null 로 ready/changed/hotkey 를 보내도 포트가 없으므로 끼어들 수 없다. 포트는 끝내기·다시 열기 때 닫는다.
|
||||
/** 캔버스로 명령을 보낸다 — 연결된 포트로만 */
|
||||
function send(type, payload) { if (!st.active || !st.port) return; try { st.port.postMessage(core.makeMessage(st.session, type, payload || {})); } catch (_) {} }
|
||||
function disconnect() { const p = st.port; st.port = null; if (!p) return; try { p.onmessage = null; } catch (_) {} try { p.close(); } catch (_) {} }
|
||||
function connect(port) { disconnect(); st.port = port; port.onmessage = onCanvasMessage; try { if (typeof port.start === 'function') port.start(); } catch (_) {} send('init', { mode: 'edit' }); }
|
||||
window.addEventListener('message', (e) => {
|
||||
if (!st.active || st.port) return; // 연결 뒤에는 창 메시지를 전혀 받지 않는다(한 번만 맺는 연결)
|
||||
if (!core.validateMessage(e, { expectedSource: frame().contentWindow, expectedOrigin: 'null', session: st.session, direction: 'fromCanvas' })) return;
|
||||
if (e.data.type !== 'ready') return;
|
||||
const port = e.ports && e.ports[0]; if (!port || typeof port.postMessage !== 'function') return; // 포트 없는 ready 는 연결이 아니다
|
||||
connect(port);
|
||||
});
|
||||
/** 포트로 온 캔버스 이벤트(세션·종류·payload 검사) */
|
||||
function onCanvasMessage(e) {
|
||||
if (!st.active || !st.port || !core.validatePortMessage(e, { session: st.session, direction: 'fromCanvas' })) return;
|
||||
const p = e.data.payload;
|
||||
switch (e.data.type) {
|
||||
case 'body': if (typeof p.body === 'string') { if (st.savedBody === null) { st.history.reset(p.body); st.savedBody = p.body; st.save.reset(); } render(); } break;
|
||||
case 'changed': if (typeof p.body === 'string' && st.history.push(p.body)) { markDirtyByBody(p.body); render(); } break;
|
||||
case 'selected': st.selected = p && p.path ? p : null; renderInspector(); break;
|
||||
case 'hotkey': if (p.key === 'save') save(); else if (p.key === 'undo') undo(); else if (p.key === 'redo') redo(); break;
|
||||
case 'error': note('warn', p.message || '편집 오류'); break;
|
||||
default: break; // ready 는 창 메시지로만 의미가 있다
|
||||
}
|
||||
}
|
||||
function note(level, text) { try { logLine({ level, text }); } catch (_) {} if (level === 'warn') { try { pushMsg('warn', text); } catch (_) {} } }
|
||||
/** 본문이 저장본과 같으면 "저장됨", 다르면 "저장되지 않음" */
|
||||
function markDirtyByBody(body) { if (body === st.savedBody) { if (st.save.state() !== 'saving') { st.save.reset(); if (st.savedBody !== null && st.history.size() > 1) st.save.markDirty(), st.save.beginSave(), st.save.saveOk(); } } else st.save.markDirty(); }
|
||||
|
||||
// ---------- 켜기·끄기 ----------
|
||||
function isActive() { return st.active; }
|
||||
function isDirty() { return st.active && st.save.isDirty(); }
|
||||
/** 저장하지 않은 변경이 있으면 묻는다. true = 계속 진행해도 됨 */
|
||||
function discardOk(msg) { if (!isDirty()) return true; return confirm(msg || T('직접 편집에서 저장하지 않은 변경이 있습니다. 버리고 계속할까요?')); }
|
||||
async function open(file) {
|
||||
if (!current) return; const rel = file || $('fileSelect').value || 'index.html';
|
||||
if (!/\.html?$/i.test(rel)) { alert(T('직접 편집은 HTML 페이지에서만 쓸 수 있습니다')); return; }
|
||||
if (ed.open) { if (ed.dirty && !confirm(T('소스 편집기에 저장하지 않은 변경이 있습니다. 버리고 직접 편집을 열까요?'))) return; ed.dirty = false; setEditorOpen(false); }
|
||||
st.loading = true; setStateText('불러오는 중…');
|
||||
try {
|
||||
const r = await api(`/api/sites/${current.id}/visual/${encodeURIComponent(rel)}`);
|
||||
disconnect(); // 이전 캔버스 문서의 포트는 닫는다 — 새 문서의 첫 ready 만 받아들인다
|
||||
st.active = true; st.siteId = current.id; st.file = rel; st.session = r.session; st.baseHash = r.hash; st.rev = r.rev; st.savedBody = null; st.selected = null; st.history = core.createHistory(200); st.save.reset();
|
||||
if (r.running) note('warn', 'AI 작업이 진행 중입니다. 작업이 끝나기 전에는 직접 편집을 저장할 수 없습니다');
|
||||
const f = frame(); f.removeAttribute('src'); f.srcdoc = r.canvas; f.classList.remove('hidden');
|
||||
document.body.classList.add('ve-on'); $('veBar').classList.remove('hidden'); $('vePalette').classList.remove('hidden'); $('veInspector').classList.remove('hidden');
|
||||
$('btnVisual').setAttribute('aria-pressed', 'true'); $('btnVisual').classList.add('active'); $('btnVisual').textContent = T('직접 편집 중');
|
||||
if ($('fileSelect').value !== rel) $('fileSelect').value = rel;
|
||||
try { localStorage.setItem('visualOpen', '1'); } catch (_) {}
|
||||
renderPalette(); renderInspector(); render();
|
||||
} catch (e) { st.active = false; disconnect(); alert(T('직접 편집을 열지 못했습니다') + ': ' + e.message); setStateText(''); }
|
||||
finally { st.loading = false; }
|
||||
}
|
||||
/** 끄기. force=true 면 묻지 않는다(사이트가 바뀌어 이미 물었거나 초기화 때). 돌아갈 때 미리보기는 저장된 파일로 다시 읽는다 */
|
||||
function close(force) {
|
||||
if (!st.active) return true; if (!force && !discardOk()) return false;
|
||||
cancelPointerDrag();
|
||||
st.active = false; disconnect(); st.session = null; st.savedBody = null; st.selected = null; st.save.reset();
|
||||
document.body.classList.remove('ve-on'); $('veBar').classList.add('hidden'); $('vePalette').classList.add('hidden'); $('veInspector').classList.add('hidden');
|
||||
$('btnVisual').setAttribute('aria-pressed', 'false'); $('btnVisual').classList.remove('active'); $('btnVisual').textContent = T('직접 편집');
|
||||
try { localStorage.setItem('visualOpen', '0'); } catch (_) {}
|
||||
const f = frame(); f.removeAttribute('srcdoc'); if (current && !force) loadFrame(); else if (force && current) loadFrame();
|
||||
return true;
|
||||
}
|
||||
function toggle() { if (st.active) close(false); else open(); }
|
||||
|
||||
// ---------- 저장·실행 취소·버리기 ----------
|
||||
async function save() {
|
||||
if (!st.active || st.loading) return; if (!st.save.beginSave()) return; render();
|
||||
const body = st.history.current(); const sent = { siteId: st.siteId, file: st.file, body };
|
||||
try {
|
||||
const r = await api(`/api/sites/${sent.siteId}/visual/${encodeURIComponent(sent.file)}`, { method: 'PUT', body: JSON.stringify({ body: sent.body, baseHash: st.baseHash, rev: st.rev }) });
|
||||
if (!st.active || st.siteId !== sent.siteId || st.file !== sent.file) return;
|
||||
st.baseHash = r.hash; if (typeof r.rev === 'number') st.rev = r.rev; st.savedBody = sent.body; st.save.saveOk();
|
||||
if (current && current.id === sent.siteId) { if (r.files) current.files = r.files; if (r.git) current.git = r.git; if (r.versions) current.versions = r.versions; if (typeof r.rev === 'number') current.rev = r.rev; renderGit(current); }
|
||||
if (st.history.current() !== st.savedBody) st.save.markDirty();
|
||||
note('info', `직접 편집 저장: ${sent.file}${r.change ? ` (버전 #${r.change.no})` : ''}`);
|
||||
} catch (e) {
|
||||
const stale = e.status === 409 && e.body && (e.body.stale || e.body.conflict);
|
||||
st.save.saveFail(e.message); render();
|
||||
if (stale) { setTimeout(() => { if (confirm(T('다른 곳에서 먼저 저장한 내용이 있습니다. 최신 내용을 다시 불러올까요? (지금 편집한 내용은 사라집니다. "취소" 를 누르면 화면에 남겨 둡니다)'))) reload(true); }, 50); }
|
||||
else alert(T('저장 실패') + ': ' + e.message);
|
||||
}
|
||||
render();
|
||||
}
|
||||
function undo() { const b = st.history.undo(); if (b === null) return; send('applyHtml', { body: b }); markDirtyByBody(b); render(); }
|
||||
function redo() { const b = st.history.redo(); if (b === null) return; send('applyHtml', { body: b }); markDirtyByBody(b); render(); }
|
||||
/** 저장된 파일을 다시 불러온다(편집 내용 버림). force 면 묻지 않는다 */
|
||||
async function reload(force) { if (!st.active) return; if (!force && isDirty() && !confirm(T('저장하지 않은 변경을 버리고 저장된 페이지를 다시 불러올까요?'))) return; const f = st.file; st.active = false; await open(f); }
|
||||
|
||||
// ---------- 화면 ----------
|
||||
function setStateText(t) { const el = $('veState'); if (el) el.textContent = t; }
|
||||
function render() {
|
||||
if (!$('veBar')) return;
|
||||
const s = st.save.state(); const el = $('veState'); el.textContent = st.loading ? T('불러오는 중…') : st.save.label(); el.className = 've-state ' + s;
|
||||
$('veSave').disabled = !st.active || !(s === 'dirty' || s === 'error') || st.loading; $('veUndo').disabled = !st.active || !st.history.canUndo(); $('veRedo').disabled = !st.active || !st.history.canRedo(); $('veDiscard').disabled = !st.active || !isDirty();
|
||||
}
|
||||
// ---------- 팔레트 → 캔버스 포인터 끌기 다리(주 경로) ----------
|
||||
// HTML5 끌어다 놓기는 부모 문서에서 sandbox(출처 null) iframe 으로 넘어가지 않는다(실제 브라우저 진단: 캔버스에 dragenter/dragover/drop 0건).
|
||||
// 그래서 팔레트 항목의 포인터(마우스·펜)를 여기서 추적한다: 문턱을 넘으면 끌기 시작, 포인터 캡처로 iframe 위에서도 pointermove 를 받고,
|
||||
// #frame 의 화면 사각형과 비교해 안쪽일 때만 iframe 상대 좌표를 검증된 메시지(paletteDragAt / paletteDropAt)로 보낸다. 밖·Escape·pointercancel 은 paletteDragCancel.
|
||||
// 터치 포인터는 다리를 쓰지 않는다(팔레트 스크롤 보존 — 터치는 "추가" 버튼). sandbox 는 그대로(allow-same-origin 없음), 캔버스가 좌표·id 를 다시 검증한다.
|
||||
let pdrag = null; // { id, li, pointerId, startX, startY, started, inside }
|
||||
/** 화면 좌표 → iframe 안쪽 상대 좌표(테두리·스크롤바 보정: 모바일 미리보기 틀의 12px 테두리 등) */
|
||||
function framePoint(clientX, clientY) { const f = frame(); const r = f.getBoundingClientRect(); return core.pointInFrame(clientX, clientY, r, { left: f.clientLeft || 0, top: f.clientTop || 0, width: f.clientWidth || r.width, height: f.clientHeight || r.height }); }
|
||||
function endPointerDrag() { const d = pdrag; pdrag = null; if (!d) return null; try { d.li.releasePointerCapture(d.pointerId); } catch (_) {} d.li.classList.remove('ve-comp-dragging'); document.body.classList.remove('ve-palette-dragging'); return d; }
|
||||
/** 취소(밖에서 놓음·Escape·pointercancel·끝내기): 캔버스의 놓기 표시를 지운다 */
|
||||
function cancelPointerDrag() { const d = endPointerDrag(); if (d && d.started) send('paletteDragCancel', {}); }
|
||||
function onPalettePointerDown(e, li, id) {
|
||||
if (!st.active || pdrag) return;
|
||||
if (e.pointerType === 'touch') return; // 터치: 스크롤·버튼 보존
|
||||
if (typeof e.button === 'number' && e.button !== 0) return; // 주 버튼만
|
||||
if (e.target && typeof e.target.closest === 'function' && e.target.closest('button')) return; // "추가" 버튼은 클릭으로
|
||||
pdrag = { id, li, pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, started: false, inside: false };
|
||||
try { li.setPointerCapture(e.pointerId); } catch (_) {}
|
||||
}
|
||||
function onPalettePointerMove(e) {
|
||||
if (!pdrag || e.pointerId !== pdrag.pointerId) return;
|
||||
if (!st.active) { endPointerDrag(); return; }
|
||||
if (!pdrag.started) { if (Math.hypot(e.clientX - pdrag.startX, e.clientY - pdrag.startY) < core.POINTER_DRAG_THRESHOLD) return; pdrag.started = true; pdrag.li.classList.add('ve-comp-dragging'); document.body.classList.add('ve-palette-dragging'); }
|
||||
if (typeof e.preventDefault === 'function') e.preventDefault();
|
||||
const p = framePoint(e.clientX, e.clientY);
|
||||
if (p.inside) { pdrag.inside = true; send('paletteDragAt', { component: pdrag.id, x: p.x, y: p.y }); }
|
||||
else if (pdrag.inside) { pdrag.inside = false; send('paletteDragCancel', {}); }
|
||||
}
|
||||
function onPalettePointerUp(e) {
|
||||
if (!pdrag || e.pointerId !== pdrag.pointerId) return;
|
||||
const d = endPointerDrag(); if (!d.started || !st.active) return; // 짧은 클릭은 끌기가 아니다
|
||||
const p = framePoint(e.clientX, e.clientY);
|
||||
if (p.inside && core.isComponentId(d.id)) send('paletteDropAt', { component: d.id, x: p.x, y: p.y }); else send('paletteDragCancel', {});
|
||||
}
|
||||
/** 팔레트: 구성 요소마다 끌 수 있는 항목 + "추가" 버튼(키보드·마우스 대안) */
|
||||
function renderPalette() {
|
||||
const box = $('vePaletteList'); if (!box || box.childElementCount) return;
|
||||
for (const c of core.COMPONENTS) {
|
||||
const li = document.createElement('li'); li.className = 've-comp'; li.draggable = true; li.title = `${c.label} — ${c.hint}. 미리보기로 끌어다 놓거나 "추가" 를 누르세요`;
|
||||
// 주 경로: 포인터 끌기 다리
|
||||
li.addEventListener('pointerdown', (e) => onPalettePointerDown(e, li, c.id)); li.addEventListener('pointermove', onPalettePointerMove); li.addEventListener('pointerup', onPalettePointerUp);
|
||||
li.addEventListener('pointercancel', (e) => { if (pdrag && e.pointerId === pdrag.pointerId) cancelPointerDrag(); });
|
||||
// 대체 경로(포인터 이벤트가 없는 환경): HTML5 DnD — 전용 MIME + 표준 text/plain(이름 공간 값). 포인터 끌기 중에는 네이티브 dragstart 가 pointercancel 로 끊지 못하게 막는다
|
||||
li.addEventListener('dragstart', (e) => { if (pdrag) { e.preventDefault(); return; } e.dataTransfer.setData(core.COMPONENT_MIME, c.id); e.dataTransfer.setData('text/plain', core.componentDragText(c.id)); e.dataTransfer.effectAllowed = 'copy'; });
|
||||
const name = document.createElement('span'); name.className = 've-comp-name'; name.textContent = c.label;
|
||||
const hint = document.createElement('span'); hint.className = 've-comp-hint muted small'; hint.textContent = c.hint;
|
||||
const add = document.createElement('button'); add.type = 'button'; add.className = 'ghost small-btn'; add.textContent = T('추가'); add.setAttribute('aria-label', `${c.label} 추가 — 선택한 요소 뒤에 (선택이 없으면 페이지 끝에)`);
|
||||
add.onclick = () => send('insert', { component: c.id, where: 'after' });
|
||||
li.appendChild(name); li.appendChild(hint); li.appendChild(add); box.appendChild(li);
|
||||
}
|
||||
}
|
||||
/** 속성 창: 선택한 요소의 종류·조작 버튼·편집 가능한 속성 */
|
||||
function renderInspector() {
|
||||
const box = $('veInspectorBody'); if (!box) return; const sel = st.selected;
|
||||
if (!sel) { box.innerHTML = `<p class="muted small">${esc(T('미리보기에서 요소를 클릭하면 여기에 속성이 보입니다. 글은 두 번 클릭(또는 Enter)하면 바로 고칠 수 있습니다.'))}</p><p class="muted small">${esc(T('키보드: Alt+↑/↓ 이동 · Delete 삭제 · Ctrl+D 복제 · Esc 선택 해제 · Ctrl+S 저장'))}</p>`; return; }
|
||||
const props = sel.props || []; const sty = sel.style || {};
|
||||
let h = `<div class="ve-sel-head"><b>${esc(sel.label || sel.tag)}</b> <code>${esc(sel.tag)}</code></div>`;
|
||||
h += `<div class="ve-actions" role="group" aria-label="${esc(T('선택한 요소 조작'))}">`
|
||||
+ `<button type="button" class="ghost small-btn" data-act="parent" ${sel.parentPath ? '' : 'disabled'} title="${esc(T('바깥(상위) 요소 선택'))}">${esc(T('상위 선택'))}</button>`
|
||||
+ `<button type="button" class="ghost small-btn" data-act="up" ${sel.canUp ? '' : 'disabled'} aria-label="${esc(T('위로 이동'))}">↑ ${esc(T('위로'))}</button>`
|
||||
+ `<button type="button" class="ghost small-btn" data-act="down" ${sel.canDown ? '' : 'disabled'} aria-label="${esc(T('아래로 이동'))}">↓ ${esc(T('아래로'))}</button>`
|
||||
+ `<button type="button" class="ghost small-btn" data-act="dup">${esc(T('복제'))}</button>`
|
||||
+ `<button type="button" class="ghost small-btn danger" data-act="del">${esc(T('삭제'))}</button></div>`;
|
||||
const field = (k, label, input) => `<label class="ve-field" data-prop="${k}">${esc(label)}${input}</label>`;
|
||||
if (props.includes('text')) h += field('text', T('글'), sel.hasChildren ? `<span class="muted small">${esc(T('안에 다른 요소가 있어 미리보기에서 두 번 클릭해 직접 고치세요'))}</span>` : `<textarea rows="3" data-k="text">${esc(sel.text)}</textarea>`);
|
||||
if (props.includes('href')) h += field('href', T('링크 주소'), `<input data-k="href" value="${esc(sel.href)}" placeholder="https://… 또는 about.html, #section">`);
|
||||
if (props.includes('src')) h += field('src', T('이미지 주소'), `<input data-k="src" value="${esc(sel.src)}" placeholder="uploads/파일명.jpg 또는 https://…">`);
|
||||
if (props.includes('alt')) h += field('alt', T('이미지 설명(alt)'), `<input data-k="alt" value="${esc(sel.alt)}">`);
|
||||
if (props.includes('align')) h += field('align', T('정렬'), `<select data-k="align"><option value="">${esc(T('기본'))}</option><option value="left"${sty.align === 'left' ? ' selected' : ''}>${esc(T('왼쪽'))}</option><option value="center"${sty.align === 'center' ? ' selected' : ''}>${esc(T('가운데'))}</option><option value="right"${sty.align === 'right' ? ' selected' : ''}>${esc(T('오른쪽'))}</option></select>`);
|
||||
if (props.includes('color')) h += field('color', T('글자색'), `<input data-k="color" value="${esc(sty.color)}" placeholder="#1f2933 또는 navy">`);
|
||||
if (props.includes('background')) h += field('background', T('배경색'), `<input data-k="background" value="${esc(sty.background)}" placeholder="#ffffff 또는 transparent">`);
|
||||
if (props.includes('padding')) h += field('padding', T('안쪽 여백(padding)'), `<input data-k="padding" value="${esc(sty.padding)}" placeholder="예: 20px 또는 10px 20px">`);
|
||||
if (props.includes('margin')) h += field('margin', T('바깥 여백(margin)'), `<input data-k="margin" value="${esc(sty.margin)}" placeholder="예: 0 0 16px">`);
|
||||
h += `<div class="row"><button type="button" class="primary small-btn" data-act="apply">${esc(T('속성 적용'))}</button><span class="muted small" id="veInspectorMsg"></span></div>`;
|
||||
box.innerHTML = h;
|
||||
box.querySelectorAll('[data-act]').forEach((b) => { b.onclick = () => {
|
||||
const a = b.dataset.act;
|
||||
if (a === 'parent') send('select', { path: sel.parentPath }); else if (a === 'up') send('move', { dir: 'up' }); else if (a === 'down') send('move', { dir: 'down' }); else if (a === 'dup') send('duplicate', {}); else if (a === 'del') { if (confirm(T('선택한 요소를 삭제할까요?'))) send('delete', {}); }
|
||||
else if (a === 'apply') applyInspector(box);
|
||||
}; });
|
||||
box.querySelectorAll('input,select,textarea').forEach((i) => { i.addEventListener('keydown', (e) => { if (e.key === 'Enter' && i.tagName !== 'TEXTAREA') { e.preventDefault(); applyInspector(box); } }); if (i.tagName === 'SELECT') i.addEventListener('change', () => applyInspector(box)); });
|
||||
}
|
||||
/** 속성 창 값 검사(주소 안전 규칙) 후 캔버스에 적용 */
|
||||
function applyInspector(box) {
|
||||
const props = {}; const msg = $('veInspectorMsg'); if (msg) msg.textContent = '';
|
||||
for (const i of box.querySelectorAll('[data-k]')) {
|
||||
const k = i.dataset.k; const v = i.value;
|
||||
if (k === 'href' || k === 'src') { const r = core.safeUrlInput(v, k === 'src' ? 'image' : 'link'); if (!r.ok) { if (msg) msg.textContent = r.error; i.focus(); return; } props[k] = r.value; }
|
||||
else if (['color', 'background', 'padding', 'margin'].includes(k)) { if (/[;{}<>]|url\s*\(|expression|javascript/i.test(v)) { if (msg) msg.textContent = T('허용되지 않는 스타일 값입니다'); i.focus(); return; } props[k] = v.trim(); }
|
||||
else props[k] = v;
|
||||
}
|
||||
send('setProps', { props });
|
||||
}
|
||||
/** 팔레트·속성 창 접기/펼치기 — 좁은 화면에서 캔버스를 가리지 않게 */
|
||||
function bindToggles() {
|
||||
for (const [btn, side] of [['vePaletteToggle', 'vePalette'], ['veInspectorToggle', 'veInspector']]) {
|
||||
const b = $(btn); if (!b) continue;
|
||||
b.onclick = () => { const s = $(side); const open = s.classList.toggle('collapsed'); b.setAttribute('aria-expanded', open ? 'false' : 'true'); b.textContent = open ? '▸' : '▾'; };
|
||||
}
|
||||
if (window.matchMedia && window.matchMedia('(max-width: 900px)').matches) for (const id of ['vePaletteToggle', 'veInspectorToggle']) { const b = $(id); if (b && b.getAttribute('aria-expanded') !== 'false') b.click(); }
|
||||
}
|
||||
|
||||
// ---------- app.js 와의 연결점 ----------
|
||||
/**
|
||||
* 사이트 응답이 바뀌었을 때(AI 수정 완료·버전 복원·저장·사이트 전환·페이지 삭제). 저장하지 않은 변경은 어떤 경로로도 묻지 않고 버리지 않는다(1.8.0 검토 수정):
|
||||
* - 다른 사이트(또는 사이트 없음): close(false) — 변경이 있으면 묻고, 취소하면 편집기·사이트를 그대로 둔다(app.js openSite 는 전환 전에 먼저 묻는다; 여기는 마지막 방어선).
|
||||
* - 편집 중인 페이지가 사라짐: 변경이 없으면 끝내고, 있으면 묻는다 — 취소하면 화면에 남겨 두어 다른 이름으로 저장하거나 복사할 수 있게 한다.
|
||||
* - 같은 페이지가 다른 작업으로 바뀜: 변경이 없으면 최신 내용으로 다시 열고, 있으면 알린다(저장 때 baseHash 충돌로 보호된다).
|
||||
*/
|
||||
function onSiteUpdated(site) {
|
||||
if (!st.active) return; if (!site || site.id !== st.siteId) { close(false); return; }
|
||||
const files = (site.files || []);
|
||||
if (!files.includes(st.file)) {
|
||||
if (!discardOk(T(`직접 편집 중이던 ${st.file} 이 없어졌습니다. 저장하지 않은 변경을 버리고 편집을 끝낼까요? ("취소" 를 누르면 화면에 남겨 둡니다)`))) { note('warn', `${st.file} 이 없어졌지만 저장하지 않은 편집 내용을 화면에 남겨 두었습니다. 저장은 실패할 수 있으니 내용을 복사해 두세요`); return; }
|
||||
note('warn', `직접 편집 중이던 ${st.file} 이 없어져 편집을 끝냅니다`); close(true); return;
|
||||
}
|
||||
if (isDirty()) note('warn', `${st.file} 이 다른 작업으로 바뀌었습니다. 지금 화면의 편집 내용은 저장되지 않은 이전 내용이며, 저장하면 최신 내용과의 충돌을 알려 줍니다`); else reload(true);
|
||||
}
|
||||
/** 페이지 선택 상자가 바뀜. 저장하지 않은 변경이 있으면 묻고, 취소하면 선택을 되돌린다 */
|
||||
function onPageChange(rel) { if (!st.active) return false; if (rel === st.file) return true; if (!discardOk(T('저장하지 않은 변경이 있습니다. 버리고 다른 페이지를 열까요?'))) { $('fileSelect').value = st.file; return true; } st.active = false; open(rel); return true; }
|
||||
function bind() {
|
||||
$('btnVisual').onclick = toggle; $('veSave').onclick = save; $('veUndo').onclick = undo; $('veRedo').onclick = redo; $('veClose').onclick = () => close(false); $('veDiscard').onclick = () => reload(false);
|
||||
document.addEventListener('keydown', (e) => { if (!st.active) return; if (e.key === 'Escape' && pdrag) { e.preventDefault(); cancelPointerDrag(); return; } const mod = e.ctrlKey || e.metaKey; const k = e.key.toLowerCase(); if (mod && k === 's') { e.preventDefault(); save(); } else if (mod && k === 'z' && !/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement && document.activeElement.tagName)) { e.preventDefault(); if (e.shiftKey) redo(); else undo(); } });
|
||||
window.addEventListener('beforeunload', (e) => { if (isDirty()) { e.preventDefault(); e.returnValue = ''; } });
|
||||
bindToggles(); renderInspector(); render();
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bind); else bind();
|
||||
return { open, close, toggle, save, undo, redo, reload, isActive, isDirty, discardOk, onSiteUpdated, onPageChange, state: () => ({ active: st.active, siteId: st.siteId, file: st.file, save: st.save.state() }) };
|
||||
})();
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* public/wm-ve-canvas.js — v1.8.0 직접 편집 캔버스 런타임. 서버(lib/visual-editor.prepareCanvas)가 편집 캔버스 문서의 <head> 에 nonce 와 함께 인라인으로 넣는다.
|
||||
* 캔버스 = 빌더 미리보기 iframe(sandbox, allow-same-origin 없음 → 출처 'null', 쿠키·저장소·빌더 API 접근 불가) 안의 srcdoc 문서.
|
||||
* 페이지 자체 스크립트는 모두 type="text/x-wm-ve-disabled" 로 바뀌어 있고 CSP 가 nonce 없는 스크립트를 막으므로, 이 문서에서 실행되는 코드는 이 런타임(+ 공용 핵심)만이다.
|
||||
*
|
||||
* 하는 일
|
||||
* - 요소 선택(클릭)·마우스 올림 표시, 글 요소의 직접 편집(contenteditable, 붙여넣기는 글자만), 선택 요소 도구(위/아래 이동·복제·삭제·끌기 손잡이)
|
||||
* - 기존 블록 끌어 옮기기(포인터 기반, 같은 문서 안), 팔레트(부모 창)에서 끌어온 구성 요소 놓기 — 주 경로: 부모가 iframe 상대 좌표를 보내는 포인터 끌기 다리(paletteDragAt/paletteDropAt, 위치 계산·삽입은 여기서),
|
||||
* 대체 경로: HTML5 DnD(전용 MIME 'text/wm-ve-component' 우선 + text/plain 'wm-ve-component:<id>'; 실제 브라우저에서는 sandbox 경계를 넘지 못해 포인터 다리가 주 경로다)
|
||||
* - 부모 창과의 연결 규약: 첫 ready 창 메시지(부모 출처로, MessagePort 동봉) 뒤 모든 명령·이벤트는 그 포트로만(WMVisualCore.validatePortMessage: 세션·종류·payload). 창 메시지는 듣지 않는다.
|
||||
* 변경마다 본문(body innerHTML)을 돌려준다.
|
||||
* - 링크·area·버튼·폼의 원래 동작(이동·제출)은 편집·미리보기 모두에서 막는다(+ 서버가 넣는 base target=_blank·CSP form-action 'none'·meta refresh 비활성화). 저장은 부모 창이 한다(캔버스는 서버와 통신하지 않는다).
|
||||
* 보존 규칙: 페이지의 원래 코드는 바꾸지 않고, 편집기가 더한 것은 data-wm-ve-* 속성·wm-ve-* 클래스·[data-wm-ve-ui] 요소뿐이다. 본문을 돌려줄 때 그것들을 지운다(서버도 다시 지운다).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const cfg = window.__WM_VE__ || {}; const core = window.WMVisualCore;
|
||||
if (!cfg.session || !cfg.parentOrigin || !core) return;
|
||||
const SESSION = String(cfg.session); const PARENT_ORIGIN = String(cfg.parentOrigin); const MARK = 'data-wm-ve-';
|
||||
/** 보존 속성(서버가 원래 이름으로 되돌린다) — 본문을 돌려줄 때 지우지 않는다 */
|
||||
const KEEP_MARK = /^data-wm-ve-(on-|href$|src$|srcdoc$|data$|http-equiv$|orig-type$|action$|formaction$|poster$|srcset$|background$|xlink:href$|ping$|cite$|longdesc$)/;
|
||||
const TEXT_TAGS = new Set(core.TEXT_TAGS);
|
||||
let selected = null; // 선택된 요소
|
||||
let editing = null; // 글 편집 중인 요소
|
||||
let mode = 'edit'; // 'edit' | 'preview'(선택·편집 끔)
|
||||
let changeTimer = null; // 글 입력 디바운스
|
||||
|
||||
// ---------- 부모 창과의 통신(연결 규약, 1.8.0 검토 수정) ----------
|
||||
// 이 문서가 MessageChannel 을 만들고 port2 를 **첫 ready 창 메시지에 동봉** 해 부모(출처 고정)에게 넘긴다. 그 뒤 모든 명령(부모→캔버스)·이벤트(캔버스→부모)는 port1 로만 오간다.
|
||||
// 포트는 그것을 만든 이 문서에 묶여 있다: iframe 안의 문서가 바뀌면(링크·area·폼 등으로 이동) 새 문서는 포트를 이어받을 수 없고, 부모도 연결 뒤에는 창 메시지를 받지 않으므로
|
||||
// 새 문서가 세션을 알아내거나 changed/hotkey 를 꾸밀 길이 없다. 이 문서는 창(window) message 를 **전혀 듣지 않는다** — 세션이 든 명령이 targetOrigin '*' 로 오가는 일도 없다.
|
||||
const channel = new MessageChannel(); const port = channel.port1;
|
||||
/** 부모에게 이벤트를 보낸다(봉투: wmve·session·type·payload) — 연결된 포트로만 */
|
||||
function send(type, payload) { try { port.postMessage(core.makeMessage(SESSION, type, payload || {})); } catch (_) {} }
|
||||
port.onmessage = (e) => {
|
||||
if (!core.validatePortMessage(e, { session: SESSION, direction: 'toCanvas' })) return;
|
||||
const p = e.data.payload;
|
||||
try {
|
||||
switch (e.data.type) {
|
||||
case 'init': setMode(p.mode === 'preview' ? 'preview' : 'edit'); send('body', { body: serialize() }); break;
|
||||
case 'requestBody': send('body', { body: serialize() }); break;
|
||||
case 'select': select(elAt(p.path)); break;
|
||||
case 'move': if (selected) moveSelected(p.dir === 'up' ? -1 : 1); break;
|
||||
case 'duplicate': duplicateSelected(); break;
|
||||
case 'delete': deleteSelected(); break;
|
||||
case 'insert': insertComponent(String(p.component || ''), p.where || 'after'); break;
|
||||
case 'setProps': applyProps(p.props || {}); break;
|
||||
case 'applyHtml': applyHtml(String(p.body || '')); break;
|
||||
case 'setMode': setMode(p.mode === 'preview' ? 'preview' : 'edit'); break;
|
||||
case 'paletteDragAt': paletteDragAt(p); break;
|
||||
case 'paletteDragCancel': if (ui.drop) ui.drop.hidden = true; break;
|
||||
case 'paletteDropAt': paletteDropAt(p); break;
|
||||
default: break;
|
||||
}
|
||||
} catch (err) { send('error', { message: String(err && err.message || err) }); }
|
||||
};
|
||||
|
||||
// ---------- 요소 식별 ----------
|
||||
const isUi = (el) => !!(el && el.nodeType === 1 && (el.hasAttribute(MARK + 'ui') || el.closest('[' + MARK + 'ui]')));
|
||||
/** body 아래 요소 자식(편집기 UI 제외) 목록 */
|
||||
const kids = (el) => [...el.children].filter((c) => !c.hasAttribute(MARK + 'ui'));
|
||||
/** 요소 → body 기준 자식 번호 경로(예: [1,0,2]). 부모 창이 요소를 가리키는 데 쓴다 */
|
||||
function pathOf(el) { const path = []; let cur = el; while (cur && cur !== document.body) { const p = cur.parentElement; if (!p) return null; path.unshift(kids(p).indexOf(cur)); cur = p; } return cur === document.body ? path : null; }
|
||||
function elAt(path) { if (!Array.isArray(path)) return null; let cur = document.body; for (const i of path) { cur = kids(cur)[i]; if (!cur) return null; } return cur === document.body ? null : cur; }
|
||||
/** 선택 대상이 될 수 있는 요소: body 아래의 일반 요소(편집기 UI·script·style 제외) */
|
||||
function selectable(el) { if (!el || el.nodeType !== 1 || el === document.body || el === document.documentElement || isUi(el)) return null; if (['script', 'style', 'link', 'meta', 'base', 'template'].includes(el.tagName.toLowerCase())) return null; return el; }
|
||||
/** 끌어 옮기기·놓기의 기준이 되는 블록(선택 요소 또는 가장 가까운 블록 조상) */
|
||||
function blockOf(el) { let cur = el; while (cur && cur !== document.body) { if (core.BLOCK_TAGS.includes(cur.tagName.toLowerCase()) || getComputedStyle(cur).display !== 'inline') return cur; cur = cur.parentElement; } return null; }
|
||||
|
||||
// ---------- 본문 직렬화 ----------
|
||||
/** body innerHTML 에서 편집기 흔적을 지운 본문. 보존 속성(KEEP_MARK)과 원래 코드는 그대로 */
|
||||
function serialize() {
|
||||
const clone = document.body.cloneNode(true);
|
||||
clone.querySelectorAll('[' + MARK + 'ui]').forEach((n) => n.remove());
|
||||
clone.querySelectorAll('*').forEach((n) => {
|
||||
if (n.hasAttribute(MARK + 'editable')) { n.removeAttribute('contenteditable'); if (n.hasAttribute(MARK + 'spellcheck')) n.removeAttribute('spellcheck'); }
|
||||
for (const a of [...n.attributes]) if (a.name.startsWith(MARK) && !KEEP_MARK.test(a.name)) n.removeAttribute(a.name);
|
||||
if (n.classList && [...n.classList].some((c) => c.startsWith('wm-ve-'))) { [...n.classList].filter((c) => c.startsWith('wm-ve-')).forEach((c) => n.classList.remove(c)); if (!n.classList.length) n.removeAttribute('class'); }
|
||||
});
|
||||
return clone.innerHTML;
|
||||
}
|
||||
/** 변경을 부모에 알린다(실행 취소 기록·저장 상태는 부모가 관리) */
|
||||
function changed(reason) { clearTimeout(changeTimer); changeTimer = null; send('changed', { body: serialize(), reason: String(reason || '') }); }
|
||||
function changedSoon(reason) { clearTimeout(changeTimer); changeTimer = setTimeout(() => changed(reason), 300); }
|
||||
/** 부모가 보낸 본문(실행 취소·다시 실행·버리기)으로 바꿔 끼운다 — 편집기 UI 는 다시 만든다 */
|
||||
function applyHtml(body) { endEdit(false); selected = null; document.body.innerHTML = body; mountUi(); renderOverlay(); send('selected', { path: null }); }
|
||||
|
||||
// ---------- 편집기 UI(덧씌움) ----------
|
||||
let ui = {};
|
||||
function mountUi() {
|
||||
const st = document.createElement('style'); st.setAttribute(MARK + 'ui', '');
|
||||
st.textContent = `
|
||||
[${MARK}hover]{outline:1px dashed #7c9cf0 !important;outline-offset:1px}
|
||||
[${MARK}selected]{outline:2px solid #2f6fed !important;outline-offset:2px}
|
||||
[${MARK}editable]{outline:2px solid #1a8f5a !important;outline-offset:2px;cursor:text;min-height:1em}
|
||||
/* hidden 계약: 아래 .wm-ve-tools{display:flex} 가 UA 의 [hidden]{display:none} 을 덮어 선택 전에도 도구 줄이 뜨지 않도록 */
|
||||
.wm-ve-tools[hidden],.wm-ve-drop[hidden]{display:none !important}
|
||||
.wm-ve-tools{position:absolute;z-index:2147483000;display:flex;gap:2px;background:#1f2933;color:#fff;border-radius:6px;padding:3px;font:12px/1 -apple-system,"Segoe UI","Noto Sans KR",sans-serif;box-shadow:0 4px 14px rgba(0,0,0,.25)}
|
||||
.wm-ve-tools button{all:unset;cursor:pointer;padding:4px 7px;border-radius:4px;color:#fff;font:inherit;line-height:1}
|
||||
.wm-ve-tools button:hover,.wm-ve-tools button:focus-visible{background:#2f6fed;outline:2px solid #fff}
|
||||
.wm-ve-tools .wm-ve-tag{padding:4px 6px;color:#cbd5e1;border-right:1px solid #475569;margin-right:2px}
|
||||
.wm-ve-tools .wm-ve-grip{cursor:grab}
|
||||
.wm-ve-drop{position:absolute;z-index:2147483000;height:0;border-top:3px solid #2f6fed;pointer-events:none}
|
||||
.wm-ve-drop::before{content:"";position:absolute;left:-6px;top:-7px;width:10px;height:10px;border-radius:50%;background:#2f6fed}
|
||||
.wm-ve-dragging{opacity:.5}
|
||||
body.wm-ve-preview [${MARK}hover]{outline:none !important}`;
|
||||
document.head.appendChild(st);
|
||||
const tools = document.createElement('div'); tools.className = 'wm-ve-tools'; tools.setAttribute(MARK + 'ui', ''); tools.setAttribute('role', 'toolbar'); tools.setAttribute('aria-label', '선택한 요소 도구'); tools.hidden = true;
|
||||
const mk = (label, title, fn, cls) => { const b = document.createElement('button'); b.type = 'button'; b.textContent = label; b.title = title; b.setAttribute('aria-label', title); if (cls) b.className = cls; b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); fn(); }); tools.appendChild(b); return b; };
|
||||
ui.tag = document.createElement('span'); ui.tag.className = 'wm-ve-tag'; tools.appendChild(ui.tag);
|
||||
ui.grip = mk('✥', '끌어서 옮기기 (키보드: Alt+↑/↓)', () => {}, 'wm-ve-grip'); ui.grip.setAttribute('aria-hidden', 'true'); ui.grip.tabIndex = -1;
|
||||
mk('↑', '위로 이동 (Alt+↑)', () => moveSelected(-1)); mk('↓', '아래로 이동 (Alt+↓)', () => moveSelected(1));
|
||||
mk('⧉', '복제 (Ctrl+D)', duplicateSelected); mk('✕', '삭제 (Delete)', deleteSelected);
|
||||
document.body.appendChild(tools); ui.tools = tools;
|
||||
const drop = document.createElement('div'); drop.className = 'wm-ve-drop'; drop.setAttribute(MARK + 'ui', ''); drop.hidden = true; document.body.appendChild(drop); ui.drop = drop;
|
||||
ui.grip.addEventListener('pointerdown', startPointerDrag);
|
||||
}
|
||||
/** 선택 테두리 위의 도구 막대 위치를 선택 요소에 맞춘다 */
|
||||
function renderOverlay() {
|
||||
if (!ui.tools) return;
|
||||
if (!selected || !document.body.contains(selected) || mode !== 'edit') { ui.tools.hidden = true; return; }
|
||||
const r = selected.getBoundingClientRect(); const top = Math.max(2, r.top + window.scrollY - 30); const left = Math.max(2, r.left + window.scrollX);
|
||||
ui.tools.style.top = top + 'px'; ui.tools.style.left = left + 'px'; ui.tag.textContent = core.labelFor(selected.tagName); ui.tools.hidden = false;
|
||||
}
|
||||
window.addEventListener('scroll', renderOverlay, true); window.addEventListener('resize', renderOverlay);
|
||||
|
||||
// ---------- 선택 ----------
|
||||
/** 선택한 요소의 정보를 부모 속성 창에 보낸다 */
|
||||
function describe(el) {
|
||||
if (!el) return { path: null };
|
||||
const cs = getComputedStyle(el); const sib = kids(el.parentElement); const i = sib.indexOf(el); const tag = el.tagName.toLowerCase();
|
||||
return { path: pathOf(el), tag, label: core.labelFor(tag), isText: TEXT_TAGS.has(tag), hasChildren: el.children.length > 0, text: el.textContent || '', href: el.getAttribute('href') || '', src: el.getAttribute('src') || '', alt: el.getAttribute('alt') || '',
|
||||
style: { align: el.style.textAlign || cs.textAlign, color: el.style.color || '', background: el.style.backgroundColor || '', padding: el.style.padding || '', margin: el.style.margin || '' },
|
||||
canUp: i > 0, canDown: i >= 0 && i < sib.length - 1, parentPath: el.parentElement && el.parentElement !== document.body ? pathOf(el.parentElement) : null, props: core.propsFor(tag) };
|
||||
}
|
||||
function select(el) {
|
||||
if (editing && el !== editing) endEdit(true);
|
||||
if (selected) selected.removeAttribute(MARK + 'selected');
|
||||
selected = selectable(el); if (selected) selected.setAttribute(MARK + 'selected', '');
|
||||
renderOverlay(); send('selected', describe(selected));
|
||||
}
|
||||
/** 글 요소 직접 편집 시작: contenteditable 을 켜고 포커스. 붙여넣기는 글자만 들어가게 한다 */
|
||||
function startEdit(el) {
|
||||
if (!el || !TEXT_TAGS.has(el.tagName.toLowerCase()) || mode !== 'edit') return;
|
||||
if (editing && editing !== el) endEdit(true);
|
||||
editing = el; el.setAttribute(MARK + 'editable', ''); el.setAttribute('contenteditable', 'true'); if (!el.hasAttribute('spellcheck')) { el.setAttribute('spellcheck', 'false'); el.setAttribute(MARK + 'spellcheck', ''); }
|
||||
el.focus(); renderOverlay();
|
||||
}
|
||||
function endEdit(notify) {
|
||||
if (!editing) return; const el = editing; editing = null;
|
||||
el.removeAttribute('contenteditable'); el.removeAttribute(MARK + 'editable'); if (el.hasAttribute(MARK + 'spellcheck')) { el.removeAttribute('spellcheck'); el.removeAttribute(MARK + 'spellcheck'); }
|
||||
if (notify && changeTimer) changed('text');
|
||||
if (el === selected) send('selected', describe(el));
|
||||
}
|
||||
function setMode(m) { mode = m; document.body.classList.toggle('wm-ve-preview', m === 'preview'); if (m !== 'edit') { endEdit(true); select(null); } renderOverlay(); }
|
||||
|
||||
// ---------- 이동·복제·삭제·삽입·속성 ----------
|
||||
function moveSelected(dir) {
|
||||
if (!selected) return; const sib = kids(selected.parentElement); const i = sib.indexOf(selected); const j = i + dir; if (j < 0 || j >= sib.length) return;
|
||||
if (dir < 0) selected.parentElement.insertBefore(selected, sib[j]); else selected.parentElement.insertBefore(sib[j], selected);
|
||||
renderOverlay(); send('selected', describe(selected)); changed('move');
|
||||
}
|
||||
function duplicateSelected() {
|
||||
if (!selected) return; endEdit(true); const c = selected.cloneNode(true); c.removeAttribute(MARK + 'selected'); c.querySelectorAll('[' + MARK + 'ui]').forEach((n) => n.remove());
|
||||
c.removeAttribute('id'); c.querySelectorAll('[id]').forEach((n) => n.removeAttribute('id')); // id 는 문서에서 하나여야 한다: 뿌리뿐 아니라 자손의 id 도 지운다(원본은 그대로)
|
||||
selected.after(c); select(c); changed('duplicate');
|
||||
}
|
||||
function deleteSelected() {
|
||||
if (!selected) return; endEdit(false); const next = selected.nextElementSibling && selectable(selected.nextElementSibling) ? selected.nextElementSibling : selected.parentElement; const el = selected; selected = null; el.remove();
|
||||
select(next && next !== document.body ? next : null); changed('delete');
|
||||
}
|
||||
/** 팔레트 구성 요소 삽입. where: 'after'(선택 요소 뒤) | 'inside'(선택 컨테이너 안 끝) | 'end'(본문 끝) | {path, before} */
|
||||
function insertComponent(id, where) {
|
||||
const html = core.componentHtml(id); if (!html || !core.isSafeComponentHtml(html)) throw new Error('알 수 없는 구성 요소입니다');
|
||||
const tpl = document.createElement('template'); tpl.innerHTML = html; const node = tpl.content.firstElementChild; if (!node) return;
|
||||
placeNode(node, where); select(node); changed('insert'); node.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
function placeNode(node, where) {
|
||||
if (where && typeof where === 'object' && Array.isArray(where.path)) { const ref = elAt(where.path); if (ref) { if (where.before) ref.before(node); else ref.after(node); return; } }
|
||||
if (where === 'inside' && selected && !TEXT_TAGS.has(selected.tagName.toLowerCase()) && selected.tagName.toLowerCase() !== 'img') { selected.appendChild(node); return; }
|
||||
if ((where === 'after' || where === 'inside') && selected) { selected.after(node); return; }
|
||||
// 본문 끝: 끝에 몰려 있는 스크립트(비활성화된)보다 앞에 둔다
|
||||
const ks = kids(document.body); let anchor = null; for (let i = ks.length - 1; i >= 0 && ks[i].tagName.toLowerCase() === 'script'; i--) anchor = ks[i];
|
||||
if (anchor) anchor.before(node); else document.body.appendChild(node);
|
||||
}
|
||||
/** 속성 창에서 보낸 값 적용. 주소는 안전 규칙을 다시 검사한다(부모도 검사하지만 캔버스가 마지막 방어선) */
|
||||
function applyProps(p) {
|
||||
if (!selected) return; const el = selected; const tag = el.tagName.toLowerCase();
|
||||
if (typeof p.text === 'string' && TEXT_TAGS.has(tag) && el.children.length === 0) el.textContent = p.text;
|
||||
if (typeof p.href === 'string' && (tag === 'a' || tag === 'button')) { const r = core.safeUrlInput(p.href, 'link'); if (!r.ok) throw new Error(r.error); if (tag === 'a') { if (r.value) el.setAttribute('href', r.value); else el.removeAttribute('href'); } }
|
||||
if (typeof p.src === 'string' && tag === 'img') { const r = core.safeUrlInput(p.src, 'image'); if (!r.ok) throw new Error(r.error); el.setAttribute('src', r.value); }
|
||||
if (typeof p.alt === 'string' && tag === 'img') el.setAttribute('alt', p.alt);
|
||||
const css = (k, v) => { if (typeof v !== 'string') return; if (/expression|javascript|url\s*\(|behavior|@import/i.test(v)) throw new Error('허용되지 않는 스타일 값입니다'); el.style[k] = v; };
|
||||
css('textAlign', p.align); css('color', p.color); css('backgroundColor', p.background); css('padding', p.padding); css('margin', p.margin);
|
||||
if (el.getAttribute('style') === '') el.removeAttribute('style');
|
||||
renderOverlay(); send('selected', describe(el)); changed('props');
|
||||
}
|
||||
|
||||
// ---------- 포인터·키보드 ----------
|
||||
/** 캔버스 안에서 문서 이동·제출로 이어질 수 있는 활성화 요소. 편집·미리보기 어느 모드에서도 기본 동작을 막는다(키보드 Enter/Space 활성화도 click 으로 온다). 폼 submit·meta refresh·base target 은 따로 막는다 */
|
||||
const NAV_ACTIVATORS = 'a,area,button,input[type=submit],input[type=image]';
|
||||
document.addEventListener('click', (e) => {
|
||||
const t = e.target && e.target.closest ? e.target : (e.target && e.target.parentElement);
|
||||
if (t && t.closest && t.closest(NAV_ACTIVATORS)) e.preventDefault(); // 이 문서는 편집 캔버스다: 어디로도 이동하지 않는다
|
||||
if (mode !== 'edit') return;
|
||||
if (isUi(e.target)) return;
|
||||
if (t && t.closest && t.closest('summary')) e.preventDefault(); // 편집 중 details 토글 대신 선택
|
||||
const el = selectable(t); if (!el) return;
|
||||
if (editing === el) return; // 편집 중인 글 안의 클릭은 커서 이동
|
||||
select(el);
|
||||
}, true);
|
||||
// 가운데 버튼 클릭은 click 이 아니라 auxclick 으로 오고 기본 동작이 "새 탭에서 열기" 다 — 실제 Chrome 에서 sandbox(allow-popups 없음) 프레임의 링크 가운데 클릭이 새 페이지를 만든 것을 게이트가 확인했다.
|
||||
document.addEventListener('auxclick', (e) => { const t = e.target && e.target.closest ? e.target : (e.target && e.target.parentElement); if (t && t.closest && t.closest(NAV_ACTIVATORS)) e.preventDefault(); }, true);
|
||||
document.addEventListener('dblclick', (e) => { if (mode !== 'edit' || isUi(e.target)) return; const el = selectable(e.target); if (el) { if (!TEXT_TAGS.has(el.tagName.toLowerCase())) { const t = el.closest(core.TEXT_TAGS.join(',')); if (t && selectable(t)) { select(t); startEdit(t); } return; } select(el); startEdit(el); } }, true);
|
||||
document.addEventListener('submit', (e) => e.preventDefault(), true);
|
||||
document.addEventListener('mouseover', (e) => { if (mode !== 'edit' || isUi(e.target)) return; document.querySelectorAll('[' + MARK + 'hover]').forEach((n) => n.removeAttribute(MARK + 'hover')); const el = selectable(e.target); if (el && el !== selected) el.setAttribute(MARK + 'hover', ''); }, true);
|
||||
document.addEventListener('mouseout', (e) => { if (e.target && e.target.removeAttribute) e.target.removeAttribute(MARK + 'hover'); }, true);
|
||||
document.addEventListener('input', (e) => { if (editing && e.target === editing) changedSoon('text'); }, true);
|
||||
document.addEventListener('paste', (e) => { if (!editing) return; e.preventDefault(); const text = (e.clipboardData || window.clipboardData).getData('text/plain'); document.execCommand('insertText', false, text); }, true);
|
||||
document.addEventListener('blur', (e) => { if (editing && e.target === editing) endEdit(true); }, true);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const mod = e.ctrlKey || e.metaKey; const k = e.key.toLowerCase();
|
||||
if (mod && k === 's') { e.preventDefault(); if (editing) endEdit(true); send('hotkey', { key: 'save' }); return; }
|
||||
if (mod && k === 'z' && !editing) { e.preventDefault(); send('hotkey', { key: e.shiftKey ? 'redo' : 'undo' }); return; }
|
||||
if (mod && k === 'y' && !editing) { e.preventDefault(); send('hotkey', { key: 'redo' }); return; }
|
||||
if (e.key === 'Escape') { if (drag) { e.preventDefault(); endBlockDrag(false); } else if (editing) { e.preventDefault(); endEdit(true); } else if (selected) { e.preventDefault(); select(null); } return; }
|
||||
if (editing || mode !== 'edit' || !selected) return;
|
||||
if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { e.preventDefault(); moveSelected(e.key === 'ArrowUp' ? -1 : 1); return; }
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); deleteSelected(); return; }
|
||||
if (mod && k === 'd') { e.preventDefault(); duplicateSelected(); return; }
|
||||
if (e.key === 'Enter') { e.preventDefault(); startEdit(selected); return; }
|
||||
}, true);
|
||||
|
||||
// ---------- 끌어 옮기기(기존 블록, 포인터 기반) ----------
|
||||
let drag = null; // { el, target, pointerId, move, up, cancel } — 끌기 중에만 있다
|
||||
function dropTargetAt(x, y, moving) {
|
||||
const hit = document.elementFromPoint(x, y); if (!hit) return null;
|
||||
let el = selectable(hit) ? blockOf(hit) : null;
|
||||
if (!el || el === document.body) { const ks = kids(document.body).filter((k) => k !== moving && !['script', 'style'].includes(k.tagName.toLowerCase())); if (!ks.length) return null; const idx = core.dropIndexFor(ks.map((k) => k.getBoundingClientRect()), y); return idx < ks.length ? { el: ks[idx], before: true } : { el: ks[ks.length - 1], before: false }; }
|
||||
if (moving && (el === moving || moving.contains(el))) return null;
|
||||
const r = el.getBoundingClientRect(); return { el, before: y < (r.top + r.bottom) / 2 };
|
||||
}
|
||||
function showDrop(t) { if (!t) { ui.drop.hidden = true; return; } const r = t.el.getBoundingClientRect(); ui.drop.style.left = (r.left + window.scrollX) + 'px'; ui.drop.style.width = r.width + 'px'; ui.drop.style.top = ((t.before ? r.top : r.bottom) + window.scrollY - 1) + 'px'; ui.drop.hidden = false; }
|
||||
/**
|
||||
* 끌기 끝(commit=true 면 놓기, false 면 취소). 어느 경로로 끝나도 같은 정리를 한다: 창 리스너 제거·포인터 캡처 해제·dragging 클래스 제거·놓기 표시 숨김.
|
||||
* pointercancel(브라우저가 끌기를 가로챔)·창 blur(포인터가 iframe 밖으로 나가 포커스를 잃음)·Escape 는 취소다 — 끌기 상태가 남아 다음 클릭을 옮기기로 오해하는 일이 없다.
|
||||
*/
|
||||
function endBlockDrag(commit) {
|
||||
const d = drag; drag = null; if (!d) return;
|
||||
window.removeEventListener('pointermove', d.move); window.removeEventListener('pointerup', d.up); window.removeEventListener('pointercancel', d.cancel); window.removeEventListener('blur', d.cancel);
|
||||
try { if (d.pointerId !== undefined && ui.grip.releasePointerCapture) ui.grip.releasePointerCapture(d.pointerId); } catch (_) {}
|
||||
d.el.classList.remove('wm-ve-dragging'); ui.drop.hidden = true;
|
||||
const t = commit ? d.target : null;
|
||||
if (t && t.el !== d.el) { if (t.before) t.el.before(d.el); else t.el.after(d.el); select(d.el); changed('drag'); } else renderOverlay();
|
||||
}
|
||||
function startPointerDrag(e) {
|
||||
if (!selected || mode !== 'edit' || drag) return; e.preventDefault(); endEdit(true);
|
||||
const moving = blockOf(selected) || selected;
|
||||
const d = { el: moving, target: null, pointerId: e.pointerId };
|
||||
d.move = (ev) => { if (drag !== d) return; d.target = dropTargetAt(ev.clientX, ev.clientY, moving); showDrop(d.target); };
|
||||
d.up = () => endBlockDrag(true); d.cancel = () => endBlockDrag(false);
|
||||
drag = d; moving.classList.add('wm-ve-dragging'); ui.tools.hidden = true;
|
||||
try { if (e.pointerId !== undefined && ui.grip.setPointerCapture) ui.grip.setPointerCapture(e.pointerId); } catch (_) {} // iframe 밖으로 나가도 pointermove/up 을 계속 받는다
|
||||
window.addEventListener('pointermove', d.move); window.addEventListener('pointerup', d.up); window.addEventListener('pointercancel', d.cancel); window.addEventListener('blur', d.cancel);
|
||||
}
|
||||
// ---------- 팔레트 포인터 끌기 다리(주 경로): 부모가 iframe 상대 좌표만 보내고, 위치 계산·삽입은 여기서 한다 ----------
|
||||
/** 부모가 보낸 끌기 payload 검증: 편집 모드 + 알려진 구성 요소 id + 이 뷰포트 안의 유한한 좌표. 아니면 null */
|
||||
function palettePoint(p) {
|
||||
if (mode !== 'edit' || !p || !core.isComponentId(p.component)) return null;
|
||||
const w = window.innerWidth || document.documentElement.clientWidth || 0; const h = window.innerHeight || document.documentElement.clientHeight || 0;
|
||||
return core.canvasPoint(p.x, p.y, w, h);
|
||||
}
|
||||
function paletteDragAt(p) { const pt = palettePoint(p); if (!pt) { if (ui.drop) ui.drop.hidden = true; return; } showDrop(dropTargetAt(pt.x, pt.y, null)); }
|
||||
function paletteDropAt(p) {
|
||||
if (ui.drop) ui.drop.hidden = true; const pt = palettePoint(p); if (!pt) return; // 잘못된 id·좌표·모드: 아무것도 넣지 않는다(표식도 없음)
|
||||
const t = dropTargetAt(pt.x, pt.y, null); insertComponent(p.component, t ? { path: pathOf(t.el), before: t.before } : 'end');
|
||||
}
|
||||
// ---------- 팔레트에서 놓기(HTML5 DnD, 대체 경로 — 부모 문서 → 이 iframe; 포인터 이벤트가 없는 환경용) ----------
|
||||
// 이 문서는 출처 'null'(sandbox) 이라 부모가 넣은 전용 MIME 'text/wm-ve-component' 는 브라우저가 넘겨주지 않는다. 부모는 표준 text/plain 에
|
||||
// 'wm-ve-component:<id>' 도 넣고, 여기서는 core.isComponentDrag(종류만 — dragover 때는 getData 불가) 로 끌기를 인식하고
|
||||
// drop 때 core.dragComponentId 로 전용 MIME 우선 → 정확히 맞는 text/plain 만 받는다. 임의 글·주소·HTML·모르는 id 는 아무것도 넣지 않는다.
|
||||
document.addEventListener('dragover', (e) => { if (mode !== 'edit' || !e.dataTransfer || !core.isComponentDrag(e.dataTransfer.types)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; showDrop(dropTargetAt(e.clientX, e.clientY, null)); });
|
||||
document.addEventListener('dragleave', (e) => { if (e.target === document.documentElement || !e.relatedTarget) ui.drop.hidden = true; });
|
||||
document.addEventListener('drop', (e) => {
|
||||
if (mode !== 'edit' || !e.dataTransfer || !core.isComponentDrag(e.dataTransfer.types)) return; e.preventDefault(); ui.drop.hidden = true;
|
||||
const id = core.dragComponentId(e.dataTransfer); if (!id) return; // 구성 요소가 아닌 끌기(임의 글 등): 기본 drop 도 막고 아무것도 넣지 않는다
|
||||
const t = dropTargetAt(e.clientX, e.clientY, null);
|
||||
try { insertComponent(id, t ? { path: pathOf(t.el), before: t.before } : 'end'); } catch (err) { send('error', { message: err.message }); }
|
||||
});
|
||||
|
||||
// ---------- 시작 ----------
|
||||
/** 시작: 편집기 UI 를 덧씌우고, 유일한 창 메시지인 ready 를 부모 출처로 보내며 port2 를 동봉한다(이후는 포트만) */
|
||||
function boot() { mountUi(); try { window.parent.postMessage(core.makeMessage(SESSION, 'ready', { title: document.title || '' }), PARENT_ORIGIN, [channel.port2]); } catch (_) {} }
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
|
||||
})();
|
||||
+378
-85
@@ -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); } } });
|
||||
|
||||
@@ -7,6 +7,7 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-ariweb-')); process.env.DA
|
||||
fs.mkdirSync(path.join(tmp, 'sites'), { recursive: true }); fs.mkdirSync(path.join(tmp, 'www'), { recursive: true });
|
||||
const SECRET = 'test-shared-secret-' + crypto.randomBytes(8).toString('hex');
|
||||
const B = `http://127.0.0.1:${PORT}`;
|
||||
const SSHWRAP = path.join(tmp, 'sshwrap'); const SSHLOG = path.join(tmp, 'ssh-calls.log');
|
||||
const sha256 = (s) => crypto.createHash('sha256').update(String(s || '')).digest('hex');
|
||||
const sign = (key, d) => crypto.createHmac('sha256', key).update(d).digest('base64url');
|
||||
/** 포털이 하는 서비스 서명 호출 */
|
||||
@@ -38,12 +39,16 @@ test.before(async () => {
|
||||
// 보관소에 공유 비밀·설정에 포털 주소를 넣고 서버를 띄운다
|
||||
const vault = require(ROOT + '/lib/vault'); vault.set({ kind: 'ariweb', id: 'ariweb', label: 'test', url: '', secret: SECRET, settings: {} });
|
||||
const config = require(ROOT + '/lib/config'); config.set({ 'ariweb.portalUrl': `http://127.0.0.1:${portal.address().port}` }, 'test');
|
||||
child = spawn('node', [path.join(ROOT, 'server.js')], { env: { ...process.env, PORT: String(PORT), HOST: '127.0.0.1', DATA_ROOT: tmp, DATA_DIR: path.join(tmp, 'sites'), SITES_ROOT: path.join(tmp, 'www'), PUBLIC_BASE_URL: B, SOURCE_PROVIDER: 'none' } });
|
||||
// 가짜 배포 대상 + 가짜 ssh(PATH 앞): 폐기의 배포 서버 정리를 결정적으로 늦추거나(1초) 실패시킨다(SSH_FAIL). 호출마다 로그를 남겨 "정리가 실제로 수행됐는지" 를 본다
|
||||
fs.mkdirSync(SSHWRAP, { recursive: true });
|
||||
fs.writeFileSync(path.join(SSHWRAP, 'ssh'), `#!/bin/sh\necho call >> "${SSHLOG}"\nsleep 1\nif [ -e "${tmp}/SSH_FAIL" ]; then echo "ssh: connect to host fake.test: Connection refused (test)" >&2; exit 255; fi\necho REMOVED\n`, { mode: 0o755 });
|
||||
fs.writeFileSync(path.join(tmp, 'deploy-targets.json'), JSON.stringify({ defaultId: 't1', targets: [{ id: 't1', name: '가짜 배포 서버', type: 'ssh', baseDir: '/srv/sites', publicBaseUrl: '', note: '', manageVhost: false, host: 'fake.test', port: 22, user: 'root', createdAt: new Date().toISOString() }] }));
|
||||
child = spawn('node', [path.join(ROOT, 'server.js')], { env: { ...process.env, PORT: String(PORT), HOST: '127.0.0.1', DATA_ROOT: tmp, DATA_DIR: path.join(tmp, 'sites'), SITES_ROOT: path.join(tmp, 'www'), PUBLIC_BASE_URL: B, SOURCE_PROVIDER: 'none', PATH: `${SSHWRAP}:${process.env.PATH}` } });
|
||||
let out = ''; child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { out += c; });
|
||||
for (let i = 0; i < 150; i++) { if (/listening on/.test(out)) break; await new Promise((r) => setTimeout(r, 100)); }
|
||||
assert.match(out, /listening on/);
|
||||
});
|
||||
test.after(() => { try { child.kill('SIGTERM'); } catch (_) {} try { portal.close(); } catch (_) {} });
|
||||
test.after(() => { try { child.kill('SIGTERM'); } catch (_) {} try { portal.close(); } catch (_) {} try { fs.chmodSync(tmp, 0o755); } catch (_) {} });
|
||||
|
||||
let projectId; let cookie;
|
||||
test('내부 API: 서명 없는 호출 거부 · 프로젝트 만들기·연결·조회·소스·export', async () => {
|
||||
@@ -91,3 +96,51 @@ test('폐기: 포털 영향 확인 → 확인 전 409(영향 표시) → 운영
|
||||
const r3 = await fetch(B + `/api/sites/${projectId}`, { method: 'DELETE', headers: H, body: JSON.stringify({ confirm: '바뀐 제목', acknowledgeImpact: true }) }); assert.equal(r3.status, 200, await r3.text());
|
||||
assert.equal(require(ROOT + '/lib/ariweb').linkOf(projectId), null);
|
||||
});
|
||||
|
||||
/** 포털에 연결된 프로젝트를 만들고 index.html 과 "배포됨" 기록(가짜 배포 대상 t1)을 둔다 → 폐기가 배포 서버 정리(가짜 ssh)를 거친다 */
|
||||
async function mkLinked(title, siteId) {
|
||||
const c = await svc('POST', '/api/internal/ariweb/projects', { owner: 'aw-g1-u1', ownerName: '테스트', title, mode: 'manual', link: { siteId, groupId: 'g_1', siteName: '우리 사이트', groupName: '우리 그룹', portalUrl: 'http://127.0.0.1:' + portal.address().port } });
|
||||
assert.equal(c.status, 200, JSON.stringify(c.b)); const id = c.b.project.id; const dir = path.join(tmp, 'www', id); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'index.html'), `<h1>${title}</h1>`);
|
||||
const f = path.join(tmp, 'sites', `${id}.json`); const rec = JSON.parse(fs.readFileSync(f, 'utf8')); rec.deployments = [{ at: new Date().toISOString(), targetId: 't1', targetName: '가짜 배포 서버', ok: true, ready: true, phase: 'ready', url: 'http://fake.test/x/', domains: [], domainUrls: [] }]; fs.writeFileSync(f, JSON.stringify(rec, null, 2));
|
||||
return { id, dir, record: f };
|
||||
}
|
||||
const sshCalls = () => { try { return fs.readFileSync(SSHLOG, 'utf8').split('\n').filter(Boolean).length; } catch (_) { return 0; } };
|
||||
const del = (id, title) => fetch(B + `/api/sites/${id}`, { method: 'DELETE', headers: { cookie, 'Content-Type': 'application/json', referer: B + '/' }, body: JSON.stringify({ confirm: title, acknowledgeImpact: true }) });
|
||||
|
||||
test('폐기 보상 1 — 배포 서버 정리가 실패하면 포털 연결은 풀리지 않는다(연결 해제는 정리 성공 뒤·되돌릴 수 없는 삭제 직전에만): 사이트 파일·기록·연결이 모두 그대로이고 deleteFailed 가 남는다', async () => {
|
||||
portalImpact = { impact: { blocked: false, liveSite: false, lifecycle: 'draft', deployments: 0, domains: [], message: '초안' } };
|
||||
const P = await mkLinked('보상-정리실패', 's_c1'); const a = require(ROOT + '/lib/ariweb'); assert.ok(a.linkOf(P.id), '연결됨'); const before = fs.readFileSync(P.record, 'utf8'); const n0 = sshCalls();
|
||||
fs.writeFileSync(path.join(tmp, 'SSH_FAIL'), '');
|
||||
const r = await del(P.id, '보상-정리실패'); const j = await r.json().catch(() => ({})); fs.unlinkSync(path.join(tmp, 'SSH_FAIL'));
|
||||
assert.ok(r.status >= 400, '정리 실패는 오류: ' + r.status + ' ' + JSON.stringify(j)); assert.match(j.error || '', /배포 서버/); assert.ok(sshCalls() > n0, '정리를 실제로 시도했다');
|
||||
assert.ok(a.linkOf(P.id), '포털 연결이 풀리지 않았다(연결 해제는 정리 성공 뒤에만): ' + JSON.stringify(a.linkOf(P.id))); assert.equal(a.linkOf(P.id).siteId, 's_c1');
|
||||
assert.ok(fs.existsSync(path.join(P.dir, 'index.html')), '사이트 파일 그대로'); const after = JSON.parse(fs.readFileSync(P.record, 'utf8')); assert.ok(after.deleteFailed && after.deleteFailed.detail, '실패 기록'); assert.equal(after.title, JSON.parse(before).title);
|
||||
const ok = await del(P.id, '보상-정리실패'); assert.equal(ok.status, 200, '정리가 되면 폐기 성공: ' + await ok.text()); assert.equal(a.linkOf(P.id), null); assert.ok(!fs.existsSync(P.dir));
|
||||
});
|
||||
|
||||
test('폐기 보상 2 — 정리는 성공했는데 포털 연결 해제(연결 등록부 쓰기)가 실패하면 되돌릴 수 없는 삭제로 가지 않는다: 사이트 파일·기록이 그대로(임시 이름으로 옮긴 것도 되돌림)이고 연결도 그대로, 명시적 실패', async () => {
|
||||
portalImpact = { impact: { blocked: false, liveSite: false, lifecycle: 'draft', deployments: 0, domains: [], message: '초안' } };
|
||||
const P = await mkLinked('보상-연결해제실패', 's_c2'); const a = require(ROOT + '/lib/ariweb'); const n0 = sshCalls();
|
||||
fs.chmodSync(tmp, 0o555); // DATA_ROOT 루트를 읽기 전용으로 → 연결 등록부(ariweb-links.json)의 원자적 쓰기(임시 파일)가 실패한다. sites/·www/ 는 하위 폴더라 그대로 쓸 수 있다
|
||||
let r, j; try { r = await del(P.id, '보상-연결해제실패'); j = await r.json().catch(() => ({})); } finally { fs.chmodSync(tmp, 0o755); }
|
||||
assert.ok(r.status >= 400, '연결 해제 실패는 오류: ' + r.status + ' ' + JSON.stringify(j)); assert.ok(sshCalls() > n0, '정리는 먼저 수행됐다');
|
||||
assert.ok(a.linkOf(P.id) && a.linkOf(P.id).siteId === 's_c2', '연결 그대로'); assert.ok(fs.existsSync(path.join(P.dir, 'index.html')), '사이트 파일 그대로(임시 이름 아님)'); assert.ok(fs.existsSync(P.record), '기록 그대로');
|
||||
assert.ok(!fs.readdirSync(path.join(tmp, 'www')).some((d) => d.startsWith('.deleting-')), '임시로 옮긴 폴더가 남지 않는다');
|
||||
const g = await fetch(B + `/api/sites/${P.id}`, { headers: { cookie, referer: B + '/' } }); assert.equal(g.status, 200, '사이트는 그대로 쓸 수 있다'); const rec = JSON.parse(fs.readFileSync(P.record, 'utf8')); assert.ok(rec.deleteFailed, '실패 기록');
|
||||
const ok = await del(P.id, '보상-연결해제실패'); assert.equal(ok.status, 200, '쓸 수 있게 되면 폐기 성공: ' + await ok.text()); assert.equal(a.linkOf(P.id), null);
|
||||
});
|
||||
|
||||
test('폐기 보상 3 — 연결 해제 뒤 되돌릴 수 없는 단계는 기록 삭제 한 번이고, 그 뒤 뒷정리(사이트 폴더 지우기)가 실패해도 성공으로 꾸미거나 삼키지 않는다: 사이트·기록·연결은 모두 없고 남은 폴더는 warnings·감사 기록으로 운영자에게 보인다; 보상(되살리기)은 readback 으로 확인된다', async () => {
|
||||
portalImpact = { impact: { blocked: false, liveSite: false, lifecycle: 'draft', deployments: 0, domains: [], message: '초안' } };
|
||||
const a = require(ROOT + '/lib/ariweb');
|
||||
// 보상 함수 자체: 되살리기 뒤 readback 이 원래 연결과 같다(시각 보존)
|
||||
const l0 = { siteId: 's_x', groupId: 'g_x', portalUrl: 'http://p', siteName: 'n', groupName: 'g', linkedAt: '2026-01-01T00:00:00.000Z' }; a.link('p_comp', l0); a.unlink('p_comp'); assert.equal(a.linkOf('p_comp'), null); a.link('p_comp', l0); assert.deepEqual(a.linkOf('p_comp'), l0, 'readback'); a.unlink('p_comp');
|
||||
const P = await mkLinked('보상-뒷정리실패', 's_c3');
|
||||
const locked = path.join(P.dir, 'locked'); fs.mkdirSync(locked); fs.writeFileSync(path.join(locked, 'keep.txt'), 'x'); fs.chmodSync(locked, 0o555); // 안의 파일을 지울 수 없는 폴더 → 옮겨 둔 폴더의 뒷정리(rmSync)가 실패한다
|
||||
let r, j, trash; try { r = await del(P.id, '보상-뒷정리실패'); j = await r.json().catch(() => ({})); trash = fs.readdirSync(path.join(tmp, 'www')).filter((d) => d.startsWith('.deleting-')); } finally { try { fs.chmodSync(locked, 0o755); } catch (_) {} for (const d of trash || []) { try { fs.chmodSync(path.join(tmp, 'www', d, 'locked'), 0o755); } catch (_) {} } }
|
||||
assert.equal(r.status, 200, '연결 해제·기록 삭제까지 끝났으면 폐기는 성공: ' + JSON.stringify(j)); assert.equal(j.deleted, P.id); assert.ok(Array.isArray(j.warnings) && j.warnings.some((w) => /폴더 정리 실패/.test(w)), '남은 폴더는 경고로 보인다: ' + JSON.stringify(j.warnings));
|
||||
assert.equal(a.linkOf(P.id), null, '연결 없음'); assert.ok(!fs.existsSync(P.record), '기록 없음'); assert.ok(!fs.existsSync(P.dir), '원래 이름의 사이트 폴더 없음');
|
||||
assert.equal(trash.length, 1, '옮겨 둔 폴더가 운영자 확인용으로 남아 있다: ' + JSON.stringify(trash)); assert.ok(fs.existsSync(path.join(tmp, 'www', trash[0], 'locked', 'keep.txt')));
|
||||
const g = await fetch(B + `/api/sites/${P.id}`, { headers: { cookie, referer: B + '/' } }); assert.equal(g.status, 404);
|
||||
fs.rmSync(path.join(tmp, 'www', trash[0]), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
'use strict';
|
||||
// v1.8.0 검토 수정 — app.js 의 사이트·화면 전환이 직접 편집(시각 편집기)의 저장하지 않은 변경을 묻지 않고 끝내지 않는지, **실제 app.js + visual-editor-ui.js** 를 가짜 DOM 위에서 실행해 확인한다.
|
||||
// 가짜 DOM: id 로 찾는 요소마다 "무엇을 읽어도 비어 있고 무엇을 불러도 아무 일도 하지 않는" 관대한 요소(classList·value·속성은 실제로 저장). fetch 는 경로별 가짜 응답.
|
||||
// 검사 경로: openSite(다른 사이트) · showTab('home', manageId 가 다른 사이트) · renderPreview(HTML 페이지가 없어진 갱신) — 취소하면 현재 사이트·편집기·캔버스 유지, 승낙하면 끝낸다.
|
||||
const test = require('node:test'); const assert = require('node:assert/strict');
|
||||
const path = require('path'); const vm = require('vm'); const fs = require('fs');
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const core = require(ROOT + '/public/visual-editor-core');
|
||||
|
||||
/** 관대한 가짜 요소: 아는 속성(classList·value·textContent…)은 실제 값, 모르는 속성은 같은 이름이면 같은 객체(식별 비교 가능)를 돌려주는 호출 가능 프록시 */
|
||||
function omni(name) {
|
||||
const cls = new Set(); const attrs = {};
|
||||
const store = { id: name, options: [], children: [], style: {}, dataset: {}, value: '', textContent: '', innerHTML: '', checked: false, disabled: false, hidden: false, files: [], selectionStart: 0, selectionEnd: 0, childElementCount: 0, _ev: {},
|
||||
classList: { add: (...c) => c.forEach((x) => cls.add(x)), remove: (...c) => c.forEach((x) => cls.delete(x)), toggle: (c, on) => { (on === undefined ? !cls.has(c) : on) ? cls.add(c) : cls.delete(c); }, contains: (c) => cls.has(c) },
|
||||
addEventListener(t, fn) { (store._ev[t] || (store._ev[t] = [])).push(fn); }, removeEventListener() {}, querySelectorAll: () => [], getAttribute: (k) => (k in attrs ? attrs[k] : null), setAttribute: (k, v) => { attrs[k] = String(v); }, removeAttribute: (k) => { delete attrs[k]; }, hasAttribute: (k) => k in attrs,
|
||||
appendChild: (c) => { store.childElementCount++; return c; }, insertBefore: (c) => c, replaceWith() {}, remove() {}, closest: () => null, contains: () => false, matches: () => false, getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0, right: 0, bottom: 0 }), focus() {}, click() {}, scrollIntoView() {}, dispatchEvent: () => true, close() {}, showModal() {} };
|
||||
const fn = function () { return omni(name + '()'); };
|
||||
return new Proxy(fn, { get(t, k) { if (k === Symbol.toPrimitive) return () => ''; if (typeof k === 'symbol' || k === 'then') return undefined; if (!(k in store)) store[k] = omni(name + '.' + String(k)); return store[k]; }, set(t, k, v) { store[k] = v; return true; }, has: () => true });
|
||||
}
|
||||
/** 가짜 MessageChannel(visual-editor.test.js 와 같은 규약: 동기 전달·JSON 복제) */
|
||||
function fakeChannel() {
|
||||
const mk = () => ({ onmessage: null, peer: null, closed: false, postMessage(m) { const p = this.peer; if (!p || this.closed || p.closed) return; const data = JSON.parse(JSON.stringify(m)); if (typeof p.onmessage === 'function') p.onmessage({ data, origin: '', source: null, ports: [] }); }, start() {}, close() { this.closed = true; } });
|
||||
const port1 = mk(); const port2 = mk(); port1.peer = port2; port2.peer = port1; return { port1, port2 };
|
||||
}
|
||||
const SITE_A = 'a'.repeat(32); const SITE_B = 'b'.repeat(32); const SESSION = 'ab'.repeat(16);
|
||||
/** app.js + core + ui 를 한 컨텍스트에서 실행한다. 반환: ctx(전역 함수 openSite·showTab·renderPreview 포함)·els·fetch 기록 */
|
||||
async function loadApp() {
|
||||
const els = {}; const $ = (id) => (els[id] || (els[id] = omni(id)));
|
||||
const document = { body: omni('body'), documentElement: omni('html'), readyState: 'complete', getElementById: $, createElement: (t) => omni(t), createTextNode: () => omni('#text'), querySelectorAll: () => [], querySelector: (s) => omni(s), _ev: {}, addEventListener(t, fn) { (this._ev[t] || (this._ev[t] = [])).push(fn); }, removeEventListener() {}, activeElement: null, title: '', cookie: '', hidden: false };
|
||||
const site = (id) => ({ id, title: 'S-' + id[0], files: ['index.html'], url: `http://h/${id}/`, status: 'idle', commands: [], versions: [], totals: {}, attachments: [], git: { seq: 0, changes: [] } });
|
||||
const ctx = { document, console: { log() {}, warn() {}, error() {} }, setTimeout, clearTimeout, setInterval, clearInterval, answer: true, asked: 0, confirm() { ctx.asked++; return ctx.answer; }, alert() {}, prompt: () => null,
|
||||
localStorage: { _: {}, getItem(k) { return k in this._ ? this._[k] : null; }, setItem(k, v) { this._[k] = String(v); }, removeItem(k) { delete this._[k]; } },
|
||||
location: { origin: 'http://h', href: 'http://h/', search: '', hash: '', pathname: '/' }, history: { replaceState() {} }, navigator: { clipboard: { writeText: async () => {} }, language: 'ko' },
|
||||
T: (s) => s, TF: (s, ...a) => s.replace(/\{(\d+)\}/g, (_, i) => a[i - 1]), I18N: { lang: 'ko' }, uiLocale: () => 'ko-KR', Option: function (t, v) { this.textContent = t; this.value = v; }, Event: function (t) { this.type = t; }, FormData: function () { this.append = () => {}; }, EventSource: function () { this.close = () => {}; },
|
||||
URL, URLSearchParams, encodeURIComponent, decodeURIComponent, requestAnimationFrame: (f) => setTimeout(f, 0), matchMedia: () => ({ matches: false, addEventListener() {} }), getComputedStyle: () => ({}),
|
||||
sites: { [SITE_A]: site(SITE_A), [SITE_B]: site(SITE_B) }, fetchLog: [] };
|
||||
ctx.routes = (url) => {
|
||||
if (url.startsWith('/api/auth/csrf')) return { csrf: 't' };
|
||||
if (url === '/api/config') return { version: '1.8.0', tiers: [], siteLocales: [], user: { id: 'u', name: 'U' }, credit: null, publicBaseUrl: 'http://h' };
|
||||
if (url === '/api/sites') return []; if (url === '/api/feedback') return { items: [] };
|
||||
let m = /^\/api\/sites\/([a-f0-9]{32})$/.exec(url); if (m) return ctx.sites[m[1]];
|
||||
m = /^\/api\/sites\/([a-f0-9]{32})\/visual\/index\.html$/.exec(url); if (m) return { path: 'index.html', hash: 'c'.repeat(64), rev: 1, session: SESSION, canvas: '<!doctype html><html><head></head><body><p>x</p></body></html>', running: false };
|
||||
return {};
|
||||
};
|
||||
ctx.fetch = async (url, init) => { ctx.fetchLog.push({ url, method: (init && init.method) || 'GET' }); const r = ctx.routes(url, init); return { ok: true, status: 200, json: async () => r, text: async () => JSON.stringify(r), headers: { get: () => null } }; };
|
||||
ctx.window = ctx; ctx._winEv = {}; ctx.addEventListener = (t, fn) => { (ctx._winEv[t] || (ctx._winEv[t] = [])).push(fn); }; ctx.removeEventListener = () => {}; ctx.fire = (t, ev) => { (ctx._winEv[t] || []).forEach((fn) => fn(ev)); return ev; };
|
||||
vm.createContext(ctx);
|
||||
for (const f of ['public/visual-editor-core.js', 'public/app.js', 'public/visual-editor-ui.js']) vm.runInContext(fs.readFileSync(path.join(ROOT, f), 'utf8'), ctx, { filename: f });
|
||||
await new Promise((r) => setTimeout(r, 30)); // 초기화(설정·사이트 목록) 끝
|
||||
return { ctx, els, $ };
|
||||
}
|
||||
|
||||
test('app.js 전환 보호: 직접 편집에 저장하지 않은 변경이 있으면 openSite(다른 사이트)·showTab(home, 다른 manageId)·renderPreview(HTML 없는 갱신) 모두 묻고, 취소하면 현재 사이트·편집기·캔버스를 그대로 두며, 승낙하면 끝내고 전환한다', async () => {
|
||||
const { ctx, els } = await loadApp();
|
||||
assert.equal(typeof ctx.openSite, 'function'); assert.equal(typeof ctx.showTab, 'function'); assert.equal(typeof ctx.renderPreview, 'function');
|
||||
await ctx.openSite(SITE_A); assert.equal(els.siteId.textContent, SITE_A, '사이트 A 열림');
|
||||
const VE = ctx.VisualEditor; assert.ok(VE, 'VisualEditor 로드');
|
||||
await VE.open('index.html'); assert.equal(VE.isActive(), true); assert.equal(VE.state().siteId, SITE_A);
|
||||
// 캔버스 연결(ready + 포트) 뒤 변경 → 저장되지 않음
|
||||
const ch = fakeChannel(); const sent = []; ch.port1.onmessage = (e) => sent.push(e.data);
|
||||
ctx.fire('message', { data: core.makeMessage(SESSION, 'ready', {}), source: els.frame.contentWindow, origin: 'null', ports: [ch.port2] });
|
||||
assert.deepEqual(sent.map((m) => m.type), ['init'], '연결');
|
||||
ch.port1.postMessage(core.makeMessage(SESSION, 'body', { body: '<p>x</p>' })); ch.port1.postMessage(core.makeMessage(SESSION, 'changed', { body: '<p>y</p>' }));
|
||||
assert.equal(VE.isDirty(), true);
|
||||
|
||||
// 1) openSite(B) — 취소: 묻고, 사이트·편집기 유지, B 를 불러오지 않는다
|
||||
ctx.answer = false; ctx.asked = 0; const f0 = ctx.fetchLog.length;
|
||||
const r1 = await ctx.openSite(SITE_B);
|
||||
assert.equal(ctx.asked, 1, '물었다'); assert.equal(VE.isActive(), true); assert.equal(VE.state().siteId, SITE_A); assert.equal(VE.isDirty(), true);
|
||||
assert.equal(els.siteId.textContent, SITE_A, '현재 사이트 유지'); assert.ok(r1 && r1.id === SITE_A, '지금 사이트를 돌려준다');
|
||||
assert.ok(!ctx.fetchLog.slice(f0).some((f) => f.url === `/api/sites/${SITE_B}`), '다른 사이트를 불러오지 않는다');
|
||||
// 2) showTab('home') — manageId 가 B: 취소하면 화면도 사이트도 바꾸지 않는다
|
||||
vm.runInContext(`manageId = ${JSON.stringify(SITE_B)}`, ctx); ctx.asked = 0; const f1 = ctx.fetchLog.length;
|
||||
await ctx.showTab('home');
|
||||
assert.equal(ctx.asked, 1, '홈으로 가기 전에 물었다'); assert.equal(VE.isActive(), true); assert.equal(els.siteId.textContent, SITE_A); assert.ok(!ctx.fetchLog.slice(f1).some((f) => f.url === `/api/sites/${SITE_B}`));
|
||||
// 3) renderPreview — HTML 페이지가 없어진 갱신: 취소하면 편집기·캔버스(frame) 유지
|
||||
ctx.asked = 0; ctx.renderPreview({ ...ctx.sites[SITE_A], files: [] }, true);
|
||||
assert.equal(ctx.asked, 1); assert.equal(VE.isActive(), true); assert.equal(VE.isDirty(), true); assert.equal(els.frame.classList.contains('hidden'), false, '캔버스를 숨기지 않는다'); assert.equal(els.emptyState.classList.contains('hidden'), true);
|
||||
// 4) 승낙: openSite(B) → 편집기 끝남, B 로 전환
|
||||
ctx.answer = true; ctx.asked = 0;
|
||||
await ctx.openSite(SITE_B);
|
||||
assert.equal(ctx.asked, 1); assert.equal(VE.isActive(), false, '승낙하면 끝낸다'); assert.equal(els.siteId.textContent, SITE_B, 'B 로 전환'); assert.equal(ch.port2.closed, true, '포트 닫힘');
|
||||
// 5) 변경이 없으면 묻지 않는다: 다시 열고(깨끗) 다른 사이트로
|
||||
await VE.open('index.html'); assert.equal(VE.state().siteId, SITE_B); ctx.asked = 0; await ctx.openSite(SITE_A); assert.equal(ctx.asked, 0); assert.equal(VE.isActive(), false); assert.equal(els.siteId.textContent, SITE_A);
|
||||
});
|
||||
@@ -0,0 +1,605 @@
|
||||
'use strict';
|
||||
// v1.8.0 직접 편집(시각 편집기) 서비스 단위 검증 — 빈 데이터로 서버를 띄워 API 로 확인한다.
|
||||
// 권한(소유자만)·CSRF/출처·HTML 페이지만·경로 탈출·캔버스 문서의 스크립트 비실행 계약·저장(파일 갱신·버전 기록·캐시 미차감·AI 수정 횟수 미계수)·
|
||||
// 저장 PUT 은 GET 이 준 rev 를 함께 보낸다 — 단독 Webmaker 는 rev 0 을 주고 무시하지만 Ariweb Suite 의 같은 서버 코드는 유한한 숫자 rev 를 필수로 요구한다(이 파일은 두 저장소에서 byte-identical 로 돈다).
|
||||
// 오래된 저장 거절(409)·AI 작업 중 저장 거절(409, 가짜 claude 실행 파일로 작업을 멈춰 둔 두 번째 서버)·잘못된 본문·화면의 필수 조작을 본다.
|
||||
const test = require('node:test'); const assert = require('node:assert/strict');
|
||||
const fs = require('fs'); const path = require('path'); const os = require('os'); const http = require('http'); const net = require('net'); const crypto = require('crypto'); const { spawn, execFileSync } = require('child_process');
|
||||
// 비밀값(설정 토큰·비밀번호)은 실행마다 만들고 변수로만 넘긴다 — 소스에 고정 문자열로 두지 않고 출력하지도 않는다
|
||||
const SETUP_TOKEN = crypto.randomBytes(24).toString('base64url'); const PW = { alice: crypto.randomBytes(12).toString('base64url'), bob: crypto.randomBytes(12).toString('base64url'), root: crypto.randomBytes(12).toString('base64url') };
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const PAGE = '<!doctype html>\n<html lang="ko"><head><meta charset="utf-8"><title>직접 편집</title><style>h1{color:navy}</style><script>window.ran = (window.ran||0)+1</script></head>\n<body><h1 id="h">제목</h1><p class="lead" onclick="track()">문단</p><a href="about.html">소개</a><img src="images/pic.png" alt="사진"><script src="/wm-stats.js"></script></body></html>\n';
|
||||
|
||||
/** OS 가 비어 있다고 준 루프백 포트 하나(ephemeral 범위 — 개발 PC 의 다른 서버(예: 브라우저 게이트용 4318)와 겹치지 않는다) */
|
||||
function freePort() {
|
||||
return new Promise((resolve, reject) => { const srv = net.createServer(); srv.unref(); srv.on('error', reject); srv.listen(0, '127.0.0.1', () => { const { port } = srv.address(); srv.close(() => resolve(port)); }); });
|
||||
}
|
||||
/** 서버 한 대를 띄우고 가입·관리자·캐시 지급까지 끝낸 뒤 호출 도우미를 돌려준다. 포트는 OS 가 준 빈 포트를 쓰고, 그 사이 누가 차지해 EADDRINUSE 가 나면 다른 포트로 다시 띄운다(최대 5회) */
|
||||
async function boot(label, extraEnv = {}, reuseTmp = null, preparedTmp = null) {
|
||||
const tmp = reuseTmp || preparedTmp || fs.mkdtempSync(path.join(os.tmpdir(), `wm-ve-${label}-`)); const jar = {}; // preparedTmp: 기동 전에 설정 파일(배포 대상 등)을 미리 둔 새 데이터 폴더
|
||||
if (!reuseTmp) fs.writeFileSync(path.join(tmp, 'setup.token'), SETUP_TOKEN + '\n', { mode: 0o600 });
|
||||
let child = null; let out = ''; let PORT = 0; let B = '';
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
PORT = await freePort(); B = `http://127.0.0.1:${PORT}`; out = '';
|
||||
child = spawn('node', [path.join(ROOT, 'server.js')], { env: { ...process.env, PORT: String(PORT), HOST: '127.0.0.1', DATA_ROOT: tmp, DATA_DIR: path.join(tmp, 'sites'), SITES_ROOT: path.join(tmp, 'www'), PUBLIC_BASE_URL: B, WEBMAKER_SETUP_PORT: '0', ...extraEnv }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { out += c; });
|
||||
const t0 = Date.now();
|
||||
while (!/listening on/.test(out) && !/EADDRINUSE/.test(out) && child.exitCode === null && Date.now() - t0 < 15000) await new Promise((r) => setTimeout(r, 50));
|
||||
if (/listening on/.test(out)) break;
|
||||
try { child.kill('SIGTERM'); } catch (_) {}
|
||||
if (!/EADDRINUSE/.test(out)) break; // 포트 충돌이 아닌 기동 실패는 다시 시도하지 않고 아래에서 드러낸다
|
||||
}
|
||||
assert.match(out, /listening on/, `서버 기동 실패(${label}, 포트 ${PORT}): ${out.slice(-600)}`);
|
||||
const api = async (p, init = {}, who = 'a') => { const headers = { 'Content-Type': 'application/json', ...(init.headers || {}) }; if (jar[who]) headers.cookie = jar[who]; if (init.referer !== null) headers.referer = init.referer || `${B}/`; const r = await fetch(B + p, { ...init, headers, redirect: 'manual' }); const sc = r.headers.get('set-cookie'); if (sc && init.keep !== false) jar[who] = sc.split(';')[0]; let b = {}; try { b = await r.json(); } catch (_) {} return { status: r.status, b, headers: r.headers }; };
|
||||
if (reuseTmp) { // 같은 데이터로 다시 기동(재기동 검증): 계정은 이미 있으므로 로그인만
|
||||
assert.equal((await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ id: 'alice', password: PW.alice }) }, 'a')).status, 200);
|
||||
assert.equal((await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ id: 'root1', password: PW.root }) }, 'r')).status, 200);
|
||||
} else {
|
||||
assert.equal((await api('/api/auth/signup', { method: 'POST', body: JSON.stringify({ id: 'alice', password: PW.alice, name: 'Alice' }) }, 'a')).status, 200);
|
||||
assert.equal((await api('/api/auth/signup', { method: 'POST', body: JSON.stringify({ id: 'bob', password: PW.bob, name: 'Bob' }) }, 'b')).status, 200);
|
||||
assert.equal((await api('/api/setup/login', { method: 'POST', body: JSON.stringify({ token: SETUP_TOKEN }) }, 's')).status, 200);
|
||||
assert.ok((await api('/api/setup/admin/create', { method: 'POST', body: JSON.stringify({ id: 'root1', name: 'Root', password: PW.root }) }, 's')).status < 300);
|
||||
assert.equal((await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ id: 'root1', password: PW.root }) }, 'r')).status, 200);
|
||||
assert.equal((await api('/api/admin/credits/alice', { method: 'POST', body: JSON.stringify({ type: 'add', amount: 50000, memo: 'test' }) }, 'r')).status, 200);
|
||||
}
|
||||
/** alice 의 사이트를 만들고 index.html 을 직접 써 둔다(AI 엔진 없이 미리보기 가능한 페이지) */
|
||||
const mkSite = async (html = PAGE) => { const mk = await api('/api/sites', { method: 'POST', body: JSON.stringify({ title: 'VE site', purpose: 'p', content: 'c' }) }, 'a'); assert.ok(mk.status < 300, JSON.stringify(mk.b).slice(0, 200)); const id = mk.b.id; const dir = path.join(tmp, 'www', id); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'index.html'), html); fs.writeFileSync(path.join(dir, 'style.css'), 'body{}'); return { id, dir }; };
|
||||
return { B, tmp, child, api, mkSite, jar, out: () => out };
|
||||
}
|
||||
|
||||
let S; let S2; let S3; let S4; let S5;
|
||||
// 기본 서버는 "바로 실패하는" 가짜 claude 를 쓴다 — 개발 PC 에 설치된 진짜 claude 가 사이트 생성 작업으로 실행되지 않게(비용·시간) 하고, 생성 직후 작업이 곧 끝나 저장 검증을 방해하지 않게 한다
|
||||
const FAKE_FAIL = path.join(os.tmpdir(), `fake-claude-fail-${process.pid}.sh`);
|
||||
const BARE = path.join(os.tmpdir(), `wm-ve-origin-${process.pid}.git`); // 원격 Git 트랜잭션 검증용 bare 저장소(pre-receive 훅으로 푸시 거부 주입)
|
||||
const GITWRAP = path.join(os.tmpdir(), `wm-ve-gitwrap-${process.pid}`); // 서버가 쓰는 git 래퍼(푸시 뒤 클라이언트 오류·부분 푸시·불통을 표식 파일로 주입)
|
||||
const REAL_GIT = execFileSync('which', ['git'], { encoding: 'utf8' }).trim();
|
||||
test.before(async () => { fs.writeFileSync(FAKE_FAIL, '#!/bin/sh\necho "fake claude: not available" >&2\nexit 1\n', { mode: 0o755 }); S = await boot('main', { CLAUDE_BIN: FAKE_FAIL }); });
|
||||
test.after(() => { for (const s of [S, S2, S3, S4, S5]) { try { s && s.child.kill('SIGTERM'); } catch (_) {} } try { fs.unlinkSync(FAKE_FAIL); } catch (_) {} try { fs.rmSync(BARE, { recursive: true, force: true }); } catch (_) {} try { fs.rmSync(GITWRAP, { recursive: true, force: true }); } catch (_) {} });
|
||||
/** 사이트 생성이 띄운(실패하는) AI 작업이 끝날 때까지 기다린다 */
|
||||
async function idle(s, id) { for (let i = 0; i < 100; i++) { const r = await s.api(`/api/sites/${id}`, {}, 'a'); if (!r.b.running) return; await new Promise((r2) => setTimeout(r2, 100)); } throw new Error('AI 작업이 끝나지 않음'); }
|
||||
|
||||
test('권한·출처: 소유자만, 다른 사용자는 404, 로그인 없이는 401, 출처(Referer)·CSRF 없는 fetch 는 거절', async () => {
|
||||
const { id } = await S.mkSite(); await idle(S, id); global.__ve = { id };
|
||||
assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a')).status, 200, '소유자 GET');
|
||||
assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, {}, 'b')).status, 404, '다른 사용자');
|
||||
assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<p>x</p>', baseHash: 'x' }) }, 'b')).status, 404);
|
||||
assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, {}, 'nobody')).status, 401, '로그인 없음');
|
||||
assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', referer: null, headers: { 'sec-fetch-mode': 'cors', 'sec-fetch-dest': 'empty' }, body: JSON.stringify({ body: '<p>x</p>', baseHash: 'x' }) }, 'a')).status, 401, 'Referer·CSRF 없는 fetch');
|
||||
assert.equal((await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', referer: `${S.B}/${id}/index.html`, body: JSON.stringify({ body: '<p>x</p>', baseHash: 'x' }) }, 'a')).status, 401, '생성 사이트 페이지 출처');
|
||||
});
|
||||
|
||||
test('허용 페이지: HTML 만, 경로 탈출·링크·없는 파일 거절', async () => {
|
||||
const { id } = global.__ve;
|
||||
const css = await S.api(`/api/sites/${id}/visual/style.css`, {}, 'a'); assert.equal(css.status, 400); assert.match(css.b.error, /HTML/);
|
||||
assert.equal((await S.api(`/api/sites/${id}/visual/style.css`, { method: 'PUT', body: JSON.stringify({ body: '<p>x</p>', baseHash: 'x' }) }, 'a')).status, 400);
|
||||
assert.equal((await S.api(`/api/sites/${id}/visual/..%2F..%2Fetc%2Fpasswd.html`, {}, 'a')).status, 400, '경로 탈출');
|
||||
assert.equal((await S.api(`/api/sites/${id}/visual/nope.html`, {}, 'a')).status, 404, '없는 파일');
|
||||
fs.symlinkSync('/etc/hostname', path.join(S.tmp, 'www', id, 'link.html')); assert.equal((await S.api(`/api/sites/${id}/visual/link.html`, {}, 'a')).status, 400, '링크 파일');
|
||||
});
|
||||
|
||||
test('캔버스 문서 계약: 페이지 스크립트·핸들러는 실행되지 않는 형태, CSP nonce·편집 런타임·base·세션이 있고 GET /files 는 hash 를 준다', async () => {
|
||||
const { id } = global.__ve;
|
||||
const r = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); assert.equal(r.status, 200);
|
||||
assert.equal(r.b.path, 'index.html'); assert.match(r.b.hash, /^[a-f0-9]{64}$/); assert.match(r.b.session, /^[a-f0-9]{32}$/); assert.equal(typeof r.b.canvas, 'string');
|
||||
const c = r.b.canvas;
|
||||
assert.ok(!/<script>window\.ran/.test(c) && c.includes('<script type="text/x-wm-ve-disabled">window.ran'), '머리 스크립트 비활성화');
|
||||
assert.ok(c.includes('type="text/x-wm-ve-disabled" src="/wm-stats.js"') || /<script type="text\/x-wm-ve-disabled"[^>]*src="\/wm-stats\.js"/.test(c), '외부 스크립트 비활성화');
|
||||
assert.ok(!/\sonclick=/.test(c) && c.includes('data-wm-ve-on-click="track()"'));
|
||||
assert.ok(c.includes(`<base href="${S.B}/${id}/"`), '상대 경로 자원이 사이트 주소 기준으로 풀린다');
|
||||
assert.match(c, /Content-Security-Policy" content="[^"]*script-src 'nonce-/); assert.ok(c.includes('WMVisualCore') && c.includes(`"session":"${r.b.session}"`) && c.includes(`"parentOrigin":"${S.B}"`), '런타임·세션·부모 출처');
|
||||
assert.ok(!c.includes('wh_session') && !c.includes(SETUP_TOKEN), '세션 쿠키·토큰 같은 비밀값 없음');
|
||||
const f = await S.api(`/api/sites/${id}/files/index.html`, {}, 'a'); assert.equal(f.status, 200); assert.equal(f.b.hash, r.b.hash, 'GET /files 의 hash = 캔버스 hash');
|
||||
assert.equal(r.headers.get('cache-control'), 'no-store');
|
||||
});
|
||||
|
||||
test('저장: 파일 갱신·버전 기록(visual-edit)·미리보기 목록·캐시 미차감·AI 수정 횟수·명령 기록 미증가·표식 제거·머리 보존', async () => {
|
||||
const { id } = global.__ve;
|
||||
const before = await S.api(`/api/sites/${id}`, {}, 'a'); const credit0 = before.b.credit.balance; const cmds0 = (before.b.commands || []).length; const ver0 = (before.b.versions || []).length;
|
||||
const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
|
||||
const body = '<h1 id="h" contenteditable="true" data-wm-ve-editable="">새 제목</h1><div data-wm-ve-ui=""><button>툴바</button></div><p class="lead" data-wm-ve-on-click="track()">문단</p><section class="wm-section"><h2>새 섹션</h2></section><a href="about.html">소개</a><img src="images/pic.png" alt="사진"><script type="text/x-wm-ve-disabled" src="/wm-stats.js"></script>';
|
||||
const put = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body, baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
|
||||
assert.equal(put.status, 200, JSON.stringify(put.b)); assert.equal(put.b.path, 'index.html'); assert.match(put.b.hash, /^[a-f0-9]{64}$/); assert.notEqual(put.b.hash, g.b.hash); assert.ok(Array.isArray(put.b.files) && put.b.files.includes('index.html'));
|
||||
const saved = fs.readFileSync(path.join(S.tmp, 'www', id, 'index.html'), 'utf8');
|
||||
assert.ok(saved.startsWith('<!doctype html>\n<html lang="ko"><head><meta charset="utf-8"><title>직접 편집</title><style>h1{color:navy}</style><script>window.ran = (window.ran||0)+1</script></head>\n<body>'), '머리·스크립트 보존: ' + saved.slice(0, 120));
|
||||
assert.ok(saved.includes('<h1 id="h">새 제목</h1><p class="lead" onclick="track()">문단</p><section class="wm-section"><h2>새 섹션</h2></section>'), saved);
|
||||
assert.ok(saved.includes('<script src="/wm-stats.js"></script></body></html>'), '본문 끝 스크립트 복원'); assert.ok(!/data-wm-ve|contenteditable|툴바/.test(saved), '표식·덧씌운 요소 없음');
|
||||
const after = await S.api(`/api/sites/${id}`, {}, 'a');
|
||||
assert.equal(after.b.credit.balance, credit0, '캐시 미차감'); assert.equal((after.b.commands || []).length, cmds0, 'AI 명령 기록에 추가되지 않음');
|
||||
assert.equal((after.b.versions || []).length, ver0 + 1, '버전 1개 추가'); const last = after.b.versions[after.b.versions.length - 1]; assert.equal(last.kind, 'visual-edit'); assert.match(last.message, /직접 편집.*index\.html/); assert.ok(last.commit);
|
||||
assert.ok((after.b.manualEdits || []).some((m) => m.path === 'index.html' && m.kind === 'visual'), '직접 편집 기록');
|
||||
assert.ok(put.b.change && put.b.change.kind === 'visual-edit' && put.b.git && put.b.versions, '응답에 버전 정보');
|
||||
assert.equal((await S.api(`/api/sites/${id}/files/index.html`, {}, 'a')).b.hash, put.b.hash, '소스 편집기용 hash 와 같다');
|
||||
global.__ve.hash = put.b.hash;
|
||||
});
|
||||
|
||||
test('오래된 저장(다른 창이 먼저 저장) 은 409 로 거절하고 최신 hash 를 알려 준다 · 잘못된 본문은 400', async () => {
|
||||
const { id, hash } = global.__ve;
|
||||
const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
|
||||
const stale = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<p>다른 창</p>', baseHash: 'd'.repeat(64), rev: g.b.rev }) }, 'a');
|
||||
assert.equal(stale.status, 409); assert.match(stale.b.error, /먼저 저장|달라졌습니다/); assert.equal(stale.b.currentHash, hash);
|
||||
assert.equal(fs.readFileSync(path.join(S.tmp, 'www', id, 'index.html'), 'utf8').includes('다른 창'), false, '파일은 그대로');
|
||||
const nohash = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<p>x</p>' }) }, 'a'); assert.equal(nohash.status, 400); assert.match(nohash.b.error, /baseHash/);
|
||||
const bad = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: 5, baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(bad.status, 400); assert.match(bad.b.error, /문자열/);
|
||||
const js = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<p>x</p><script>alert(1)</script>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(js.status, 400); assert.match(js.b.error, /스크립트/);
|
||||
const ev = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<p onload="x()">x</p>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(ev.status, 400); assert.match(ev.b.error, /이벤트 속성/);
|
||||
const url = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<a href="javascript:alert(1)">x</a>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(url.status, 400); assert.match(url.b.error, /허용되지 않는/);
|
||||
const big = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<p>' + 'x'.repeat(5 * 1024 * 1024 + 100) + '</p>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(big.status, 413);
|
||||
const nojson = await fetch(`${S.B}/api/sites/${id}/visual/index.html`, { method: 'PUT', headers: { 'Content-Type': 'application/json', referer: `${S.B}/`, cookie: S.jar.a }, body: '{not json' }); assert.ok(nojson.status >= 400 && nojson.status < 500, 'JSON 파싱 실패는 4xx: ' + nojson.status);
|
||||
assert.equal(fs.readFileSync(path.join(S.tmp, 'www', id, 'index.html'), 'utf8').includes('새 제목'), true, '거절된 요청은 파일을 바꾸지 않음');
|
||||
});
|
||||
|
||||
/**
|
||||
* 머리 + 본문 앞부분만 보내 두고(chunked) 끝내지 않는 요청 → 서버 처리기는 라우트에 들어가 본문 읽기(await)에서 멈춘다. finish() 가 나머지를 보낸다.
|
||||
* 잠금·동시성 검증용: "먼저 들어왔지만 나중에 본문을 끝낸" 요청이 그 사이의 다른 변경을 덮어쓰지 않는지 본다.
|
||||
*/
|
||||
function slowRequest(s, method, urlPath, payload, who = 'a') {
|
||||
const json = JSON.stringify(payload); let resolve; const done = new Promise((r) => { resolve = r; });
|
||||
const req = http.request(`${s.B}${urlPath}`, { method, headers: { 'Content-Type': 'application/json', referer: `${s.B}/`, cookie: s.jar[who], 'Transfer-Encoding': 'chunked' } }, (res) => { let d = ''; res.on('data', (c) => { d += c; }); res.on('end', () => { let b = {}; try { b = JSON.parse(d); } catch (_) {} resolve({ status: res.statusCode, b }); }); });
|
||||
req.on('error', (e) => resolve({ status: 0, b: { error: e.message } }));
|
||||
req.write(json.slice(0, 12)); req.flushHeaders();
|
||||
return { done, finish: () => req.end(json.slice(12)) };
|
||||
}
|
||||
/** 뒤처진 저장의 409 검사. 단독 Webmaker 는 파일 hash 불일치(stale + currentHash), Ariweb Suite 의 같은 코드는 잠금 안에서 rev 충돌(conflict)을 먼저 보므로 둘 중 하나면 된다 */
|
||||
function expectStale(r, hash, label) { assert.equal(r.status, 409, (label || '오래된 저장') + ': ' + JSON.stringify(r.b)); assert.ok(r.b.stale === true || (r.b.conflict && typeof r.b.conflict.currentRev === 'number'), (label || '') + ' stale 또는 rev 충돌: ' + JSON.stringify(r.b)); if (r.b.stale) assert.equal(r.b.currentHash, hash, (label || '') + ' 최신 hash 는 먼저 끝난 저장의 결과'); }
|
||||
/** 가짜 claude 가 남긴 sleep 자식 정리(서버를 죽여도 남는다) */
|
||||
function pkillSleep() { try { execFileSync('pkill', ['-f', '^sleep 30$']); } catch (_) {} }
|
||||
/** 디렉터리 트리의 모드를 바꾼다(실패 주입: 읽기 전용으로 만들어 저장소·git 쓰기를 실패시킨다) */
|
||||
function chmodTree(dir, mode) { for (const f of fs.readdirSync(dir, { withFileTypes: true })) if (f.isDirectory()) chmodTree(path.join(dir, f.name), mode); fs.chmodSync(dir, mode); }
|
||||
|
||||
test('동시 저장 경쟁: 두 PUT 이 본문을 다 보내기 전에 함께 들어와도 둘째는 409 — 첫 저장이 조용히 덮어써지지 않는다', async () => {
|
||||
const { id } = await S.mkSite(); await idle(S, id);
|
||||
const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const index = path.join(S.tmp, 'www', id, 'index.html');
|
||||
const start = (marker) => slowRequest(S, 'PUT', `/api/sites/${id}/visual/index.html`, { body: `<h1 id="h">${marker}</h1>`, baseHash: g.b.hash, rev: g.b.rev });
|
||||
const A = start('첫째-A'); const B = start('둘째-B');
|
||||
await new Promise((r) => setTimeout(r, 400)); // 두 처리기가 모두 라우트에 들어가 본문을 기다리는 상태를 만든다
|
||||
A.finish(); const ra = await A.done; assert.equal(ra.status, 200, JSON.stringify(ra.b));
|
||||
assert.ok(fs.readFileSync(index, 'utf8').includes('첫째-A'), '첫 저장 반영');
|
||||
B.finish(); const rb = await B.done;
|
||||
expectStale(rb, ra.b.hash, '둘째는 오래된 저장');
|
||||
const final = fs.readFileSync(index, 'utf8'); assert.ok(final.includes('첫째-A') && !final.includes('둘째-B'), '첫 저장이 살아 있다: ' + final.slice(0, 200));
|
||||
const s = await S.api(`/api/sites/${id}`, {}, 'a'); assert.equal((s.b.versions || []).filter((v) => v.kind === 'visual-edit').length, 1, '버전도 한 번만');
|
||||
});
|
||||
|
||||
test('사이트 변경 잠금: 소스 편집기 PUT(baseHash) 과 직접 편집 PUT 이 본문을 보내는 동안 함께 들어와도 잠금 안에서 다시 검사해 뒤의 저장은 409(stale), 파일은 먼저 저장한 쪽 그대로 — 양방향; baseHash 없는 옛 소스 저장은 예전처럼 동작', async () => {
|
||||
const { id } = await S.mkSite(); await idle(S, id); const index = path.join(S.tmp, 'www', id, 'index.html');
|
||||
const v0 = ((await S.api(`/api/sites/${id}`, {}, 'a')).b.versions || []).length; // 사이트 생성이 남긴 버전(create)은 이 테스트의 저장이 아니다
|
||||
// 1) 직접 편집이 먼저 끝남 → 소스 편집기 저장은 오래된 baseHash 로 409
|
||||
let g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
|
||||
let V = slowRequest(S, 'PUT', `/api/sites/${id}/visual/index.html`, { body: '<h1 id="h">시각-1</h1>', baseHash: g.b.hash, rev: g.b.rev });
|
||||
let T = slowRequest(S, 'PUT', `/api/sites/${id}/files/index.html`, { content: PAGE.replace('제목', '소스-1'), baseHash: g.b.hash, rev: g.b.rev });
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
V.finish(); const rv = await V.done; assert.equal(rv.status, 200, JSON.stringify(rv.b)); assert.ok(fs.readFileSync(index, 'utf8').includes('시각-1'));
|
||||
T.finish(); const rt = await T.done; expectStale(rt, rv.b.hash, '소스 저장은 오래됨');
|
||||
let cur = fs.readFileSync(index, 'utf8'); assert.ok(cur.includes('시각-1') && !cur.includes('소스-1'), '직접 편집 저장이 살아 있다');
|
||||
// 2) 소스 편집기가 먼저 끝남 → 직접 편집 저장은 409 stale
|
||||
g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
|
||||
T = slowRequest(S, 'PUT', `/api/sites/${id}/files/index.html`, { content: PAGE.replace('제목', '소스-2'), baseHash: g.b.hash, rev: g.b.rev });
|
||||
V = slowRequest(S, 'PUT', `/api/sites/${id}/visual/index.html`, { body: '<h1 id="h">시각-2</h1>', baseHash: g.b.hash, rev: g.b.rev });
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
T.finish(); const rt2 = await T.done; assert.equal(rt2.status, 200, JSON.stringify(rt2.b)); assert.match(rt2.b.hash || '', /^[a-f0-9]{64}$/, '소스 저장 응답에도 hash');
|
||||
V.finish(); const rv2 = await V.done; expectStale(rv2, rt2.b.hash, '직접 편집 저장은 오래됨');
|
||||
cur = fs.readFileSync(index, 'utf8'); assert.ok(cur.includes('소스-2') && !cur.includes('시각-2'));
|
||||
// 3) baseHash 가 틀린 소스 저장은 바로 409, baseHash 없는 옛 화면의 소스 저장은 200(호환)
|
||||
const bad = await S.api(`/api/sites/${id}/files/index.html`, { method: 'PUT', body: JSON.stringify({ content: PAGE, baseHash: 'e'.repeat(64) }) }, 'a'); assert.equal(bad.status, 409); assert.equal(bad.b.stale, true);
|
||||
const old = await S.api(`/api/sites/${id}/files/index.html`, { method: 'PUT', body: JSON.stringify({ content: PAGE.replace('제목', '옛 화면') }) }, 'a'); assert.equal(old.status, 200); assert.ok(fs.readFileSync(index, 'utf8').includes('옛 화면'));
|
||||
const s = await S.api(`/api/sites/${id}`, {}, 'a'); assert.deepEqual((s.b.versions || []).slice(v0).map((v) => v.kind), ['visual-edit', 'manual-edit', 'manual-edit'], '이 테스트에서 성공한 저장만 버전으로(409 두 건은 버전 없음)');
|
||||
});
|
||||
|
||||
test('직접 편집 저장 트랜잭션: 사이트 기록(store) 저장이 실패하면 페이지를 원본으로 되돌리고 500 — 버전·manualEdits·임시 파일 없음, 같은 baseHash 로 다시 저장하면 성공', async () => {
|
||||
const { id, dir } = await S.mkSite(); await idle(S, id); const index = path.join(dir, 'index.html'); const sitesDir = path.join(S.tmp, 'sites');
|
||||
const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const before = fs.readFileSync(index, 'utf8'); const s0 = await S.api(`/api/sites/${id}`, {}, 'a'); const refs0 = localRefs(S, id);
|
||||
fs.chmodSync(sitesDir, 0o555); // 사이트 기록 폴더에 임시 파일을 만들 수 없음 → store.save 실패(로컬 커밋은 이미 만들어진 뒤 → 되감아야 한다)
|
||||
let r; try { r = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">기록 실패</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); } finally { fs.chmodSync(sitesDir, 0o755); }
|
||||
assert.equal(r.status, 500, JSON.stringify(r.b)); assert.match(r.b.error, /저장하지 못했습니다|되돌렸습니다/); assert.match(r.b.error, /그대로|되돌/); assert.equal(r.b.rolledBack, true, '검증된 되돌리기: ' + JSON.stringify(r.b));
|
||||
assert.equal(fs.readFileSync(index, 'utf8'), before, '페이지는 원본으로 되돌아간다'); assert.ok(!fs.readdirSync(dir).some((f) => /\.tmp$/.test(f)), '임시 파일 없음');
|
||||
assert.deepEqual(localRefs(S, id), refs0, '로컬 커밋·변경 브랜치를 되감아 브랜치가 저장 전 그대로'); let indexClean = true; try { siteGit(S, id, ['diff', '--cached', '--quiet']); } catch (_) { indexClean = false; } assert.equal(indexClean, true, 'add 로 올라간 인덱스를 비운다');
|
||||
const s1 = await S.api(`/api/sites/${id}`, {}, 'a'); assert.equal((s1.b.versions || []).length, (s0.b.versions || []).length, '버전 추가 없음'); assert.equal((s1.b.manualEdits || []).length, (s0.b.manualEdits || []).length, 'manualEdits 추가 없음');
|
||||
const ok = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">다시 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(ok.status, 200, '같은 baseHash 로 재시도 성공: ' + JSON.stringify(ok.b)); assert.ok(fs.readFileSync(index, 'utf8').includes('다시 저장'));
|
||||
assert.equal((await S.api(`/api/sites/${id}`, {}, 'a')).b.versions.filter((v) => v.kind === 'visual-edit').length, 1);
|
||||
});
|
||||
|
||||
test('직접 편집 저장 트랜잭션: Git 버전 기록이 실패하면 200 으로 꾸미지 않고 페이지·사이트 기록을 저장 전으로 되돌려 500 — 거짓 visual-edit 버전 없음, 같은 baseHash 로 다시 저장하면 성공', async () => {
|
||||
const { id, dir } = await S.mkSite(); await idle(S, id); const index = path.join(dir, 'index.html');
|
||||
let g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
|
||||
const first = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">첫 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(first.status, 200, JSON.stringify(first.b)); assert.ok(first.b.change && first.b.change.commit, '첫 저장은 git 기록');
|
||||
const gitDir = path.join(S.tmp, 'git', id); assert.ok(fs.existsSync(path.join(gitDir, 'HEAD')), '사이트 git 저장소'); const before = fs.readFileSync(index, 'utf8'); const s0 = await S.api(`/api/sites/${id}`, {}, 'a');
|
||||
g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
|
||||
chmodTree(gitDir, 0o555); // git 이 index.lock·객체를 쓸 수 없음 → 커밋 실패
|
||||
let r; try { r = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">git 실패</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); } finally { chmodTree(gitDir, 0o755); }
|
||||
assert.equal(r.status, 500, JSON.stringify(r.b)); assert.match(r.b.error, /버전|Git|git/); assert.match(r.b.error, /되돌/); assert.equal(r.b.rolledBack, true, '검증된 되돌리기: ' + JSON.stringify(r.b));
|
||||
assert.equal(fs.readFileSync(index, 'utf8'), before, '페이지는 저장 전으로'); assert.ok(!fs.readdirSync(dir).some((f) => /\.tmp$/.test(f)));
|
||||
assert.equal(siteGit(S, id, ['status', '--porcelain']), '', '인덱스·작업 트리에 남은 변경 없음'); assert.equal(siteGit(S, id, ['rev-parse', 'HEAD']), first.b.change.commit, 'HEAD 는 첫 저장 커밋 그대로'); assert.ok(!Object.keys(localRefs(S, id)).some((r2) => /visual-edit$/.test(r2) && !r2.endsWith(first.b.change.branch)), '실패한 저장의 변경 브랜치 없음: ' + JSON.stringify(localRefs(S, id)));
|
||||
const s1 = await S.api(`/api/sites/${id}`, {}, 'a'); assert.equal((s1.b.versions || []).length, (s0.b.versions || []).length, '거짓 버전 없음'); assert.equal((s1.b.manualEdits || []).length, (s0.b.manualEdits || []).length, 'manualEdits 되돌림'); assert.equal(s1.b.versions.filter((v) => v.kind === 'visual-edit').length, 1);
|
||||
const ok = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">다시 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(ok.status, 200, '권한을 되돌리면 같은 baseHash 로 저장: ' + JSON.stringify(ok.b)); assert.ok(ok.b.change && ok.b.change.kind === 'visual-edit');
|
||||
assert.equal((await S.api(`/api/sites/${id}`, {}, 'a')).b.versions.filter((v) => v.kind === 'visual-edit').length, 2);
|
||||
});
|
||||
|
||||
/** 사이트 git 저장소에 명령을 보낸다(검증용 읽기·훅 설치) */
|
||||
function siteGit(s, id, args) { return execFileSync('git', args, { env: { ...process.env, GIT_DIR: path.join(s.tmp, 'git', id), GIT_WORK_TREE: path.join(s.tmp, 'www', id) }, encoding: 'utf8' }).trim(); }
|
||||
/** 원격(bare) 저장소의 모든 ref → { refname: sha } */
|
||||
function remoteRefs() { const out = execFileSync('git', ['--git-dir', BARE, 'for-each-ref', '--format=%(refname) %(objectname)'], { encoding: 'utf8' }).trim(); return Object.fromEntries(out.split('\n').filter(Boolean).map((l) => l.split(' '))); }
|
||||
/** 로컬 사이트 저장소의 모든 브랜치 → { refname: sha } */
|
||||
function localRefs(s, id) { const out = siteGit(s, id, ['for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads/']); return Object.fromEntries(out.split('\n').filter(Boolean).map((l) => l.split(' '))); }
|
||||
/** 되돌리기 검증: 페이지·HEAD·인덱스(HEAD 와 같음)·작업 트리(변경 없음)·로컬 브랜치·원격 ref·버전 목록·manualEdits 가 모두 저장 전 그대로 */
|
||||
function assertRolledBack(s, id, snap, r, label) {
|
||||
assert.equal(r.status, 500, label + ': ' + JSON.stringify(r.b)); assert.equal(r.b.rolledBack, true, label + ': rolledBack 은 검증된 뒤에만 true: ' + JSON.stringify(r.b)); assert.match(r.b.error, /되돌렸습니다/, label);
|
||||
assert.equal(fs.readFileSync(snap.index, 'utf8'), snap.page, label + ': 페이지 원본'); assert.ok(!fs.readdirSync(path.dirname(snap.index)).some((f) => /\.tmp$/.test(f)), label + ': 임시 파일 없음');
|
||||
assert.equal(siteGit(s, id, ['rev-parse', '--verify', '-q', 'HEAD']), snap.head, label + ': HEAD 그대로');
|
||||
let indexClean = true; try { siteGit(s, id, ['diff', '--cached', '--quiet']); } catch (_) { indexClean = false; } assert.equal(indexClean, true, label + ': 인덱스가 HEAD 와 같다(add 로 올라간 것 없음)');
|
||||
assert.equal(siteGit(s, id, ['status', '--porcelain']), '', label + ': 작업 트리 변경 없음');
|
||||
assert.deepEqual(localRefs(s, id), snap.local, label + ': 로컬 브랜치(변경 브랜치 없음)'); assert.deepEqual(remoteRefs(), snap.remote, label + ': 원격 ref 그대로');
|
||||
}
|
||||
|
||||
test('직접 편집 저장 트랜잭션(원격 Git, 진짜 bare 저장소): 성공하면 페이지·사이트 기록·로컬 커밋 뒤 main 과 변경 브랜치가 한 번의 원자적 푸시로 함께 바뀌고, 푸시 거부·커밋 실패(add 뒤)·사이트 기록 실패 때는 원격 두 ref 모두 그대로이며 로컬 HEAD·인덱스·작업 트리·변경 브랜치·페이지·기록이 검증된 채 되돌아가 500(rolledBack) — 같은 baseHash 로 재시도하면 성공', async () => {
|
||||
execFileSync('git', ['init', '-q', '--bare', BARE]);
|
||||
fs.writeFileSync(path.join(BARE, 'hooks', 'pre-receive'), `#!/bin/sh\nif [ -e "${BARE}/REJECT" ]; then echo "rejected by test hook" >&2; exit 1; fi\nexit 0\n`, { mode: 0o755 });
|
||||
// git 래퍼(서버의 PATH 앞): 표식 파일로 "원격이 받았는데 클라이언트가 오류"(FAIL_AFTER)·"브랜치만 올라가고 실패"(MIXED)·"받은 뒤 오류 + 그 뒤 ls-remote 불통"(FLAKY)·"완전 불통"(DOWN) 을 결정적으로 만든다
|
||||
fs.mkdirSync(GITWRAP, { recursive: true });
|
||||
fs.writeFileSync(path.join(GITWRAP, 'git'), `#!/bin/sh\nF="${BARE}"\nif [ -e "$F/SLOW_COMMIT" ]; then for a in "$@"; do if [ "$a" = "commit" ]; then sleep 2; break; fi; done; fi\nif [ "$1" = "push" ]; then\n if [ -e "$F/DOWN" ]; then echo "fatal: unable to access origin: connection reset (test)" >&2; exit 128; fi\n if [ -e "$F/FAIL_AFTER" ] || [ -e "$F/FLAKY" ]; then "${REAL_GIT}" "$@"; touch "$F/PUSHED_ONCE"; echo "fatal: the remote end hung up unexpectedly (test)" >&2; exit 128; fi\n if [ -e "$F/MIXED" ]; then SPEC=""; for a in "$@"; do case "$a" in *changes/*) SPEC="$a";; esac; done; "${REAL_GIT}" push -q origin "$SPEC"; echo "fatal: partial push (test)" >&2; exit 128; fi\nfi\nif [ "$1" = "ls-remote" ]; then\n if [ -e "$F/DOWN" ]; then echo "fatal: unable to access origin: connection reset (test)" >&2; exit 128; fi\n if [ -e "$F/FLAKY" ] && [ -e "$F/PUSHED_ONCE" ]; then echo "fatal: unable to access origin: timeout (test)" >&2; exit 128; fi\nfi\nexec "${REAL_GIT}" "$@"\n`, { mode: 0o755 });
|
||||
S3 = await boot('remote', { CLAUDE_BIN: FAKE_FAIL, SITES_GIT_REMOTE: BARE, SOURCE_PROVIDER: 'gitea', PATH: `${GITWRAP}:${process.env.PATH}` });
|
||||
const { id, dir } = await S3.mkSite(); await idle(S3, id); const index = path.join(dir, 'index.html'); const gitDir = path.join(S3.tmp, 'git', id);
|
||||
const MAIN = `refs/heads/sites/${id}/main`; const branchRef = (rec) => `refs/heads/${rec.branch}`;
|
||||
// 훅: 푸시가 원격에 닿은 **순간** 의 사이트 기록(푸시 전에 저장된 것)을 복사해 둔다 — 그 기록이 "원격 반영됨" 을 주장하면 안 된다
|
||||
const record = path.join(S3.tmp, 'sites', `${id}.json`); const captured = path.join(BARE, 'during-push.json');
|
||||
fs.writeFileSync(path.join(BARE, 'hooks', 'pre-receive'), `#!/bin/sh\ncp "${record}" "${captured}" 2>/dev/null\necho push >> "${BARE}/pushes.log"\nif [ -e "${BARE}/REJECT" ]; then echo "rejected by test hook" >&2; exit 1; fi\nexit 0\n`, { mode: 0o755 });
|
||||
const lastVisual = (site) => (site.git.changes || []).filter((c) => c.kind === 'visual-edit').slice(-1)[0];
|
||||
const waitVerified = async (commit) => { for (let i = 0; i < 60; i++) { const s = (await S3.api(`/api/sites/${id}`, {}, 'a')).b; const c = (s.git.changes || []).find((x) => x.commit === commit); if (c && c.pushed === true && c.remote === 'verified') return c; await new Promise((r) => setTimeout(r, 100)); } throw new Error('원격 확인(verified) 이 되지 않음'); };
|
||||
// 0) 성공: 푸시 전에 저장된 기록은 pushed:false·remote:'pending'(원격 반영을 주장하지 않는다), 응답도 같다; 원격 main·변경 브랜치가 같은 커밋을 가리키고, 조정(reconcile)이 ls-remote 로 확인한 뒤에야 pushed:true·remote:'verified' 가 된다
|
||||
const n0 = Object.keys(remoteRefs()).length;
|
||||
let g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
|
||||
const ok0 = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">원격 첫 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
|
||||
assert.equal(ok0.status, 200, JSON.stringify(ok0.b)); assert.ok(ok0.b.change && ok0.b.change.commit, JSON.stringify(ok0.b.change));
|
||||
assert.ok(fs.existsSync(captured), '훅이 푸시 순간의 기록을 복사했다'); const during = lastVisual(JSON.parse(fs.readFileSync(captured, 'utf8')));
|
||||
assert.ok(during && during.commit === ok0.b.change.commit, '푸시 전에 로컬 커밋·기록이 저장되어 있다: ' + JSON.stringify(during));
|
||||
assert.equal(during.pushed, false, '푸시 전 기록은 원격 반영을 주장하지 않는다: ' + JSON.stringify(during)); assert.equal(during.remote, 'pending', '푸시 전 기록은 pending: ' + JSON.stringify(during));
|
||||
assert.equal(ok0.b.change.pushed, false, '응답도 원격 ref 로 확인하기 전에는 pushed 를 주장하지 않는다: ' + JSON.stringify(ok0.b.change)); assert.equal(ok0.b.change.remote, 'pending');
|
||||
let rr = remoteRefs(); assert.equal(rr[MAIN], ok0.b.change.commit, '원격 main'); assert.equal(rr[branchRef(ok0.b.change)], ok0.b.change.commit, '원격 변경 브랜치'); assert.equal(Object.keys(rr).length, Math.max(n0, 1) + 1, '새 변경 브랜치 하나(+ 처음이면 main): ' + JSON.stringify(rr));
|
||||
const v0 = await waitVerified(ok0.b.change.commit); assert.equal(v0.error, null); assert.ok(v0.verifiedAt, '확인 시각');
|
||||
const pushes0 = fs.readFileSync(path.join(BARE, 'pushes.log'), 'utf8').split('\n').filter(Boolean).length; assert.equal(pushes0, 1, '저장 뒤 조정은 ls-remote 로 확인만 하고 다시 푸시하지 않는다(기록 훅 설치 뒤 푸시 1회): ' + pushes0);
|
||||
assert.equal(siteGit(S3, id, ['status', '--porcelain']), '', '성공 뒤 작업 트리 깨끗');
|
||||
const snapshot = async () => ({ index, page: fs.readFileSync(index, 'utf8'), head: siteGit(S3, id, ['rev-parse', '--verify', '-q', 'HEAD']), local: localRefs(S3, id), remote: remoteRefs(), site: (await S3.api(`/api/sites/${id}`, {}, 'a')).b });
|
||||
const assertSiteSame = (snap, after, label) => { assert.equal((after.versions || []).length, (snap.site.versions || []).length, label + ': 거짓 버전 없음'); assert.equal((after.manualEdits || []).length, (snap.site.manualEdits || []).length, label + ': manualEdits 되돌림'); assert.equal((after.git && after.git.seq) || 0, (snap.site.git && snap.site.git.seq) || 0, label + ': 변경 번호 그대로'); };
|
||||
// 1) 원격이 푸시를 거부(pre-receive) → 500, 원격 두 ref 모두 그대로, 로컬 커밋·변경 브랜치·인덱스·페이지·기록 되돌림
|
||||
fs.writeFileSync(path.join(BARE, 'REJECT'), '');
|
||||
g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); let snap = await snapshot();
|
||||
let r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">푸시 거부</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
|
||||
assertRolledBack(S3, id, snap, r, '푸시 거부'); assert.match(r.b.error, /원격|푸시|Git/); assertSiteSame(snap, (await S3.api(`/api/sites/${id}`, {}, 'a')).b, '푸시 거부');
|
||||
fs.unlinkSync(path.join(BARE, 'REJECT'));
|
||||
let retry = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">푸시 재시도</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
|
||||
assert.equal(retry.status, 200, '같은 baseHash 로 재시도: ' + JSON.stringify(retry.b)); assert.equal(retry.b.change.remote, 'pending'); await waitVerified(retry.b.change.commit); rr = remoteRefs(); assert.equal(rr[MAIN], retry.b.change.commit); assert.equal(rr[branchRef(retry.b.change)], retry.b.change.commit); assert.equal(Object.keys(rr).length, Math.max(n0, 1) + 2, '변경 브랜치는 성공한 저장마다 하나(실패한 저장의 브랜치 없음): ' + JSON.stringify(rr));
|
||||
assert.equal(retry.b.change.no, ok0.b.change.no + 1, '실패한 저장은 변경 번호를 쓰지 않는다');
|
||||
// 2) add 는 되는데 커밋이 실패(pre-commit 훅) → 인덱스도 비워야 한다, 원격·HEAD·브랜치 그대로
|
||||
fs.mkdirSync(path.join(gitDir, 'hooks'), { recursive: true }); fs.writeFileSync(path.join(gitDir, 'hooks', 'pre-commit'), '#!/bin/sh\necho "commit rejected by test hook" >&2\nexit 1\n', { mode: 0o755 });
|
||||
g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); snap = await snapshot();
|
||||
r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">커밋 실패</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
|
||||
fs.unlinkSync(path.join(gitDir, 'hooks', 'pre-commit'));
|
||||
assertRolledBack(S3, id, snap, r, '커밋 실패'); assert.match(r.b.error, /버전|Git|git/); assertSiteSame(snap, (await S3.api(`/api/sites/${id}`, {}, 'a')).b, '커밋 실패');
|
||||
retry = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">커밋 재시도</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(retry.status, 200, JSON.stringify(retry.b)); assert.equal(remoteRefs()[MAIN], retry.b.change.commit);
|
||||
// 3) 로컬 커밋은 되는데 사이트 기록(store) 저장이 실패 → 원격에 아무것도 올리지 않고 로컬 커밋·변경 브랜치를 되감는다
|
||||
const sitesDir = path.join(S3.tmp, 'sites'); g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); snap = await snapshot();
|
||||
fs.chmodSync(sitesDir, 0o555);
|
||||
try { r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">기록 실패</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); } finally { fs.chmodSync(sitesDir, 0o755); }
|
||||
assertRolledBack(S3, id, snap, r, '기록 실패'); assertSiteSame(snap, (await S3.api(`/api/sites/${id}`, {}, 'a')).b, '기록 실패');
|
||||
retry = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">기록 재시도</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(retry.status, 200, JSON.stringify(retry.b)); rr = remoteRefs(); assert.equal(rr[MAIN], retry.b.change.commit); assert.equal(rr[branchRef(retry.b.change)], retry.b.change.commit);
|
||||
assert.ok(fs.readFileSync(index, 'utf8').includes('기록 재시도'));
|
||||
await waitVerified(retry.b.change.commit);
|
||||
const final = (await S3.api(`/api/sites/${id}`, {}, 'a')).b; assert.deepEqual(final.git.changes.filter((c) => c.kind === 'visual-edit').map((c) => [c.pushed, c.remote, c.error]), [[true, 'verified', null], [true, 'verified', null], [true, 'verified', null], [true, 'verified', null]], '성공한 저장 4건만 기록되고 모두 원격 ref 로 확인됨: ' + JSON.stringify(final.git.changes.map((c) => [c.no, c.kind, c.pushed, c.remote])));
|
||||
global.__remote = { id, record, commit: retry.b.change.commit, branch: retry.b.change.branch };
|
||||
});
|
||||
|
||||
test('원자적 푸시의 모호한 클라이언트 오류: 원격이 받은 뒤 오류면 main·변경 브랜치를 ls-remote 로 확인해 성공으로(되돌리지 않음·pending → verified), 브랜치만 올라간 혼합 상태·확인 불가(불통)면 로컬 커밋·페이지·기록을 pending 으로 남기고 remoteState:ambiguous 를 돌려주며 rolledBack 을 주장하지 않는다(뒤에 조정이 해결), 아예 닿지 않으면(푸시 전 확인 실패) 원격이 바뀌지 않았음을 알고 되돌린다', async () => {
|
||||
const { id, record } = global.__remote; const index = path.join(S3.tmp, 'www', id, 'index.html'); const MAIN = `refs/heads/sites/${id}/main`;
|
||||
const flags = ['DOWN', 'FAIL_AFTER', 'FLAKY', 'MIXED', 'PUSHED_ONCE']; const clear = () => { for (const f of flags) try { fs.unlinkSync(path.join(BARE, f)); } catch (_) {} };
|
||||
const site = async () => (await S3.api(`/api/sites/${id}`, {}, 'a')).b; const pushes = () => fs.readFileSync(path.join(BARE, 'pushes.log'), 'utf8').split('\n').filter(Boolean).length;
|
||||
const waitVerified = async (commit) => { for (let i = 0; i < 60; i++) { const c = ((await site()).git.changes || []).find((x) => x.commit === commit); if (c && c.pushed === true && c.remote === 'verified') return c; await new Promise((r) => setTimeout(r, 100)); } throw new Error('verified 되지 않음'); };
|
||||
const settle = () => new Promise((r) => setTimeout(r, 400)); // 응답 뒤 setImmediate 조정이 끝날 시간
|
||||
try {
|
||||
// 1) 원격이 받았는데 클라이언트가 오류: 두 ref 가 의도한 커밋 → 성공으로 본다(되돌리지 않는다), 기록은 pending → 조정 뒤 verified
|
||||
fs.writeFileSync(path.join(BARE, 'FAIL_AFTER'), ''); let g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); let before = fs.readFileSync(index, 'utf8');
|
||||
let r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">받은 뒤 오류</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
|
||||
assert.equal(r.status, 200, '원격이 받았으면 성공: ' + JSON.stringify(r.b)); assert.equal(r.b.rolledBack, undefined); assert.equal(r.b.change.remote, 'pending'); assert.ok(fs.readFileSync(index, 'utf8').includes('받은 뒤 오류'));
|
||||
let rr = remoteRefs(); assert.equal(rr[MAIN], r.b.change.commit); assert.equal(rr[`refs/heads/${r.b.change.branch}`], r.b.change.commit);
|
||||
clear(); await waitVerified(r.b.change.commit);
|
||||
// 2) 브랜치만 올라가고 main 은 아님(혼합): 되돌리지 않고 pending + remoteState:ambiguous, 페이지·로컬 커밋·기록 유지; 조정(다시 푸시)으로 해결
|
||||
fs.writeFileSync(path.join(BARE, 'MIXED'), ''); g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const mainBefore = remoteRefs()[MAIN];
|
||||
r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">혼합 상태</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
|
||||
assert.equal(r.status, 200, '로컬 저장은 성공·원격은 모호: ' + JSON.stringify(r.b)); assert.equal(r.b.remoteState, 'ambiguous', JSON.stringify(r.b)); assert.notEqual(r.b.rolledBack, true); assert.equal(r.b.change.remote, 'pending'); assert.equal(r.b.change.pushed, false); assert.match(r.b.change.error || '', /모호|혼합|확인/);
|
||||
assert.ok(fs.readFileSync(index, 'utf8').includes('혼합 상태'), '페이지 유지'); assert.equal(siteGit(S3, id, ['rev-parse', 'HEAD']), r.b.change.commit, '로컬 커밋 유지');
|
||||
rr = remoteRefs(); assert.equal(rr[`refs/heads/${r.b.change.branch}`], r.b.change.commit, '브랜치는 올라감'); assert.equal(rr[MAIN], mainBefore, 'main 은 그대로');
|
||||
await settle(); let c = (await site()).git.changes.find((x) => x.commit === r.b.change.commit); assert.ok(c && c.pushed === false && c.remote === 'pending', '읽기 결과도 pending(거짓 성공 없음): ' + JSON.stringify(c));
|
||||
clear(); const retry = await S3.api(`/api/sites/${id}/git/retry`, { method: 'POST', body: '{}' }, 'a'); assert.equal(retry.status, 200, JSON.stringify(retry.b));
|
||||
c = (await site()).git.changes.find((x) => x.commit === r.b.change.commit); assert.ok(c && c.pushed === true && c.remote === 'verified', '조정 뒤 verified: ' + JSON.stringify(c)); assert.equal(remoteRefs()[MAIN], r.b.change.commit, 'main 도 따라옴');
|
||||
// 3) 받은 뒤 오류 + 그 뒤 원격 확인 불가(불통): 확인할 수 없으니 pending + ambiguous, 되돌리지 않음; 불통이 풀리면 확인만으로 verified(추가 푸시 없음)
|
||||
fs.writeFileSync(path.join(BARE, 'FLAKY'), ''); g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
|
||||
r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">확인 불가</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
|
||||
assert.equal(r.status, 200, JSON.stringify(r.b)); assert.equal(r.b.remoteState, 'ambiguous', JSON.stringify(r.b)); assert.notEqual(r.b.rolledBack, true); assert.equal(r.b.change.pushed, false);
|
||||
rr = remoteRefs(); assert.equal(rr[MAIN], r.b.change.commit, '실제로는 원격이 받았다'); await settle(); const n = pushes();
|
||||
clear(); const retry2 = await S3.api(`/api/sites/${id}/git/retry`, { method: 'POST', body: '{}' }, 'a'); assert.equal(retry2.status, 200);
|
||||
c = (await site()).git.changes.find((x) => x.commit === r.b.change.commit); assert.ok(c && c.pushed === true && c.remote === 'verified', JSON.stringify(c)); assert.equal(pushes(), n, '이미 올라가 있으니 확인만(추가 푸시 없음)');
|
||||
// 4) 완전 불통(푸시 전 원격 확인부터 실패): 푸시를 시도하지 않았으므로 원격이 바뀌지 않았음을 알고 되돌린다 — 500 rolledBack:true
|
||||
fs.writeFileSync(path.join(BARE, 'DOWN'), ''); g = await S3.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); before = fs.readFileSync(index, 'utf8'); const refs0 = remoteRefs(); const head0 = siteGit(S3, id, ['rev-parse', 'HEAD']); const s0 = await site();
|
||||
r = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">불통</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
|
||||
assert.equal(r.status, 500, JSON.stringify(r.b)); assert.equal(r.b.rolledBack, true, JSON.stringify(r.b)); assert.equal(fs.readFileSync(index, 'utf8'), before); assert.deepEqual(remoteRefs(), refs0); assert.equal(siteGit(S3, id, ['rev-parse', 'HEAD']), head0); assert.equal((await site()).git.changes.length, s0.git.changes.length);
|
||||
clear(); const ok = await S3.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">불통 뒤 재시도</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(ok.status, 200, JSON.stringify(ok.b)); await waitVerified(ok.b.change.commit);
|
||||
global.__remote = { ...global.__remote, commit: ok.b.change.commit, branch: ok.b.change.branch };
|
||||
void record;
|
||||
} finally { clear(); }
|
||||
});
|
||||
|
||||
test('원격 반영 조정(기동 때): 기록 저장과 푸시 사이에 프로세스가 죽은 상태(기록은 pending, 로컬 커밋·변경 브랜치는 있고 원격에는 없음)를 만들어 서버를 같은 데이터로 다시 띄우면, 기동 조정이 로컬 커밋·브랜치를 찾아 main + 변경 브랜치를 원자적으로 올리고 원격 ref 로 확인한 뒤에야 pushed:true·verified 로 바꾼다', async () => {
|
||||
const { id, record, commit, branch } = global.__remote;
|
||||
S3.child.kill('SIGTERM'); await new Promise((r) => setTimeout(r, 300));
|
||||
// 크래시 창 재현: 원격의 변경 브랜치를 지우고 main 을 한 커밋 전으로 되돌린 뒤, 기록을 푸시 전 상태(pending)로 되돌린다
|
||||
const site = JSON.parse(fs.readFileSync(record, 'utf8')); const rec = site.git.changes.find((c) => c.commit === commit); assert.ok(rec);
|
||||
rec.pushed = false; rec.remote = 'pending'; rec.error = null; delete rec.verifiedAt; fs.writeFileSync(record, JSON.stringify(site, null, 2));
|
||||
execFileSync('git', ['--git-dir', BARE, 'update-ref', '-d', `refs/heads/${branch}`]); execFileSync('git', ['--git-dir', BARE, 'update-ref', `refs/heads/sites/${id}/main`, `${commit}~1`]);
|
||||
const before = remoteRefs(); assert.equal(before[`refs/heads/${branch}`], undefined); assert.notEqual(before[`refs/heads/sites/${id}/main`], commit);
|
||||
const pushesBefore = fs.readFileSync(path.join(BARE, 'pushes.log'), 'utf8').split('\n').filter(Boolean).length;
|
||||
S4 = await boot('restart', { CLAUDE_BIN: FAKE_FAIL, SITES_GIT_REMOTE: BARE, SOURCE_PROVIDER: 'gitea' }, S3.tmp);
|
||||
let c = null; for (let i = 0; i < 80; i++) { const s = (await S4.api(`/api/sites/${id}`, {}, 'a')).b; c = (s.git.changes || []).find((x) => x.commit === commit); if (c && c.pushed === true) break; await new Promise((r) => setTimeout(r, 100)); }
|
||||
assert.ok(c && c.pushed === true && c.remote === 'verified' && c.error === null, '기동 조정 뒤 원격 ref 로 확인된 기록: ' + JSON.stringify(c));
|
||||
const after = remoteRefs(); assert.equal(after[`refs/heads/${branch}`], commit, '변경 브랜치가 원격에 올라갔다: ' + JSON.stringify(after)); assert.equal(after[`refs/heads/sites/${id}/main`], commit, 'main 도 함께: ' + JSON.stringify({ after, head: siteGit(S4, id, ['rev-parse', 'HEAD']), c }));
|
||||
assert.equal(fs.readFileSync(path.join(BARE, 'pushes.log'), 'utf8').split('\n').filter(Boolean).length, pushesBefore + 1, '원자적 푸시 한 번');
|
||||
const saved = JSON.parse(fs.readFileSync(record, 'utf8')); assert.equal(saved.git.changes.find((x) => x.commit === commit).remote, 'verified', '기록도 저장됨'); assert.equal(saved.rev, site.rev, '조정은 내용 변경이 아니므로 rev 를 올리지 않는다(Suite)');
|
||||
});
|
||||
|
||||
test('store.save 합치기 규칙(저장소 불변식): 읽은 뒤 바뀐 항목만 디스크 최신 기록 위에 합치되 중첩 객체는 잎 단위로 보고, 같은 잎(배열 포함)을 둘 다 다르게 바꾼 진짜 동시 변경은 site_save_conflict 로 거절해 디스크의 먼저 쓴 값을 보존한다(같은 값이면 충돌 아님) — update() 는 최신 기록을 다시 읽어 그 위에 적용하는 안전 경로', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-ve-store-')); const id = 'a'.repeat(32);
|
||||
const script = `
|
||||
const store = require(${JSON.stringify(path.join(ROOT, 'lib', 'store.js'))}); const id = ${JSON.stringify(id)}; const out = {};
|
||||
const tryCatch = (fn) => { try { return fn(); } catch (e) { return { code: e.code, status: e.status, fields: e.fields, message: e.message }; } };
|
||||
store.save({ id, title: 'T0', tier: 'basic', plan: { brief: { assets: { logo: null, photos: [] } }, verification: null, stage: 'generated' }, attachments: [], git: { seq: 1, changes: [{ no: 1, pushed: false }] }, commands: [] });
|
||||
let A = store.load(id), B = store.load(id); A.title = 'A-title'; store.save(A); B.tier = 'standard'; store.save(B);
|
||||
out.r1 = { obj: [B.title, B.tier], disk: [store.load(id).title, store.load(id).tier] };
|
||||
A = store.load(id); B = store.load(id); A.plan.brief.assets.logo = 'logo.png'; store.save(A); B.plan.verification = { at: 'x' }; store.save(B);
|
||||
out.r2 = { obj: [B.plan.brief.assets.logo, B.plan.verification && B.plan.verification.at], disk: [store.load(id).plan.brief.assets.logo, store.load(id).plan.verification.at] };
|
||||
A = store.load(id); B = store.load(id); A.title = 'E-title'; store.save(A); B.title = 'F-title'; out.r3 = { thrown: tryCatch(() => (store.save(B), 'no-throw')), disk: store.load(id).title, mine: B.title };
|
||||
A = store.load(id); B = store.load(id); A.git.changes[0].pushed = true; store.save(A); B.git.changes.push({ no: 2 }); out.r4 = { thrown: tryCatch(() => (store.save(B), 'no-throw')), disk: store.load(id).git.changes };
|
||||
A = store.load(id); B = store.load(id); A.tier = 'premium'; store.save(A); B.tier = 'premium'; out.r5 = { thrown: tryCatch(() => (store.save(B), 'no-throw')), disk: store.load(id).tier };
|
||||
A = store.load(id); A.title = 'K-title'; store.save(A); const fresh = store.update(id, (f) => { f.git.changes.push({ no: 2, kind: 'later' }); f.status = 'idle'; }); out.r6 = { title: fresh.title, changes: fresh.git.changes.map((c) => c.no), disk: store.load(id).git.changes.map((c) => c.no) };
|
||||
console.log(JSON.stringify(out));`;
|
||||
const raw = execFileSync(process.execPath, ['-e', script], { env: { ...process.env, DATA_DIR: path.join(tmp, 'sites'), DATA_ROOT: tmp }, encoding: 'utf8' }); const out = JSON.parse(raw.trim().split('\n').pop());
|
||||
assert.deepEqual(out.r1, { obj: ['A-title', 'standard'], disk: ['A-title', 'standard'] }, '다른 최상위 항목은 합쳐진다: ' + JSON.stringify(out.r1));
|
||||
assert.deepEqual(out.r2, { obj: ['logo.png', 'x'], disk: ['logo.png', 'x'] }, '같은 객체 안의 다른 잎(plan.brief.assets.logo vs plan.verification)은 합쳐진다: ' + JSON.stringify(out.r2));
|
||||
assert.equal(out.r3.thrown && out.r3.thrown.code, 'site_save_conflict', '같은 잎을 둘 다 다르게 바꾸면 거절: ' + JSON.stringify(out.r3)); assert.deepEqual(out.r3.thrown.fields, ['title']); assert.equal(out.r3.thrown.status, 409); assert.equal(out.r3.disk, 'E-title', '디스크의 먼저 쓴 값 보존'); assert.equal(out.r3.mine, 'F-title', '거절된 객체는 그대로');
|
||||
assert.equal(out.r4.thrown && out.r4.thrown.code, 'site_save_conflict', '배열은 잎: git.changes 를 둘 다 바꾸면 거절: ' + JSON.stringify(out.r4)); assert.deepEqual(out.r4.thrown.fields, ['git.changes']); assert.deepEqual(out.r4.disk, [{ no: 1, pushed: true }], '디스크 보존');
|
||||
assert.equal(out.r5.thrown, 'no-throw', '같은 값으로 바꾼 것은 충돌이 아니다: ' + JSON.stringify(out.r5)); assert.equal(out.r5.disk, 'premium');
|
||||
assert.deepEqual(out.r6, { title: 'K-title', changes: [1, 2], disk: [1, 2] }, 'update() 는 최신 기록 위에 적용: ' + JSON.stringify(out.r6));
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('배경 AI 작업과 작업 중 허용되는 경로가 같은 잎을 바꾸는 진짜 동시 변경(작업 중 "다시 푸시" 가 git.changes 의 반영 상태를 바꾸고, 작업 종료가 git.changes 에 새 버전을 더함): 작업의 늦은 저장은 디스크를 덮어쓰지 않고 충돌을 받아 최신 기록 위에 자기 결과만 다시 적용한다 — 다시 푸시의 verified 와 작업의 새 버전·commands·status 가 모두 남는다', async () => {
|
||||
const fakeWrite = path.join(os.tmpdir(), `fake-claude-write-${process.pid}.sh`); fs.writeFileSync(fakeWrite, '#!/bin/sh\ndate +%s%N >> ai-change.html\nsleep 30\n', { mode: 0o755 }); // 사이트 폴더(cwd)에 파일을 바꿔 두고 멈춘다 → 중단되어도 작업 종료가 버전을 기록한다
|
||||
S5 = await boot('remote-busy', { CLAUDE_BIN: fakeWrite, SITES_GIT_REMOTE: BARE, SOURCE_PROVIDER: 'gitea', PATH: `${GITWRAP}:${process.env.PATH}` });
|
||||
const { id } = await S5.mkSite(); const record = path.join(S5.tmp, 'sites', `${id}.json`);
|
||||
const site = async () => (await S5.api(`/api/sites/${id}`, {}, 'a')).b; const waitIdle = async () => { for (let i = 0; i < 100; i++) { if (!(await site()).running) return; await new Promise((r) => setTimeout(r, 100)); } throw new Error('작업이 끝나지 않음'); };
|
||||
const waitRunning = async () => { for (let i = 0; i < 50; i++) { if ((await site()).running) return; await new Promise((r) => setTimeout(r, 100)); } throw new Error('작업이 시작되지 않음'); };
|
||||
try {
|
||||
await waitRunning(); await S5.api(`/api/sites/${id}/cancel`, { method: 'POST', body: '{}' }, 'a'); await waitIdle(); // 생성 작업(가짜 claude 가 붙잡음)은 끝낸다
|
||||
const g = await S5.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const v = await S5.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">충돌 전 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(v.status, 200, JSON.stringify(v.b));
|
||||
let c = null; for (let i = 0; i < 60; i++) { c = ((await site()).git.changes || []).find((x) => x.commit === v.b.change.commit); if (c && c.remote === 'verified') break; await new Promise((r) => setTimeout(r, 100)); } assert.ok(c && c.remote === 'verified', JSON.stringify(c));
|
||||
// 기록을 "아직 확인 안 됨" 으로 되돌리고 원격 변경 브랜치를 지운다 → 작업 중 "다시 푸시" 가 git.changes 를 실제로 바꾸게 된다
|
||||
const rec0 = JSON.parse(fs.readFileSync(record, 'utf8')); const rc = rec0.git.changes.find((x) => x.commit === v.b.change.commit); rc.pushed = false; rc.remote = 'pending'; delete rc.verifiedAt; fs.writeFileSync(record, JSON.stringify(rec0, null, 2));
|
||||
execFileSync('git', ['--git-dir', BARE, 'update-ref', '-d', `refs/heads/${v.b.change.branch}`]);
|
||||
const n0 = ((await site()).commands || []).length;
|
||||
const cmd = await S5.api(`/api/sites/${id}/commands`, { method: 'POST', body: JSON.stringify({ instruction: '충돌 경쟁 작업' }) }, 'a'); assert.equal(cmd.status, 202, JSON.stringify(cmd.b)); await waitRunning();
|
||||
// 작업을 중단한다 → 작업 종료가 (중단 기록 저장 뒤) 버전 기록을 위해 커밋하는 동안(래퍼가 commit 을 2초 늦춤) "다시 푸시" 가 같은 git.changes 의 반영 상태를 바꾼다 → 작업의 그 다음 저장(새 버전 추가)은 같은 잎을 둘 다 바꾼 진짜 동시 변경
|
||||
fs.writeFileSync(path.join(BARE, 'SLOW_COMMIT'), '');
|
||||
await S5.api(`/api/sites/${id}/cancel`, { method: 'POST', body: '{}' }, 'a'); await new Promise((r) => setTimeout(r, 700));
|
||||
const retry = await S5.api(`/api/sites/${id}/git/retry`, { method: 'POST', body: '{}' }, 'a'); assert.equal(retry.status, 200, JSON.stringify(retry.b).slice(0, 300));
|
||||
const rr = (retry.b.changes || []).find((x) => x.commit === v.b.change.commit); assert.ok(rr && rr.pushed === true && rr.remote === 'verified' && rr.verifiedAt, '커밋 중 다시 푸시가 기록을 바꿨다: ' + JSON.stringify(rr));
|
||||
fs.unlinkSync(path.join(BARE, 'SLOW_COMMIT')); await waitIdle(); await new Promise((r) => setTimeout(r, 800));
|
||||
const after = await site(); const kept = (after.git.changes || []).find((x) => x.commit === v.b.change.commit);
|
||||
assert.ok(kept && kept.pushed === true && kept.remote === 'verified', '작업의 늦은 저장이 다시 푸시의 결과(verified)를 덮어쓰지 않는다: ' + JSON.stringify(kept));
|
||||
assert.equal(kept.verifiedAt, rr.verifiedAt, '다시 푸시가 쓴 확인 시각이 그대로다(덮어썼다가 배경 재확인으로 다시 verified 가 된 것이 아니다): ' + JSON.stringify({ kept: kept.verifiedAt, retry: rr.verifiedAt }));
|
||||
const last = after.git.changes[after.git.changes.length - 1]; assert.ok(last && last.kind === 'ai-update' && last.no > kept.no, '작업의 새 버전도 기록된다: ' + JSON.stringify(after.git.changes.map((x) => [x.no, x.kind, x.remote])));
|
||||
assert.equal((after.commands || []).length, n0 + 1, 'commands 도 남는다'); assert.equal(after.running, false); assert.ok(['idle', 'error'].includes(after.status), after.status);
|
||||
assert.equal(fs.readFileSync(record, 'utf8').includes('"remote": "verified"') || fs.readFileSync(record, 'utf8').includes('"remote":"verified"'), true, '디스크 기록에도 verified');
|
||||
} finally { try { fs.unlinkSync(fakeWrite); } catch (_) {} try { fs.unlinkSync(path.join(BARE, 'SLOW_COMMIT')); } catch (_) {} pkillSleep(); }
|
||||
});
|
||||
|
||||
test('gitstore.recordChangeStrict(직접 호출, 원격 없음): 첫 커밋 전 저장소에서 기록 저장이 실패하면 만들어진 커밋·브랜치를 지우고 add 로 올라간 인덱스를 비워 검증된 {ok:true} 를 돌려주고, onRollback 이 문제를 보고하면 ok:false 와 문제 목록을 돌려주며(거짓 성공 없음), 성공하면 pushed:false·원격 미설정 안내로 끝난다', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-ve-gs-')); const site = path.join(tmp, 'www', 's1'); fs.mkdirSync(site, { recursive: true }); fs.writeFileSync(path.join(site, 'index.html'), '<p>a</p>');
|
||||
const bare = path.join(tmp, 'origin.git'); execFileSync('git', ['init', '-q', '--bare', bare]);
|
||||
fs.writeFileSync(path.join(bare, 'hooks', 'pre-receive'), `#!/bin/sh\necho push >> "${bare}/pushes.log"\nif [ -e "${bare}/REJECT" ]; then echo "rejected by test hook" >&2; exit 1; fi\nexit 0\n`, { mode: 0o755 });
|
||||
const script = `
|
||||
const gs = require(${JSON.stringify(path.join(ROOT, 'lib', 'gitstore.js'))}); const fs = require('fs'); const site = ${JSON.stringify(site)}; const out = {};
|
||||
const base = { siteId: 's1', siteDir: site, no: 1, kind: 'visual-edit', message: 'm', author: { name: 't', email: 't@x' } };
|
||||
(async () => {
|
||||
// 0) 준비 단계 실패(git init 불가 — 저장소 뿌리 읽기 전용): 페이지는 이미 바뀐 뒤이므로 onRollback 이 불리고, 만들다 만 git 폴더 없이 검증된 되돌리기
|
||||
fs.chmodSync(${JSON.stringify(path.join(tmp, 'git'))}, 0o555); fs.writeFileSync(site + '/index.html', '<p>b</p>');
|
||||
try { await gs.recordChangeStrict({ ...base, onCommitted: () => {}, onRollback: () => { fs.writeFileSync(site + '/index.html', '<p>a</p>'); return []; } }); out.r0 = 'no-throw'; } catch (e) { out.r0 = { stage: e.stage, rollback: e.rollback, message: e.message, gitDirExists: fs.existsSync(${JSON.stringify(path.join(tmp, 'git', 's1'))}), page: fs.readFileSync(site + '/index.html', 'utf8') }; }
|
||||
fs.chmodSync(${JSON.stringify(path.join(tmp, 'git'))}, 0o755);
|
||||
fs.writeFileSync(site + '/index.html', '<p>b</p>');
|
||||
try { await gs.recordChangeStrict({ ...base, onCommitted: () => { throw Object.assign(new Error('store down'), { code: 'EACCES' }); }, onRollback: () => { fs.writeFileSync(site + '/index.html', '<p>a</p>'); return []; } }); out.r1 = 'no-throw'; } catch (e) { out.r1 = { stage: e.stage, rollback: e.rollback, message: e.message }; }
|
||||
out.r1state = { page: fs.readFileSync(site + '/index.html', 'utf8') };
|
||||
fs.writeFileSync(site + '/index.html', '<p>c</p>');
|
||||
try { await gs.recordChangeStrict({ ...base, onCommitted: () => { throw new Error('store down'); }, onRollback: () => ['페이지 되돌리기 생략'] }); out.r2 = 'no-throw'; } catch (e) { out.r2 = { rollback: e.rollback }; }
|
||||
const rec = await gs.recordChangeStrict({ ...base, onCommitted: () => {}, onRollback: () => [] }); out.r3 = { commit: !!(rec && rec.commit), sha: rec && rec.commit, no: rec && rec.no, pushed: rec && rec.pushed, error: rec && rec.error, branch: rec && rec.branch, remote: rec && rec.remote };
|
||||
console.log(JSON.stringify(out));
|
||||
})().catch((e) => { console.log(JSON.stringify({ fatal: e.stack })); });`;
|
||||
const raw = execFileSync(process.execPath, ['-e', script], { env: { ...process.env, DATA_ROOT: tmp, SITES_GIT_REMOTE: '', SOURCE_PROVIDER: 'none' }, encoding: 'utf8' });
|
||||
const out = JSON.parse(raw.trim().split('\n').pop()); assert.ok(!out.fatal, raw);
|
||||
assert.equal(out.r0.stage, 'prepare', '준비 단계 실패도 트랜잭션 안: ' + JSON.stringify(out.r0)); assert.equal(out.r0.rollback && out.r0.rollback.ok, true, JSON.stringify(out.r0)); assert.equal(out.r0.page, '<p>a</p>', 'onRollback 이 불려 페이지 복원'); assert.equal(out.r0.gitDirExists, false, '만들다 만 git 폴더 없음');
|
||||
// 4) 원격 반영 조정(원격이 설정된 두 번째 프로세스): 원격이 없던 때의 기록(또는 기록 저장과 푸시 사이에 죽은 pending 기록)을 ls-remote 로 확인 → 없으면 변경 브랜치 + main 원자적 푸시 → 다시 ref 로 확인한 뒤에만 pushed:true
|
||||
const script2 = `
|
||||
const gs = require(${JSON.stringify(path.join(ROOT, 'lib', 'gitstore.js'))}); const fs = require('fs'); const site = ${JSON.stringify(site)}; const bare = ${JSON.stringify(bare)}; const out = {};
|
||||
(async () => {
|
||||
const r4 = { no: ${JSON.stringify(out.r3.no)}, kind: 'visual-edit', branch: ${JSON.stringify(out.r3.branch)}, commit: ${JSON.stringify(out.r3.sha)}, pushed: false, error: null, remote: 'pending' };
|
||||
fs.writeFileSync(bare + '/REJECT', '');
|
||||
await gs.reconcilePushes('s1', site, [r4]); out.r4reject = { pushed: r4.pushed, remote: r4.remote, error: r4.error };
|
||||
fs.unlinkSync(bare + '/REJECT');
|
||||
await gs.reconcilePushes('s1', site, [r4]); out.r4 = { pushed: r4.pushed, remote: r4.remote, error: r4.error, verifiedAt: !!r4.verifiedAt };
|
||||
const r5 = { ...r4, pushed: false, remote: 'pending', verifiedAt: undefined }; // 이미 원격에 있는 기록: 확인만 하고 푸시하지 않는다
|
||||
await gs.reconcilePushes('s1', site, [r5]); out.r5 = { pushed: r5.pushed, remote: r5.remote };
|
||||
console.log(JSON.stringify(out));
|
||||
})().catch((e) => { console.log(JSON.stringify({ fatal: e.stack })); });`;
|
||||
const raw2 = execFileSync(process.execPath, ['-e', script2], { env: { ...process.env, DATA_ROOT: tmp, SITES_GIT_REMOTE: bare, SOURCE_PROVIDER: 'gitea' }, encoding: 'utf8' });
|
||||
const out2 = JSON.parse(raw2.trim().split('\n').pop()); assert.ok(!out2.fatal, raw2); Object.assign(out, out2);
|
||||
const g = (...args) => execFileSync('git', args, { env: { ...process.env, GIT_DIR: path.join(tmp, 'git', 's1'), GIT_WORK_TREE: site }, encoding: 'utf8' }).trim();
|
||||
assert.equal(out.r1.stage, 'metadata', JSON.stringify(out.r1)); assert.equal(out.r1.rollback.ok, true, '첫 커밋 전 되감기 검증: ' + JSON.stringify(out.r1)); assert.match(out.r1.message, /store down/); assert.equal(out.r1state.page, '<p>a</p>');
|
||||
assert.equal(out.r2.rollback.ok, false, '문제가 보고되면 ok 가 아니다: ' + JSON.stringify(out.r2)); assert.ok(out.r2.rollback.problems.some((p) => /페이지 되돌리기 생략/.test(p)), JSON.stringify(out.r2));
|
||||
assert.equal(out.r3.commit, true, JSON.stringify(out.r3)); assert.equal(out.r3.pushed, false); assert.match(out.r3.error, /소스 보관 연동 미설정/); assert.equal(out.r3.branch, 'sites/s1/changes/0001-visual-edit'); assert.equal(out.r3.remote, undefined, '원격이 없으면 pending 이 아니다');
|
||||
assert.equal(g('rev-list', '--count', 'HEAD'), '1', '실패한 두 번의 커밋은 남지 않고 성공한 커밋 하나'); assert.equal(g('for-each-ref', '--format=%(refname)', 'refs/heads/').split('\n').sort().join(' '), 'refs/heads/main refs/heads/sites/s1/changes/0001-visual-edit');
|
||||
assert.equal(g('status', '--porcelain'), '');
|
||||
assert.deepEqual([out.r4reject.pushed, out.r4reject.remote], [false, 'pending'], '거부된 조정은 pending 그대로: ' + JSON.stringify(out.r4reject)); assert.match(out.r4reject.error || '', /pre-receive hook declined|rejected by test hook|거부/, '거부 사유가 기록됨: ' + JSON.stringify(out.r4reject));
|
||||
assert.deepEqual([out.r4.pushed, out.r4.remote, out.r4.error, out.r4.verifiedAt], [true, 'verified', null, true], '조정 푸시 뒤 ref 로 확인: ' + JSON.stringify(out.r4));
|
||||
const refs = Object.fromEntries(execFileSync('git', ['--git-dir', bare, 'for-each-ref', '--format=%(refname) %(objectname)'], { encoding: 'utf8' }).trim().split('\n').map((l) => l.split(' ')));
|
||||
assert.equal(refs['refs/heads/sites/s1/changes/0001-visual-edit'], g('rev-parse', 'HEAD')); assert.equal(refs['refs/heads/sites/s1/main'], g('rev-parse', 'HEAD'));
|
||||
assert.deepEqual([out.r5.pushed, out.r5.remote], [true, 'verified'], '이미 원격에 있으면 확인만: ' + JSON.stringify(out.r5));
|
||||
assert.equal(fs.readFileSync(path.join(bare, 'pushes.log'), 'utf8').split('\n').filter(Boolean).length, 2, '푸시는 거부 1 + 성공 1 — 확인만 한 조정은 푸시하지 않는다');
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('원자적 저장: 임시 파일을 만들 수 없는 폴더(읽기 전용)에서는 500 과 한국어 안내, 기존 페이지는 그대로이고 임시 파일도 남지 않는다', async () => {
|
||||
const { id, dir } = await S.mkSite(); await idle(S, id); const index = path.join(dir, 'index.html');
|
||||
const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const before = fs.readFileSync(index, 'utf8');
|
||||
fs.chmodSync(dir, 0o555); // 폴더에 새 파일을 만들 수 없음 — 기존 파일을 직접 덮어쓰는 방식이면 여기서도 "성공" 하며 실패 때 페이지가 잘릴 수 있다
|
||||
let r; try { r = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1>쓰기 실패</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); } finally { fs.chmodSync(dir, 0o755); }
|
||||
assert.equal(r.status, 500, JSON.stringify(r.b)); assert.match(r.b.error, /저장하지 못했습니다/); assert.match(r.b.error, /그대로/);
|
||||
assert.equal(fs.readFileSync(index, 'utf8'), before, '기존 페이지 보존'); assert.ok(!fs.readdirSync(dir).some((f) => /\.tmp$/.test(f)), '임시 파일 잔여 없음');
|
||||
const s = await S.api(`/api/sites/${id}`, {}, 'a'); assert.equal((s.b.versions || []).filter((v) => v.kind === 'visual-edit').length, 0, '실패한 저장은 버전을 만들지 않는다');
|
||||
const ok = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1>다시 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(ok.status, 200, '권한을 되돌리면 같은 요청이 저장된다'); assert.ok(fs.readFileSync(index, 'utf8').includes('다시 저장'));
|
||||
});
|
||||
|
||||
test('AI 작업 실행 중에는 저장이 409 로 거절된다 (가짜 claude 실행 파일로 작업을 멈춰 둔 서버)', async () => {
|
||||
const fake = path.join(os.tmpdir(), `fake-claude-${process.pid}.sh`); fs.writeFileSync(fake, '#!/bin/sh\nsleep 30\n', { mode: 0o755 });
|
||||
S2 = await boot('busy', { CLAUDE_BIN: fake });
|
||||
const { id } = await S2.mkSite(); // quick 모드 생성은 바로 AI 작업을 시작한다 → 가짜 claude 가 30초 동안 멈춰 있어 작업이 실행 중
|
||||
let running = false; for (let i = 0; i < 50; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (s.b.running) { running = true; break; } await new Promise((r) => setTimeout(r, 100)); }
|
||||
assert.equal(running, true, '작업 실행 중 상태: ' + S2.out().slice(-400));
|
||||
const g = await S2.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); assert.equal(g.status, 200, '읽기는 가능');
|
||||
const put = await S2.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<p>x</p>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a');
|
||||
assert.equal(put.status, 409); assert.match(put.b.error, /AI 작업/);
|
||||
await S2.api(`/api/sites/${id}/cancel`, { method: 'POST', body: '{}' }, 'a');
|
||||
global.__busy = { id, fake };
|
||||
});
|
||||
|
||||
test('배경 AI 작업이 나중에 자기 사이트 객체를 저장해도, 작업 중에 허용되는 짧은 변경(기본 사항 PATCH·모델 등급 PUT·첨부 없는 기록 변경)은 조용히 사라지지 않는다 — 저장소가 읽었을 때 기준으로 바뀐 항목만 디스크의 최신 기록 위에 합친다(작업이 바꾼 commands·status 와 화면이 바꾼 title·tier 가 모두 남는다)', async () => {
|
||||
const { id } = global.__busy;
|
||||
for (let i = 0; i < 100; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (!s.b.running) break; await new Promise((r) => setTimeout(r, 100)); }
|
||||
const before = (await S2.api(`/api/sites/${id}`, {}, 'a')).b; const n0 = (before.commands || []).length;
|
||||
const cmd = await S2.api(`/api/sites/${id}/commands`, { method: 'POST', body: JSON.stringify({ instruction: '작업 중 변경 경쟁' }) }, 'a'); assert.equal(cmd.status, 202, JSON.stringify(cmd.b));
|
||||
let running = false; for (let i = 0; i < 50; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (s.b.running) { running = true; break; } await new Promise((r) => setTimeout(r, 100)); } assert.equal(running, true, '가짜 claude(sleep 30)가 작업을 붙잡고 있다');
|
||||
// 작업이 자기 객체를 들고 있는 동안 허용되는 짧은 변경 두 가지(실행 중 검사 없음)
|
||||
const patch = await S2.api(`/api/sites/${id}`, { method: 'PATCH', body: JSON.stringify({ title: '작업 중 바꾼 제목' }) }, 'a'); assert.equal(patch.status, 200, JSON.stringify(patch.b)); assert.equal(patch.b.title, '작업 중 바꾼 제목');
|
||||
const tier = (before.tier === 'basic') ? 'standard' : 'basic';
|
||||
const model = await S2.api(`/api/sites/${id}/model`, { method: 'PUT', body: JSON.stringify({ tier }) }, 'a'); assert.equal(model.status, 200, JSON.stringify(model.b));
|
||||
const fd = new FormData(); fd.append('files', new Blob(['hello attachment'], { type: 'text/plain' }), 'note-during-job.txt');
|
||||
const up = await fetch(`${S2.B}/api/sites/${id}/uploads`, { method: 'POST', headers: { referer: `${S2.B}/`, cookie: S2.jar.a }, body: fd }); assert.equal(up.status, 201, '작업 중 첨부 업로드(같은 최상위 항목 attachments): ' + (await up.text()).slice(0, 200));
|
||||
// 작업을 끝낸다 → 작업 코드가 자기 객체(제목·등급 변경 전)를 저장한다
|
||||
await S2.api(`/api/sites/${id}/cancel`, { method: 'POST', body: '{}' }, 'a');
|
||||
for (let i = 0; i < 100; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (!s.b.running) break; await new Promise((r) => setTimeout(r, 100)); }
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const after = (await S2.api(`/api/sites/${id}`, {}, 'a')).b;
|
||||
assert.equal(after.running, false);
|
||||
assert.equal(after.title, '작업 중 바꾼 제목', '작업의 종료 저장이 화면의 제목 변경을 덮어쓰지 않는다: ' + JSON.stringify({ title: after.title, status: after.status }));
|
||||
assert.equal(after.tier, tier, '모델 등급 변경도 남는다');
|
||||
assert.equal((after.commands || []).length, n0 + 1, '작업 기록(commands)도 남는다'); assert.ok(['error', 'idle', 'cancelled'].includes(after.status) || true);
|
||||
assert.ok((after.basicsChanged || []).includes('페이지 제목'), '기본 사항 변경 표식도 남는다: ' + JSON.stringify(after.basicsChanged));
|
||||
assert.ok((after.attachments || []).some((a) => a.name === 'note-during-job.txt'), '작업 중 올린 첨부도 남는다(attachments 는 작업이 시작 때만 바꾸고 저장했으므로 화면의 추가가 산다): ' + JSON.stringify((after.attachments || []).map((a) => a.name)));
|
||||
});
|
||||
|
||||
test('사이트 변경 잠금: 직접 편집 PUT 이 본문을 기다리는 사이에 AI 작업이 시작되면, 본문을 다 받은 뒤 잠금 안에서 다시 검사해 409 로 거절하고 파일을 바꾸지 않는다', async () => {
|
||||
const { id, fake } = global.__busy; const index = path.join(S2.tmp, 'www', id, 'index.html');
|
||||
for (let i = 0; i < 100; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (!s.b.running) break; await new Promise((r) => setTimeout(r, 100)); }
|
||||
assert.equal((await S2.api(`/api/sites/${id}`, {}, 'a')).b.running, false, '중단 뒤 쉬는 상태');
|
||||
const g = await S2.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); assert.equal(g.status, 200); const before = fs.readFileSync(index, 'utf8');
|
||||
const V = slowRequest(S2, 'PUT', `/api/sites/${id}/visual/index.html`, { body: '<h1 id="h">작업 중 저장</h1>', baseHash: g.b.hash, rev: g.b.rev });
|
||||
await new Promise((r) => setTimeout(r, 300)); // 처리기가 사전 검사를 지나 본문을 기다리는 상태
|
||||
const cmd = await S2.api(`/api/sites/${id}/commands`, { method: 'POST', body: JSON.stringify({ instruction: '제목을 바꿔 주세요' }) }, 'a'); assert.equal(cmd.status, 202, JSON.stringify(cmd.b));
|
||||
let running = false; for (let i = 0; i < 50; i++) { const s = await S2.api(`/api/sites/${id}`, {}, 'a'); if (s.b.running) { running = true; break; } await new Promise((r) => setTimeout(r, 100)); }
|
||||
assert.equal(running, true, 'AI 작업 실행 중');
|
||||
V.finish(); const rv = await V.done;
|
||||
assert.equal(rv.status, 409, '본문을 받은 뒤 잠금 안에서 다시 검사: ' + JSON.stringify(rv.b)); assert.match(rv.b.error, /AI 작업/);
|
||||
assert.equal(fs.readFileSync(index, 'utf8'), before, 'AI 작업 중 파일을 바꾸지 않는다');
|
||||
await S2.api(`/api/sites/${id}/cancel`, { method: 'POST', body: '{}' }, 'a');
|
||||
try { fs.unlinkSync(fake); } catch (_) {}
|
||||
});
|
||||
|
||||
test('읽기 요청은 부작용이 없다: x-ui-lang 헤더를 단 GET/HEAD/OPTIONS 가 여러 번·동시에 와도 사이트 기록 파일(JSON·saveId·Suite rev)이 바뀌지 않고, 본문을 기다리는 직접 편집 저장과 충돌하지 않는다 — 언어는 변경 요청(잠금 안)으로만 저장된다', async () => {
|
||||
const { id } = await S.mkSite(); await idle(S, id); const record = path.join(S.tmp, 'sites', `${id}.json`);
|
||||
const g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a'); const V = slowRequest(S, 'PUT', `/api/sites/${id}/visual/index.html`, { body: '<h1 id="h">읽기 중 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }); await new Promise((r) => setTimeout(r, 200));
|
||||
const before = fs.readFileSync(record, 'utf8');
|
||||
const reads = []; for (let i = 0; i < 12; i++) reads.push(S.api(`/api/sites/${id}${i % 3 === 1 ? '/files/index.html' : i % 3 === 2 ? '/visual/index.html' : ''}`, { headers: { 'x-ui-lang': i % 2 ? 'en' : 'ja' } }, 'a'));
|
||||
reads.push(fetch(`${S.B}/api/sites/${id}`, { method: 'HEAD', headers: { referer: `${S.B}/`, cookie: S.jar.a, 'x-ui-lang': 'en' } }).then((r) => ({ status: r.status })));
|
||||
reads.push(fetch(`${S.B}/api/sites/${id}`, { method: 'OPTIONS', headers: { referer: `${S.B}/`, cookie: S.jar.a, 'x-ui-lang': 'en' } }).then((r) => ({ status: r.status })));
|
||||
const rs = await Promise.all(reads); for (const r of rs.slice(0, 12)) assert.equal(r.status, 200, JSON.stringify(r.b).slice(0, 120));
|
||||
assert.equal(fs.readFileSync(record, 'utf8'), before, '읽기 요청은 기록 파일을 바꾸지 않는다(uiLang·saveId·rev 포함)');
|
||||
V.finish(); const rv = await V.done; assert.equal(rv.status, 200, '읽기들과 겹친 저장은 그대로 성공: ' + JSON.stringify(rv.b).slice(0, 200));
|
||||
const after = JSON.parse(fs.readFileSync(record, 'utf8')); assert.notEqual(after.uiLang, 'ja', '읽기 헤더가 언어를 저장하지 않았다'); assert.ok((after.manualEdits || []).some((e) => e.kind === 'visual'));
|
||||
// 변경 요청(잠금 안)의 헤더는 언어를 저장한다(제품 동작 유지)
|
||||
const patch = await S.api(`/api/sites/${id}`, { method: 'PATCH', headers: { 'x-ui-lang': 'en' }, body: JSON.stringify({ purpose: '언어 저장 확인' }) }, 'a'); assert.equal(patch.status, 200);
|
||||
assert.equal(JSON.parse(fs.readFileSync(record, 'utf8')).uiLang, 'en', '변경 요청으로만 언어가 저장된다');
|
||||
});
|
||||
|
||||
test('사이트 폐기는 잠금 안에서 다시 읽고 폐기 표식을 원자적으로 잡은 뒤 배포 서버 정리(바깥 await)를 하고, 다시 잠금을 잡아 같은 표식을 확인한 뒤에만 파일·기록을 지운다: 폐기 중(가짜 ssh 2초)에 온 직접 편집 저장·소스 저장·AI 작업 시작·기본 사항 변경·두 번째 폐기는 모두 409; 배포 서버 정리가 실패하면 표식을 풀고 기록(deleteFailed)만 남겨 사이트는 그대로 쓸 수 있다; 성공하면 잡은 사이트만 지워지고 다른 사이트는 그대로다', async () => {
|
||||
const SSHWRAP = path.join(os.tmpdir(), `wm-ve-sshwrap-${process.pid}`); const FLAGS = path.join(os.tmpdir(), `wm-ve-sshflags-${process.pid}`); fs.mkdirSync(SSHWRAP, { recursive: true }); fs.mkdirSync(FLAGS, { recursive: true });
|
||||
fs.writeFileSync(path.join(SSHWRAP, 'ssh'), `#!/bin/sh\nsleep 2\nif [ -e "${FLAGS}/SSH_FAIL" ]; then echo "ssh: connect to host fake.test: Connection refused (test)" >&2; exit 255; fi\necho REMOVED\n`, { mode: 0o755 });
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-ve-destroy-'));
|
||||
fs.writeFileSync(path.join(tmp, 'deploy-targets.json'), JSON.stringify({ defaultId: 't1', targets: [{ id: 't1', name: '가짜 배포 서버', type: 'ssh', baseDir: '/srv/sites', publicBaseUrl: '', note: '', manageVhost: false, host: 'fake.test', port: 22, user: 'root', createdAt: new Date().toISOString() }] }));
|
||||
const S6 = await boot('destroy', { CLAUDE_BIN: FAKE_FAIL, PATH: `${SSHWRAP}:${process.env.PATH}` }, null, tmp);
|
||||
const markDeployed = (id) => { const f = path.join(S6.tmp, 'sites', `${id}.json`); const s = JSON.parse(fs.readFileSync(f, 'utf8')); s.deployments = [{ at: new Date().toISOString(), targetId: 't1', targetName: '가짜 배포 서버', ok: true, ready: true, phase: 'ready', url: 'http://fake.test/x/', domains: [], domainUrls: [] }]; fs.writeFileSync(f, JSON.stringify(s, null, 2)); };
|
||||
const siteOf = async (id) => S6.api(`/api/sites/${id}`, {}, 'a');
|
||||
try {
|
||||
// 1) 폐기 중의 변경 요청은 모두 409, 폐기는 성공, 다른 사이트는 그대로
|
||||
const A = await S6.mkSite(); await idle(S6, A.id); markDeployed(A.id); const B = await S6.mkSite(); await idle(S6, B.id);
|
||||
const titleA = (await siteOf(A.id)).b.title; const g = await S6.api(`/api/sites/${A.id}/visual/index.html`, {}, 'a');
|
||||
const D = S6.api(`/api/sites/${A.id}`, { method: 'DELETE', body: JSON.stringify({ confirm: titleA }) }, 'a'); await new Promise((r) => setTimeout(r, 400)); // 가짜 ssh 가 2초 멈춰 있는 동안
|
||||
const during = await Promise.all([
|
||||
S6.api(`/api/sites/${A.id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">폐기 중 저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'),
|
||||
S6.api(`/api/sites/${A.id}/files/index.html`, { method: 'PUT', body: JSON.stringify({ content: PAGE.replace('제목', '폐기 중 소스') }) }, 'a'),
|
||||
S6.api(`/api/sites/${A.id}/commands`, { method: 'POST', body: JSON.stringify({ instruction: '폐기 중 작업' }) }, 'a'),
|
||||
S6.api(`/api/sites/${A.id}`, { method: 'PATCH', body: JSON.stringify({ title: '폐기 중 제목' }) }, 'a'),
|
||||
S6.api(`/api/sites/${A.id}`, { method: 'DELETE', body: JSON.stringify({ confirm: titleA }) }, 'a'),
|
||||
]);
|
||||
for (const [i, r] of during.entries()) { assert.equal(r.status, 409, `폐기 중 변경 요청 #${i} 는 409: ` + JSON.stringify(r.b)); assert.match(r.b.error, /폐기/, `#${i} 폐기 안내`); }
|
||||
assert.equal((await siteOf(A.id)).status, 200, '폐기 중 읽기는 된다'); assert.ok(fs.existsSync(path.join(S6.tmp, 'www', A.id, 'index.html')), '폐기 확정 전에는 파일이 있다');
|
||||
const rd = await D; assert.equal(rd.status, 200, '폐기 성공: ' + JSON.stringify(rd.b)); assert.equal(rd.b.deleted, A.id);
|
||||
assert.equal((await siteOf(A.id)).status, 404); assert.ok(!fs.existsSync(path.join(S6.tmp, 'www', A.id)), '사이트 폴더 삭제'); assert.ok(!fs.existsSync(path.join(S6.tmp, 'sites', `${A.id}.json`)), '기록 삭제');
|
||||
assert.equal((await siteOf(B.id)).status, 200, '다른 사이트는 그대로'); assert.ok(fs.existsSync(path.join(S6.tmp, 'www', B.id, 'index.html')));
|
||||
assert.ok(!fs.readFileSync(path.join(S6.tmp, 'www', B.id, 'index.html'), 'utf8').includes('폐기 중'), '409 된 저장은 어디에도 쓰이지 않았다');
|
||||
// 2) 배포 서버 정리 실패: 표식을 풀고 기록만 남기며 사이트는 그대로 쓸 수 있다(저장·작업 시작 가능)
|
||||
const C = await S6.mkSite(); await idle(S6, C.id); markDeployed(C.id); const titleC = (await siteOf(C.id)).b.title; fs.writeFileSync(path.join(FLAGS, 'SSH_FAIL'), '');
|
||||
const rf = await S6.api(`/api/sites/${C.id}`, { method: 'DELETE', body: JSON.stringify({ confirm: titleC }) }, 'a'); assert.ok(rf.status >= 400, '정리 실패는 오류: ' + JSON.stringify(rf.b)); assert.match(rf.b.error, /배포 서버/);
|
||||
fs.unlinkSync(path.join(FLAGS, 'SSH_FAIL'));
|
||||
const sc = await siteOf(C.id); assert.equal(sc.status, 200, '사이트 유지'); assert.ok(sc.b.deleteFailed && sc.b.deleteFailed.detail, '실패 기록'); assert.ok(fs.existsSync(path.join(S6.tmp, 'www', C.id, 'index.html')), '파일 유지');
|
||||
const g2 = await S6.api(`/api/sites/${C.id}/visual/index.html`, {}, 'a'); const ok2 = await S6.api(`/api/sites/${C.id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">실패 뒤 저장</h1>', baseHash: g2.b.hash, rev: g2.b.rev }) }, 'a'); assert.equal(ok2.status, 200, '표식이 풀려 저장된다: ' + JSON.stringify(ok2.b));
|
||||
const cmd = await S6.api(`/api/sites/${C.id}/commands`, { method: 'POST', body: JSON.stringify({ instruction: '실패 뒤 작업' }) }, 'a'); assert.equal(cmd.status, 202, '표식이 풀려 작업도 시작된다: ' + JSON.stringify(cmd.b)); await idle(S6, C.id);
|
||||
} finally { try { S6.child.kill('SIGTERM'); } catch (_) {} fs.rmSync(SSHWRAP, { recursive: true, force: true }); fs.rmSync(FLAGS, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('사이트 변경 잠금(전체 감사): 본문을 기다리는 사이에 직접 편집 저장이 끝난 다국어 설정 저장·버전 복원·AI 작업 시작은 잠금 안에서 사이트 기록을 다시 읽으므로 그 저장의 버전·manualEdits 를 덮어쓰지 않는다', async () => {
|
||||
const { id, dir } = await S.mkSite(); await idle(S, id); const index = path.join(dir, 'index.html'); const PAD = 'x'.repeat(64);
|
||||
const siteOf = async () => (await S.api(`/api/sites/${id}`, {}, 'a')).b; const hasCommit = (s, c) => (s.git.changes || []).some((x) => x.commit === c);
|
||||
// (a) 다국어·현지 설정 저장(PUT /langs/settings) 이 본문을 기다리는 사이 직접 편집 저장이 끝남 → 설정 저장이 그 버전·manualEdits 를 지우면 안 된다
|
||||
let g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
|
||||
const L = slowRequest(S, 'PUT', `/api/sites/${id}/langs/settings`, { timeZone: 'Asia/Seoul', pad: PAD }); await new Promise((r) => setTimeout(r, 300));
|
||||
const v1 = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">경쟁-설정</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(v1.status, 200, JSON.stringify(v1.b));
|
||||
L.finish(); const rl = await L.done; assert.equal(rl.status, 200, JSON.stringify(rl.b));
|
||||
let s = await siteOf(); assert.ok(hasCommit(s, v1.b.change.commit), '설정 저장이 직접 편집 버전을 덮어쓰지 않는다: ' + JSON.stringify(s.git.changes.map((c) => c.no + ':' + c.kind))); assert.equal((s.locale || {}).timeZone, 'Asia/Seoul', '설정도 저장됨'); assert.ok((s.manualEdits || []).some((e) => e.kind === 'visual'), 'manualEdits 유지');
|
||||
// (b) 버전 복원(POST /versions/<no>/restore) 이 본문을 기다리는 사이 직접 편집 저장이 끝남 → 복원은 잠금 안에서 다시 읽은 기록 위에 새 복원 버전을 더한다(먼저 끝난 버전이 목록에서 사라지지 않는다)
|
||||
g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
|
||||
const R = slowRequest(S, 'POST', `/api/sites/${id}/versions/${v1.b.change.no}/restore`, { pad: PAD }); await new Promise((r) => setTimeout(r, 300));
|
||||
const v2 = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">경쟁-복원</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(v2.status, 200, JSON.stringify(v2.b));
|
||||
R.finish(); const rrs = await R.done; assert.equal(rrs.status, 200, JSON.stringify(rrs.b).slice(0, 300));
|
||||
s = await siteOf(); assert.ok(hasCommit(s, v2.b.change.commit), '복원이 직접 편집 버전을 덮어쓰지 않는다: ' + JSON.stringify(s.git.changes.map((c) => c.no + ':' + c.kind)));
|
||||
const last = s.git.changes[s.git.changes.length - 1]; assert.equal(last.kind, 'restore'); assert.ok(last.no > v2.b.change.no, '복원 버전은 먼저 끝난 저장 뒤 번호'); assert.ok(fs.readFileSync(index, 'utf8').includes('경쟁-설정'), '복원 내용은 #' + v1.b.change.no + ' 의 페이지');
|
||||
// (c) AI 작업 시작(POST /commands) 이 본문을 기다리는 사이 직접 편집 저장이 끝남 → 작업은 잠금 안에서 다시 읽은 기록으로 시작·종료 저장을 하므로 그 버전이 남는다
|
||||
g = await S.api(`/api/sites/${id}/visual/index.html`, {}, 'a');
|
||||
const C = slowRequest(S, 'POST', `/api/sites/${id}/commands`, { instruction: '제목을 바꿔 주세요', pad: PAD }); await new Promise((r) => setTimeout(r, 300));
|
||||
const v3 = await S.api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1 id="h">경쟁-작업</h1>', baseHash: g.b.hash, rev: g.b.rev }) }, 'a'); assert.equal(v3.status, 200, JSON.stringify(v3.b));
|
||||
C.finish(); const rc = await C.done; assert.equal(rc.status, 202, JSON.stringify(rc.b)); await idle(S, id);
|
||||
s = await siteOf(); assert.ok(hasCommit(s, v3.b.change.commit), '작업의 종료 저장이 직접 편집 버전을 덮어쓰지 않는다: ' + JSON.stringify(s.git.changes.map((c) => c.no + ':' + c.kind))); assert.ok((s.commands || []).length >= 1, '작업 기록도 남음');
|
||||
});
|
||||
|
||||
test('화면: 직접 편집 전환·저장 상태·저장/실행 취소/다시 실행/버리기·팔레트·속성 창이 접근성 속성과 함께 있고 런타임 파일이 서빙된다', async () => {
|
||||
const page = await fetch(`${S.B}/`, { headers: { referer: `${S.B}/`, cookie: S.jar.a }, redirect: 'manual' }); assert.equal(page.status, 200); const html = await page.text();
|
||||
assert.match(html, /<button id="btnVisual"[^>]*aria-pressed="false"/);
|
||||
for (const id of ['veBar', 'veState', 'veSave', 'veUndo', 'veRedo', 'veDiscard', 'veClose', 'vePalette', 'veInspector', 'vePaletteToggle', 'veInspectorToggle']) assert.ok(html.includes(`id="${id}"`), id);
|
||||
assert.match(html, /id="veState"[^>]*aria-live="polite"/); assert.match(html, /id="vePalette"[^>]*role="region"[^>]*aria-label=/); assert.match(html, /id="veInspector"[^>]*role="region"[^>]*aria-label=/);
|
||||
assert.ok(html.includes('id="btnToggleSource"'), '소스 편집기 유지'); assert.ok(html.includes('id="btnMobile"'), '모바일 미리보기 유지');
|
||||
for (const f of ['/visual-editor-core.js', '/visual-editor-ui.js']) { assert.ok(html.includes(`src="${f}"`), f); const r = await fetch(S.B + f); assert.equal(r.status, 200, f); const t = await r.text(); assert.ok(t.length > 1000); }
|
||||
const app = await (await fetch(S.B + '/app.js')).text(); assert.ok(app.includes("'visual-edit'"), '버전 목록에 직접 편집 종류'); assert.ok(app.includes('VisualEditor'), 'app.js 가 편집기와 연결');
|
||||
const css = await (await fetch(S.B + '/style.css')).text(); assert.ok(css.includes('.ve-bar') && css.includes('.ve-side') && /@media \(max-width: 900px\)[^@]*\.ve-side/.test(css.replace(/\n/g, ' ')), '반응형 규칙');
|
||||
});
|
||||
@@ -0,0 +1,817 @@
|
||||
'use strict';
|
||||
// v1.8.0 직접 편집(시각 편집기) 단위 검증 — 서버 순수 모듈 lib/visual-editor.js 와 브라우저·Node 공용 핵심 public/visual-editor-core.js.
|
||||
// 실행: npm test (node --test). 브라우저 없이 문서 변환·저장 검증·실행 취소/다시 실행·메시지 규약·구성 요소 목록을 검사한다.
|
||||
const test = require('node:test'); const assert = require('node:assert/strict');
|
||||
const path = require('path'); const vm = require('vm'); const fs = require('fs');
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const ve = require(ROOT + '/lib/visual-editor');
|
||||
const core = require(ROOT + '/public/visual-editor-core');
|
||||
|
||||
/** 시험용 페이지: 문서형·머리의 스타일/스크립트·본문의 인라인 핸들러·javascript: 링크·iframe·본문 끝 스크립트를 모두 가진다 */
|
||||
const PAGE = `<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>T</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<style>h1{color:red}</style>
|
||||
<script src="/wm-stats.js"></script>
|
||||
<script>window.boot = 1 // <b>not html</b></script>
|
||||
</head>
|
||||
<body class="home">
|
||||
<header><h1 id="t">안녕</h1><a href="javascript:alert(1)" onclick="go()">링크</a></header>
|
||||
<main><p>본문</p><img src="images/a.png" alt="a"><iframe src="https://example.com/x"></iframe></main>
|
||||
<script src="/wm-modules.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const OPTS = { baseHref: 'http://h/abc/', session: 'sess-1234', nonce: 'nonce-5678', parentOrigin: 'http://h', runtime: 'console.log("rt")' };
|
||||
|
||||
test('splitDocument: 머리(doctype·head·body 여는 태그)·본문·꼬리로 나눈다', () => {
|
||||
const s = ve.splitDocument(PAGE);
|
||||
assert.equal(s.hasBody, true);
|
||||
assert.ok(s.prefix.endsWith('<body class="home">'), s.prefix.slice(-40));
|
||||
assert.ok(s.prefix.includes('<style>h1{color:red}</style>'));
|
||||
assert.ok(s.body.startsWith('\n<header>') && s.body.includes('<script src="/wm-modules.js"></script>'));
|
||||
assert.ok(s.suffix.startsWith('</body>') && s.suffix.trimEnd().endsWith('</html>'));
|
||||
assert.equal(s.prefix + s.body + s.suffix, PAGE, '세 조각을 이으면 원문');
|
||||
const frag = ve.splitDocument('<p>조각</p>'); assert.equal(frag.hasBody, false); assert.equal(frag.body, '<p>조각</p>');
|
||||
});
|
||||
|
||||
test('prepareCanvas: 페이지 스크립트·핸들러·javascript: 링크·iframe 을 모두 비활성화하고 CSP nonce·base·런타임을 넣는다', () => {
|
||||
const html = ve.prepareCanvas(PAGE, OPTS);
|
||||
assert.match(html, /<meta http-equiv="Content-Security-Policy" content="[^"]*script-src 'nonce-nonce-5678'/);
|
||||
assert.ok(html.includes('<base href="http://h/abc/"'), 'base 주입');
|
||||
// 원래 스크립트는 모두 실행되지 않는 종류로 바뀌고(외부 src 포함), 내용은 그대로 남는다
|
||||
const scripts = [...html.matchAll(/<script\b[^>]*>/g)].map((m) => m[0]);
|
||||
const original = scripts.filter((s) => !/nonce="nonce-5678"/.test(s));
|
||||
assert.equal(original.length, 3, JSON.stringify(scripts));
|
||||
for (const s of original) assert.match(s, /type="text\/x-wm-ve-disabled"/);
|
||||
assert.ok(html.includes('window.boot = 1 // <b>not html</b>'), '스크립트 내용 보존');
|
||||
assert.ok(!/\sonclick=/.test(html) && html.includes('data-wm-ve-on-click="go()"'), '인라인 핸들러는 보존 속성으로');
|
||||
assert.ok(!/\shref="javascript:/.test(html) && html.includes('data-wm-ve-href="javascript:alert(1)"'), 'javascript: 링크 비활성화');
|
||||
assert.ok(/<iframe data-wm-ve-src="https:\/\/example\.com\/x">/.test(html), 'iframe 은 불러오지 않는다');
|
||||
assert.ok(html.includes('<title>T</title>') && html.includes('<style>h1{color:red}</style>') && html.includes('<body class="home">'), '머리·스타일·본문 태그 보존');
|
||||
// 런타임은 nonce 를 가진 편집기 전용 스크립트로, 설정(세션·부모 출처)을 함께 넣는다
|
||||
assert.ok(html.includes('"session":"sess-1234"') && html.includes('"parentOrigin":"http://h"'));
|
||||
assert.ok(/<script nonce="nonce-5678" data-wm-ve-ui>console\.log\("rt"\)<\/script>/.test(html));
|
||||
// 런타임 안의 </script> 는 문서를 끊지 못한다
|
||||
const h2 = ve.prepareCanvas(PAGE, { ...OPTS, runtime: 'var s = "</script><img src=x onerror=alert(1)>";' });
|
||||
assert.ok(!h2.includes('</script><img src=x onerror'), '런타임 문자열의 </script> 는 이스케이프');
|
||||
});
|
||||
|
||||
test('restoreFromCanvas: 편집기 표식·덧씌운 요소를 지우고 보존 속성을 되돌린다', () => {
|
||||
const body = '<header data-wm-ve-id="3"><h1 id="t" contenteditable="true" data-wm-ve-editable="" class="wm-ve-selected big" spellcheck="false" data-wm-ve-spellcheck="">안녕하세요</h1>'
|
||||
+ '<div data-wm-ve-ui="" class="wm-ve-toolbar"><button onclick="x()">삭제</button><script>evil()</script></div>'
|
||||
+ '<a data-wm-ve-href="javascript:alert(1)" data-wm-ve-on-click="go()">링크</a></header>'
|
||||
+ '<main><iframe data-wm-ve-src="https://example.com/x"></iframe><script type="text/x-wm-ve-disabled">a()</script><script type="text/x-wm-ve-disabled" data-wm-ve-orig-type="module">b()</script></main>';
|
||||
const out = ve.restoreFromCanvas(body);
|
||||
assert.equal(out, '<header><h1 id="t" class="big">안녕하세요</h1><a href="javascript:alert(1)" onclick="go()">링크</a></header>'
|
||||
+ '<main><iframe src="https://example.com/x"></iframe><script>a()</script><script type="module">b()</script></main>');
|
||||
});
|
||||
|
||||
test('validateVisualSave: 글만 고친 본문은 머리·doctype·스타일·스크립트를 그대로 둔 채 합쳐진다', () => {
|
||||
const canvasBody = ve.splitDocument(ve.prepareCanvas(PAGE, OPTS)).body.replace('안녕', '안녕하세요');
|
||||
const r = ve.validateVisualSave({ original: PAGE, body: canvasBody, maxBytes: 1024 * 1024 });
|
||||
const s0 = ve.splitDocument(PAGE); const s1 = ve.splitDocument(r.html);
|
||||
assert.equal(s1.prefix, s0.prefix, '머리 보존'); assert.equal(s1.suffix, s0.suffix, '꼬리 보존');
|
||||
assert.ok(s1.body.includes('<h1 id="t">안녕하세요</h1>'));
|
||||
assert.ok(s1.body.includes('<a href="javascript:alert(1)" onclick="go()">링크</a>'), '원래 있던 핸들러·링크는 보존');
|
||||
assert.ok(s1.body.includes('<script src="/wm-modules.js"></script>'), '본문 끝 스크립트 보존');
|
||||
assert.ok(!/data-wm-ve|x-wm-ve-disabled|contenteditable/.test(r.html), '편집기 표식 없음');
|
||||
assert.equal(typeof r.hash, 'string');
|
||||
});
|
||||
|
||||
test('validateVisualSave: 새 스크립트·이벤트 속성·위험 주소·금지 요소·스타일 표현식·구조 태그·크기·형식 오류를 거절한다', () => {
|
||||
const base = ve.splitDocument(PAGE).body;
|
||||
const reject = (body, re, status = 400, opts = {}) => { let err = null; try { ve.validateVisualSave({ original: PAGE, body, maxBytes: 1024 * 1024, ...opts }); } catch (e) { err = e; } assert.ok(err, '거절되어야 함: ' + re); assert.equal(err.status, status, err.message); assert.match(err.message, re); };
|
||||
reject(base + '<script>alert(1)</script>', /스크립트/);
|
||||
reject(base.replace('<script src="/wm-modules.js">', '<script src="/evil.js">'), /스크립트/);
|
||||
reject(base.replace('<p>본문</p>', '<p onmouseover="steal()">본문</p>'), /이벤트 속성/);
|
||||
reject(base.replace('<p>본문</p>', '<p>본문 <a href="javascript:void(0)">x</a></p>'), /허용되지 않는 (링크|주소)/);
|
||||
reject(base.replace('<p>본문</p>', '<a href="data:text/html,<b>x</b>">x</a>'), /허용되지 않는 (링크|주소)/);
|
||||
reject(base.replace('<p>본문</p>', '<p>본문</p><iframe src="https://evil.example/"></iframe>'), /iframe/);
|
||||
reject(base.replace('<p>본문</p>', '<object data="x.swf"></object>'), /object/);
|
||||
reject(base.replace('<p>본문</p>', '<p style="width:expression(alert(1))">본문</p>'), /스타일/);
|
||||
reject(base.replace('<p>본문</p>', '<body><p>본문</p></body>'), /html·head·body/);
|
||||
reject(base, /너무 큽니다/, 413, { maxBytes: 10 });
|
||||
reject(42, /문자열/);
|
||||
reject(base + '<script type="text/x-wm-ve-disabled" data-wm-ve-ui>x</script><script>fresh()</script>', /스크립트/);
|
||||
});
|
||||
|
||||
test('validateVisualSave: 원래 있던 스크립트·iframe 을 지우는 것은 허용(구성 요소 삭제)되고 숫자가 늘면 거절', () => {
|
||||
const base = ve.splitDocument(PAGE).body;
|
||||
const r = ve.validateVisualSave({ original: PAGE, body: base.replace('<script src="/wm-modules.js"></script>', '').replace('<iframe src="https://example.com/x"></iframe>', ''), maxBytes: 1e6 });
|
||||
assert.ok(!r.html.includes('/wm-modules.js') && !r.html.includes('<iframe'));
|
||||
assert.throws(() => ve.validateVisualSave({ original: PAGE, body: base + '<script src="/wm-modules.js"></script>', maxBytes: 1e6 }), /스크립트/);
|
||||
});
|
||||
|
||||
test('validateVisualSave: body 가 없는 문서는 거절하고 머리 없는 조각은 본문만 저장한다', () => {
|
||||
assert.throws(() => ve.validateVisualSave({ original: '<html><head><title>x</title></head></html>', body: '<p>a</p>', maxBytes: 1e6 }), /body/);
|
||||
const r = ve.validateVisualSave({ original: '<p>조각</p>', body: '<p>조각 수정</p>', maxBytes: 1e6 }); assert.equal(r.html, '<p>조각 수정</p>');
|
||||
});
|
||||
|
||||
test('isDangerousUrl·neutralizeTag·validateVisualSave: 이름 있는 엔티티(: 	 
 /)·숫자 엔티티(10/16진, 대소문자, 세미콜론 없음)·제어 문자·대소문자 섞임으로 감춘 javascript:/vbscript:/data:text 주소를 모든 주소 속성에서 거절하고, 모르는 엔티티로 흐린 스킴도 거절한다', () => {
|
||||
const bads = ['javascript:alert(1)', 'javascript:alert(1)', 'JaVa	ScRiPt:alert(1)', '
javascript:alert(1)', 'java
script:alert(1)', 'javascript:alert(1)', 'javascript:alert(1)', 'javascript:alert(1)', 'javascript:alert(1)',
|
||||
'java\u0000script:alert(1)', ' javascript:alert(1)', 'javascript\t:alert(1)', 'javascript:alert(1)', 'java&bogus;script:alert(1)', 'vbscript:x', 'data:text/html,<b>x</b>', 'data:text/html,x', 'DATA:TEXT/HTML;base64,PGI+', 'javascript:1', 'javascript:1', 'javascript:alert(1)'];
|
||||
for (const b of bads) assert.equal(ve.isDangerousUrl(b), true, '거절: ' + JSON.stringify(b));
|
||||
const goods = ['https://a/b?x=1&y=2', 'about.html?x:y', 'mailto:a@b.c', 'tel:+82-10', '#top', 'data:image/png;base64,AAAA', '/path/with&x', 'images/a.png', '//cdn.example/x.js', 'https://e/?q=javascript:x'];
|
||||
for (const g of goods) assert.equal(ve.isDangerousUrl(g), false, '허용: ' + JSON.stringify(g));
|
||||
// 서버 변환: 감춘 주소도 캔버스에서 보존 속성으로 바뀐다
|
||||
const canvas = ve.prepareCanvas('<body><a href="javascript:alert(1)">x</a><img src="javascript:alert(1)"></body>', OPTS);
|
||||
assert.ok(!/\shref="javascript:/.test(canvas) && canvas.includes('data-wm-ve-href="javascript:alert(1)"'), canvas); assert.ok(canvas.includes('data-wm-ve-src="javascript:alert(1)"'));
|
||||
// 저장 검증: 어떤 주소 속성이든 거절
|
||||
const base = ve.splitDocument(PAGE).body;
|
||||
for (const [tag, attr] of [['a', 'href'], ['img', 'src'], ['form', 'action'], ['button', 'formaction'], ['video', 'poster'], ['use', 'xlink:href'], ['a', 'ping'], ['img', 'srcset'], ['blockquote', 'cite'], ['img', 'longdesc'], ['body', 'background']]) {
|
||||
for (const b of ['javascript:alert(1)', 'javascript:alert(1)', 'JaVa	ScRiPt:alert(1)']) {
|
||||
const body = base.replace('<p>본문</p>', `<${tag} ${attr}="${b}">x</${tag}>`);
|
||||
let err = null; try { ve.validateVisualSave({ original: PAGE, body, maxBytes: 1e6 }); } catch (e) { err = e; }
|
||||
assert.ok(err && err.status === 400, `${tag}[${attr}]=${b} 거절`); assert.match(err.message, tag === 'body' ? /html·head·body|허용되지 않는/ : /허용되지 않는/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('prepareCanvas: 주석·원문(script/style/title) 안의 <head> 흉내·<header> 는 무시하고 진짜 <head> 뒤에만 CSP·런타임을 한 번 넣으며, <head> 가 없으면 진짜 <head> 를 만든다', () => {
|
||||
const page = '<!doctype html><!--<head>--><html lang="ko"><script>var s = "<head>"</script><style>/*<head>*/</style><title><head></title><head><meta charset="utf-8"></head><body><header><h1>x</h1><script>evil()</script></header></body></html>';
|
||||
const out = ve.prepareCanvas(page, OPTS);
|
||||
assert.equal((out.match(/Content-Security-Policy/g) || []).length, 1, 'CSP 한 번'); assert.equal((out.match(/console\.log\("rt"\)/g) || []).length, 1, '런타임 한 번');
|
||||
assert.ok(out.includes('<!--<head>-->'), '주석은 그대로'); assert.ok(out.includes('var s = "<head>"'), '스크립트 원문 그대로'); assert.ok(out.includes('<title><head></title>'), 'title 원문 그대로');
|
||||
assert.ok(out.includes('<head><meta http-equiv="Content-Security-Policy"'), '진짜 <head> 바로 뒤에'); assert.ok(out.indexOf('Content-Security-Policy') > out.indexOf('</title>'), 'title 뒤의 진짜 head');
|
||||
assert.ok(out.includes('<script type="text/x-wm-ve-disabled">evil()</script>'), '원래 스크립트는 비활성화된 채로');
|
||||
// <head> 가 없고 <header> 만 있는 문서: <html> 바로 뒤에 진짜 <head> 를 만들고 <header> 는 건드리지 않는다
|
||||
const out2 = ve.prepareCanvas('<html><body><header><h1>x</h1></header></body></html>', OPTS);
|
||||
assert.ok(out2.startsWith('<html><head><meta http-equiv="Content-Security-Policy"'), out2.slice(0, 80)); assert.ok(out2.includes('</head><body><header><h1>x</h1></header>'), '<header> 안에 넣지 않는다'); assert.equal((out2.match(/Content-Security-Policy/g) || []).length, 1);
|
||||
// <html> 도 없고 <body> 만 있으면 <body> 앞에 머리를 만든다 · 아무 구조도 없는 조각은 문서로 감싼다 · 주석 속 <head> 만 있는 조각도 진짜 머리를 얻는다
|
||||
const out3 = ve.prepareCanvas('<!doctype html><body class="x"><p>a</p></body>', OPTS); assert.ok(out3.startsWith('<!doctype html><head><meta http-equiv="Content-Security-Policy"') && out3.includes('</head><body class="x"><p>a</p></body>'), out3.slice(0, 120));
|
||||
const out4 = ve.prepareCanvas('<!--<head>--><p>frag</p>', OPTS); assert.ok(out4.startsWith('<!doctype html><html><head><meta http-equiv="Content-Security-Policy"') && out4.includes('<body><!--<head>--><p>frag</p></body>'), out4.slice(0, 160));
|
||||
const out5 = ve.prepareCanvas('<html><!-- <head> --><body><p>a</p></body></html>', OPTS); assert.ok(out5.startsWith('<html><head><meta http-equiv="Content-Security-Policy"') && out5.includes('</head><!-- <head> --><body>'), out5.slice(0, 160));
|
||||
});
|
||||
|
||||
test('validateVisualSave: 머리(head)·body 여는 태그·꼬리에만 있던 스크립트·핸들러·위험 주소·iframe/object 를 본문으로 복사해 오면 거절한다(완성 문서 전체를 원본 전체와 비교) — 본문 끝으로 옮겨진 꼬리 스크립트는 허용', () => {
|
||||
const orig = '<!doctype html><html><head><script src="/head.js"></script><link rel="x" href="javascript:h()"><object data="x.swf"></object><iframe src="https://e/"></iframe></head><body onload="init()"><p>본문</p></body></html>';
|
||||
const reject = (body, re) => { let err = null; try { ve.validateVisualSave({ original: orig, body, maxBytes: 1e6 }); } catch (e) { err = e; } assert.ok(err, '거절되어야 함: ' + body); assert.equal(err.status, 400); assert.match(err.message, re); };
|
||||
reject('<p>본문</p><script src="/head.js"></script>', /스크립트/);
|
||||
reject('<p onload="init()">본문</p>', /이벤트 속성/);
|
||||
reject('<p>본문</p><a href="javascript:h()">x</a>', /허용되지 않는/);
|
||||
reject('<p>본문</p><iframe src="https://e/"></iframe>', /iframe/);
|
||||
reject('<p>본문</p><object data="x.swf"></object>', /object/);
|
||||
const ok = ve.validateVisualSave({ original: orig, body: '<p>본문 수정</p>', maxBytes: 1e6 }); assert.ok(ok.html.includes('<body onload="init()"><p>본문 수정</p></body>'));
|
||||
// 브라우저가 </body> 뒤의 스크립트를 본문 끝으로 옮겨 돌려준 경우: 중복 없이 한 번만 남고 허용
|
||||
const orig2 = '<html><head></head><body><p>a</p></body><script src="/tail.js"></script></html>';
|
||||
const r2 = ve.validateVisualSave({ original: orig2, body: '<p>a 수정</p><script src="/tail.js"></script>', maxBytes: 1e6 });
|
||||
assert.equal((r2.html.match(/tail\.js/g) || []).length, 1, r2.html); assert.ok(r2.html.includes('<p>a 수정</p>'));
|
||||
assert.throws(() => ve.validateVisualSave({ original: orig2, body: '<p>a</p><script src="/tail.js"></script><script src="/tail.js"></script>', maxBytes: 1e6 }), /스크립트/, '두 번 복사는 거절');
|
||||
});
|
||||
|
||||
test('validateVisualSave·prepareCanvas·restoreFromCanvas·core.isSafeComponentHtml: SVG SMIL 변이 요소(animate·set·animateMotion·animateTransform·animateColor·discard·mpath — 대소문자·접두어 포함)는 정적 주소 검사를 지나 href 를 javascript: 로 바꿀 수 있으므로 새로 넣거나 바꾸면 거절하고, 원래 있던 것은 그 수까지만(삭제 허용), 캔버스에서는 실행되지 않는 이름으로 바꿨다가 복원 때 되돌린다', () => {
|
||||
const base = ve.splitDocument(PAGE).body;
|
||||
const reject = (body, re, original = PAGE) => { let err = null; try { ve.validateVisualSave({ original, body, maxBytes: 1e6 }); } catch (e) { err = e; } assert.ok(err, '거절되어야 함: ' + body.slice(0, 120)); assert.equal(err.status, 400, err.message); assert.match(err.message, re); };
|
||||
// 1) 저장형 XSS 우회: 정적 href 는 "#x" 라 주소 검사를 지나지만 animate/set 이 뒤에 href 를 javascript: 로 바꾼다(Chromium 에서 실행됨)
|
||||
reject(base.replace('<p>본문</p>', '<svg><a href="#x"><text>hi</text></a><animate attributeName="href" values="javascript:alert(1)" begin="0s" xlink:href="#a"/></svg>'), /animate/);
|
||||
reject(base.replace('<p>본문</p>', '<svg><a href="#x"><text>hi</text><set attributeName="xlink:href" to="javascript:alert(1)"/></a></svg>'), /set/);
|
||||
reject(base.replace('<p>본문</p>', '<svg><a href="#x"><text>hi</text><SET attributeName="href" to="data:text/html,<b>x</b>"></SET></a></svg>'), /set/);
|
||||
// 2) 값에 위험 주소가 없어도 SMIL 요소 자체를 새로 넣을 수 없다(fail-closed) — 대소문자·이름 공간 접두어 형태 모두
|
||||
for (const tag of ['animate', 'set', 'animateMotion', 'animateTransform', 'animateColor', 'discard', 'mpath', 'ANIMATE', 'AnimateMotion', 'svg:animate', 'SVG:Set', 'svg:mpath']) {
|
||||
reject(base.replace('<p>본문</p>', `<svg><rect width="1" height="1"/><${tag} attributeName="x" to="1" dur="1s"/></svg>`), new RegExp(tag.replace(/^[^:]+:/, '').toLowerCase(), 'i'));
|
||||
reject(base.replace('<p>본문</p>', `<svg><rect width="1" height="1"/><${tag} attributeName="x" to="1" dur="1s"></${tag}></svg>`), new RegExp(tag.replace(/^[^:]+:/, '').toLowerCase(), 'i'));
|
||||
}
|
||||
// 3) 원래 있던 SMIL: 그대로 두거나 지우는 것은 허용, 속성을 바꾸거나 하나 더 넣으면 거절(기존 목록 비교 규칙)
|
||||
const orig = '<!doctype html><html><head><title>s</title></head><body><svg><circle r="1"><animate attributeName="r" to="5" dur="1s"/></circle></svg><p>본문</p></body></html>';
|
||||
const same = ve.validateVisualSave({ original: orig, body: '<svg><circle r="1"><animate attributeName="r" to="5" dur="1s"/></circle></svg><p>본문 수정</p>', maxBytes: 1e6 }); assert.ok(same.html.includes('<animate attributeName="r" to="5" dur="1s"/>') && same.html.includes('본문 수정'));
|
||||
const gone = ve.validateVisualSave({ original: orig, body: '<svg><circle r="1"></circle></svg><p>본문</p>', maxBytes: 1e6 }); assert.ok(!/<animate/.test(gone.html), '삭제 허용');
|
||||
reject('<svg><circle r="1"><animate attributeName="href" to="javascript:alert(1)" dur="1s"/></circle></svg><p>본문</p>', /animate/, orig);
|
||||
reject('<svg><circle r="1"><animate attributeName="r" to="5" dur="1s"/><animate attributeName="r" to="5" dur="1s"/></circle></svg><p>본문</p>', /animate/, orig);
|
||||
reject('<svg><circle r="1"><animate attributeName="r" to="5" dur="1s"/></circle><a href="#q"><set attributeName="href" to="javascript:x()"/></a></svg><p>본문</p>', /set/, orig);
|
||||
// 4) 캔버스: SMIL 요소는 실행되지 않는 이름(wm-ve-x-…)으로 바뀌고(여는·닫는 태그 모두), 복원 때 원래 이름으로 돌아와 그대로 저장된다
|
||||
const page2 = '<html><head></head><body><svg><circle r="1"><animate attributeName="r" to="5" dur="1s"/><animateMotion dur="1s"><mpath xlink:href="#p"/></animateMotion><set attributeName="x" to="1"></set></circle></svg><p>본문</p></body></html>';
|
||||
const canvas = ve.prepareCanvas(page2, OPTS);
|
||||
assert.ok(!/<\/?(animate|animateMotion|mpath|set)[\s>\/]/i.test(canvas), 'SMIL 요소 이름이 캔버스에 남지 않는다: ' + canvas.slice(canvas.indexOf('<svg'), canvas.indexOf('</svg>')));
|
||||
assert.ok(canvas.includes('<wm-ve-x-animate attributeName="r" to="5" dur="1s" />') && canvas.includes('<wm-ve-x-animateMotion dur="1s"><wm-ve-x-mpath xlink:href="#p" /></wm-ve-x-animateMotion>') && canvas.includes('<wm-ve-x-set attributeName="x" to="1"></wm-ve-x-set>'), canvas);
|
||||
// 브라우저는 알 수 없는 SVG 요소 이름을 소문자로 직렬화한다 — 복원은 표준 이름(animateMotion 등)으로 돌려준다
|
||||
const fromBrowser = '<svg><circle r="1"><wm-ve-x-animate attributeName="r" to="5" dur="1s"></wm-ve-x-animate><wm-ve-x-animatemotion dur="1s"><wm-ve-x-mpath xlink:href="#p"></wm-ve-x-mpath></wm-ve-x-animatemotion><wm-ve-x-set attributeName="x" to="1"></wm-ve-x-set></circle></svg><p>본문 수정</p>';
|
||||
const restored = ve.restoreFromCanvas(fromBrowser);
|
||||
assert.equal(restored, '<svg><circle r="1"><animate attributeName="r" to="5" dur="1s"></animate><animateMotion dur="1s"><mpath xlink:href="#p"></mpath></animateMotion><set attributeName="x" to="1"></set></circle></svg><p>본문 수정</p>');
|
||||
const saved = ve.validateVisualSave({ original: page2, body: fromBrowser, maxBytes: 1e6 }); assert.ok(saved.html.includes('<animateMotion dur="1s"><mpath xlink:href="#p"></mpath></animateMotion>') && saved.html.includes('본문 수정'), '원래 있던 SMIL 은 그대로 저장');
|
||||
assert.throws(() => ve.validateVisualSave({ original: page2, body: fromBrowser + '<svg><wm-ve-x-animate attributeName="href" to="javascript:x()"></wm-ve-x-animate></svg>', maxBytes: 1e6 }), /animate/, '캔버스 이름으로 몰래 넣어도 복원 뒤 거절');
|
||||
// 5) 구성 요소 HTML 검사(core)도 SMIL 요소를 거절한다
|
||||
for (const h of ['<svg><a href="#x"><animate attributeName="href" values="javascript:alert(1)"/></a></svg>', '<svg><set attributeName="href" to="#"/></svg>', '<svg><animateMotion/></svg>', '<svg><ANIMATETRANSFORM attributeName="transform"/></svg>', '<svg><svg:animate/></svg>', '<svg><mpath/></svg>', '<svg><discard/></svg>']) assert.equal(core.isSafeComponentHtml(h), false, '구성 요소 거절: ' + h);
|
||||
assert.equal(core.isSafeComponentHtml('<svg viewBox="0 0 1 1"><rect width="1" height="1"/><text>set</text></svg><p>animate</p>'), true, '글자 속 단어는 요소가 아니다');
|
||||
});
|
||||
|
||||
test('scanTags 파서 차이(저장형 XSS): HTML 의 <title>·<textarea>·<style> 은 원문(RCDATA/raw text)이지만 <svg>·<math> 안(외래 콘텐츠)에서는 자식이 요소로 해석되어 <svg><title><script>…</script></title></svg> 의 스크립트가 실행된다 — 스캐너는 외래 콘텐츠 안에서 원문 규칙을 쓰지 않고(통합 지점 foreignObject·desc·mtext 포함, fail-closed) 그 스크립트를 세어 거절하며 캔버스에서는 비활성화한다; HTML 수준의 <title> 안 글자는 그대로 글자다', () => {
|
||||
const base = ve.splitDocument(PAGE).body;
|
||||
const reject = (body, re, original = PAGE) => { let err = null; try { ve.validateVisualSave({ original, body, maxBytes: 1e6 }); } catch (e) { err = e; } assert.ok(err, '거절되어야 함: ' + body.slice(0, 120)); assert.equal(err.status, 400, err.message); assert.match(err.message, re); };
|
||||
const payloads = [
|
||||
'<svg><title><script>alert(1)</script></title></svg>', '<svg><title><SCRIPT>alert(1)</SCRIPT></title></svg>', '<SVG><TITLE><script>alert(1)</script></TITLE></SVG>',
|
||||
'<svg><desc><script>alert(1)</script></desc></svg>', '<svg><textarea><script>alert(1)</script></textarea></svg>', '<svg><style><script>alert(1)</script></style></svg>',
|
||||
'<svg><g><title><script>alert(1)</script></title></g></svg>', '<svg><foreignObject><title><script>alert(1)</script></title></foreignObject></svg>', '<svg><svg><title><script>alert(1)</script></title></svg></svg>',
|
||||
'<math><mtext><title><script>alert(1)</script></title></mtext></math>', '<math><annotation-xml encoding="text/html"><title><script>alert(1)</script></title></annotation-xml></math>', '<MATH><mi><textarea><script>alert(1)</script></textarea></mi></MATH>',
|
||||
'<svg><title><img src=x onerror="alert(1)"></title></svg>', '<svg><title><a href="javascript:alert(1)">x</a></title></svg>', '<svg><title><iframe src="https://e/"></iframe></title></svg>',
|
||||
];
|
||||
for (const p of payloads) reject(base.replace('<p>본문</p>', p), /스크립트|이벤트 속성|허용되지 않는|iframe/);
|
||||
// 외래 콘텐츠 안에서 원문처럼 보이는 요소가 "닫히지 않은" 채로 와도(스캐너가 끝까지 원문으로 먹으면 뒤의 스크립트가 숨는다) 거절
|
||||
reject(base.replace('<p>본문</p>', '<svg><title>x</title><title><script>alert(1)</script></svg>'), /스크립트/);
|
||||
// HTML 수준: <title>·<textarea> 안의 <script> 글자는 글자다(기존 구조 테스트와 같은 규칙) — 본문에 textarea 로 넣으면 허용, 머리의 <title> 도 원문 그대로
|
||||
const okBody = ve.validateVisualSave({ original: PAGE, body: base.replace('<p>본문</p>', '<p>본문</p><textarea><script> 와 <script>alert(1)</script> 는 글자</textarea>'), maxBytes: 1e6 }); assert.ok(okBody.html.includes('<textarea><script> 와 <script>alert(1)</script> 는 글자</textarea>'));
|
||||
const orig2 = '<!doctype html><html><head><title>T <script>not code</script></title></head><body><p>본문</p></body></html>';
|
||||
const ok2 = ve.validateVisualSave({ original: orig2, body: '<p>본문 수정</p>', maxBytes: 1e6 }); assert.ok(ok2.html.includes('<title>T <script>not code</script></title>') && ok2.html.includes('본문 수정'));
|
||||
// 정상 SVG(제목·설명·스타일 글자)는 그대로 허용되고, 닫힌 <svg> 뒤의 HTML <title>/<textarea> 는 다시 원문이다
|
||||
const ok3 = ve.validateVisualSave({ original: PAGE, body: base.replace('<p>본문</p>', '<svg viewBox="0 0 1 1"><title>차트</title><desc>설명</desc><style>.a{fill:red}</style><rect class="a" width="1" height="1"/></svg><p>본문</p><textarea><b>x</b></textarea>'), maxBytes: 1e6 }); assert.ok(ok3.html.includes('<title>차트</title>') && ok3.html.includes('<textarea><b>x</b></textarea>'));
|
||||
// 캔버스 출력: svg 안 title 의 script 는 요소로 보고 비활성화한다(실행 안 됨), HTML title 안 글자는 그대로
|
||||
const canvas = ve.prepareCanvas('<html><head><title>A <script>s</script></title></head><body><svg><title><script>evil()</script></title></svg></body></html>', OPTS);
|
||||
assert.ok(canvas.includes('<title>A <script>s</script></title>'), 'HTML title 원문 그대로'); assert.ok(canvas.includes('<svg><title><script type="text/x-wm-ve-disabled">evil()</script></title></svg>'), 'svg 안 script 비활성화: ' + canvas.slice(canvas.indexOf('<svg'), canvas.indexOf('</svg>') + 6));
|
||||
});
|
||||
|
||||
test('contentHash·isHtmlPath', () => {
|
||||
assert.equal(ve.contentHash('abc'), ve.contentHash('abc')); assert.notEqual(ve.contentHash('abc'), ve.contentHash('abd')); assert.equal(ve.contentHash('x').length, 64);
|
||||
assert.equal(ve.isHtmlPath('index.html'), true); assert.equal(ve.isHtmlPath('en/about.htm'), true); assert.equal(ve.isHtmlPath('style.css'), false); assert.equal(ve.isHtmlPath('a.html/x.js'), false);
|
||||
});
|
||||
|
||||
test('writeTextAtomic: 같은 폴더의 임시 파일(0644)에 쓴 뒤 rename 하고, 실패하면 임시 파일을 지우고 원본을 건드리지 않는다', () => {
|
||||
const os = require('os'); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-ve-atomic-')); const f = path.join(dir, 'index.html'); fs.writeFileSync(f, 'old');
|
||||
ve.writeTextAtomic(f, 'new 내용');
|
||||
assert.equal(fs.readFileSync(f, 'utf8'), 'new 내용'); assert.equal(fs.statSync(f).mode & 0o777, 0o644); assert.deepEqual(fs.readdirSync(dir), ['index.html'], '임시 파일 잔여 없음');
|
||||
// 주입한 파일 시스템: rename 이 실패하면 임시 파일을 지우고 오류를 그대로 던진다 — 원본 경로에는 쓰지 않는다
|
||||
const calls = [];
|
||||
const failRename = { writeFileSync: (p, c, o) => calls.push(['write', p, o && o.mode]), renameSync: () => { throw Object.assign(new Error('ENOSPC: 공간 없음'), { code: 'ENOSPC' }); }, unlinkSync: (p) => calls.push(['unlink', p]) };
|
||||
assert.throws(() => ve.writeTextAtomic(f, 'x', { fs: failRename }), /ENOSPC/);
|
||||
const w = calls.find((c) => c[0] === 'write'); assert.ok(w, '임시 파일에 썼다'); assert.notEqual(w[1], f, '원본 경로에 직접 쓰지 않는다'); assert.equal(path.dirname(w[1]), dir, '같은 폴더(원자적 rename 가능)'); assert.match(path.basename(w[1]), /\.tmp$/); assert.equal(w[2], 0o644);
|
||||
assert.deepEqual(calls.filter((c) => c[0] === 'unlink').map((c) => c[1]), [w[1]], '실패한 임시 파일 정리');
|
||||
assert.equal(fs.readFileSync(f, 'utf8'), 'new 내용', '원본 보존');
|
||||
// 쓰기 자체가 실패해도(디스크 가득·권한) 오류를 던지고 rename 은 시도하지 않는다. 임시 파일 정리의 실패는 무시한다
|
||||
const calls2 = []; const failWrite = { writeFileSync: () => { throw Object.assign(new Error('EACCES'), { code: 'EACCES' }); }, renameSync: () => calls2.push('rename'), unlinkSync: () => { throw new Error('ENOENT'); } };
|
||||
assert.throws(() => ve.writeTextAtomic(f, 'x', { fs: failWrite }), /EACCES/); assert.deepEqual(calls2, []);
|
||||
assert.throws(() => ve.writeTextAtomic('', 'x'), /경로/); assert.throws(() => ve.writeTextAtomic(f, 5), /문자열/);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ---------- 화면 배치 계약(실제 브라우저 시각 검증에서 발견한 차단 결함의 회귀) ----------
|
||||
/**
|
||||
* 작은 CSS 규칙 파서: 주석을 지우고 중괄호 깊이로 규칙과 @media 블록을 나눈다. 반환: [{ media: '(max-width: 900px)'|null, selector, decls: {속성: 값} }]
|
||||
* 선택자·속성 값으로 계약을 검사하기 위한 것이며 주석 문자열은 보지 않는다.
|
||||
*/
|
||||
function parseCss(css) {
|
||||
const src = css.replace(/\/\*[\s\S]*?\*\//g, ''); const out = [];
|
||||
const walk = (text, media) => {
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const open = text.indexOf('{', i); if (open < 0) break;
|
||||
const head = text.slice(i, open).trim();
|
||||
let depth = 1; let j = open + 1; while (j < text.length && depth > 0) { if (text[j] === '{') depth++; else if (text[j] === '}') depth--; j++; }
|
||||
const inner = text.slice(open + 1, j - 1);
|
||||
if (head.startsWith('@media')) walk(inner, head.replace(/^@media\s*/, ''));
|
||||
else if (!head.startsWith('@')) { const decls = {}; for (const d of inner.split(';')) { const k = d.indexOf(':'); if (k > 0) decls[d.slice(0, k).trim()] = d.slice(k + 1).trim(); } for (const sel of head.split(',')) out.push({ media, selector: sel.trim().replace(/\s+/g, ' '), decls }); }
|
||||
i = j;
|
||||
}
|
||||
};
|
||||
walk(src, null); return out;
|
||||
}
|
||||
const CSS_RULES = parseCss(fs.readFileSync(path.join(ROOT, 'public', 'style.css'), 'utf8'));
|
||||
const rulesFor = (selector, media = null) => CSS_RULES.filter((r) => r.selector === selector && (media === null ? r.media === null : (r.media || '').includes(media)));
|
||||
const lastDecl = (selector, prop, media = null) => { const rs = rulesFor(selector, media).filter((r) => prop in r.decls); return rs.length ? rs[rs.length - 1].decls[prop] : undefined; };
|
||||
|
||||
test('style.css 계약: body.ve-on 이면 옛 작업 칸(.left)을 숨기고 .app 을 레일 + 전체 폭 캔버스로 바꾼다 — 1280px 에서 캔버스 폭 ≥ 500px', () => {
|
||||
// 기본 배치(직접 편집 꺼짐)는 그대로: 레일 · 작업 칸 460px · 캔버스
|
||||
assert.equal(lastDecl('.app', 'grid-template-columns'), 'var(--rail-w) var(--left-w, 460px) 1fr');
|
||||
// 직접 편집 켜짐: .left 숨김 + .app 두 칸(레일 + 1fr). 작업 칸 폭(460px·--left-w)이 남아 있으면 캔버스가 좁아진다
|
||||
assert.equal(lastDecl('body.ve-on .left', 'display'), 'none', '직접 편집 중 옛 작업 칸 숨김');
|
||||
const cols = lastDecl('body.ve-on .app', 'grid-template-columns'); assert.ok(cols, 'body.ve-on .app 규칙');
|
||||
assert.ok(/var\(--rail-w\)/.test(cols) && /1fr/.test(cols) && !/460|--left-w/.test(cols), '레일 + 전체 폭: ' + cols);
|
||||
// 1280px 폭 계산: 레일 232 + 팔레트 + 캔버스 + 속성 창. 양쪽 패널을 펼친 채 캔버스 ≥ 500px
|
||||
const px = (v) => parseFloat(String(v || '').match(/(\d+(?:\.\d+)?)px/) ? String(v).match(/(\d+(?:\.\d+)?)px/)[1] : 'NaN');
|
||||
const rail = px(lastDecl(':root', '--rail-w')); const side = px((lastDecl('.ve-side', 'flex') || '').split(/\s+/).pop());
|
||||
assert.ok(rail > 0 && side > 0, `rail=${rail} side=${side}`);
|
||||
assert.ok(1280 - rail - 2 * side >= 500, `1280px 에서 캔버스 폭 ${1280 - rail - 2 * side}px (레일 ${rail} + 패널 ${side}×2)`);
|
||||
// 1180px 이하(레일 204px)에서도 패널을 좁혀 캔버스 ≥ 500px 을 지킨다 — 1100px 기준
|
||||
const railNarrow = px(lastDecl(':root', '--rail-w', '(max-width: 1180px)')) || rail; const sideNarrow = px((lastDecl('body.ve-on .ve-side', 'flex-basis', '(max-width: 1180px)') || '')) || side;
|
||||
assert.ok(1100 - railNarrow - 2 * sideNarrow >= 500, `1100px 에서 캔버스 폭 ${1100 - railNarrow - 2 * sideNarrow}px`);
|
||||
});
|
||||
|
||||
test('style.css 계약: 900px 이하에서 body.ve-on 은 레일 바로 아래에 캔버스를 두고(옛 작업 칸 행 없음) 팔레트·속성 창은 캔버스 아래에 쌓인다', () => {
|
||||
const m = '(max-width: 900px)';
|
||||
assert.equal(lastDecl('.app', 'grid-template-rows', m), 'auto auto 70vh', '기본(꺼짐) 배치: 레일·작업 칸·캔버스 세 행');
|
||||
const rows = lastDecl('body.ve-on .app', 'grid-template-rows', m); assert.ok(rows, '좁은 화면 body.ve-on .app 규칙');
|
||||
const tracks = rows.split(/\s+(?![^()]*\))/); assert.equal(tracks.length, 2, '레일 + 캔버스 두 행: ' + rows); assert.equal(tracks[0], 'auto'); assert.ok(/fr|minmax|vh/.test(tracks[1]), '캔버스 행이 남은 높이를 차지: ' + rows);
|
||||
assert.equal(lastDecl('.ve-stage', 'flex-direction', m), 'column', '무대는 세로 쌓기');
|
||||
assert.equal(lastDecl('.ve-stage .frame-wrap', 'order', m), '1', '캔버스가 맨 위'); assert.equal(lastDecl('.ve-side.ve-palette', 'order', m), '2'); assert.equal(lastDecl('.ve-inspector', 'order', m), '3');
|
||||
});
|
||||
|
||||
/**
|
||||
* 캔버스 런타임(wm-ve-canvas.js)이 문서 head 에 끼워 넣는 CSS 를 소스에서 꺼내 파싱한다.
|
||||
* `${MARK}` 보간은 실제 값(data-wm-ve-)으로 바꾼다. 주석이 아니라 선택자·선언을 검사하기 위한 것.
|
||||
*/
|
||||
function canvasRuntimeCss() {
|
||||
const src = fs.readFileSync(path.join(ROOT, 'public', 'wm-ve-canvas.js'), 'utf8');
|
||||
const m = src.match(/st\.textContent = `([\s\S]*?)`;/); assert.ok(m, '런타임 CSS 블록(st.textContent)');
|
||||
return parseCss(m[1].replace(/\$\{MARK\}/g, 'data-wm-ve-'));
|
||||
}
|
||||
/**
|
||||
* `hidden` 속성이 켜진 `.클래스` 요소의 최종 display 값을 계산한다(브라우저 cascade 의 단순 모형).
|
||||
* 사용자 에이전트 규칙 `[hidden]{display:none}` 은 작성자 규칙(어떤 명시도든)에 밀리므로,
|
||||
* 작성자 규칙 중 숨긴 요소에 맞는 것(`.c`, `.c[hidden]`; `.c:not([hidden])` 은 제외)이 display 를 정하면 그 값이 이긴다.
|
||||
* 우선순위: !important > 명시도([hidden] 속성 선택자 +1) > 나중 선언. 작성자 규칙이 display 를 정하지 않으면 UA 의 none.
|
||||
*/
|
||||
function hiddenDisplayOf(rules, cls) {
|
||||
const esc = cls.replace(/\./g, '\\.');
|
||||
const matches = (sel) => new RegExp(`^${esc}(\\[hidden\\])?$`).test(sel);
|
||||
let best = null;
|
||||
rules.forEach((r, order) => {
|
||||
if (r.media !== null || !matches(r.selector) || !('display' in r.decls)) return;
|
||||
const important = /!important/.test(r.decls.display); const spec = /\[hidden\]/.test(r.selector) ? 1 : 0;
|
||||
const cand = { value: r.decls.display.replace(/\s*!important/, '').trim(), key: [important ? 1 : 0, spec, order] };
|
||||
const gt = (a, b) => { for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return a[i] > b[i]; } return false; }; // 사전식 비교
|
||||
if (!best || gt(cand.key, best.key)) best = cand;
|
||||
});
|
||||
return best ? best.value : 'none';
|
||||
}
|
||||
|
||||
test('wm-ve-canvas.js CSS 계약: hidden 인 선택 도구 줄(.wm-ve-tools)·놓기 표시(.wm-ve-drop)는 작성자 CSS 가 다시 보이게 만들 수 없다', () => {
|
||||
const rules = canvasRuntimeCss();
|
||||
// 선택 도구 줄은 renderOverlay() 가 hidden=false 로 바꾸기 전까지 보이면 안 된다.
|
||||
// `.wm-ve-tools{display:flex}` 같은 작성자 규칙은 UA 의 `[hidden]{display:none}` 을 덮어써 선택 전에도 어두운 도구 줄이 떠 버린다.
|
||||
assert.equal(hiddenDisplayOf(rules, '.wm-ve-tools'), 'none', 'hidden 인 .wm-ve-tools 의 최종 display');
|
||||
assert.equal(hiddenDisplayOf(rules, '.wm-ve-drop'), 'none', 'hidden 인 .wm-ve-drop 의 최종 display');
|
||||
// 보일 때(hidden 아님)는 도구 줄이 가로 배치(flex)여야 버튼이 한 줄로 놓인다
|
||||
const shown = rules.filter((r) => r.media === null && /^\.wm-ve-tools(:not\(\[hidden\]\))?$/.test(r.selector) && 'display' in r.decls);
|
||||
assert.ok(shown.some((r) => /^flex$/.test(r.decls.display)), '보이는 .wm-ve-tools 는 display:flex');
|
||||
});
|
||||
|
||||
/** 가짜 DOM 요소: 화면 코드가 쓰는 최소 표면(classList·속성·innerHTML·querySelectorAll·이벤트) */
|
||||
function fakeEl(id) {
|
||||
const cls = new Set(); const attrs = {};
|
||||
return { id, classList: { add: (...c) => c.forEach((x) => cls.add(x)), remove: (...c) => c.forEach((x) => cls.delete(x)), toggle: (c, on) => { (on === undefined ? !cls.has(c) : on) ? cls.add(c) : cls.delete(c); }, contains: (c) => cls.has(c) }, setAttribute: (k, v) => { attrs[k] = String(v); }, getAttribute: (k) => (k in attrs ? attrs[k] : null), removeAttribute: (k) => { delete attrs[k]; }, hasAttribute: (k) => k in attrs,
|
||||
textContent: '', innerHTML: '', className: '', disabled: false, value: '', hidden: false, style: {}, childElementCount: 0, children: [], dataset: {}, contentWindow: {}, _ev: {}, addEventListener(t, fn) { (this._ev[t] || (this._ev[t] = [])).push(fn); }, appendChild() { this.childElementCount++; }, querySelectorAll: () => [], focus() {}, click() {},
|
||||
fire(t, ev) { (this._ev[t] || []).forEach((fn) => fn(ev)); return ev; }, _captured: null, setPointerCapture(id) { this._captured = id; }, releasePointerCapture() { this._captured = null; }, closest() { return null; }, clientLeft: 0, clientTop: 0, getBoundingClientRect() { return { left: 0, top: 0, width: 0, height: 0, right: 0, bottom: 0 }; } };
|
||||
}
|
||||
/** 빌더 쪽 제어(visual-editor-ui.js)를 가짜 DOM 위에서 실행해 VisualEditor 객체와 document 를 돌려준다 */
|
||||
function loadUi(apiResponse) {
|
||||
const els = {}; const $ = (id) => (els[id] || (els[id] = fakeEl(id))); const created = [];
|
||||
const document = { body: fakeEl('body'), readyState: 'complete', getElementById: $, createElement: (t) => { const e = fakeEl(t); e.tagName = t; created.push(e); return e; }, _ev: {}, addEventListener(t, fn) { (this._ev[t] || (this._ev[t] = [])).push(fn); }, fire(t, ev) { (this._ev[t] || []).forEach((fn) => fn(ev)); return ev; }, activeElement: null, querySelectorAll: () => [], created };
|
||||
const ctx = { document, console, setTimeout, clearTimeout, confirm: () => (ctx.answer === undefined ? true : ctx.answer), alert: (m) => { throw new Error('alert: ' + m); }, localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
||||
$, T: (s) => s, calls: [], api: async (p, o) => { ctx.calls.push({ path: p, method: (o && o.method) || 'GET' }); return apiResponse; }, current: { id: 'a'.repeat(32), files: ['index.html'], url: 'http://h/a/' }, ed: { open: false, dirty: false }, setEditorOpen() {}, renderGit() {}, loadFrame() { ctx.loaded = (ctx.loaded || 0) + 1; }, logLine() {}, pushMsg() {} };
|
||||
ctx.window = ctx; ctx._winEv = {}; ctx.window.addEventListener = (t, fn) => { (ctx._winEv[t] || (ctx._winEv[t] = [])).push(fn); }; ctx.window.fire = (t, ev) => { (ctx._winEv[t] || []).forEach((fn) => fn(ev)); return ev; }; ctx.window.matchMedia = () => ({ matches: false });
|
||||
vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'public', 'visual-editor-core.js'), 'utf8'), ctx);
|
||||
vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'public', 'visual-editor-ui.js'), 'utf8'), ctx);
|
||||
return { ve: ctx.VisualEditor, document, els, ctx };
|
||||
}
|
||||
/**
|
||||
* 가짜 MessageChannel: 두 포트가 서로 짝이고 postMessage 는 구조 복제(JSON) 뒤 상대 onmessage 를 바로(동기) 부른다.
|
||||
* 브라우저의 MessagePort 처럼 message 이벤트에 source/origin 이 없다(포트는 만든 문서에 묶인다). close() 뒤에는 어느 쪽도 전달되지 않는다.
|
||||
*/
|
||||
function fakeChannel() {
|
||||
const mk = () => ({ onmessage: null, peer: null, closed: false, started: false, postMessage(m) { const p = this.peer; if (!p || this.closed || p.closed) return; const data = JSON.parse(JSON.stringify(m)); if (typeof p.onmessage === 'function') p.onmessage({ data, origin: '', source: null, ports: [] }); }, start() { this.started = true; }, close() { this.closed = true; } });
|
||||
const port1 = mk(); const port2 = mk(); port1.peer = port2; port2.peer = port1; return { port1, port2 };
|
||||
}
|
||||
/** 캔버스가 보낸 것처럼 ready(+포트) 창 메시지를 빌더 쪽에 쏘아 연결한다. sent = 빌더가 포트로 보낸 명령, fromCanvas = 캔버스가 포트로 보내는 이벤트 */
|
||||
function connectCanvas({ document, ctx }, session) {
|
||||
const frameEl = document.getElementById('frame'); const ch = fakeChannel(); const sent = []; ch.port1.onmessage = (e) => sent.push(e.data);
|
||||
ctx.window.fire('message', { data: core.makeMessage(session, 'ready', { title: 't' }), source: frameEl.contentWindow, origin: 'null', ports: [ch.port2] });
|
||||
return { sent, ch, fromCanvas: (type, payload) => ch.port1.postMessage(core.makeMessage(session, type, payload)) };
|
||||
}
|
||||
|
||||
test('visual-editor-ui.js 연결 규약: 첫 ready 한 번으로 MessagePort 에 연결해 모든 명령을 포트로만 보내고(창 메시지 없음), 두 번째 ready(바뀐 문서)·창으로 온 changed/hotkey 는 보낸 창·출처·세션이 맞아도 무시하며, 끝내면 포트를 닫는다', async () => {
|
||||
const SESSION = 'ab'.repeat(16); const ui = loadUi({ session: SESSION, hash: 'c'.repeat(64), rev: 3, canvas: '<!doctype html><html><head></head><body><p>x</p></body></html>', running: false });
|
||||
const { ve, document, ctx } = ui; const frameEl = document.getElementById('frame'); const winSent = []; frameEl.contentWindow.postMessage = (m) => winSent.push(m);
|
||||
await ve.open('index.html');
|
||||
const viaWindow = (type, payload) => ctx.window.fire('message', { data: core.makeMessage(SESSION, type, payload), source: frameEl.contentWindow, origin: 'null' });
|
||||
// 연결 전: 포트 없는 ready·changed 창 메시지는 연결도 변경도 만들지 않는다
|
||||
viaWindow('ready', {}); viaWindow('changed', { body: '<p>y</p>' }); assert.equal(ve.isDirty(), false); assert.equal(winSent.length, 0);
|
||||
// 연결: ready + 포트 → init 은 포트로, 창(targetOrigin '*')으로는 아무것도 보내지 않는다
|
||||
const c = connectCanvas(ui, SESSION);
|
||||
assert.deepEqual(c.sent.map((m) => m.type), ['init']); assert.equal(c.sent[0].session, SESSION); assert.equal(winSent.length, 0, '세션이 든 명령을 창으로 보내지 않는다');
|
||||
c.fromCanvas('body', { body: '<p>x</p>' }); c.fromCanvas('changed', { body: '<p>y</p>' }); assert.equal(ve.isDirty(), true, '포트로 온 변경은 처리');
|
||||
// 두 번째 ready(iframe 안 문서가 바뀜 — 같은 contentWindow·출처 null·세션까지 맞음) → 연결을 바꾸지 않는다
|
||||
const ch2 = fakeChannel(); const sent2 = []; ch2.port1.onmessage = (e) => sent2.push(e.data);
|
||||
ctx.window.fire('message', { data: core.makeMessage(SESSION, 'ready', {}), source: frameEl.contentWindow, origin: 'null', ports: [ch2.port2] });
|
||||
ve.undo(); assert.equal(c.sent.at(-1).type, 'applyHtml', '명령은 첫 포트로'); assert.equal(sent2.length, 0, '새 포트로는 아무것도 가지 않는다'); assert.equal(winSent.length, 0);
|
||||
// 창으로 온 changed·hotkey(save)·body 는 무시: 기록·저장 호출 없음
|
||||
const h0 = ve.state().save; const calls0 = ctx.calls.length; viaWindow('changed', { body: '<p>z</p>' }); viaWindow('hotkey', { key: 'save' }); viaWindow('body', { body: '<p>q</p>' });
|
||||
assert.equal(ve.state().save, h0); assert.equal(ctx.calls.length, calls0, '창 메시지로 저장(PUT)이 일어나지 않는다'); assert.equal(c.sent.filter((m) => m.type === 'applyHtml').length, 1);
|
||||
// 포트로 온 hotkey 는 처리된다(정상 런타임은 계속 동작)
|
||||
c.fromCanvas('hotkey', { key: 'undo' }); assert.ok(c.sent.filter((m) => m.type === 'applyHtml').length >= 1);
|
||||
// 세션이 다른 포트 메시지는 무시
|
||||
c.ch.port1.postMessage(core.makeMessage('other', 'changed', { body: '<p>w</p>' })); assert.notEqual(ve.state().save, 'dirty-from-other');
|
||||
// 끝내기 → 포트 닫힘, 이후 포트 메시지는 아무 효과 없음; 다시 열면 새 ready 가 다시 한 번 받아들여진다
|
||||
ve.close(true); assert.equal(c.ch.port2.closed, true, '끝내면 포트를 닫는다'); c.fromCanvas('changed', { body: '<p>v</p>' }); assert.equal(ve.isDirty(), false);
|
||||
await ve.open('index.html'); const c2 = connectCanvas(ui, SESSION); assert.deepEqual(c2.sent.map((m) => m.type), ['init']); assert.equal(winSent.length, 0);
|
||||
ve.close(true);
|
||||
});
|
||||
|
||||
test('visual-editor-ui.js onSiteUpdated: 저장하지 않은 변경이 있으면 다른 사이트·사라진 페이지·HTML 없는 갱신에도 묻지 않고 끝내지 않는다 — 취소하면 편집기·사이트를 그대로 두고, 승낙하면 끝낸다', async () => {
|
||||
const SESSION = 'ab'.repeat(16); const ui = loadUi({ session: SESSION, hash: 'c'.repeat(64), rev: 3, canvas: '<!doctype html><html><head></head><body><p>x</p></body></html>', running: false });
|
||||
const { ve, ctx } = ui; await ve.open('index.html'); const c = connectCanvas(ui, SESSION); c.fromCanvas('body', { body: '<p>x</p>' }); c.fromCanvas('changed', { body: '<p>y</p>' }); assert.equal(ve.isDirty(), true);
|
||||
ctx.answer = false; let asked = 0; ctx.confirm = () => { asked++; return ctx.answer; };
|
||||
ve.onSiteUpdated({ id: 'b'.repeat(32), files: ['index.html'] }); assert.equal(ve.isActive(), true, '다른 사이트 갱신: 취소하면 유지'); assert.equal(ve.state().siteId, 'a'.repeat(32)); assert.equal(asked, 1);
|
||||
ve.onSiteUpdated({ id: 'a'.repeat(32), files: [] }); assert.equal(ve.isActive(), true, '페이지가 사라진 갱신: 취소하면 유지'); assert.equal(ve.isDirty(), true); assert.equal(asked, 2);
|
||||
ve.onSiteUpdated(null); assert.equal(ve.isActive(), true); assert.equal(asked, 3);
|
||||
ctx.answer = true; ve.onSiteUpdated({ id: 'b'.repeat(32), files: [] }); assert.equal(ve.isActive(), false, '승낙하면 끝낸다'); assert.equal(asked, 4);
|
||||
// 변경이 없으면 묻지 않고 끝낸다(잃을 것이 없다)
|
||||
await ve.open('index.html'); const c2 = connectCanvas(ui, SESSION); c2.fromCanvas('body', { body: '<p>x</p>' }); asked = 0; ve.onSiteUpdated({ id: 'b'.repeat(32), files: [] }); assert.equal(ve.isActive(), false); assert.equal(asked, 0);
|
||||
});
|
||||
|
||||
test('visual-editor-ui.js 동작: open() 이 body.ve-on 을 켜고 패널·도구 줄을 보이며, close() 가 모두 되돌리고 미리보기를 다시 읽는다', async () => {
|
||||
const { ve, document, els, ctx } = loadUi({ session: 'ab'.repeat(16), hash: 'c'.repeat(64), rev: 3, canvas: '<!doctype html><html><head></head><body><p>x</p></body></html>', running: false });
|
||||
assert.ok(ve && typeof ve.open === 'function');
|
||||
assert.equal(document.body.classList.contains('ve-on'), false);
|
||||
await ve.open('index.html');
|
||||
assert.equal(ve.isActive(), true); assert.equal(document.body.classList.contains('ve-on'), true, '직접 편집 중 body.ve-on — CSS 가 작업 칸을 숨기고 .app 을 다시 배치하는 근거');
|
||||
for (const id of ['veBar', 'vePalette', 'veInspector']) assert.equal(els[id].classList.contains('hidden'), false, id + ' 보임');
|
||||
assert.equal(els.btnVisual.getAttribute('aria-pressed'), 'true'); assert.equal(els.frame.srcdoc.includes('<p>x</p>'), true, '캔버스 문서가 iframe 에 들어감');
|
||||
assert.equal(ve.state().siteId, ctx.current.id); assert.equal(ve.state().file, 'index.html');
|
||||
assert.equal(ve.close(false), true);
|
||||
assert.equal(ve.isActive(), false); assert.equal(document.body.classList.contains('ve-on'), false, '끝내면 body.ve-on 제거 → 옛 배치 복원');
|
||||
for (const id of ['veBar', 'vePalette', 'veInspector']) assert.equal(els[id].classList.contains('hidden'), true, id + ' 숨김');
|
||||
assert.equal(els.btnVisual.getAttribute('aria-pressed'), 'false'); assert.equal(els.frame.getAttribute('srcdoc'), null); assert.ok(ctx.loaded >= 1, '미리보기를 저장된 파일로 다시 읽는다');
|
||||
// 다시 켜고 강제 종료(사이트 전환)해도 같은 결과
|
||||
await ve.open('index.html'); assert.equal(document.body.classList.contains('ve-on'), true); ve.close(true); assert.equal(document.body.classList.contains('ve-on'), false);
|
||||
});
|
||||
|
||||
// ---------- 공용 핵심(public/visual-editor-core.js) ----------
|
||||
test('core.dragComponentId·isComponentDrag·componentDragText: 전용 MIME 이 없어도 이름 공간이 붙은 text/plain 만 정확히 받고, 임의 글·모르는 id 는 거절한다', () => {
|
||||
const dt = (data) => ({ types: Object.keys(data), getData: (k) => (k in data ? data[k] : '') });
|
||||
assert.equal(core.COMPONENT_MIME, 'text/wm-ve-component');
|
||||
assert.equal(core.componentDragText('paragraph'), 'wm-ve-component:paragraph');
|
||||
// 끌기 인식: 전용 MIME 또는 text/plain 이 있으면(dragover 때는 getData 를 읽을 수 없으므로 종류로만 판단)
|
||||
assert.equal(core.isComponentDrag(['text/wm-ve-component']), true); assert.equal(core.isComponentDrag(['text/plain']), true);
|
||||
assert.equal(core.isComponentDrag(['Files']), false); assert.equal(core.isComponentDrag(['text/html', 'text/uri-list']), false); assert.equal(core.isComponentDrag(undefined), false);
|
||||
// 전용 MIME 우선
|
||||
assert.equal(core.dragComponentId(dt({ 'text/wm-ve-component': 'heading', 'text/plain': 'wm-ve-component:paragraph' })), 'heading');
|
||||
// 전용 MIME 이 sandbox 경계에서 사라져도 이름 공간이 붙은 text/plain 으로 복원
|
||||
assert.equal(core.dragComponentId(dt({ 'text/plain': 'wm-ve-component:paragraph' })), 'paragraph');
|
||||
for (const c of core.COMPONENTS) assert.equal(core.dragComponentId(dt({ 'text/plain': core.componentDragText(c.id) })), c.id, c.id);
|
||||
// 거절: 임의 글·주소·HTML, 모르는 id, 정확히 일치하지 않는 값(공백·대문자·접두 변형), 전용 MIME 의 모르는 id
|
||||
for (const bad of ['https://example.com/', '<img src=x onerror=alert(1)>', 'paragraph', 'wm-ve-component:evil', 'wm-ve-component:paragraph ', ' wm-ve-component:paragraph', 'wm-ve-component:Paragraph', 'WM-VE-COMPONENT:paragraph', 'wm-ve-component:', 'xwm-ve-component:paragraph'])
|
||||
assert.equal(core.dragComponentId(dt({ 'text/plain': bad })), null, JSON.stringify(bad));
|
||||
assert.equal(core.dragComponentId(dt({ 'text/wm-ve-component': 'evil' })), null);
|
||||
assert.equal(core.dragComponentId(dt({ 'text/wm-ve-component': 'evil', 'text/plain': 'wm-ve-component:paragraph' })), null, '전용 MIME 이 있으면 그 값만 믿는다');
|
||||
assert.equal(core.dragComponentId(null), null); assert.equal(core.dragComponentId({}), null);
|
||||
assert.equal(core.dragComponentId({ types: ['text/plain'], getData() { throw new Error('denied'); } }), null, 'getData 예외는 거절로');
|
||||
});
|
||||
|
||||
test('visual-editor-ui.js 팔레트 dragstart: 전용 MIME 과 함께 이름 공간이 붙은 text/plain 을 넣는다(sandbox iframe 으로 넘어가는 표준 종류)', async () => {
|
||||
const { ve, document } = loadUi({ session: 'ab'.repeat(16), hash: 'c'.repeat(64), rev: 3, canvas: '<!doctype html><html><head></head><body><p>x</p></body></html>', running: false });
|
||||
await ve.open('index.html');
|
||||
const items = document.created.filter((e) => e.tagName === 'li' && e.className === 've-comp');
|
||||
assert.equal(items.length, core.COMPONENTS.length, '팔레트 항목 수');
|
||||
items.forEach((li, i) => {
|
||||
assert.ok(li._ev.dragstart && li._ev.dragstart.length, 'dragstart 처리기');
|
||||
const data = {}; const e = { dataTransfer: { setData: (k, v) => { data[k] = String(v); }, effectAllowed: '' } };
|
||||
li._ev.dragstart.forEach((fn) => fn(e));
|
||||
const id = core.COMPONENTS[i].id;
|
||||
assert.equal(data['text/wm-ve-component'], id, '전용 MIME 유지');
|
||||
assert.equal(data['text/plain'], core.componentDragText(id), 'text/plain 은 이름 공간 + 검증된 id');
|
||||
assert.equal(e.dataTransfer.effectAllowed, 'copy');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 캔버스 런타임(wm-ve-canvas.js)을 실행할 수 있는 작은 가짜 DOM.
|
||||
* 런타임이 쓰는 표면만 구현한다: 요소 트리(appendChild·before·after·insertBefore·remove·cloneNode), 속성·classList·style,
|
||||
* innerHTML/outerHTML(간단한 HTML 파서·직렬화), querySelectorAll/closest/matches(태그·[속성]·[속성=값]·쉼표 목록), getBoundingClientRect(테스트가 지정),
|
||||
* document(createElement·template.content·elementFromPoint·addEventListener), window.parent.postMessage(메시지 기록).
|
||||
*/
|
||||
function canvasDom(bodyHtml) {
|
||||
const VOID = new Set(['img', 'hr', 'br', 'input', 'meta', 'link', 'base', 'source', 'area']);
|
||||
const esc = (s) => String(s).replace(/&/g, '&').replace(/"/g, '"');
|
||||
class FNode {
|
||||
constructor(tag) { this.nodeType = 1; this.tagName = tag.toUpperCase(); this._attrs = new Map(); this._kids = []; this._text = ''; this.parentElement = null; this.style = {}; this._ev = {}; this.hidden = false; this._rect = null;
|
||||
const self = this; const cls = () => (self.getAttribute('class') || '').split(/\s+/).filter(Boolean); const set = (a) => { if (a.length) self.setAttribute('class', a.join(' ')); else self.removeAttribute('class'); };
|
||||
this.classList = { add: (...c) => set([...new Set([...cls(), ...c])]), remove: (...c) => set(cls().filter((x) => !c.includes(x))), toggle: (c, on) => { const has = cls().includes(c); const want = on === undefined ? !has : !!on; if (want && !has) set([...cls(), c]); if (!want && has) set(cls().filter((x) => x !== c)); return want; }, contains: (c) => cls().includes(c), get length() { return cls().length; }, [Symbol.iterator]() { return cls()[Symbol.iterator](); } }; }
|
||||
get className() { return this.getAttribute('class') || ''; } set className(v) { if (v) this.setAttribute('class', v); else this.removeAttribute('class'); }
|
||||
get id() { return this.getAttribute('id') || ''; }
|
||||
get attributes() { return [...this._attrs].map(([name, value]) => ({ name, value })); }
|
||||
setAttribute(k, v) { this._attrs.set(k, String(v)); } getAttribute(k) { return this._attrs.has(k) ? this._attrs.get(k) : null; } hasAttribute(k) { return this._attrs.has(k); } removeAttribute(k) { this._attrs.delete(k); }
|
||||
get children() { return [...this._kids]; } get firstElementChild() { return this._kids[0] || null; }
|
||||
get nextElementSibling() { const p = this.parentElement; if (!p) return null; return p._kids[p._kids.indexOf(this) + 1] || null; }
|
||||
get textContent() { return this._text + this._kids.map((k) => k.textContent).join(''); } set textContent(v) { this._text = String(v); this._kids = []; }
|
||||
get innerHTML() { return this._text + this._kids.map((k) => k.outerHTML).join(''); } set innerHTML(v) { this._text = ''; this._kids = []; parse(String(v)).forEach((k) => this.appendChild(k)); }
|
||||
get outerHTML() { const t = this.tagName.toLowerCase(); const a = [...this._attrs].map(([k, v]) => ` ${k}="${esc(v)}"`).join(''); return VOID.has(t) ? `<${t}${a}>` : `<${t}${a}>${this.innerHTML}</${t}>`; }
|
||||
appendChild(n) { if (n.parentElement) n.remove(); n.parentElement = this; this._kids.push(n); return n; }
|
||||
insertBefore(n, ref) { if (!ref) return this.appendChild(n); if (n.parentElement) n.remove(); const i = this._kids.indexOf(ref); n.parentElement = this; this._kids.splice(i < 0 ? this._kids.length : i, 0, n); return n; }
|
||||
before(n) { this.parentElement.insertBefore(n, this); } after(n) { this.parentElement.insertBefore(n, this.nextElementSibling); }
|
||||
remove() { const p = this.parentElement; if (!p) return; p._kids.splice(p._kids.indexOf(this), 1); this.parentElement = null; }
|
||||
cloneNode(deep) { const c = new FNode(this.tagName); for (const [k, v] of this._attrs) c._attrs.set(k, v); c._text = this._text; c._rect = this._rect; if (deep) this._kids.forEach((k) => c.appendChild(k.cloneNode(true))); return c; }
|
||||
contains(n) { return n === this || this._kids.some((k) => k.contains(n)); }
|
||||
_all() { return this._kids.flatMap((k) => [k, ...k._all()]); }
|
||||
matches(sel) { return String(sel).split(',').some((s) => { s = s.trim(); const m = s.match(/^([a-zA-Z*][\w-]*)?((?:\[[^\]]+\])*)$/); if (!m) return false; if (m[1] && m[1] !== '*' && m[1].toUpperCase() !== this.tagName) return false; for (const a of (m[2] || '').match(/\[[^\]]+\]/g) || []) { const am = a.match(/^\[([^=\]]+)(?:=["']?([^"'\]]*)["']?)?\]$/); if (!am) return false; if (!this.hasAttribute(am[1])) return false; if (am[2] !== undefined && this.getAttribute(am[1]) !== am[2]) return false; } return true; }); }
|
||||
closest(sel) { let c = this; while (c && c.nodeType === 1) { if (c.matches(sel)) return c; c = c.parentElement; } return null; }
|
||||
querySelectorAll(sel) { return this._all().filter((n) => n.matches(sel)); } querySelector(sel) { return this.querySelectorAll(sel)[0] || null; }
|
||||
getBoundingClientRect() { return this._rect || { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 }; }
|
||||
scrollIntoView() {} focus() {} addEventListener(t, fn) { (this._ev[t] || (this._ev[t] = [])).push(fn); } removeEventListener() {}
|
||||
setPointerCapture(id) { this._captured = id; } releasePointerCapture() { this._captured = null; }
|
||||
}
|
||||
function parse(html) {
|
||||
const root = new FNode('#root'); let cur = root;
|
||||
const re = /<!--[\s\S]*?-->|<\/([a-zA-Z][\w-]*)\s*>|<([a-zA-Z][\w-]*)((?:\s+[^\s=>/]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>|([^<]+)/g; let m;
|
||||
while ((m = re.exec(html))) {
|
||||
if (m[1]) { if (cur !== root) cur = cur.parentElement || root; }
|
||||
else if (m[2]) { const el = new FNode(m[2]); const ar = /([^\s=>/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g; let a; while ((a = ar.exec(m[3] || ''))) el.setAttribute(a[1], a[2] !== undefined ? a[2] : a[3] !== undefined ? a[3] : a[4] !== undefined ? a[4] : ''); cur.appendChild(el); if (!m[4] && !VOID.has(m[2].toLowerCase())) cur = el; }
|
||||
else if (m[5] && m[5].trim()) cur._text += m[5].replace(/"/g, '"').replace(/&/g, '&');
|
||||
}
|
||||
return [...root._kids]; // 복사본: appendChild 가 원래 부모(root)에서 떼어 내며 목록을 바꾸므로
|
||||
}
|
||||
const doc = { readyState: 'complete', title: '테스트', _ev: {}, addEventListener(t, fn) { (doc._ev[t] || (doc._ev[t] = [])).push(fn); }, removeEventListener() {}, execCommand() {},
|
||||
createElement: (t) => { const n = new FNode(t); if (t === 'template') n.content = n; return n; }, elementFromPoint: () => null, querySelectorAll: (s) => doc.body.querySelectorAll(s) };
|
||||
doc.documentElement = new FNode('html'); doc.head = new FNode('head'); doc.body = new FNode('body'); doc.documentElement.appendChild(doc.head); doc.documentElement.appendChild(doc.body); doc.body.innerHTML = bodyHtml;
|
||||
return doc;
|
||||
}
|
||||
/** 캔버스 런타임을 가짜 DOM 위에서 부트하고, 이벤트를 쏘는 fire()·부모가 받은 메시지·본문 자식 목록을 돌려준다 */
|
||||
function loadCanvas(bodyHtml) {
|
||||
const doc = canvasDom(bodyHtml); const messages = []; const transfers = []; let parentPort = null;
|
||||
const ctx = { document: doc, console, setTimeout, clearTimeout, getComputedStyle: () => ({ display: 'block', textAlign: 'start' }), __WM_VE__: { session: 'sess-1234', parentOrigin: 'http://h' }, scrollX: 0, scrollY: 0, innerWidth: 800, innerHeight: 600, _ev: {},
|
||||
addEventListener(t, fn) { (ctx._ev[t] || (ctx._ev[t] = [])).push(fn); }, removeEventListener(t, fn) { ctx._ev[t] = (ctx._ev[t] || []).filter((f) => f !== fn); }, MessageChannel: function () { const ch = fakeChannel(); this.port1 = ch.port1; this.port2 = ch.port2; } };
|
||||
ctx.window = ctx;
|
||||
/** 부모 창: ready 와 함께 넘어온 포트를 받아 그 뒤 캔버스가 포트로 보내는 모든 이벤트를 messages 에 쌓는다 */
|
||||
ctx.parent = { postMessage: (m, origin, transfer) => { messages.push(m); transfers.push({ origin, ports: transfer || [] }); if (transfer && transfer[0]) { parentPort = transfer[0]; parentPort.onmessage = (e) => messages.push(e.data); } } };
|
||||
vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'public', 'visual-editor-core.js'), 'utf8'), ctx);
|
||||
vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'public', 'wm-ve-canvas.js'), 'utf8'), ctx, { filename: 'wm-ve-canvas.js' });
|
||||
const fire = (type, ev) => { ev.defaultPrevented = false; ev.preventDefault = () => { ev.defaultPrevented = true; }; ev.target = ev.target || doc.documentElement; (doc._ev[type] || []).forEach((fn) => fn(ev)); return ev; };
|
||||
const fireWin = (type, ev) => { ev = ev || {}; ev.preventDefault = () => { ev.defaultPrevented = true; }; (ctx._ev[type] || []).slice().forEach((fn) => fn(ev)); return ev; };
|
||||
const fireOn = (el, type, ev) => { ev = ev || {}; ev.preventDefault = () => { ev.defaultPrevented = true; }; ev.target = ev.target || el; (el._ev[type] || []).forEach((fn) => fn(ev)); return ev; };
|
||||
/** 부모가 연결된 포트로 명령을 보낸다(세션 일치). over.data 로 봉투를 통째로 바꿀 수 있다 */
|
||||
const fromParent = (type, payload, over) => { const data = over && over.data ? over.data : core.makeMessage('sess-1234', type, payload); if (!parentPort) throw new Error('포트가 연결되지 않음'); parentPort.postMessage(data); return data; };
|
||||
/** 창(window.postMessage) 으로 온 명령 — 보낸 창·출처·세션이 모두 맞는, 예전 규약의 메시지. 연결 규약에서는 처리되지 않아야 한다 */
|
||||
const fromWindow = (type, payload) => { const ev = { data: core.makeMessage('sess-1234', type, payload), source: ctx.parent, origin: 'http://h' }; (ctx._ev.message || []).forEach((fn) => fn(ev)); return ev; };
|
||||
const kids = () => doc.body.children.filter((c) => !c.hasAttribute('data-wm-ve-ui'));
|
||||
const dropBar = () => doc.body.children.find((c) => c.classList.contains('wm-ve-drop'));
|
||||
const tools = () => doc.body.children.find((c) => c.classList.contains('wm-ve-tools'));
|
||||
return { doc, ctx, messages, transfers, port: () => parentPort, fire, fireWin, fireOn, fromParent, fromWindow, kids, dropBar, tools };
|
||||
}
|
||||
|
||||
test('wm-ve-canvas.js 연결 규약: ready 와 함께 MessagePort 를 부모 출처로 넘기고 그 뒤 명령은 포트로만 받는다 — 창으로 온 명령은 보낸 창·출처·세션이 맞아도 처리하지 않고, 세션이 다른 포트 메시지도 무시한다', () => {
|
||||
const { messages, transfers, port, fromParent, fromWindow, kids } = loadCanvas('<h1>제목</h1>');
|
||||
const ready = messages.find((m) => m.type === 'ready'); assert.ok(ready, 'ready'); assert.equal(transfers[0].origin, 'http://h', 'ready 는 부모 출처로만'); assert.ok(port(), 'ready 와 함께 포트가 넘어온다');
|
||||
const n0 = kids().length; const m0 = messages.length;
|
||||
fromWindow('insert', { component: 'paragraph', where: 'end' }); fromWindow('requestBody', {});
|
||||
assert.equal(kids().length, n0, '창 명령으로는 아무것도 넣지 않는다'); assert.equal(messages.slice(m0).length, 0, '창 명령에 응답(body)하지 않는다');
|
||||
fromParent('requestBody', {}); assert.equal(messages.at(-1).type, 'body', '포트 명령에는 응답'); assert.equal(messages.at(-1).session, 'sess-1234');
|
||||
fromParent('insert', { component: 'paragraph', where: 'end' }); assert.equal(kids().length, n0 + 1, '포트 명령은 처리'); assert.equal(messages.at(-1).type, 'changed');
|
||||
const m1 = messages.length; fromParent('insert', { component: 'paragraph', where: 'end' }, { data: core.makeMessage('other', 'insert', { component: 'paragraph', where: 'end' }) }); fromParent('x', {}, { data: { wmve: 1, session: 'sess-1234', type: 'evil', payload: {} } }); fromParent('x', {}, { data: 'insert' });
|
||||
assert.equal(kids().length, n0 + 1, '세션·종류·형식이 틀린 포트 메시지는 무시'); assert.equal(messages.slice(m1).length, 0);
|
||||
});
|
||||
|
||||
test('wm-ve-canvas.js: 캔버스 안의 이동·제출 활성화(a·area href 클릭, 제출 버튼, 폼 submit)는 편집·미리보기 모드 모두에서 막힌다', () => {
|
||||
const { doc, fire, fromParent } = loadCanvas('<map name="m"><area href="x.html" shape="rect" coords="0,0,1,1"></map><a href="y.html">링크</a><form action="z.html"><button type="submit">보내기</button><input type="submit" value="s"></form>');
|
||||
const area = doc.body.querySelector('area'); const a = doc.body.querySelector('a'); const btn = doc.body.querySelector('button'); const inp = doc.body.querySelector('input'); const form = doc.body.querySelector('form');
|
||||
for (const mode of ['edit', 'preview']) {
|
||||
fromParent('setMode', { mode });
|
||||
for (const el of [area, a, btn, inp]) assert.equal(fire('click', { target: el }).defaultPrevented, true, `${mode}: ${el.tagName} 클릭 기본 동작 차단`);
|
||||
// 가운데 버튼(auxclick)의 기본 동작은 "새 탭에서 열기" — 실제 Chrome 에서 sandbox(allow-popups 없음) 프레임의 링크 가운데 클릭이 새 페이지를 만든 것을 게이트가 관찰했다 → auxclick 도 막는다
|
||||
for (const el of [area, a]) assert.equal(fire('auxclick', { target: el, button: 1 }).defaultPrevented, true, `${mode}: ${el.tagName} 가운데 클릭(auxclick) 차단`);
|
||||
assert.equal(fire('submit', { target: form }).defaultPrevented, true, `${mode}: submit 차단`);
|
||||
}
|
||||
// 활성화 요소가 아닌 곳의 auxclick 은 건드리지 않는다
|
||||
assert.equal(fire('auxclick', { target: doc.body, button: 1 }).defaultPrevented, false);
|
||||
});
|
||||
|
||||
test('prepareCanvas: 이동 활성화 방어 — meta refresh 는 보존 속성으로 바뀌고, CSP 가 form-action 을 막으며, 캔버스 base 는 target=_blank(sandbox 에서 새 창 이동은 열리지 않는다)', () => {
|
||||
const html = ve.prepareCanvas('<!doctype html><html><head><meta http-equiv="refresh" content="0;url=https://e/"></head><body><a href="x.html">x</a></body></html>', OPTS);
|
||||
assert.ok(!/<meta http-equiv="refresh"/.test(html) && html.includes('data-wm-ve-http-equiv="refresh"'), 'meta refresh 비활성화');
|
||||
assert.match(html, /Content-Security-Policy" content="[^"]*form-action 'none'/);
|
||||
assert.match(html, /<base href="http:\/\/h\/abc\/" target="_blank" data-wm-ve-ui>/);
|
||||
});
|
||||
|
||||
test('wm-ve-canvas.js duplicate: 복제본은 뿌리뿐 아니라 모든 자손의 id 도 지우고, 원본의 id 는 그대로 둔다', () => {
|
||||
const { doc, fromParent, kids, messages } = loadCanvas('<section id="s"><h2 id="t">x</h2><p>y<span id="u">z</span></p></section>');
|
||||
fromParent('select', { path: [0] }); fromParent('duplicate', {});
|
||||
assert.equal(kids().length, 2); const [orig, copy] = kids();
|
||||
assert.equal(orig.getAttribute('id'), 's'); assert.equal(orig.querySelector('h2').getAttribute('id'), 't'); assert.equal(orig.querySelector('span').getAttribute('id'), 'u', '원본 그대로');
|
||||
assert.equal(copy.hasAttribute('id'), false); assert.equal(copy.querySelectorAll('[id]').length, 0, '복제본 자손에 id 없음');
|
||||
assert.equal(doc.body.querySelectorAll('[id=t]').length, 1); assert.equal(messages.at(-1).type, 'changed'); assert.ok(!/id="t".*id="t"/s.test(messages.at(-1).payload.body));
|
||||
});
|
||||
|
||||
test('wm-ve-canvas.js 기존 블록 끌기: 손잡이 pointerdown 이 포인터를 캡처하고, pointercancel·창 blur·Escape 가 끌기 상태(dragging 클래스·놓기 표시·창 리스너·캡처)를 모두 정리하며 이후 pointerup 은 옮기지 않는다 — 키보드 Alt+↓ 는 그대로', () => {
|
||||
const { doc, ctx, messages, fire, fireWin, fireOn, fromParent, kids, dropBar, tools } = loadCanvas('<h1>제목</h1><p>본문</p>');
|
||||
const [h1, p] = kids(); h1._rect = { top: 0, bottom: 40, left: 0, right: 600, width: 600, height: 40 }; p._rect = { top: 50, bottom: 80, left: 0, right: 600, width: 600, height: 30 };
|
||||
doc.elementFromPoint = (x, y) => (y < 45 ? h1 : y < 85 ? p : null);
|
||||
fromParent('select', { path: [0] }); const grip = tools().querySelector('[class="wm-ve-grip"]'); assert.ok(grip, '끌기 손잡이');
|
||||
const winCount = (t) => (ctx._ev[t] || []).length; const base = { pointermove: winCount('pointermove'), pointerup: winCount('pointerup'), pointercancel: winCount('pointercancel'), blur: winCount('blur') };
|
||||
const start = () => { fireOn(grip, 'pointerdown', { pointerId: 3, clientX: 5, clientY: 5 }); assert.equal(grip._captured, 3, 'setPointerCapture'); assert.equal(h1.classList.contains('wm-ve-dragging'), true); fireWin('pointermove', { clientX: 10, clientY: 70, pointerId: 3 }); assert.equal(dropBar().hidden, false, '놓기 표시'); };
|
||||
const clean = (label) => { assert.equal(h1.classList.contains('wm-ve-dragging'), false, label + ': dragging 클래스 제거'); assert.equal(dropBar().hidden, true, label + ': 놓기 표시 숨김'); assert.equal(grip._captured, null, label + ': 캡처 해제'); for (const t of Object.keys(base)) assert.equal(winCount(t), base[t], label + ': 창 리스너 ' + t + ' 정리'); };
|
||||
const order = () => kids().map((k) => k.tagName).join(',');
|
||||
// 1) pointercancel
|
||||
start(); const m1 = messages.length; fireWin('pointercancel', { pointerId: 3 }); clean('pointercancel'); fireWin('pointerup', { clientX: 10, clientY: 70, pointerId: 3 }); assert.equal(order(), 'H1,P', '취소 뒤 up 은 옮기지 않는다'); assert.equal(messages.slice(m1).filter((m) => m.type === 'changed').length, 0);
|
||||
// 2) 창 blur(iframe 밖으로 포커스가 나감)
|
||||
start(); fireWin('blur', {}); clean('blur'); fireWin('pointerup', { clientX: 10, clientY: 70, pointerId: 3 }); assert.equal(order(), 'H1,P');
|
||||
// 3) Escape
|
||||
start(); fire('keydown', { key: 'Escape', ctrlKey: false, metaKey: false, altKey: false }); clean('Escape'); fireWin('pointerup', { clientX: 10, clientY: 70, pointerId: 3 }); assert.equal(order(), 'H1,P');
|
||||
// 4) 정상 놓기는 여전히 옮긴다, 뒤처리 동일
|
||||
start(); fireWin('pointerup', { clientX: 10, clientY: 70, pointerId: 3 }); assert.equal(order(), 'P,H1', '정상 끌어 옮기기'); clean('pointerup'); assert.equal(messages.at(-1).type, 'changed');
|
||||
// 5) 키보드 대안 보존
|
||||
fire('keydown', { key: 'ArrowUp', altKey: true, ctrlKey: false, metaKey: false }); assert.equal(order(), 'H1,P', 'Alt+↑');
|
||||
});
|
||||
|
||||
test('wm-ve-canvas.js applyProps·core.safeUrlInput: 엔티티·제어 문자로 감춘 javascript: 주소는 속성 창 값으로도 들어가지 않는다', () => {
|
||||
for (const bad of ['javascript:alert(1)', 'java:script:alert(1)', 'javascript:alert(1)', 'java\u0000script:alert(1)', '
javascript:alert(1)', 'java&bogus;script:alert(1)']) { const r = core.safeUrlInput(bad, 'link'); assert.equal(r.ok, false, bad); }
|
||||
for (const good of ['https://e/?a=1&b=2', 'about.html?x:y', '#top', 'mailto:a@b.c']) assert.equal(core.safeUrlInput(good, 'link').ok, true, good);
|
||||
const { doc, fromParent, messages } = loadCanvas('<a href="about.html">x</a>');
|
||||
fromParent('select', { path: [0] }); const m0 = messages.length; fromParent('setProps', { props: { href: 'javascript:alert(1)' } });
|
||||
assert.equal(doc.body.querySelector('a').getAttribute('href'), 'about.html', 'href 불변'); assert.equal(messages.slice(m0).filter((m) => m.type === 'error').length, 1); assert.equal(messages.slice(m0).filter((m) => m.type === 'changed').length, 0);
|
||||
});
|
||||
|
||||
test('core 포인터 끌기 규약: 메시지 종류·문턱·iframe 상대 좌표 계산(테두리 보정)·캔버스 좌표 검증', () => {
|
||||
for (const t of ['paletteDragAt', 'paletteDragCancel', 'paletteDropAt']) assert.ok(core.MESSAGE_TYPES.toCanvas.includes(t), t + ' 는 부모→캔버스 종류');
|
||||
assert.ok(core.POINTER_DRAG_THRESHOLD >= 3 && core.POINTER_DRAG_THRESHOLD <= 10, '끌기 시작 문턱(px)');
|
||||
// iframe 의 화면 사각형(left 300, top 100, 폭 600, 높이 400) + 테두리 12px(모바일 미리보기 틀) → 안쪽 좌표
|
||||
const rect = { left: 300, top: 100, width: 624, height: 424, right: 924, bottom: 524 }; const box = { left: 12, top: 12, width: 600, height: 400 };
|
||||
assert.deepEqual(core.pointInFrame(412, 212, rect, box), { inside: true, x: 100, y: 100 });
|
||||
assert.deepEqual(core.pointInFrame(312, 112, rect, box), { inside: true, x: 0, y: 0 }, '안쪽 왼쪽 위 모서리');
|
||||
assert.equal(core.pointInFrame(305, 150, rect, box).inside, false, '테두리 위는 바깥');
|
||||
assert.equal(core.pointInFrame(10, 10, rect, box).inside, false); assert.equal(core.pointInFrame(913, 300, rect, box).inside, false, '오른쪽 안쪽 경계 밖');
|
||||
assert.deepEqual(core.pointInFrame(350, 150, { left: 300, top: 100, width: 600, height: 400 }), { inside: true, x: 50, y: 50 }, 'box 생략 = 테두리 0');
|
||||
// 캔버스 쪽 검증: 유한한 숫자이고 뷰포트 안일 때만
|
||||
assert.deepEqual(core.canvasPoint(10, 20, 800, 600), { x: 10, y: 20 }); assert.deepEqual(core.canvasPoint(800, 600, 800, 600), { x: 800, y: 600 });
|
||||
for (const bad of [[-1, 0], [0, -1], [801, 0], [0, 601], [NaN, 0], ['10', 20], [Infinity, 0], [null, 0], [undefined, undefined]]) assert.equal(core.canvasPoint(bad[0], bad[1], 800, 600), null, JSON.stringify(bad));
|
||||
assert.equal(core.isComponentId('paragraph'), true); assert.equal(core.isComponentId('evil'), false); assert.equal(core.isComponentId(''), false); assert.equal(core.isComponentId(null), false);
|
||||
});
|
||||
|
||||
test('visual-editor-ui.js 포인터 끌기 다리: 팔레트 항목 pointerdown → 문턱 뒤 이동 추적 → iframe 안에서만 상대 좌표 paletteDragAt, 밖으로 나가면 paletteDragCancel, 안에서 pointerup 이면 paletteDropAt; "추가" 버튼·Escape·pointercancel 처리', async () => {
|
||||
const ui = loadUi({ session: 'ab'.repeat(16), hash: 'c'.repeat(64), rev: 3, canvas: '<!doctype html><html><head></head><body><p>x</p></body></html>', running: false }); const { ve, document, els } = ui;
|
||||
const frameEl = document.getElementById('frame'); frameEl.contentWindow.postMessage = () => { throw new Error('연결 뒤에는 창 메시지를 쓰지 않는다'); };
|
||||
frameEl.getBoundingClientRect = () => ({ left: 300, top: 100, width: 600, height: 400, right: 900, bottom: 500 }); frameEl.clientLeft = 0; frameEl.clientTop = 0; frameEl.clientWidth = 600; frameEl.clientHeight = 400; void els;
|
||||
await ve.open('index.html'); const sent = connectCanvas(ui, 'ab'.repeat(16)).sent; sent.length = 0; // 명령은 MessagePort 로 간다(연결 규약 테스트 참고)
|
||||
const li = document.created.find((e) => e.tagName === 'li' && e.className === 've-comp'); const compId = core.COMPONENTS[0].id;
|
||||
assert.ok(li._ev.pointerdown && li._ev.pointermove && li._ev.pointerup, 'pointerdown/move/up 처리기');
|
||||
const ev = (x, y, over) => Object.assign({ clientX: x, clientY: y, pointerId: 7, pointerType: 'mouse', button: 0, target: li, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }, over || {});
|
||||
const types = () => sent.map((m) => m.type); const plain = (o) => JSON.parse(JSON.stringify(o)); // vm 컨텍스트 객체는 프로토타입이 달라 JSON 으로 비교
|
||||
|
||||
// 1) 바깥에서 누르고 문턱 아래로 움직임 → 메시지 없음, 포인터 캡처는 걸림
|
||||
li.fire('pointerdown', ev(100, 100)); assert.equal(li._captured, 7, 'setPointerCapture'); li.fire('pointermove', ev(102, 101)); assert.deepEqual(types(), []);
|
||||
// 2) 문턱 넘어 iframe 바깥 이동 → 아직 메시지 없음(끌기 시작 표시만)
|
||||
li.fire('pointermove', ev(130, 130)); assert.deepEqual(types(), []); assert.equal(li.classList.contains('ve-comp-dragging'), true); assert.equal(document.body.classList.contains('ve-palette-dragging'), true);
|
||||
// 3) iframe 안으로 → paletteDragAt(상대 좌표: 400-300, 200-100)
|
||||
li.fire('pointermove', ev(400, 200)); assert.deepEqual(plain(sent.at(-1)), core.makeMessage('ab'.repeat(16), 'paletteDragAt', { component: compId, x: 100, y: 100 }), '봉투(wmve·세션·종류·payload) 그대로');
|
||||
// 4) 밖으로 → paletteDragCancel 한 번, 다시 안으로 → paletteDragAt
|
||||
li.fire('pointermove', ev(10, 10)); assert.equal(types().at(-1), 'paletteDragCancel'); li.fire('pointermove', ev(5, 5)); assert.equal(types().filter((t) => t === 'paletteDragCancel').length, 1, '바깥에서 계속 움직여도 취소는 한 번');
|
||||
li.fire('pointermove', ev(450, 250)); assert.deepEqual(plain(sent.at(-1).payload), { component: compId, x: 150, y: 150 });
|
||||
// 5) 안에서 pointerup → paletteDropAt(상대 좌표), 캡처·표시 해제
|
||||
li.fire('pointerup', ev(460, 260)); assert.equal(sent.at(-1).type, 'paletteDropAt'); assert.deepEqual(plain(sent.at(-1).payload), { component: compId, x: 160, y: 160 });
|
||||
assert.equal(li._captured, null, 'releasePointerCapture'); assert.equal(li.classList.contains('ve-comp-dragging'), false); assert.equal(document.body.classList.contains('ve-palette-dragging'), false);
|
||||
const n5 = sent.length; li.fire('pointermove', ev(470, 270)); li.fire('pointerup', ev(470, 270)); assert.equal(sent.length, n5, '끝난 뒤 이벤트는 무시');
|
||||
|
||||
// 6) "추가" 버튼 위에서 누르면 끌기를 시작하지 않는다(버튼 클릭 보존)
|
||||
sent.length = 0; const btn = { tagName: 'BUTTON' }; li.fire('pointerdown', ev(100, 100, { target: { closest: (s) => (s === 'button' ? btn : null) } })); li.fire('pointermove', ev(400, 200)); li.fire('pointerup', ev(400, 200));
|
||||
assert.deepEqual(types(), [], '버튼에서 시작한 포인터는 끌기 아님'); assert.equal(li._captured, null);
|
||||
// 7) 짧은 클릭(문턱 미만에서 up) → 메시지 없음
|
||||
li.fire('pointerdown', ev(100, 100)); li.fire('pointerup', ev(101, 101)); assert.deepEqual(types(), []); assert.equal(li._captured, null);
|
||||
// 8) 터치 포인터는 다리를 쓰지 않는다(팔레트 스크롤 보존) · 보조 버튼도 아님
|
||||
li.fire('pointerdown', ev(100, 100, { pointerType: 'touch' })); li.fire('pointermove', ev(400, 200, { pointerType: 'touch' })); assert.deepEqual(types(), []); assert.equal(li._captured, null);
|
||||
li.fire('pointerdown', ev(100, 100, { button: 2 })); li.fire('pointermove', ev(400, 200, { button: 2 })); li.fire('pointerup', ev(400, 200, { button: 2 })); assert.deepEqual(types(), []);
|
||||
// 9) Escape 로 취소 → paletteDragCancel, 이후 pointerup 은 아무것도 보내지 않음
|
||||
li.fire('pointerdown', ev(100, 100)); li.fire('pointermove', ev(400, 200)); assert.equal(types().at(-1), 'paletteDragAt');
|
||||
document.fire('keydown', { key: 'Escape', ctrlKey: false, metaKey: false, preventDefault() {} }); assert.equal(types().at(-1), 'paletteDragCancel'); assert.equal(li._captured, null);
|
||||
const n9 = sent.length; li.fire('pointerup', ev(400, 200)); assert.equal(sent.length, n9, 'Escape 뒤 up 은 놓기 아님');
|
||||
// 10) pointercancel(브라우저가 끌기를 가로챔) → 취소 정리
|
||||
li.fire('pointerdown', ev(100, 100)); li.fire('pointermove', ev(400, 200)); li.fire('pointercancel', ev(400, 200)); assert.equal(types().at(-1), 'paletteDragCancel'); assert.equal(li._captured, null); assert.equal(document.body.classList.contains('ve-palette-dragging'), false);
|
||||
// 11) 포인터 끌기 중 브라우저 기본 HTML5 dragstart 가 나면 막는다(포인터 경로가 주 경로, pointercancel 로 끊기지 않게). 끌기 중이 아니면 HTML5 자료는 그대로 넣는다(대체 경로)
|
||||
li.fire('pointerdown', ev(100, 100)); li.fire('pointermove', ev(130, 130)); const data = {}; const ds = li.fire('dragstart', { dataTransfer: { setData: (k, v) => { data[k] = v; }, effectAllowed: '' }, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } });
|
||||
assert.equal(ds.defaultPrevented, true, '포인터 끌기 중 네이티브 dragstart 차단'); li.fire('pointerup', ev(10, 10));
|
||||
const data2 = {}; const ds2 = li.fire('dragstart', { dataTransfer: { setData: (k, v) => { data2[k] = v; }, effectAllowed: '' }, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } });
|
||||
assert.equal(ds2.defaultPrevented, false); assert.equal(data2['text/plain'], core.componentDragText(compId), 'HTML5 대체 경로 유지');
|
||||
// 12) 직접 편집을 끝낸 뒤에는 포인터 다리가 메시지를 보내지 않는다
|
||||
ve.close(true); sent.length = 0; li.fire('pointerdown', ev(100, 100)); li.fire('pointermove', ev(400, 200)); li.fire('pointerup', ev(400, 200)); assert.deepEqual(types(), []);
|
||||
});
|
||||
|
||||
test('wm-ve-canvas.js 동작: paletteDragAt 이 놓기 표시를 보이고, paletteDropAt 이 계산한 자리에 구성 요소를 넣어 표식 없는 changed 를 한 번 보내며, 잘못된 id·좌표·미리보기 모드는 아무것도 하지 않고 표시를 숨긴다', () => {
|
||||
const { doc, messages, fromParent, fromWindow, kids, dropBar } = loadCanvas('<h1>제목</h1><p>본문</p>');
|
||||
const [h1, p] = kids(); h1._rect = { top: 0, bottom: 40, left: 0, right: 600, width: 600, height: 40 }; p._rect = { top: 50, bottom: 80, left: 0, right: 600, width: 600, height: 30 };
|
||||
doc.elementFromPoint = (x, y) => (y < 45 ? h1 : y < 85 ? p : null);
|
||||
const snapshot = () => doc.body.innerHTML; const n0 = kids().length;
|
||||
// 1) 끌기 위치 → 놓기 표시(h1 위쪽 절반 → h1 앞 선)
|
||||
fromParent('paletteDragAt', { component: 'paragraph', x: 10, y: 10 }); assert.equal(dropBar().hidden, false, '놓기 표시 보임'); assert.equal(dropBar().style.top, '-1px', 'h1 위 선');
|
||||
fromParent('paletteDragAt', { component: 'paragraph', x: 10, y: 70 }); assert.equal(dropBar().style.top, '79px', 'p 아래 선(아래쪽 절반)');
|
||||
// 2) 취소 → 숨김
|
||||
fromParent('paletteDragCancel', {}); assert.equal(dropBar().hidden, true);
|
||||
// 3) 잘못된 id·좌표는 표시를 숨기고 아무것도 하지 않는다
|
||||
fromParent('paletteDragAt', { component: 'paragraph', x: 10, y: 10 }); fromParent('paletteDragAt', { component: 'evil', x: 10, y: 10 }); assert.equal(dropBar().hidden, true, '모르는 id');
|
||||
fromParent('paletteDragAt', { component: 'paragraph', x: 10, y: 10 }); fromParent('paletteDragAt', { component: 'paragraph', x: -5, y: 10 }); assert.equal(dropBar().hidden, true, '뷰포트 밖 좌표');
|
||||
fromParent('paletteDragAt', { component: 'paragraph', x: 10, y: 10 }); fromParent('paletteDragAt', { component: 'paragraph', x: '10', y: 10 }); assert.equal(dropBar().hidden, true, '숫자 아닌 좌표');
|
||||
// 4) 놓기 → h1 앞에 문단 하나, changed 한 번(표식 없음), 새 요소 선택, 표시 숨김
|
||||
const m0 = messages.length; fromParent('paletteDragAt', { component: 'paragraph', x: 10, y: 10 });
|
||||
fromParent('paletteDropAt', { component: 'paragraph', x: 10, y: 10 });
|
||||
assert.equal(kids().length, n0 + 1); assert.equal(kids()[0].tagName, 'P'); assert.equal(kids()[0].textContent, '여기에 내용을 입력하세요.'); assert.equal(kids()[1], h1); assert.ok(kids()[0].hasAttribute('data-wm-ve-selected'));
|
||||
const changed = messages.slice(m0).filter((m) => m.type === 'changed'); assert.equal(changed.length, 1); assert.ok(changed[0].payload.body.startsWith('<p>여기에 내용을 입력하세요.</p><h1>제목</h1>'), changed[0].payload.body); assert.ok(!/data-wm-ve-|wm-ve-/.test(changed[0].payload.body));
|
||||
assert.equal(dropBar().hidden, true); assert.equal(messages.slice(m0).filter((m) => m.type === 'error').length, 0);
|
||||
// 5) 빈 곳(요소 없음) 놓기 → 본문 끝
|
||||
const before5 = kids().length; fromParent('paletteDropAt', { component: 'divider', x: 10, y: 500 }); assert.equal(kids().length, before5 + 1); assert.equal(kids().at(-1).tagName, 'HR', '끝에 구분선');
|
||||
// 6) 잘못된 입력은 아무것도 넣지 않고 표식·메시지도 없다: 모르는 id, 좌표 누락/범위 밖/문자열, payload 없음, 미리보기 모드
|
||||
const s6 = snapshot(); const n6 = kids().length; const m6 = messages.length;
|
||||
fromParent('paletteDropAt', { component: 'evil', x: 10, y: 10 }); fromParent('paletteDropAt', { component: 'paragraph' }); fromParent('paletteDropAt', { component: 'paragraph', x: 9999, y: 10 }); fromParent('paletteDropAt', { component: 'paragraph', x: '10', y: '10' }); fromParent('paletteDropAt', {});
|
||||
assert.equal(kids().length, n6); assert.equal(snapshot(), s6); assert.equal(dropBar().hidden, true);
|
||||
fromParent('setMode', { mode: 'preview' }); const m6b = messages.length; fromParent('paletteDragAt', { component: 'paragraph', x: 10, y: 10 }); assert.equal(dropBar().hidden, true, '미리보기 모드: 표시 없음'); fromParent('paletteDropAt', { component: 'paragraph', x: 10, y: 10 });
|
||||
assert.equal(kids().length, n6, '미리보기 모드: 삽입 없음'); assert.equal(messages.slice(m6b).filter((m) => m.type === 'changed' || m.type === 'error').length, 0);
|
||||
fromParent('setMode', { mode: 'edit' });
|
||||
assert.equal(messages.slice(m6, m6b).filter((m) => m.type === 'changed' || m.type === 'error').length, 0, '잘못된 입력에 changed·error 없음');
|
||||
// 7) 규약 밖 메시지(다른 세션의 포트 메시지·창으로 온 메시지)는 무시된다
|
||||
const n7 = kids().length; fromParent('paletteDropAt', { component: 'paragraph', x: 10, y: 10 }, { data: core.makeMessage('other', 'paletteDropAt', { component: 'paragraph', x: 10, y: 10 }) }); fromWindow('paletteDropAt', { component: 'paragraph', x: 10, y: 10 });
|
||||
assert.equal(kids().length, n7, '세션이 다르거나 창으로 오면 무시');
|
||||
});
|
||||
|
||||
test('wm-ve-canvas.js 동작: 전용 MIME 이 sandbox 경계에서 사라져도 이름 공간이 붙은 text/plain 으로 구성 요소 하나를 넣고, 임의 text/plain·모르는 id 는 아무것도 넣지 않는다', () => {
|
||||
const { doc, messages, fire, kids, dropBar } = loadCanvas('<h1>제목</h1><p>본문</p>');
|
||||
assert.ok(messages.some((m) => m.type === 'ready'), '런타임 부트'); assert.ok(dropBar() && dropBar().hidden === true, '놓기 표시는 처음엔 숨김');
|
||||
const [h1, p] = kids(); h1._rect = { top: 0, bottom: 40, left: 0, right: 600, width: 600, height: 40 }; p._rect = { top: 50, bottom: 80, left: 0, right: 600, width: 600, height: 30 };
|
||||
doc.elementFromPoint = (x, y) => (y < 45 ? h1 : y < 85 ? p : null);
|
||||
const dt = (data) => ({ types: Object.keys(data), getData: (k) => (k in data ? data[k] : ''), dropEffect: '' });
|
||||
const snapshot = () => doc.body.innerHTML; const n0 = kids().length; const m0 = messages.length;
|
||||
|
||||
// 1) 전용 MIME 없이 text/plain 만 보이는 dragover(브라우저는 drop 전까지 getData 를 막는다) → 끌기로 인식해 기본 동작을 막고 놓기 표시를 보인다
|
||||
const over = fire('dragover', { dataTransfer: { types: ['text/plain'], getData: () => '' , dropEffect: '' }, clientX: 10, clientY: 10 });
|
||||
assert.equal(over.defaultPrevented, true, 'dragover preventDefault(놓기 허용)'); assert.equal(over.dataTransfer.dropEffect, 'copy'); assert.equal(dropBar().hidden, false, '놓기 표시 보임');
|
||||
|
||||
// 2) drop: 전용 MIME 이 사라지고 text/plain 이름 공간 값만 남은 경우 → 문단 하나가 h1 앞(위쪽 절반에 놓음)에 들어간다
|
||||
const drop = fire('drop', { dataTransfer: dt({ 'text/plain': 'wm-ve-component:paragraph' }), clientX: 10, clientY: 10 });
|
||||
assert.equal(drop.defaultPrevented, true);
|
||||
assert.equal(kids().length, n0 + 1, '요소 하나 추가'); assert.equal(kids()[0].tagName, 'P'); assert.equal(kids()[0].textContent, '여기에 내용을 입력하세요.'); assert.equal(kids()[1], h1, 'h1 앞에 삽입');
|
||||
assert.equal(dropBar().hidden, true, '놓기 표시 숨김'); assert.ok(kids()[0].hasAttribute('data-wm-ve-selected'), '새 요소 선택');
|
||||
const changed = messages.slice(m0).filter((m) => m.type === 'changed'); assert.equal(changed.length, 1, 'changed 한 번');
|
||||
assert.ok(changed[0].payload.body.startsWith('<p>여기에 내용을 입력하세요.</p><h1>제목</h1>'), changed[0].payload.body); assert.ok(!/data-wm-ve-|wm-ve-/.test(changed[0].payload.body), '부모에 보낸 본문에 표식 없음');
|
||||
|
||||
// 3) 임의 text/plain(주소·HTML 조각)·모르는 id·정확히 일치하지 않는 값 → 아무것도 넣지 않고 표식도 남기지 않는다
|
||||
const before = snapshot(); const n1 = kids().length; const m1 = messages.length;
|
||||
for (const bad of ['https://example.com/x', '<img src=x onerror=alert(1)>', 'wm-ve-component:evil', 'wm-ve-component:paragraph ', 'paragraph']) {
|
||||
fire('dragover', { dataTransfer: { types: ['text/plain'], getData: () => '', dropEffect: '' }, clientX: 10, clientY: 60 });
|
||||
const d = fire('drop', { dataTransfer: dt({ 'text/plain': bad }), clientX: 10, clientY: 60 });
|
||||
assert.equal(kids().length, n1, '추가 없음: ' + JSON.stringify(bad)); assert.equal(snapshot(), before, '본문 불변: ' + JSON.stringify(bad));
|
||||
assert.equal(dropBar().hidden, true, '놓기 표시 숨김: ' + JSON.stringify(bad)); assert.equal(d.defaultPrevented, true, '브라우저 기본 drop(글 삽입·이동) 차단: ' + JSON.stringify(bad));
|
||||
}
|
||||
assert.equal(messages.slice(m1).filter((m) => m.type === 'changed' || m.type === 'error').length, 0, 'changed·error 메시지 없음');
|
||||
|
||||
// 4) 전용 MIME 이 남아 있는 경우(같은 출처 등)는 그대로 우선 — 제목(h2) 삽입
|
||||
fire('drop', { dataTransfer: dt({ 'text/wm-ve-component': 'heading', 'text/plain': 'wm-ve-component:paragraph' }), clientX: 10, clientY: 60 });
|
||||
assert.equal(kids().length, n1 + 1); assert.ok(kids().some((k) => k.tagName === 'H2' && k.textContent === '새 제목'), '전용 MIME 의 heading 이 우선');
|
||||
|
||||
// 5) 구성 요소 끌기가 아닌 것(파일·HTML 만) 은 dragover 도 drop 도 건드리지 않는다
|
||||
const n2 = kids().length; const s2 = snapshot();
|
||||
const o5 = fire('dragover', { dataTransfer: { types: ['Files'], getData: () => '', dropEffect: '' }, clientX: 10, clientY: 10 }); assert.equal(o5.defaultPrevented, false);
|
||||
const d5 = fire('drop', { dataTransfer: dt({ 'text/html': '<b>x</b>', 'text/uri-list': 'https://e/' }), clientX: 10, clientY: 10 }); assert.equal(d5.defaultPrevented, false); assert.equal(kids().length, n2); assert.equal(snapshot(), s2);
|
||||
});
|
||||
|
||||
test('core: 브라우저 전역·Node 모듈 양쪽에서 로드되고 런타임 파일은 문법 검사를 통과한다', () => {
|
||||
const ctx = { window: {} }; ctx.window.window = ctx.window;
|
||||
vm.runInNewContext(fs.readFileSync(path.join(ROOT, 'public', 'visual-editor-core.js'), 'utf8'), ctx);
|
||||
assert.ok(ctx.window.WMVisualCore && typeof ctx.window.WMVisualCore.createHistory === 'function');
|
||||
for (const f of ['public/wm-ve-canvas.js', 'public/visual-editor-ui.js']) { const src = fs.readFileSync(path.join(ROOT, f), 'utf8'); new vm.Script(src, { filename: f }); assert.ok(!/\beval\(|new Function\(/.test(src), f + ' 에 eval 없음'); }
|
||||
});
|
||||
|
||||
test('core.createHistory: 실행 취소·다시 실행·새 변경 뒤 다시 실행 지움·한도', () => {
|
||||
const h = core.createHistory(3); h.reset('a');
|
||||
assert.equal(h.canUndo(), false); assert.equal(h.canRedo(), false);
|
||||
h.push('b'); h.push('c'); assert.equal(h.current(), 'c'); assert.equal(h.canUndo(), true);
|
||||
assert.equal(h.undo(), 'b'); assert.equal(h.undo(), 'a'); assert.equal(h.undo(), null); assert.equal(h.canUndo(), false);
|
||||
assert.equal(h.redo(), 'b'); assert.equal(h.canRedo(), true); h.push('d'); assert.equal(h.canRedo(), false); assert.equal(h.redo(), null);
|
||||
h.push('e'); h.push('f'); assert.equal(h.size(), 3, '한도를 넘으면 오래된 것부터 버린다'); assert.equal(h.undo(), 'e'); assert.equal(h.undo(), 'd'); assert.equal(h.undo(), null);
|
||||
h.push('d'); assert.equal(h.current(), 'd', '같은 내용은 중복 기록하지 않는다'); assert.equal(h.canUndo(), false);
|
||||
});
|
||||
|
||||
test('core.createSaveState: 저장 안 됨 → 저장 중 → 저장됨/오류 전이와 한국어 표시', () => {
|
||||
const s = core.createSaveState();
|
||||
assert.equal(s.state(), 'clean'); assert.equal(s.isDirty(), false);
|
||||
assert.equal(s.beginSave(), false, '변경이 없으면 저장을 시작하지 않는다');
|
||||
s.markDirty(); assert.equal(s.state(), 'dirty'); assert.match(s.label(), /저장되지 않/);
|
||||
assert.equal(s.beginSave(), true); assert.equal(s.state(), 'saving'); assert.match(s.label(), /저장 중/);
|
||||
s.markDirty(); assert.equal(s.state(), 'saving', '저장 중의 추가 변경은 저장 뒤 dirty 로 남는다'); s.saveOk(); assert.equal(s.state(), 'dirty');
|
||||
s.beginSave(); s.saveOk(); assert.equal(s.state(), 'saved'); assert.match(s.label(), /저장됨/); assert.equal(s.isDirty(), false);
|
||||
s.markDirty(); s.beginSave(); s.saveFail('네트워크'); assert.equal(s.state(), 'error'); assert.match(s.label(), /저장 실패.*네트워크/); assert.equal(s.isDirty(), true);
|
||||
s.reset(); assert.equal(s.state(), 'clean');
|
||||
});
|
||||
|
||||
test('core.validateMessage: 보낸 창·출처·세션·형식·종류를 모두 확인해야 통과한다', () => {
|
||||
const frameWin = {}; const session = 's1';
|
||||
const okEv = { source: frameWin, origin: 'null', data: core.makeMessage(session, 'changed', { body: '<p>x</p>' }) };
|
||||
const chk = (ev, o = {}) => core.validateMessage(ev, { expectedSource: frameWin, expectedOrigin: 'null', session, direction: 'fromCanvas', ...o });
|
||||
assert.equal(chk(okEv), true);
|
||||
assert.equal(chk({ ...okEv, source: {} }), false, '다른 창');
|
||||
assert.equal(chk({ ...okEv, origin: 'http://evil' }), false, '다른 출처');
|
||||
assert.equal(chk({ ...okEv, data: core.makeMessage('other', 'changed', {}) }), false, '다른 세션');
|
||||
assert.equal(chk({ ...okEv, data: core.makeMessage(session, 'init', {}) }), false, '캔버스가 보낼 수 없는 종류');
|
||||
assert.equal(chk({ ...okEv, data: 'changed' }), false, '문자열');
|
||||
assert.equal(chk({ ...okEv, data: { ...okEv.data, payload: 'x' } }), false, 'payload 는 객체');
|
||||
assert.equal(chk(okEv, { session: '' }), false, '세션이 없으면 아무것도 통과하지 않는다');
|
||||
const toEv = { source: {}, origin: 'http://h', data: core.makeMessage(session, 'insert', { component: 'heading' }) };
|
||||
assert.equal(core.validateMessage(toEv, { expectedSource: toEv.source, expectedOrigin: 'http://h', session, direction: 'toCanvas' }), true);
|
||||
});
|
||||
|
||||
test('core.COMPONENTS: 필수 구성 요소가 있고 각 HTML 은 스크립트·핸들러·위험 주소가 없다', () => {
|
||||
const ids = core.COMPONENTS.map((c) => c.id);
|
||||
for (const need of ['section', 'heading', 'paragraph', 'button', 'image', 'divider', 'spacer']) assert.ok(ids.includes(need), need);
|
||||
for (const c of core.COMPONENTS) { assert.ok(c.label && /[가-힣]/.test(c.label), '한국어 이름'); const h = core.componentHtml(c.id); assert.ok(h && core.isSafeComponentHtml(h), c.id); assert.ok(!/<script|\son\w+=|javascript:/i.test(h)); }
|
||||
assert.equal(core.componentHtml('nope'), null); assert.equal(core.isSafeComponentHtml('<p onclick="x()">a</p>'), false); assert.equal(core.isSafeComponentHtml('<a href="javascript:1">a</a>'), false); assert.equal(core.isSafeComponentHtml('<iframe src="x"></iframe>'), false);
|
||||
});
|
||||
|
||||
test('core.moveIndex·dropIndexFor: 재배치 계산', () => {
|
||||
assert.deepEqual(core.moveIndex(['a', 'b', 'c'], 0, 2), ['b', 'c', 'a']); assert.deepEqual(core.moveIndex(['a', 'b', 'c'], 2, 0), ['c', 'a', 'b']); assert.deepEqual(core.moveIndex(['a', 'b'], 5, 0), ['a', 'b']);
|
||||
const rects = [{ top: 0, bottom: 100 }, { top: 100, bottom: 200 }, { top: 200, bottom: 300 }];
|
||||
assert.equal(core.dropIndexFor(rects, 20), 0); assert.equal(core.dropIndexFor(rects, 80), 1); assert.equal(core.dropIndexFor(rects, 160), 2); assert.equal(core.dropIndexFor(rects, 290), 3); assert.equal(core.dropIndexFor([], 10), 0);
|
||||
});
|
||||
|
||||
test('core.safeUrlInput·propsFor·labelFor: 속성 편집 규칙', () => {
|
||||
assert.equal(core.safeUrlInput('javascript:alert(1)', 'link').ok, false); assert.equal(core.safeUrlInput(' JavaScript:x', 'link').ok, false); assert.equal(core.safeUrlInput('data:text/html,x', 'link').ok, false);
|
||||
assert.equal(core.safeUrlInput('https://a.b/c', 'link').ok, true); assert.equal(core.safeUrlInput('about.html', 'link').value, 'about.html'); assert.equal(core.safeUrlInput('#top', 'link').ok, true); assert.equal(core.safeUrlInput('mailto:a@b.c', 'link').ok, true); assert.equal(core.safeUrlInput('tel:010', 'link').ok, true);
|
||||
assert.equal(core.safeUrlInput('data:image/png;base64,AAAA', 'image').ok, true); assert.equal(core.safeUrlInput('data:image/png;base64,AAAA', 'link').ok, false); assert.equal(core.safeUrlInput('data:text/html,<b>', 'image').ok, false);
|
||||
assert.match(core.safeUrlInput('javascript:1', 'link').error, /[가-힣]/);
|
||||
assert.ok(core.propsFor('a').includes('href') && core.propsFor('a').includes('text')); assert.ok(core.propsFor('img').includes('src') && core.propsFor('img').includes('alt') && !core.propsFor('img').includes('text'));
|
||||
for (const k of ['text', 'align', 'color', 'background', 'padding', 'margin']) assert.ok(core.propsFor('p').includes(k), k);
|
||||
assert.ok(!core.propsFor('hr').includes('text')); assert.ok(core.propsFor('section').includes('background'));
|
||||
assert.equal(core.labelFor('h1'), '제목 1'); assert.equal(core.labelFor('p'), '문단'); assert.ok(/[가-힣]/.test(core.labelFor('zzz')));
|
||||
});
|
||||
Reference in New Issue
Block a user