Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bfde82c5e | ||
|
|
0a56d4612c | ||
|
|
4b3714e37c | ||
|
|
12994279a5 | ||
|
|
648247f224 | ||
|
|
d8e7beeb86 | ||
|
|
dfb708e933 | ||
|
|
0b9c70e90c |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
# Ariweb Suite 개발 지시 — 작성자: Neo · v0.2.0
|
||||
|
||||
**작성자:** Neo
|
||||
**작성일:** 2026-10-28
|
||||
**이전 버전:** v0.1.1(최초 통합 개발 지시, Hera) · v0.1.4(미반영 오류 수정·HAL AI 대시보드·다중 AI 공동 작업창 지시, Neo)
|
||||
**이번 개정:** 도입 모듈 Webmaker 에 **직접 편집(시각 편집기)** 사용자 기능을 더한다. 새 사용자 기능이므로 가운데 버전 번호를 하나 올린다 — Suite 0.1.x → **0.2.0**, Webmaker 1.7.3 → **1.8.0**. v0.1.1 의 모든 기준과 v0.1.4 의 지시는 그대로 유효하며, 이 문서는 추가 요구만 적는다(v0.1.4 항목은 이번 구현 범위가 아니다).
|
||||
|
||||
## 개정 이력
|
||||
- **v0.1.1 · Hera · 2026-10-26:** 최초 통합 개발 지시, Webmaker·AriMail 재사용 원칙, 사용량 경고 70·80·90·95%.
|
||||
- **v0.1.4 · Neo · 2026-10-27:** patch 자동 증가 규칙, 미반영 오류 수정 전수 반영, HAL AI 대시보드, 다중 AI 공동 작업창.
|
||||
- **v0.2.0 · Neo · 2026-10-28:** Webmaker 직접 편집(끌어다 놓기 시각 편집기) — Suite `modules/webmaker` 와 기존 Webmaker 저장소 양쪽에 반영. 엄격한 TDD, 격리 작업 트리, 커밋·push·병합·배포는 Neo 가 별도 검토 뒤 수행.
|
||||
|
||||
## 1. 사용자 요구(약화 금지)
|
||||
Webmaker 가 초안을 만들어 보이는 페이지가 생긴 뒤, **직접 시각 편집 모드**를 더한다. Wix·아임웹 방식의 끌어다 놓기를 적용한다. 변경은 Suite 의 `modules/webmaker` 와 기존 Webmaker 저장소 **양쪽**에 반영한다.
|
||||
|
||||
## 2. 필수 동작(계약)
|
||||
1. 오른쪽 미리보기 도구 줄의 분명한 **직접 편집 전환**. 사이트와 HTML 페이지가 골라져 있을 때만 켜진다. 기존 소스 편집기는 유지한다.
|
||||
2. 시각 모드: 캔버스에서 보이는 글을 바로 편집, 보이는 요소 선택, 기존 블록 끌어 순서 바꾸기, 팔레트 구성 요소를 올바른 자리에 끌어다 놓기. 구성 요소 최소: 섹션/컨테이너·제목·문단·버튼/링크·이미지·구분선·여백.
|
||||
3. 선택 요소 조작: 이동·복제·삭제·요소에 맞는 기본 속성(글·링크 주소·이미지 주소/대체 글·정렬·색·배경·여백). Wix 전체 기능을 흉내 내지 않는 일관되고 안전한 첫 릴리스.
|
||||
4. 저장 상태(저장되지 않음/저장 중/저장됨/오류) 표시, 저장, 실행 취소/다시 실행, 버리기/다시 불러오기. Ctrl/Cmd+S 저장. 페이지·사이트 전환·화면 이동·창 닫기 전 저장하지 않은 작업 보호. 저장은 실제 선택 HTML 파일을 갱신하고 기존 Webmaker git/버전 이력에 남기며 미리보기·소스 편집기를 갱신하고 상대 경로 자원이 계속 동작한다. **직접 편집은 AI 캐시를 쓰지 않고 AI 수정으로 세지 않는다.**
|
||||
5. 시각 모드에서 **페이지 스크립트는 실행되지 않는다**. 캔버스는 빌더 쿠키·저장소·API 와 격리된다. 보낸 창·출처·세션을 검사하는 **한정된 postMessage 규약**만 쓰고, 임의 메시지나 HTML 속성을 권한으로 믿지 않는다. 저장 전에 편집기 표식을 제거하고 원본 doctype/head/styles/scripts 를 보존하며 편집하지 않은 코드를 조용히 지우지 않는다.
|
||||
6. 거절(한국어 안내): HTML 아닌 파일, 너무 큰 내용, 경로 탈출, 다른 사용자 사이트, 진행 중 AI 작업, 오래된 동시 저장, 잘못된 payload, `javascript:`/`data:text/html` 링크, 시각 구성 요소로 들어오는 인라인 이벤트 핸들러, 금지 요소/주소. 임의 HTML 실행 API 를 만들지 않는다.
|
||||
7. 기존 테넌트 격리·CSRF/출처 검사·sandbox·소스 편집기·모바일 미리보기·버전/복원·배포·가져오기·다국어·모듈·SEO·Site Brief 동작 보존. Suite 의 저장본 `rev` 동시 편집 충돌(§9.3)도 직접 편집 저장에 적용한다.
|
||||
8. 반응형: 팔레트·속성 창이 캔버스를 가리지 않음, 키보드 사용자용 포커스 가능한 조작과 보이는 선택 표시, 끌어다 놓기의 버튼/키보드 대안.
|
||||
9. 새 모듈·눈에 띄지 않는 함수에 한국어 상세 주석.
|
||||
|
||||
## 3. TDD·검증 요구
|
||||
- 조각마다 실패하는 테스트를 먼저 쓰고 실행해 RED 를 확인한 뒤 최소 구현으로 GREEN. RED→GREEN 증거를 두 저장소의 `docs/verification/` 에 실제 출력으로 남긴다(조작 금지).
|
||||
- 권한, CSRF, 허용 페이지, 낙관적 동시성/오래된 저장 거절, 안전한 정화/규약, 스크립트 비실행 계약, 저장이 버전 이력을 만드는 것, 실행 취소/다시 실행 핵심, 끌어 옮기기/구성 요소 삽입 핵심, 저장 상태/이동 보호, 필수 접근성 조작을 모두 다룬다. 편집 문서·규약·상태 로직은 순수 모듈로 뽑아 Node 테스트가 동작을 검사하게 한다. 큰 프레임워크·런타임 의존성을 더하지 않는다.
|
||||
- 두 저장소에서 집중 테스트 뒤 전체 묶음을 실행한다.
|
||||
|
||||
## 4. 버전·이력·출처 요구
|
||||
- Webmaker: package/runtime/릴리스 표기 1.8.0, `CHANGELOG.md`·`ERRORS.md`·`README.md`·`Dev_Order_v1.8.0.md`.
|
||||
- Suite: 루트 package/runtime/릴리스 표기 0.2.0. `modules/webmaker` 의 도입 버전/upstream 메타데이터는 Webmaker 1.8.0 을 나타내되 Suite 전용 연결 변경을 잃지 않는다. `VERSION_HISTORY.md` 에 작성자 Neo·이전 버전·재사용 함수와 수정 함수·고객/운영자 화면·직접 편집·끌어다 놓기·저장·버전·보안 동작·도입 소스 출처(원본 저장소·불변 기준 커밋·원본/대상 경로·수정 여부·라이선스 검토)·테스트·알려진 제한·미확정 정책값. 릴리스/카탈로그/manifest 는 도구가 요구하는 범위에서 일관되게(`IMPORTED_SOURCES`, `scripts/release.mjs` 는 package.json 버전을 따른다).
|
||||
- 비밀번호·키·토큰·개인 정보·비밀값은 어디에도 넣지 않는다.
|
||||
|
||||
## 5. 저장소 규율
|
||||
- 독립 Webmaker 와 Suite `modules/webmaker` 의 모든 차이를 확인하고 보존한다. Suite 전용 HAL/포털/연동 변경을 upstream 파일로 통째로 덮어쓰지 않는다.
|
||||
- 지정된 두 작업 트리 밖(dirty main 체크아웃·`Ariweb-suite-mail-hal`·Hera 세션/작업 트리)은 건드리지 않는다.
|
||||
- 커밋·push·병합·배포·운영 접근을 하지 않는다. Neo 가 독립 검토 뒤 수행한다.
|
||||
|
||||
## 6. 완료 조건(이번 구현의 인도 기준)
|
||||
두 저장소의 변경 파일 목록, RED·GREEN 명령과 결과, 전체 묶음 결과, 버전, 보안 결정, 알려진 제한, 막힌 항목을 보고한다. 구현 결과와 증거: Webmaker `CHANGELOG.md` 1.8.0 절·`docs/verification/visual-editor-1.8.0-tdd.md`, Suite `VERSION_HISTORY.md` 0.2.0 절·`docs/verification/webmaker-visual-editor-0.2.0.md`.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Ariweb Suite 개발 지시 — 작성자: Neo · v0.2.1
|
||||
|
||||
**작성자:** Neo
|
||||
**작성일:** 2026-10-29
|
||||
**이전 버전:** v0.2.0(Webmaker 직접 편집 — 시각 편집기, Neo, 2026-10-28) · v0.1.4(미반영 오류 수정·HAL AI 대시보드·다중 AI 공동 작업창 지시, Neo) · v0.1.1(최초 통합 개발 지시, Hera)
|
||||
**이번 개정:** HAL 운영자 콘솔의 **고위험 재인증 안내 위치 수정**. 모든 일반 화면에 늘 떠 있던 전역 배너를 없애고, 그 안내는 backend 가 고위험 요청을 실제로 거부한 바로 그 때 여는 재인증 창 안에만 둔다. 사용자 기능 추가가 아닌 운영자 화면 수정이므로 끝 버전 번호만 올린다 — Suite 0.2.0 → **0.2.1**. v0.1.1·v0.1.4·v0.2.0 의 모든 기준과 지시는 그대로 유효하며, 이 문서는 추가 요구만 적는다(v0.1.4 항목은 이번 구현 범위가 아니다).
|
||||
|
||||
## 개정 이력
|
||||
- **v0.1.1 · Hera · 2026-10-26:** 최초 통합 개발 지시, Webmaker·AriMail 재사용 원칙, 사용량 경고 70·80·90·95%.
|
||||
- **v0.1.4 · Neo · 2026-10-27:** patch 자동 증가 규칙, 미반영 오류 수정 전수 반영, HAL AI 대시보드, 다중 AI 공동 작업창.
|
||||
- **v0.2.0 · Neo · 2026-10-28:** Webmaker 직접 편집(끌어다 놓기 시각 편집기) — Suite `modules/webmaker` 와 기존 Webmaker 저장소 양쪽에 반영.
|
||||
- **v0.2.1 · Neo · 2026-10-29:** HAL 운영자 콘솔 고위험 재인증 안내를 전역 배너에서 거부 시점 재인증 창으로 옮김. 엄격한 TDD, 격리 작업 트리(`feature/hal-risk-banner-v0.2.1`, 기준 origin/main `0a56d4612c86dddfcbe644e775980d3c5b413b2e`), 커밋·push·병합·배포는 Neo 가 별도 검토 뒤 수행.
|
||||
|
||||
## 1. 목표
|
||||
1. HAL 운영자 콘솔의 모든 일반 화면에 항상 보이던 다음 전역 배너를 **제거**한다: `! 고위험 작업(서버 삭제·되돌리기·인증 키 변경·복구)은 최근 10분 안의 재인증이 필요합니다 — 재인증`.
|
||||
2. 이 안내는 backend 가 고위험 작업 요청을 `reauth_required` 로 거부하여 재인증이 필요한 **바로 그 시점**에만, 재인증 창(modal) 안에서 보인다.
|
||||
3. backend 보안 경계(`requireRecent`, CSRF, 3단계 확인, 고위험 작업 분류와 거부 동작)는 한 글자도 약화하지 않는다.
|
||||
4. Suite 버전을 0.2.1 로 올리고 package/runtime/release 표기와 `VERSION_HISTORY.md` 를 일관되게 갱신한다.
|
||||
|
||||
## 2. 사용자 요구(약화 금지)
|
||||
- 운영자는 단순 화면 조회, 로그인 직후, 로그인 10분 경과, 일반 화면 이동 중에 고위험 재인증 안내를 **보지 않는다**. 안내는 실제로 고위험 작업을 시도해 서버가 거부한 순간에만 뜬다.
|
||||
- 계정 화면의 **수동 계정 재인증** 단추와 동작은 유지한다. 수동 재인증 창에는 고위험 안내를 억지로 붙이지 않는다(비밀번호만 묻는다).
|
||||
- 다른 전역 배너(접근권 만료 안내·HAL 자체 건강 비정상·긴급 복구 세션 진행·부트스트랩 미완료·비밀번호 변경 필요)는 그대로 둔다 — 배너 영역 자체를 없애는 것이 아니다.
|
||||
|
||||
## 3. 보안 불변조건
|
||||
1. `hal/app.mjs` 의 `requireRecent`(최근 10분 재인증), CSRF 검사, 3단계 확인(`confirmImpact` → `confirmIdentifiers` → `confirmExecute`), 고위험 작업 분류(서버 삭제·격리·역할 변경, 모듈 되돌리기·배포·재조정, 작업 revert, 운영자 계정·접근권 발급, MFA 해제, 설정 변경, 메일 가져오기 적용·되돌리기, 릴리스 승인, 긴급 복구 선언·승인·자격, DR 설정·복원)과 403 `reauth_required` 거부 동작은 **변경 금지**.
|
||||
2. 재인증 창을 **취소**하거나 재인증이 **실패**하면 원래 고위험 요청은 실행되지 않고, 원래 오류가 호출자에게 그대로 전파된다.
|
||||
3. 재인증에 **성공**하면 원 요청을 기존처럼 **딱 한 번** 재시도한다. 재시도도 거부되면 다시 묻지 않고 오류를 올린다(무한 반복 금지). `reauth_required` 가 아닌 오류는 재인증을 묻지 않는다.
|
||||
4. 판단은 전부 서버가 한다. 브라우저 쪽 변경은 "거부됐을 때 어떻게 묻고 몇 번 다시 보내는가"와 안내 문구 위치에 한정한다. 재인증 API(`POST /ops/api/reauth`)의 CSRF 요구도 그대로다.
|
||||
5. 비밀번호·토큰·키·개인정보를 파일·출력·commit 에 넣지 않는다.
|
||||
|
||||
## 4. TDD·검증
|
||||
- production code 보다 먼저 최소 회귀 테스트를 쓰고 실제로 실행해 RED(예상 이유로 실패)를 확인한 뒤 최소 구현으로 GREEN. 그 다음 targeted HAL UI/auth 테스트, 마지막에 전체 Suite 테스트(`node test/run.mjs`).
|
||||
- 다뤄야 할 계약: (가) 일반 화면(운영 현황·서버·릴리스·작업·복구·중계 화면·계정)에 로그인 직후·10분 경과·화면 이동 모두 전역 고위험 배너 없음, 계정 화면 수동 재인증 단추 유지·고위험 안내 없음, 다른 배너 유지; (나) backend 경계 그대로 — 10분 경과 뒤 고위험 API 403 `reauth_required`·거부된 요청 미실행·CSRF 없는 재인증 거부·틀린 비밀번호 거부·재인증 뒤 통과·3단계 확인 별도 요구; (다) 재인증 창 문구 — 거부 시점에만 고위험 안내, 수동은 없음; (라) 취소 시 원 요청 미실행·오류 전파, 성공 시 딱 한 번 재시도, 재시도 거부 시 재질문 없음, 다른 오류는 재인증 질문 없음; (마) 정적 계약 — `hal/ui.mjs` 에 배너 코드 없음, `hal/ui/public/ops.js` 가 순수 helper 를 쓰고 안내 문구를 직접 품지 않음.
|
||||
- 브라우저 스크립트는 DOM 없이 돌지 않으므로, 재인증 흐름·창 문구를 **테스트 가능한 순수 helper**(`hal/ui/public/reauth.mjs`)로 분리하고 ops.js 가 그것을 쓰는지는 정적 계약으로 못 박는다. 큰 dependency 를 더하지 않는다(외부 npm 의존성 0 유지).
|
||||
- 기존 test runner 가 만드는 `docs/verification/local-e2e-*` 임시 파일은 최종 diff 에 남기지 않는다(이미 커밋된 2026-10-26·27 기록은 그대로).
|
||||
|
||||
## 5. 범위·저장소 규율
|
||||
- 수정 허용: HAL UI(`hal/ui.mjs`, `hal/ui/render.mjs`(공개 사본은 심볼릭 링크), `hal/ui/public/ops.js`, 새 `hal/ui/public/reauth.mjs`), 관련 테스트, Suite 0.2.1 버전/이력(`package.json`·`README.md`·`VERSION_HISTORY.md`·버전을 검사하는 테스트), 이 개발 지시.
|
||||
- 금지: Webmaker standalone 기능, 보안 경계, 승인 artifact 불변성(0.2.0 검토 수정), 다른 모듈. commit·push·merge·release build·운영 배포 금지 — Neo 가 독립 검토 뒤 수행한다.
|
||||
- 코딩은 Claude Fable 5.1 로만 수행한다. 기존 v0.2.0 문서는 덮어쓰지 않는다. 파일 이름의 `Suitle` 철자는 식별자이므로 그대로 보존한다.
|
||||
|
||||
## 6. 완료 조건(이번 구현의 인도 기준)
|
||||
변경 파일 목록, RED 명령과 실패 이유, GREEN·targeted·전체 테스트의 실제 결과, 보안 결정, 남은 제한을 정확히 보고하고 프롬프트로 돌아온다. 구현 결과와 증거: `VERSION_HISTORY.md` 0.2.1 절(작성자·이전 버전·요구·수정한 기존 기능·운영자 화면·보안 결정·테스트·알려진 제한·미확정 정책값), 회귀 테스트 `test/hal-reauth-banner.test.mjs`.
|
||||
+66
-11
@@ -1,18 +1,25 @@
|
||||
{
|
||||
"generatedAt": "2026-10-26T12:02:43.474Z",
|
||||
"generatedAt": "2026-10-28T14:53:58.554Z",
|
||||
"sources": [
|
||||
{
|
||||
"name": "webmaker",
|
||||
"repo": "https://source.ibit.kr/OZ-Group/WebHosting_AI_agent",
|
||||
"commit": "3b10859884ef2fd0f2af11934ab6c16370fd64b8",
|
||||
"commit": "4b8c4d0de0f69025001a9e0a28a58a94e1a1f088",
|
||||
"dest": "modules/webmaker",
|
||||
"license": "OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가.",
|
||||
"upstream": {
|
||||
"version": "1.8.0",
|
||||
"branch": "feature/visual-editor-v1.8.0",
|
||||
"headCommit": "4b8c4d0de0f69025001a9e0a28a58a94e1a1f088",
|
||||
"workingTreeDirty": false,
|
||||
"note": "고정 커밋의 깨끗한 체크아웃"
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"from": "README.md",
|
||||
"to": "modules/webmaker/README.md",
|
||||
"status": "unchanged",
|
||||
"origHash": "a7aa1eb7a6fdae8d"
|
||||
"origHash": "d28788cd6b28ec72"
|
||||
},
|
||||
{
|
||||
"from": "deploy/deploy.sh",
|
||||
@@ -134,6 +141,12 @@
|
||||
"status": "unchanged",
|
||||
"origHash": "d93102519cb39333"
|
||||
},
|
||||
{
|
||||
"from": "docs/verification/visual-editor-1.8.0-tdd.md",
|
||||
"to": "modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md",
|
||||
"status": "unchanged",
|
||||
"origHash": "7c3757a3731e1a84"
|
||||
},
|
||||
{
|
||||
"from": "hooks/askpass.sh",
|
||||
"to": "modules/webmaker/hooks/askpass.sh",
|
||||
@@ -318,7 +331,7 @@
|
||||
"from": "lib/gitstore.js",
|
||||
"to": "modules/webmaker/lib/gitstore.js",
|
||||
"status": "unchanged",
|
||||
"origHash": "d4650fab7b85f9b6"
|
||||
"origHash": "b9d68792f6638161"
|
||||
},
|
||||
{
|
||||
"from": "lib/google.js",
|
||||
@@ -552,7 +565,7 @@
|
||||
"from": "lib/store.js",
|
||||
"to": "modules/webmaker/lib/store.js",
|
||||
"status": "modified",
|
||||
"origHash": "8132dcde60fc5789"
|
||||
"origHash": "82156230f6fcb726"
|
||||
},
|
||||
{
|
||||
"from": "lib/templates.js",
|
||||
@@ -572,6 +585,12 @@
|
||||
"status": "modified",
|
||||
"origHash": "0730f701278d33a1"
|
||||
},
|
||||
{
|
||||
"from": "lib/visual-editor.js",
|
||||
"to": "modules/webmaker/lib/visual-editor.js",
|
||||
"status": "unchanged",
|
||||
"origHash": "5680bdfc5504d968"
|
||||
},
|
||||
{
|
||||
"from": "lib/xlsx.js",
|
||||
"to": "modules/webmaker/lib/xlsx.js",
|
||||
@@ -774,7 +793,7 @@
|
||||
"from": "package.json",
|
||||
"to": "modules/webmaker/package.json",
|
||||
"status": "unchanged",
|
||||
"origHash": "624dc8ee663ac2ba"
|
||||
"origHash": "7b5c2bf8a18df617"
|
||||
},
|
||||
{
|
||||
"from": "public/404.html",
|
||||
@@ -798,13 +817,13 @@
|
||||
"from": "public/admin.js",
|
||||
"to": "modules/webmaker/public/admin.js",
|
||||
"status": "unchanged",
|
||||
"origHash": "fa5725fbe6356bbc"
|
||||
"origHash": "6f37dafdf4502684"
|
||||
},
|
||||
{
|
||||
"from": "public/app.js",
|
||||
"to": "modules/webmaker/public/app.js",
|
||||
"status": "modified",
|
||||
"origHash": "3baf113fbacdae72"
|
||||
"origHash": "f8a5d75f9d7b734a"
|
||||
},
|
||||
{
|
||||
"from": "public/credits-ui.js",
|
||||
@@ -840,7 +859,7 @@
|
||||
"from": "public/index.html",
|
||||
"to": "modules/webmaker/public/index.html",
|
||||
"status": "modified",
|
||||
"origHash": "bc33e85029da63b7"
|
||||
"origHash": "400f72c882088d8e"
|
||||
},
|
||||
{
|
||||
"from": "public/login.html",
|
||||
@@ -912,7 +931,7 @@
|
||||
"from": "public/style.css",
|
||||
"to": "modules/webmaker/public/style.css",
|
||||
"status": "modified",
|
||||
"origHash": "041d7f216677b02d"
|
||||
"origHash": "3a9d2f8e50e7b00a"
|
||||
},
|
||||
{
|
||||
"from": "public/templates-ui.js",
|
||||
@@ -920,6 +939,18 @@
|
||||
"status": "unchanged",
|
||||
"origHash": "6adc66d8e2204b1f"
|
||||
},
|
||||
{
|
||||
"from": "public/visual-editor-core.js",
|
||||
"to": "modules/webmaker/public/visual-editor-core.js",
|
||||
"status": "unchanged",
|
||||
"origHash": "e1ee3bb64d63ee7c"
|
||||
},
|
||||
{
|
||||
"from": "public/visual-editor-ui.js",
|
||||
"to": "modules/webmaker/public/visual-editor-ui.js",
|
||||
"status": "unchanged",
|
||||
"origHash": "a24aaae4b1b28e1e"
|
||||
},
|
||||
{
|
||||
"from": "public/webmail.html",
|
||||
"to": "modules/webmaker/public/webmail.html",
|
||||
@@ -950,6 +981,12 @@
|
||||
"status": "unchanged",
|
||||
"origHash": "7e72375620ac125d"
|
||||
},
|
||||
{
|
||||
"from": "public/wm-ve-canvas.js",
|
||||
"to": "modules/webmaker/public/wm-ve-canvas.js",
|
||||
"status": "unchanged",
|
||||
"origHash": "fb32a5abf9d4f33d"
|
||||
},
|
||||
{
|
||||
"from": "scripts/acme-dns-hook.js",
|
||||
"to": "modules/webmaker/scripts/acme-dns-hook.js",
|
||||
@@ -1014,7 +1051,7 @@
|
||||
"from": "server.js",
|
||||
"to": "modules/webmaker/server.js",
|
||||
"status": "modified",
|
||||
"origHash": "4585fd7fb07d3b4b"
|
||||
"origHash": "b15f25a0b4f8278b"
|
||||
},
|
||||
{
|
||||
"from": "setup-server.js",
|
||||
@@ -1063,6 +1100,24 @@
|
||||
"to": "modules/webmaker/test/server.test.js",
|
||||
"status": "unchanged",
|
||||
"origHash": "b931228730a8bd70"
|
||||
},
|
||||
{
|
||||
"from": "test/visual-editor-app.test.js",
|
||||
"to": "modules/webmaker/test/visual-editor-app.test.js",
|
||||
"status": "unchanged",
|
||||
"origHash": "d65ab5fb5885da48"
|
||||
},
|
||||
{
|
||||
"from": "test/visual-editor-server.test.js",
|
||||
"to": "modules/webmaker/test/visual-editor-server.test.js",
|
||||
"status": "unchanged",
|
||||
"origHash": "b5de130875d5ef36"
|
||||
},
|
||||
{
|
||||
"from": "test/visual-editor.test.js",
|
||||
"to": "modules/webmaker/test/visual-editor.test.js",
|
||||
"status": "unchanged",
|
||||
"origHash": "bd42f92d91224b08"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+22
-13
@@ -1,18 +1,19 @@
|
||||
# 도입 소스 기록(IMPORTED_SOURCES)
|
||||
|
||||
생성: 2026-10-26T12:02:43.474Z — `node scripts/import-sources.mjs` 가 만든다. 파일별 '수정' 은 원본 커밋과 현재 파일의 해시 비교 결과이며, 수정 이유는 VERSION_HISTORY.md 의 "도입 소스" 절과 각 파일 머리 주석의 `[Ariweb Suite 변경]` 표시로 설명한다.
|
||||
생성: 2026-10-28T14:53:58.554Z — `node scripts/import-sources.mjs` 가 만든다. 파일별 '수정' 은 원본 커밋과 현재 파일의 해시 비교 결과이며, 수정 이유는 VERSION_HISTORY.md 의 "도입 소스" 절과 각 파일 머리 주석의 `[Ariweb Suite 변경]` 표시로 설명한다.
|
||||
|
||||
## webmaker
|
||||
|
||||
- 원본 저장소: https://source.ibit.kr/OZ-Group/WebHosting_AI_agent
|
||||
- 고정 커밋: `3b10859884ef2fd0f2af11934ab6c16370fd64b8`
|
||||
- 고정 커밋(불변 기준): `4b8c4d0de0f69025001a9e0a28a58a94e1a1f088`
|
||||
- 원본 버전(package.json): 1.8.0 · 브랜치: `feature/visual-editor-v1.8.0` · 작업 트리: 깨끗함
|
||||
- 대상 경로: `modules/webmaker/`
|
||||
- 파일 수: 176 (수정 8)
|
||||
- 파일 수: 184 (수정 8)
|
||||
- 라이선스 검토: OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가.
|
||||
|
||||
| 원본 경로 | 대상 경로 | 수정 | 원본 해시 |
|
||||
|---|---|---|---|
|
||||
| README.md | modules/webmaker/README.md | 그대로 | a7aa1eb7a6fdae8d |
|
||||
| README.md | modules/webmaker/README.md | 그대로 | d28788cd6b28ec72 |
|
||||
| deploy/deploy.sh | modules/webmaker/deploy/deploy.sh | 그대로 | 6f016f8dcf56a3b2 |
|
||||
| deploy/front-proxy-webmaker.conf | modules/webmaker/deploy/front-proxy-webmaker.conf | 그대로 | 0ec1641b554a3b50 |
|
||||
| deploy/install-arimail.sh | modules/webmaker/deploy/install-arimail.sh | 그대로 | d8626ef07df49738 |
|
||||
@@ -33,6 +34,7 @@
|
||||
| docs/FIRST-INSTALL.md | modules/webmaker/docs/FIRST-INSTALL.md | 그대로 | 642ce20eb96cabb9 |
|
||||
| docs/GITEA-LOCAL.md | modules/webmaker/docs/GITEA-LOCAL.md | 그대로 | 4d7462b52ef34783 |
|
||||
| docs/SSL-WILDCARD.md | modules/webmaker/docs/SSL-WILDCARD.md | 그대로 | d93102519cb39333 |
|
||||
| docs/verification/visual-editor-1.8.0-tdd.md | modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md | 그대로 | 7c3757a3731e1a84 |
|
||||
| hooks/askpass.sh | modules/webmaker/hooks/askpass.sh | 그대로 | c86b42091d07748d |
|
||||
| hooks/guard.js | modules/webmaker/hooks/guard.js | 그대로 | 4804e4cd337db53c |
|
||||
| hooks/guard.sh | modules/webmaker/hooks/guard.sh | 그대로 | 29df99b89bc31912 |
|
||||
@@ -63,7 +65,7 @@
|
||||
| lib/feedback.js | modules/webmaker/lib/feedback.js | 그대로 | c5860a676dd6118a |
|
||||
| lib/forms.js | modules/webmaker/lib/forms.js | 그대로 | e2f1b1bb21e5bbdd |
|
||||
| lib/fx.js | modules/webmaker/lib/fx.js | 그대로 | 7514ae9840fa6307 |
|
||||
| lib/gitstore.js | modules/webmaker/lib/gitstore.js | 그대로 | d4650fab7b85f9b6 |
|
||||
| lib/gitstore.js | modules/webmaker/lib/gitstore.js | 그대로 | b9d68792f6638161 |
|
||||
| lib/google.js | modules/webmaker/lib/google.js | 그대로 | bfdaa70652f846cc |
|
||||
| lib/i18n.js | modules/webmaker/lib/i18n.js | 그대로 | 944c12aa16d00f45 |
|
||||
| lib/jobpids.js | modules/webmaker/lib/jobpids.js | 그대로 | f5df0824a0a457e9 |
|
||||
@@ -102,10 +104,11 @@
|
||||
| lib/sitemsg.js | modules/webmaker/lib/sitemsg.js | 그대로 | 09bcc4cb52e93a57 |
|
||||
| lib/ssl.js | modules/webmaker/lib/ssl.js | 그대로 | f7a5bb79900d8bdd |
|
||||
| lib/stats.js | modules/webmaker/lib/stats.js | 그대로 | fc2de57d4be0960a |
|
||||
| lib/store.js | modules/webmaker/lib/store.js | **수정** | 8132dcde60fc5789 |
|
||||
| lib/store.js | modules/webmaker/lib/store.js | **수정** | 82156230f6fcb726 |
|
||||
| lib/templates.js | modules/webmaker/lib/templates.js | 그대로 | bb44e537d0b28b16 |
|
||||
| lib/uploads.js | modules/webmaker/lib/uploads.js | 그대로 | 5441ea45ed434575 |
|
||||
| lib/vault.js | modules/webmaker/lib/vault.js | **수정** | 0730f701278d33a1 |
|
||||
| lib/visual-editor.js | modules/webmaker/lib/visual-editor.js | 그대로 | 5680bdfc5504d968 |
|
||||
| lib/xlsx.js | modules/webmaker/lib/xlsx.js | 그대로 | dc353dd3b5fd6e10 |
|
||||
| locales/ko.json | modules/webmaker/locales/ko.json | 그대로 | 20abf531ef8320f6 |
|
||||
| locales/site/ar.json | modules/webmaker/locales/site/ar.json | 그대로 | 8644e815887e9bb1 |
|
||||
@@ -139,18 +142,18 @@
|
||||
| modules/members/widget.html | modules/webmaker/modules/members/widget.html | 그대로 | 3a0c8409adda26db |
|
||||
| modules/reviews/widget.html | modules/webmaker/modules/reviews/widget.html | 그대로 | de7ad783f7cad79a |
|
||||
| modules/survey/widget.html | modules/webmaker/modules/survey/widget.html | 그대로 | 62b2a3c2ee267b66 |
|
||||
| package.json | modules/webmaker/package.json | 그대로 | 624dc8ee663ac2ba |
|
||||
| package.json | modules/webmaker/package.json | 그대로 | 7b5c2bf8a18df617 |
|
||||
| public/404.html | modules/webmaker/public/404.html | 그대로 | 22b18c443cbb8f50 |
|
||||
| public/admin.css | modules/webmaker/public/admin.css | 그대로 | b9b4a4cbaa9dc3ec |
|
||||
| public/admin.html | modules/webmaker/public/admin.html | 그대로 | 6853f78ac57ef360 |
|
||||
| public/admin.js | modules/webmaker/public/admin.js | 그대로 | fa5725fbe6356bbc |
|
||||
| public/app.js | modules/webmaker/public/app.js | **수정** | 3baf113fbacdae72 |
|
||||
| public/admin.js | modules/webmaker/public/admin.js | 그대로 | 6f37dafdf4502684 |
|
||||
| public/app.js | modules/webmaker/public/app.js | **수정** | f8a5d75f9d7b734a |
|
||||
| public/credits-ui.js | modules/webmaker/public/credits-ui.js | 그대로 | 44e43d5179d77578 |
|
||||
| public/domains-ui.js | modules/webmaker/public/domains-ui.js | 그대로 | ea77c164db309303 |
|
||||
| public/flow-ui.js | modules/webmaker/public/flow-ui.js | 그대로 | e9a92ab29b71840e |
|
||||
| public/i18n.js | modules/webmaker/public/i18n.js | 그대로 | c6c4039435b77583 |
|
||||
| public/import.js | modules/webmaker/public/import.js | 그대로 | 3f17de67ac16b9c4 |
|
||||
| public/index.html | modules/webmaker/public/index.html | **수정** | bc33e85029da63b7 |
|
||||
| public/index.html | modules/webmaker/public/index.html | **수정** | 400f72c882088d8e |
|
||||
| public/login.html | modules/webmaker/public/login.html | 그대로 | dd870dfc417adc65 |
|
||||
| public/manage-ui.js | modules/webmaker/public/manage-ui.js | 그대로 | c57d9d54dea3ab8d |
|
||||
| public/modules-ui.js | modules/webmaker/public/modules-ui.js | 그대로 | 5f4f54a0abe692c4 |
|
||||
@@ -162,13 +165,16 @@
|
||||
| public/site-admin.html | modules/webmaker/public/site-admin.html | 그대로 | d598563c7d617bbb |
|
||||
| public/site-admin.js | modules/webmaker/public/site-admin.js | 그대로 | 762da69b23f1db85 |
|
||||
| public/site-info.js | modules/webmaker/public/site-info.js | 그대로 | 99060343aaca0ab3 |
|
||||
| public/style.css | modules/webmaker/public/style.css | **수정** | 041d7f216677b02d |
|
||||
| public/style.css | modules/webmaker/public/style.css | **수정** | 3a9d2f8e50e7b00a |
|
||||
| public/templates-ui.js | modules/webmaker/public/templates-ui.js | 그대로 | 6adc66d8e2204b1f |
|
||||
| public/visual-editor-core.js | modules/webmaker/public/visual-editor-core.js | 그대로 | e1ee3bb64d63ee7c |
|
||||
| public/visual-editor-ui.js | modules/webmaker/public/visual-editor-ui.js | 그대로 | a24aaae4b1b28e1e |
|
||||
| public/webmail.html | modules/webmaker/public/webmail.html | 그대로 | 981fee72b5c72bed |
|
||||
| public/webmail.js | modules/webmaker/public/webmail.js | 그대로 | 6cb9a50f39c8d025 |
|
||||
| public/wizard.js | modules/webmaker/public/wizard.js | 그대로 | 2ba97adfbb4f7cc1 |
|
||||
| public/wm-modules.js | modules/webmaker/public/wm-modules.js | 그대로 | efc7526ba17c338d |
|
||||
| public/wm-stats.js | modules/webmaker/public/wm-stats.js | 그대로 | 7e72375620ac125d |
|
||||
| public/wm-ve-canvas.js | modules/webmaker/public/wm-ve-canvas.js | 그대로 | fb32a5abf9d4f33d |
|
||||
| scripts/acme-dns-hook.js | modules/webmaker/scripts/acme-dns-hook.js | 그대로 | 4401e102dde4c3ec |
|
||||
| scripts/admin.js | modules/webmaker/scripts/admin.js | 그대로 | 02adaf25dabad0ec |
|
||||
| scripts/backup-data.js | modules/webmaker/scripts/backup-data.js | 그대로 | 94b331c3e9555664 |
|
||||
@@ -179,7 +185,7 @@
|
||||
| scripts/site-msg-catalog.py | modules/webmaker/scripts/site-msg-catalog.py | 그대로 | 1f9b1ea8adbc174c |
|
||||
| scripts/ssl-wildcard.js | modules/webmaker/scripts/ssl-wildcard.js | 그대로 | 68cbebdb59a5f6d9 |
|
||||
| scripts/unit-check.js | modules/webmaker/scripts/unit-check.js | 그대로 | ba15624f696892b2 |
|
||||
| server.js | modules/webmaker/server.js | **수정** | 4585fd7fb07d3b4b |
|
||||
| server.js | modules/webmaker/server.js | **수정** | b15f25a0b4f8278b |
|
||||
| setup-server.js | modules/webmaker/setup-server.js | 그대로 | 6545aab3185765a8 |
|
||||
| templates/contact-widget.html | modules/webmaker/templates/contact-widget.html | 그대로 | 8063f64226c13914 |
|
||||
| test/dnszone-import.test.js | modules/webmaker/test/dnszone-import.test.js | 그대로 | 33e1209656a7bf3f |
|
||||
@@ -188,11 +194,14 @@
|
||||
| test/mailservers.test.js | modules/webmaker/test/mailservers.test.js | 그대로 | dfb80acca9782be0 |
|
||||
| test/registrar-route53.test.js | modules/webmaker/test/registrar-route53.test.js | 그대로 | 84e9eba32ad5c7ef |
|
||||
| test/server.test.js | modules/webmaker/test/server.test.js | 그대로 | b931228730a8bd70 |
|
||||
| test/visual-editor-app.test.js | modules/webmaker/test/visual-editor-app.test.js | 그대로 | d65ab5fb5885da48 |
|
||||
| test/visual-editor-server.test.js | modules/webmaker/test/visual-editor-server.test.js | 그대로 | b5de130875d5ef36 |
|
||||
| test/visual-editor.test.js | modules/webmaker/test/visual-editor.test.js | 그대로 | bd42f92d91224b08 |
|
||||
|
||||
## arimail
|
||||
|
||||
- 원본 저장소: https://source.ibit.kr/OZ-Group/AriMail
|
||||
- 고정 커밋: `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`
|
||||
- 고정 커밋(불변 기준): `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`
|
||||
- 대상 경로: `modules/arimail/`
|
||||
- 파일 수: 88 (수정 15)
|
||||
- 라이선스 검토: OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. haraka/ 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
고객이 회원가입·로그인 → 상품 선택 → 사이트 제작(Webmaker) → 배포 → 도메인·HTTPS → 메일(AriMail) → 백업 → 운영(재배포·복원·이전·소유권·해지)까지 **하나의 연결된 서비스**로 이용하는 통합 플랫폼이다.
|
||||
운영자는 HAL(`hal.ariweb.net`)에서 릴리스·서버·모듈·작업·장애·복구를 관리하고, HAL 이 Node Agent 를 통해 나머지 모든 모듈을 배포·갱신·되돌리기·복구한다.
|
||||
|
||||
- 버전: **0.1.1** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md)
|
||||
- 버전: **0.2.1** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md)
|
||||
- 설계·개발 규약: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) · 포털↔HAL 작업 계약: [docs/HAL-JOBS.md](docs/HAL-JOBS.md) · HAL↔Agent 프로토콜: [docs/HAL-AGENT.md](docs/HAL-AGENT.md)
|
||||
- 도입 소스(Webmaker 1.7.3 · AriMail 1.0.11): [IMPORTED_SOURCES.md](IMPORTED_SOURCES.md) — `node scripts/import-sources.mjs` 로 갱신
|
||||
- 개발 지시 원문: `Ariweb-Suitle_Dev-Order_v0.1.1.md`
|
||||
- 도입 소스(Webmaker 1.8.0 · AriMail 1.0.11): [IMPORTED_SOURCES.md](IMPORTED_SOURCES.md) — `node scripts/import-sources.mjs` 로 갱신(참조 저장소와 나란히 개발 중이면 `--allow-dirty`)
|
||||
- 개발 지시 원문: `Ariweb-Suitle_Dev-Order_v0.1.1.md`(기준) · `Ariweb-Suitle_Dev-Order_v0.1.4.md` · `Ariweb-Suitle_Dev-Order_v0.2.0.md`(Webmaker 직접 편집) · `Ariweb-Suitle_Dev-Order_v0.2.1.md`(HAL 고위험 재인증 안내 위치)
|
||||
|
||||
## 구성
|
||||
|
||||
|
||||
+128
-1
@@ -1,5 +1,120 @@
|
||||
# VERSION_HISTORY
|
||||
|
||||
## 0.2.1 (2026-10-29)
|
||||
|
||||
- **작성자**: 개발 지시 — Neo(`Ariweb-Suitle_Dev-Order_v0.2.1.md`, 오즈 승인) · 구현 — Claude Fable 5.1(격리 작업 트리 `feature/hal-risk-banner-v0.2.1`, 기준 origin/main `0a56d4612c86dddfcbe644e775980d3c5b413b2e`). 커밋·push·병합·릴리스 빌드·운영 배포는 하지 않았다 — Neo 가 검토 뒤 수행한다.
|
||||
- **이전 버전**: 0.2.0(2026-10-28, Webmaker 직접 편집 — 아래 절). 0.2.1 은 **끝 버전 번호만 올리는 운영자 화면 수정**이다: 사용자 기능·도입 모듈·보안 경계 변경 없음.
|
||||
- **구성**: HAL · 고객 포털 · Node Agent · 도입 모듈 Webmaker 1.8.0 · AriMail 1.0.11 모두 0.2.0 그대로(HAL 운영자 콘솔 화면 2곳 + 브라우저 스크립트만 수정). 외부 npm 의존성 없음, Node 22.13+.
|
||||
|
||||
### 요구(사용자 요구 — 약화 없이 반영)
|
||||
HAL 운영자 콘솔의 모든 일반 화면 위에 재인증이 10분을 넘기면 늘 떠 있던 전역 배너 `! 고위험 작업(서버 삭제·되돌리기·인증 키 변경·복구)은 최근 10분 안의 재인증이 필요합니다 — 재인증` 을 없앤다. 그 안내는 backend 가 고위험 요청을 실제로 `reauth_required` 로 거부한 **바로 그 시점**의 재인증 창 안에서만 보인다. 단순 화면 조회·로그인 직후·10분 경과·일반 이동 중에는 보이지 않는다. 계정 화면의 수동 재인증 단추·동작은 유지하되 고위험 안내를 붙이지 않는다. backend 의 `requireRecent`·CSRF·3단계 확인·고위험 분류·거부 동작은 그대로다.
|
||||
|
||||
### 수정한 기존 기능
|
||||
- `hal/ui.mjs` `shell()`: 재인증 만료(`!session.recentlyAuthed`)를 전역 배너로 그리던 한 줄을 제거(이력 주석으로 대체). 다른 전역 배너(기기 귀속 접근권 만료 안내·HAL 자체 건강 비정상·긴급 복구 세션 진행·부트스트랩 미완료·비밀번호 변경 필요)와 `.alerts` 영역은 그대로.
|
||||
- `hal/ui/public/ops.js`: `call()`(API 호출 + 재인증 재시도)과 `reauth()` 창이 새 순수 helper `hal/ui/public/reauth.mjs` 의 `callWithReauth`·`reauthPrompt` 를 쓴다. 서버가 `reauth_required` 로 거부했을 때 여는 재인증 창에만 고위험 안내(note, HTML 이스케이프)를 붙이고, 계정 화면의 수동 재인증(`data-act="reauth"`)은 `reauth()` 를 인자 없이 불러 안내 없이 비밀번호만 묻는다. 재시도 규칙은 전과 같다(성공 시 딱 한 번, 취소·실패 시 원 요청 미실행).
|
||||
- `hal/ui/render.mjs` `accountsView`(공개 사본 `hal/ui/public/render.mjs` 는 심볼릭 링크): 계정 화면 "최근 재인증" 상태 배지 문구 `재인증 필요(고위험 작업)` → `만료(10분 지남)`. 조회 화면이 고위험 안내를 품지 않게 하는 중립 표기이며 상태 판정·단추는 같다.
|
||||
- 새 파일 `hal/ui/public/reauth.mjs`(순수, DOM·fetch 없음, `/ops/ui/reauth.mjs` 로 정적 제공): `REAUTH_REQUIRED`·`HIGH_RISK_NOTICE`(옛 배너 문구 그대로 — 위치만 옮김)·`reauthPrompt({required})`·`callWithReauth(run, reauth)`. 브라우저와 Node 테스트가 같은 파일을 쓴다.
|
||||
- 버전 표기: `package.json` 0.2.1(HAL 런타임 `hal/deps.mjs VERSION`·`scripts/release.mjs` 는 이 값을 따른다), `README.md` 버전·개발 지시 목록, `test/webmaker-visual-editor.test.mjs` 의 Suite 버전 검사 0.2.1.
|
||||
|
||||
### 고객 화면
|
||||
- 변경 없음.
|
||||
|
||||
### 운영자 화면
|
||||
- 일반 화면(운영 현황·고객·주문·상품·사이트·메일·도메인·서버·백업·이전·릴리스·작업·장애·감사·복구·계정)에서 고위험 재인증 전역 배너가 사라진다. 운영자는 고위험 작업(서버 삭제·격리·역할 변경, 모듈 되돌리기·배포·재조정, 작업 되돌리기, 운영자 추가·접근권 발급, MFA 해제, 설정 변경, 메일 가져오기 적용·되돌리기, 릴리스 승인, 긴급 복구 선언·승인·자격, DR 설정·복원)을 실제로 시도해 서버가 거부한 순간에만 "재인증(최근 10분)" 창 위쪽에서 `고위험 작업(서버 삭제·되돌리기·인증 키 변경·복구)은 최근 10분 안의 재인증이 필요합니다` 를 본다.
|
||||
- 계정 화면: "재인증" 단추 유지(수동 재인증 창은 비밀번호만), "최근 재인증" 행은 `유효(10분)` / `만료(10분 지남)`. 운영자 조치 항목 없음(설정값·보관소·DB 변경 없음). 배포 뒤 브라우저 캐시의 옛 `ops.js` 가 새 `reauth.mjs` 를 못 찾는 경우는 없다(옛 ops.js 는 helper 를 가져오지 않음).
|
||||
|
||||
### 보안 결정
|
||||
- 서버 쪽 판단 코드는 한 줄도 바꾸지 않았다(`hal/app.mjs` `requireRecent`·CSRF·`threeStep`·고위험 경로 분류, `lib/session.mjs` `recentlyAuthed`, `POST /ops/api/reauth`). 변경은 "거부됐을 때 브라우저가 어떻게 묻고 몇 번 다시 보내는가"와 안내 문구 위치뿐이다.
|
||||
- 재인증 창 취소·재인증 실패 → 원 요청을 다시 보내지 않고 원래 오류를 호출자에게 전파(고위험 작업이 몰래 실행되지 않음). 성공 → 같은 method·path·body 로 **딱 한 번** 재시도(`api` 직접 호출, `call` 재귀 아님). 재시도도 거부되면 다시 묻지 않는다(무한 반복 금지). `reauth_required` 외의 오류는 재인증을 묻지 않는다.
|
||||
- 안내 문구는 helper 단일 출처이며 `R.esc` 로 이스케이프해 modal 에 넣는다. 비밀번호·토큰·키·개인정보는 파일·테스트·출력에 없다 — 새 테스트 `test/hal-reauth-banner.test.mjs` 의 운영자 비밀번호 fixture 는 정적 literal 없이 `node:crypto` `randomBytes` 로 실행마다 생성하며(정답·오답 값이 서로 다름 보장) 출력에도 찍지 않는다.
|
||||
|
||||
### 테스트
|
||||
- RED(production 변경 전, `node --no-warnings test/hal-reauth-banner.test.mjs`): **통과 1 · 실패 4** — ① `/ops/: 10분 경과 뒤에도 고위험 배너 없음 (실제 false)`(배너가 아직 그려짐) ② ③ `ERR_MODULE_NOT_FOUND hal/ui/public/reauth.mjs`(helper 없음, 문구·재시도 계약 테스트 2건) ④ `hal/ui.mjs 는 고위험 배너 문구를 더 이상 그리지 않음 (실제 false)`(정적 계약). 통과 1건은 backend 경계 보호 테스트(변경 대상이 아니므로 처음부터 통과해야 한다).
|
||||
- GREEN(최소 구현 뒤): `test/hal-reauth-banner.test.mjs` **5/5**; targeted `test/hal-ui.test.mjs` **7/7**, `test/hal.test.mjs` **16/16**(기존 reauth_required → 재인증 → 통과 흐름 포함). `node --check` 로 `ops.js`·`reauth.mjs` 구문 확인.
|
||||
- 전체 Suite `node test/run.mjs`: **25개 파일 실패 0**(새 `hal-reauth-banner` 5 포함, 로컬 E2E 11 통과; 러너가 만든 `docs/verification/local-e2e-2026-10-28.*` 임시 파일은 diff 에서 제거). 기존 테스트 변경은 `test/webmaker-visual-editor.test.mjs` 의 Suite 버전 검사 0.2.1 뿐.
|
||||
- 새 테스트가 못 박는 계약: 일반 화면 6곳 + 계정 화면에 로그인 직후·10분 경과 모두 전역 배너·배너 영역 재인증 링크·`고위험 작업(` 문구 없음, 계정 수동 재인증 단추 유지, 부트스트랩 배너 유지 / backend: 10분 경과 뒤 서버 삭제·격리 403 `reauth_required`(`detail.action=reauth`)·거부된 요청 미실행·CSRF 없는 재인증 403 `csrf`·틀린 비밀번호 거부·재인증 뒤 `recentlyAuthed`·3단계 확인 없이는 여전히 거부 / helper: 수동 창 note 없음·거부 창 note = 옛 배너 문구·취소 시 run 1회·오류 전파·성공 시 run 2회·재시도 거부 시 재질문 없음·다른 오류는 재인증 없음·정상 응답 그대로 / 정적: `hal/ui.mjs` 배너 코드 없음, `ops.js` 가 `./reauth.mjs`·`callWithReauth`·`reauthPrompt` 를 쓰고 `reauth: () => reauth()`(인자 없음)이며 문구를 직접 품지 않음.
|
||||
|
||||
### 알려진 제한
|
||||
- 실제 브라우저(DOM)에서 재인증 modal 이 열리는 모습은 자동 테스트하지 않았다(저장소에 브라우저 러너 없음, 큰 dependency 추가 금지). 흐름·문구는 순수 helper 로, 결선은 정적 계약으로 검사했다. 배포 뒤 운영자가 10분 경과 상태에서 고위험 단추(예: 서버 격리) 하나를 눌러 안내가 붙은 재인증 창이 뜨고 취소 시 실행되지 않음을 한 번 눈으로 확인하는 것을 권한다.
|
||||
- `hal/ui/render.mjs` `relayError({code:'reauth_required'})` 가 포털 중계 응답에 "재인증 필요" 상태 패널을 그리는 동작은 그대로다(이것도 서버가 거부한 그 시점의 표시이며 고위험 문구는 없다).
|
||||
- `docs/verification/` 에 0.2.1 전용 검증 문서는 만들지 않았다(지시 범위: HAL UI·테스트·버전/이력·개발 지시). RED/GREEN 증거는 이 절과 인도 보고에 있다.
|
||||
|
||||
### 미확정 정책값
|
||||
- 0.2.0 의 미확정 정책값 그대로. 재인증 유효 시간(10분)·고위험 작업 분류는 바꾸지 않았고 이번 범위 밖이다.
|
||||
|
||||
## 0.2.0 (2026-10-28)
|
||||
|
||||
- **작성자**: 개발 지시 — Neo(`Ariweb-Suitle_Dev-Order_v0.2.0.md`) · 구현 — Claude Fable 5.1(격리 작업 트리 `feature/visual-editor-v0.2.0`, 기준 main `4b3714e37c543fcfa8862dbc68c76f62a9b0bf97`). 커밋·push·병합·배포·운영 서버 접근은 하지 않았다 — Neo 가 검토 뒤 수행한다.
|
||||
- **이전 버전**: 0.1.1(2026-10-26, 첫 구현 릴리스 — 아래 절) · 0.1.4 개발 지시(2026-10-27, HAL AI 대시보드·다중 AI 공동 작업창 지시 문서만 추가, `4b3714e`). 0.2.0 은 **가운데 버전 번호를 올리는 기능 릴리스**다: 도입 모듈 Webmaker 에 사용자 기능(직접 편집)이 더해졌고 Webmaker 자체도 1.7.3 → **1.8.0** 으로 올랐다. 0.1.1 의 모든 기준·0.1.4 의 지시는 그대로 유효하다(이번 범위 밖의 0.1.4 항목은 구현하지 않았다).
|
||||
- **구성**: HAL · 고객 포털 · Node Agent 는 0.1.1 그대로. 도입 모듈 **Webmaker 1.8.0**(직접 편집 포함) · AriMail 1.0.11(변경 없음). 외부 npm 의존성 없음, Node 22.13+.
|
||||
|
||||
### 요구(사용자 요구 — 약화 없이 반영)
|
||||
Webmaker 가 초안을 만들어 보이는 페이지가 생긴 뒤 **직접 시각 편집 모드**를 더한다(Wix·아임웹 방식 끌어다 놓기). 변경은 Suite 의 `modules/webmaker` 와 기존 Webmaker 저장소 양쪽에 반영한다. 필수 동작·보안 규칙은 `Ariweb-Suitle_Dev-Order_v0.2.0.md` 에 계약으로 적었다.
|
||||
|
||||
### 재사용한 기존 기능(그대로 도입)
|
||||
- Webmaker 1.8.0 의 **직접 편집(시각 편집기)** 전부 — 미리보기 도구 줄 "직접 편집" 전환(사이트·HTML 페이지가 골라져 있을 때만), 캔버스 글 바로 고치기(두 번 클릭/Enter), 요소 선택·도구 막대(↑↓·복제·삭제·끌기 손잡이), 기존 블록 끌어 옮기기, 구성 요소 팔레트 7종(섹션/컨테이너·제목·문단·버튼(링크)·이미지·구분선·여백) 끌어다 놓기 + "추가" 버튼, 속성 창(상위 선택·이동·복제·삭제·글·링크 주소·이미지 주소/대체 글·정렬·글자색·배경색·안쪽/바깥 여백), 저장 상태(변경 없음/저장되지 않음/저장 중/저장됨/저장 실패 — aria-live), 저장(Ctrl/Cmd+S), 실행 취소/다시 실행, 버리고 다시 불러오기, 편집 끝내기, 페이지·사이트·화면 전환·새로고침·소스 보기 전환·창 닫기 전 저장하지 않은 변경 보호. 기존 소스 편집기·모바일 미리보기·버전/복원·배포·가져오기·다국어·모듈·SEO·Site Brief 는 그대로.
|
||||
- 재사용한 함수(Webmaker 기존 코드를 그대로 호출): `resolveTextFile`(경로 검증·링크 거부), `listFiles`, `gitRecord`/`lib/gitstore.recordChange`(버전 기록 — 새 종류 `visual-edit`), `publicView`, `broadcast`, `jobOf`(AI 작업·배포 중 판정), `readBody`, `sessionUser`·`fromTenantPage`·`auth.csrfOk`(출처·CSRF), `siteUrl`, `isSecureReq`, `MAX_TEXT_BYTES`(5MB 상한), `store.save`(Suite `rev` 증가). 화면: `discardOk`·`renderPreview`·`renderGit`·`loadFrame`·`setEditorOpen`·`logLine`·`pushMsg`·`api`(CSRF 헤더)·`T`.
|
||||
|
||||
### 수정한 기존 기능
|
||||
- **Webmaker(`modules/webmaker`, 파일 머리·줄 주석 `v1.8.0`)**: `server.js` — `require('./lib/visual-editor')`, `veRuntime()`·`requestOrigin()`, `GET /files` 응답에 `hash`, 새 경로 `GET/PUT /api/sites/<id>/visual/<페이지>`(아래 "직접 편집·끌어다 놓기·저장·버전·보안"). `public/app.js` — `discardOk`(직접 편집 변경도 묻기), `VER_KIND` 에 `visual-edit`(기존 `manual-edit` 는 "소스 편집" 으로 표기), `renderPreview`(직접 편집 중이면 미리보기 덮어쓰지 않고 `VisualEditor.onSiteUpdated`), `btnToggleSource`(한 번에 하나), `beforeunload`, `resetToNew`, `btnReload`·`fileSelect.onchange`, `openSite` 끝(사이트 바뀌면 편집 종료). `public/index.html` — `#btnVisual`, `#veBar`, `.ve-stage`(`#vePalette`·`#frameWrap`·`#veInspector`), 스크립트 2개. `public/style.css` — `.ve-*`(900px 이하 세로 배치·접기). `public/admin.js` — 버전 종류 표기. `package.json` 1.8.0.
|
||||
- **Suite 최소 연결 변경(`[Ariweb Suite 변경]`, 0.1.1 의 8개 파일 변경은 그대로 유지)**: `modules/webmaker/server.js` 의 직접 편집 `PUT` 에도 저장본 `rev` 충돌 검사(`revConflict`, §9.3)를 소스 편집기·기본 사항 저장과 같이 적용 — 화면이 보낸 `rev` 가 다르면 409 와 양쪽 요약(`conflict.currentRev` 등). 화면(`visual-editor-ui.js`)은 `GET /visual` 의 `rev` 를 기억해 저장 때 보내고 응답의 `rev` 로 갱신한다(upstream Webmaker 는 `rev` 를 돌려주되 무시한다). 그 밖의 Suite 전용 변경(SSO·내부 API·배너·`lib/ariweb.js`·`store.rev`)은 바뀌지 않았다 — `diff` 로 upstream 과의 차이가 0.1.1 의 Suite 블록 + 1.8.0 공통 변경만임을 확인했다.
|
||||
- **Suite 도구**: `scripts/import-sources.mjs` 에 `--allow-dirty`(참조 저장소를 나란히 개발 중일 때 고정 커밋 위의 작업 트리 변경·추적되지 않은 새 파일을 가져오고 `upstream{version·branch·headCommit·workingTreeDirty·note}` 를 기록) 와 `--only` 때 다른 소스 기록 유지. `hal/modules/index.mjs`·`agent/jobs/module.mjs` 의 Webmaker 버전 주석, `portal/webmaker/REUSE.md` 갱신. Agent·포털 코드 동작은 바뀌지 않았다(릴리스 artifact 는 `package.json` 0.2.0 으로 `webmaker-0.2.0.tar.gz` 등으로 만들어진다). **`scripts/release.mjs` 와 `hal/core.mjs` 는 바뀌었다** — 도입 소스 출처 fail-closed(아래 ⑪)와 릴리스 승인 세 검사 필수(아래 종결 검토 ③).
|
||||
- 오류 수정: Webmaker 직접 편집 저장에서 JSON 파싱 실패가 500 으로 나오던 것을 400 한국어 안내로(TDD 중 발견, 직접 편집 경로에만 적용).
|
||||
- **검토 수정(Neo 독립 검토)** — ① 출시 차단: 직접 편집 PUT 이 본문을 읽기(await) 전에 파일 hash 를 잡아 두어 두 저장이 나란히 들어오면 둘째가 첫째를 조용히 덮어쓸 수 있었다 → 본문을 다 읽은 뒤 사이트 기록(`store.load`)·파일을 다시 읽어 hash(Suite 는 `rev` 도)를 비교하고 검증·쓰기까지 await 없이 이어 간다(둘째는 409 stale, 첫 저장 보존). 결정적 회귀 테스트(chunked PUT 두 개의 본문을 순서대로 완료). ② 안전 강화: 직접 편집 저장을 `lib/visual-editor.writeTextAtomic`(같은 폴더 임시 파일 0644 → rename, 실패 때 정리·오류 전달, 파일 시스템 주입 가능)로 바꿔 쓰기 실패·중단 때 마지막 정상 페이지가 잘리지 않는다(500 + "기존 페이지는 그대로 있습니다", 버전 미생성). 소스 편집기의 기존 쓰기는 바꾸지 않았다.
|
||||
|
||||
### 고객 화면
|
||||
- 포털 사이트 상세 › 제작 탭 › "제작 화면 열기"(SSO) → Webmaker 빌더 오른쪽 미리보기 도구 줄의 **직접 편집**. 포털 자체 화면은 바뀌지 않았다. 직접 편집은 AI Wallet(캐시)을 쓰지 않으므로 포털 AI Wallet 카드에 영향이 없고, 직접 편집 저장본(버전 `visual-edit`)은 포털 배포 화면의 저장본 선택·Source 변경 이력에 그대로 보인다(내부 API `projects/<id>` 의 `versionList`).
|
||||
- 포털 상단 배너(그룹·운영 사이트)는 직접 편집 중에도 그대로 보인다.
|
||||
|
||||
### 운영자 화면
|
||||
- HAL 운영자 콘솔 변경 없음. Webmaker 관리자 콘솔의 버전·변경 종류 표기에 "직접 편집"(`visual-edit`)이 추가되고 기존 `manual-edit` 는 "소스 편집" 으로 보인다. 운영자 조치 항목 없음(설정값·보관소 변경 없음).
|
||||
|
||||
### 직접 편집 · 끌어다 놓기 · 저장 · 버전 · 보안 동작
|
||||
- **캔버스**: `GET /api/sites/<id>/visual/<페이지>` 가 저장된 HTML 을 편집 캔버스 문서로 바꿔 준다(`lib/visual-editor.prepareCanvas`). 페이지 `<script>`(외부 src 포함)는 `type="text/x-wm-ve-disabled"`, 인라인 이벤트 속성은 `data-wm-ve-on-*`, `javascript:` 링크·iframe/frame/embed src·object data·meta http-equiv·base href 는 `data-wm-ve-*` 보존 속성으로 바뀌어 **페이지 스크립트가 실행되지 않는다**(지우지 않고 보존). `<meta http-equiv="Content-Security-Policy">` 가 nonce 있는 편집 런타임(인라인 `visual-editor-core.js`+`wm-ve-canvas.js`)만 허용하고, `<base href="<사이트 주소>/<폴더>/">` 로 상대 경로 자원이 저장 페이지와 같게 풀린다. 화면은 이를 기존 미리보기 iframe(sandbox, allow-same-origin 없음 → 출처 null, 쿠키·저장소·빌더 API 접근 불가)의 `srcdoc` 에 넣는다. 응답에 32자리 무작위 세션·파일 sha256(`hash`)·`rev`.
|
||||
- **규약**: 봉투 `{wmve:1, session, type, payload}`. 부모는 iframe `contentWindow` + 출처 `'null'` + 세션 + 허용 종류(ready·selected·changed·error·body·hotkey)만, 캔버스는 `window.parent` + 부모 출처 + 세션 + 허용 종류(init·select·move·duplicate·delete·insert·setProps·applyHtml·setMode·requestBody)만 처리한다(`WMVisualCore.validateMessage`). HTML 속성·메시지 내용은 권한으로 쓰지 않는다. 캔버스는 서버와 통신하지 않는다.
|
||||
- **저장**: `PUT /api/sites/<id>/visual/<페이지>` `{body, baseHash, rev}` — 원본 doctype·`<head>`·`<body …>`·꼬리는 글자 그대로 두고 **본문만** 바꿔 끼운다. 보존 속성 복원, 편집기 표식 제거. 거절(한국어): 로그인 401 · 다른 사용자 404 · 생성 사이트 출처/CSRF 없음 401 · HTML 아님 400 · 경로 탈출·링크 400 · AI 작업·배포 중 409 · `rev` 불일치 409(+ 양쪽 요약, Suite) · `baseHash` 불일치 409(+ currentHash) · baseHash 없음/형식·본문 비문자열·JSON 파싱 실패 400 · 5MB 초과 413 · 원본에 없던 스크립트(추가·변경)·인라인 이벤트 속성·javascript:/vbscript:/data:text 주소·iframe/object/embed/meta/link/base·스타일 표현식·본문 안 html/head/body 400. 저장 뒤 `manualEdits{kind:'visual'}`, 버전 `visual-edit`("직접 편집: <경로>"), Suite `rev` +1, 미리보기·소스 편집기용 `hash`·`files`·`git`·`versions` 갱신. **캐시 미차감·AI 명령·수정 횟수 미계수.**
|
||||
- **끌어다 놓기**: 팔레트 → 캔버스는 **포인터 끌기 다리가 주 경로**(부모가 팔레트 항목의 포인터를 추적해 iframe 안쪽일 때만 상대 좌표를 검증된 메시지 `paletteDragAt`/`paletteDropAt`/`paletteDragCancel` 로 보내고 캔버스가 id·좌표·모드를 다시 검사해 스스로 놓기 표시·삽입 — Chromium 이 HTML5 끌기를 출처 'null' sandbox iframe 으로 넘기지 않기 때문), HTML5 끌어다 놓기는 포인터 이벤트가 없는 환경의 **대체 경로**(`text/wm-ve-component` + `text/plain` 이름 공간 값만 받고 구성 요소 HTML 은 `isSafeComponentHtml` 검사). 기존 블록은 포인터 기반 끌어 옮기기(같은 문서 안, 놓을 자리 표시). 버튼(↑↓·추가)·키보드(Alt+↑/↓·Delete·Ctrl+D·Enter·Esc·Ctrl+S·Ctrl+Z/Ctrl+Shift+Z) 대안.
|
||||
- **반응형·접근성**: 팔레트·속성 창은 캔버스 옆, 900px 이하에서 아래로 내려가며 접힌다(캔버스를 가리지 않음). 전환 버튼 `aria-pressed`, 도구 줄 `role=toolbar`, 상태 `aria-live=polite`, 팔레트·속성 창 `role=region`+`aria-label`, 접기 `aria-expanded`, 캔버스 도구 버튼 `aria-label`·포커스 표시.
|
||||
|
||||
### 도입 소스(출처)
|
||||
- 원본 저장소 `https://source.ibit.kr/OZ-Group/WebHosting_AI_agent`, **고정 커밋 `4b8c4d0de0f69025001a9e0a28a58a94e1a1f088`**(Webmaker 1.8.0 — `feature/visual-editor-v1.8.0`·main 원격 ref 검증됨; 0.1.1 의 불변 기준 커밋 `3b10859884ef2fd0f2af11934ab6c16370fd64b8`(1.7.3) 위의 변경). 개발 중에는 커밋 전 작업 트리(참조 저장소 worktree `/home/master/work/WebHoting_AI_Agent-visual-editor-v180`)에서 `node scripts/import-sources.mjs --only webmaker --allow-dirty` 로 가져왔고, 커밋 뒤 `scripts/import-sources.mjs` 의 `pin` 을 `4b8c4d0` 으로 올렸다. 깨끗한 도입 기록(`IMPORTED_SOURCES.json/.md`, 그 pin 으로 `--allow-dirty` 없이 다시 만든 것)의 webmaker 항목: `commit` = `upstream.headCommit` = `4b8c4d0`, `upstream{version:'1.8.0', branch:'feature/visual-editor-v1.8.0', workingTreeDirty:false}`, 파일 184개(0.1.1 의 176 + 신규 8: `lib/visual-editor.js`, `public/visual-editor-core.js`, `public/wm-ve-canvas.js`, `public/visual-editor-ui.js`, `test/visual-editor.test.js`, `test/visual-editor-server.test.js`, `test/visual-editor-app.test.js`, `docs/verification/visual-editor-1.8.0-tdd.md` — 모두 원본과 동일), 수정 8개(0.1.1 과 같은 `lib/auth.js lib/config.js lib/store.js lib/vault.js public/app.js public/index.html public/style.css server.js` — Suite 블록 때문). 이 작업 트리에 있는 `IMPORTED_SOURCES.json/.md` 는 아직 개발 중 기록(기준 `3b10859`, `workingTreeDirty:true`)이라 Neo 가 올린 pin 으로 다시 만든다(이 작업 트리의 참조 체크아웃은 그 커밋에 있지 않아 여기서는 재생성하지 않았다).
|
||||
- 원본 경로 → 대상 경로는 `IMPORTED_SOURCES.md` 표(`modules/webmaker/<같은 경로>`). 라이선스 검토: OZ-Group 내부 전용(private, 별도 LICENSE 없음) — 같은 조직의 Suite 재사용 허용, 외부 배포 불가(0.1.1 과 같음). AriMail 은 변경 없음(a07f69f).
|
||||
|
||||
- **실제 브라우저 시각 검증 수정(Neo 의 브라우저 게이트)** — 직접 편집을 켜도 옛 작업 칸(`.left`)이 남아 1280×633 에서 캔버스 iframe 이 115px 로 줄고 390×844 에서 캔버스가 y≈2228 로 밀리는 출시 차단 결함. `body.ve-on` 의 CSS 배치 규칙이 없던 것이 원인(JS 전환은 정상, 가짜 DOM 실행 테스트로 확인). `modules/webmaker/public/style.css` 에 `body.ve-on .left { display:none }`·`body.ve-on .app { grid-template-columns: var(--rail-w) minmax(0,1fr) }`(1280px 캔버스 568px)·1180px 이하 패널 190px·900px 이하 레일+캔버스 두 행 규칙을 추가(독립 Webmaker 와 동일). 끝내면 원래 세 칸 배치 복원. 회귀: CSS 계약 테스트 2건 + JS 전환 동작 테스트 1건(`test/visual-editor.test.js`).
|
||||
- **실제 브라우저 시각 검증 수정 2(배치 수정 뒤 게이트)** — 직접 편집을 켜고 요소를 고르기 전인데 어두운 선택 도구 줄(`.wm-ve-tools`)이 이미 페이지 위에 보이는 출시 차단 결함(DOM 은 `hidden`, 속성 창은 "선택한 요소 없음"). 캔버스 런타임의 작성자 규칙 `.wm-ve-tools{display:flex}` 가 UA 의 `[hidden]{display:none}` 을 덮어쓴 것이 원인. `modules/webmaker/public/wm-ve-canvas.js` 런타임 CSS 에 `.wm-ve-tools[hidden],.wm-ve-drop[hidden]{display:none !important}` 한 줄 추가(독립 Webmaker 와 byte-identical). `renderOverlay()` 가 `hidden=false` 로 바꿀 때만 보이고 선택 해제·미리보기·`applyHtml` 뒤 다시 숨겨진다. 회귀: 런타임 CSS 를 파싱해 `hidden` 요소의 최종 display 를 cascade 모형으로 계산하는 계약 테스트 1건(`test/visual-editor.test.js`, RED actual 'flex' → GREEN).
|
||||
|
||||
- **실제 브라우저 게이트 3(Playwright 자동 게이트)** — 실제 Chrome 에서 frameLocator 로 캔버스 iframe 을 직접 조작해 선택·글 편집·실행 취소/다시 실행·Alt+↑/↓ 이동은 통과했지만, 팔레트 → 캔버스 실제 HTML5 끌어다 놓기가 들어가지 않는 출시 차단 결함. 부모는 전용 MIME `text/wm-ve-component` 만 넣는데 캔버스는 `sandbox="allow-scripts"`(allow-same-origin 없음) 출처 'null' 문서라 Chromium 이 다른 출처의 전용 MIME 끌기 자료를 넘겨주지 않는 것이 원인. 전용 MIME 은 유지하고 표준 `text/plain` 에 `wm-ve-component:<id>` 를 함께 넣으며, 캔버스는 `core.dragComponentId` 한곳에서 전용 MIME 우선 → 정확히 일치하는 text/plain + 알려진 id 만 받는다(임의 글·모르는 id 는 삽입·표식 없음). `modules/webmaker/public/visual-editor-core.js`·`visual-editor-ui.js`·`wm-ve-canvas.js`(독립 Webmaker 와 byte-identical). sandbox·CSP·메시지 검증·저장 검증·"추가" 버튼 대안 불변. 회귀: 순수 규칙·팔레트 dragstart·캔버스 동작(가짜 DOM 위 실제 런타임 부트) 3건(`test/visual-editor.test.js`, RED → GREEN).
|
||||
|
||||
- **실제 브라우저 게이트 4(살균 DnD 진단) — 포인터 끌기 다리** — MIME 보완 뒤에도 끌어다 놓기가 실패해 부모·캔버스 양쪽에 살균 리스너(종류·플래그만)를 붙여 확인: 부모 dragstart 2건(두 종류 모두 유효)인데 캔버스 dragenter·dragover·drop 0건. Chromium 은 HTML5 끌기 자체를 부모 → 출처 'null' sandbox iframe 으로 넘기지 않으므로(제품 결함) sandbox 를 유지한 채 포인터 다리를 주 경로로 넣었다: 부모 팔레트 항목이 포인터(마우스·펜)를 추적해("추가" 버튼·터치·보조 버튼 제외, 포인터 캡처, 4px 문턱) iframe 안쪽일 때만 상대 좌표를 검증된 메시지 `paletteDragAt`/`paletteDropAt`(밖·Escape·pointercancel·끝내기는 `paletteDragCancel`)로 보내고, 캔버스가 메시지 규약 뒤 id(`core.isComponentId`)·좌표(`core.canvasPoint`)·편집 모드를 다시 검사해 스스로 놓기 표시·삽입을 한다(잘못된 입력은 삽입·표식 없음). HTML5 DnD 는 대체 경로로 남김. `modules/webmaker/public/visual-editor-core.js`·`visual-editor-ui.js`·`wm-ve-canvas.js`(독립 Webmaker 와 byte-identical) + `style.css` 2줄(`.ve-comp { user-select:none; touch-action:pan-y }`·끌기 중 class). 터치 스크롤·"추가" 버튼·키보드·CSP·저장 검증 불변. 회귀: 규약·좌표 순수 규칙 / 부모 포인터 순서 동작 / 캔버스 수신 동작 3건(`test/visual-editor.test.js`, RED → GREEN).
|
||||
|
||||
- **독립 검토(읽기 전용) 차단 항목 12건 수정** — 보안 7·규격/회귀 5. 공유 파일은 참조 저장소와 byte-identical 로 반영하고 Suite 블록 파일(`server.js`·`app.js`·`lib/store.js`)은 패치했다(상세·RED 증거: `docs/verification/webmaker-visual-editor-0.2.0.md`, upstream `modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md`). ① 캔버스 연결 규약 — 캔버스가 `MessageChannel` 포트를 첫 'ready' 창 메시지에 동봉하고 그 뒤 모든 명령·이벤트는 포트로만(`core.validatePortMessage`); 부모는 첫 ready 한 번만 연결·연결 뒤 창 메시지 전면 무시·끝내기 때 포트 닫음; 캔버스는 창 메시지를 듣지 않음(iframe 안 문서가 바뀌어도 세션·명령을 이어받을 수 없다). 심층 방어: a/area/제출 클릭·submit 차단(두 모드), `<base target="_blank">`, CSP form-action none, meta refresh 비활성화. ② 주소 스킴 정규화 — 서버·화면·캔버스가 한 구현(`core.urlScheme`)으로 이름·숫자 엔티티(두 겹)·제어·폭 없는 문자를 걷어내고, 스킴 자리의 모르는 엔티티는 거절. ③ `prepareCanvas` 가 구조 스캐너로 진짜 head/html/body 를 찾아 CSP·런타임을 넣고 없으면 진짜 `<head>` 생성. ④ 저장 검증을 완성 문서 전체 ↔ 원본 전체 비교로(머리 요소를 본문에 복사 불가). ⑤ 사이트별 변경 잠금 `withSiteLock` — 소스/직접 편집 PUT·버전 복원은 본문을 읽은 뒤 잠금 안에서 실행 중·배포 중·기록(rev)·hash 재검사 → 쓰기; AI/기획/가져오기/복사/다국어/배포 시작은 `claimJob`; 소스 편집기 PUT 에 선택적 `baseHash`(화면이 보냄, 409 때 다시 불러오기 질문). ⑥ 직접 편집 저장 트랜잭션 — 기록/Git 실패 때 페이지·사이트 기록(rev 포함, `store.save(prev,{restore:true})` 는 rev 를 올리지 않음)을 되돌리고 커밋을 되감아 500(`gitRecord` strict; 종결 검토 ② 에서 원격 푸시 순서·검증된 되돌리기로 재설계). ⑦ **Suite 전용**: 직접 편집 저장은 유한한 숫자 `rev` 필수(없음·문자열·null → 400 `revRequired`, 오래됨·다른 파일 저장으로 오른 rev → 409). ⑧ 전환 보호 — `openSite` 는 전환 전에 묻고 취소하면 현재 사이트 유지, 홈 탭도 묻고, `onSiteUpdated`/`renderPreview` 는 HTML 페이지가 없어져도 변경이 있으면 묻고 캔버스를 남겨 둔다. ⑨ 복제가 자손 id 도 제거. ⑩ 블록 끌기 — 손잡이 포인터 캡처, pointercancel·창 blur·Escape 가 상태를 모두 정리. ⑪ **Suite 전용**: 새 `lib/provenance.mjs` — `scripts/release.mjs build` 는 IMPORTED_SOURCES 의 출처가 깨끗하지 않으면(workingTreeDirty=true·불완전·commit≠headCommit) artifact 를 만들지 않고 종료 1, manifest.json 에 `checks.provenanceClean/provenanceProblems`·`imported[].upstream`; `register --approve` 는 `provenanceClean !== true` 면 승인하지 않고, `hal/core.approveRelease` 는 `provenanceClean === false` 를 `release_checks_failed` 로 거절(종결 검토 ③ 에서 세 검사 모두 명시적 통과 필수로 강화). 개발 중 기록(`workingTreeDirty: true`)으로는 build 가 멈췄다 — 참조 저장소 1.8.0 이 `4b8c4d0` 으로 커밋·검증되어 pin 을 올렸고, 그 pin 으로 다시 만든 깨끗한 기록(`workingTreeDirty:false`, 184 파일·수정 8)으로 build·approve 가 된다. ⑫ 문서 수치를 최종값으로 맞추고 브라우저 게이트 상태를 기록. 도입 기록(`IMPORTED_SOURCES.json`, 생성 manifest 기준): webmaker **184개 파일**(수정 8: lib/auth.js·lib/config.js·lib/store.js·lib/vault.js·public/app.js·public/index.html·public/style.css·server.js), 고정 커밋 `3b10859`, upstream 1.8.0 `feature/visual-editor-v1.8.0` workingTreeDirty=true. upstream 대비 Suite 블록 차이: `server.js` 115줄(107 → 115), `app.js` 4줄, `style.css` 5줄, `index.html` 2줄, `lib/store.js` 4줄.
|
||||
- **브라우저 게이트 상태** — Neo 의 Playwright 게이트(네이티브 포인터 순서)는 이 검토 **전** 에 37/37 통과. 수정 뒤 Neo 의 첫 재실행은 `edit-text` 에서 멈췄는데 원인은 게이트 멱등성 결함(같은 저장 폴더·같은 고정 제목 → 변경 없음 = clean)이었고 제품 결함이 아니다(로컬 결정적 재현). 같은 조사에서 가운데 클릭(`auxclick`) 틈을 찾아 캔버스 런타임에 차단을 더했다. 고친 게이트(실행마다 다른 제목, 연결 규약·이동 활성화 6종·위조 창 메시지 단언 추가)는 로컬 Chrome 156 에서 61/61(연속 2회), **Neo 호스트의 실제 Chrome 에서도 61/61 통과(2026-10-28)** — 수정 뒤 브라우저 재실행은 완료되었고 남은 재실행은 없다(종결 검토 3건은 브라우저 동작을 바꾸지 않는다).
|
||||
- **독립 검토 2차(종결 검토) 차단 항목 3건 수정 + 문서 수정** — 참조 저장소와 같은 코드(공유 파일 byte-identical, `server.js` 는 Suite 블록 보존 패치; 상세·RED/GREEN: `docs/verification/webmaker-visual-editor-0.2.0.md`, upstream `modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md`, 변경 이력 §9). ① **SVG SMIL 저장형 XSS 우회(보안)** — `validateVisualSave` 가 `<animate attributeName="href" values="javascript:…">`·`<set …>` 를 받았다(정적 주소만 검사, 위험 요소 목록에 SMIL 없음; Chromium 에서 실행 확인). `lib/visual-editor.js` 가 SMIL 요소 전부(`animate·set·animateMotion·animateTransform·animateColor·discard·mpath`, 대소문자·`svg:` 접두어 포함)를 위험 요소 묶음으로 세어 원본 수까지만 허용(삭제 가능, 추가·변경 거절), 캔버스에서는 `wm-ve-x-<이름>` 비활성 이름으로 바꿨다가 복원 때 표준 이름으로 되돌린다; `core.isSafeComponentHtml` 도 거절. ② **Git 저장 트랜잭션 재설계** — `gitstore.recordChangeStrict`: 스냅샷 → 로컬 커밋·변경 브랜치 → 사이트 기록 저장(rev +1) → 원격이 있으면 main + 변경 브랜치를 `git push --atomic` 한 번으로(마지막 단계, 그 뒤 상태 변경 없음); 어느 단계가 실패해도 `reset --mixed`(커밋 되감기 + 인덱스 비움, 첫 커밋 전이면 브랜치 삭제 + 빈 인덱스)·변경 브랜치 삭제/복원·페이지 원본·`store.save(prev,{restore:true})`(rev 보존) 뒤 **검증**(HEAD·인덱스·브랜치·작업 트리·페이지 바이트·기록) — `rolledBack:true` 는 검증 통과 때만, 아니면 `rolledBack:false`+`rollbackProblems`. 강제 푸시 되돌리기 없음, 원격 없는 저장소는 기존처럼 로컬 커밋. 푸시 **전** 에 저장되는 기록은 원격 반영을 주장하지 않는다(`pushed:false, remote:'pending'`, 응답도 같음) — 별도 조정 `reconcilePushes`(저장 뒤·기동 때·다음 기록 때, 잠금 안)가 `ls-remote` 로 원격 ref 를 확인한 뒤에만 `pushed:true, remote:'verified'` 로 바꾸고, 원격에 없으면(기록 저장과 푸시 사이에 프로세스가 죽은 경우) 로컬 변경 브랜치 + main 을 원자적으로 올린 뒤 확인한다. 조정의 기록 저장은 `{restore:true}` 라 rev 를 올리지 않는다(Suite 동시 편집 충돌 규칙과 무관한 메타데이터). ③ **Suite 전용 — 릴리스 승인 fail-open**: `hal/core.releaseApprovalProblem` 한 규칙으로 `testsPassed === true`·`dirty === false`·`provenanceClean === true` 를 모두 요구(없음·null·문자열 거절) — `approveRelease`·`registerRelease(approve:true)`·운영자 API `POST /ops/api/releases/:v/approve`·`scripts/release.mjs register --approve`(DB 열기 전) 모두 적용, `--skip-tests` 빌드는 등록만 가능. 기존 테스트 픽스처(hal-cli·hal·e2e 가짜 릴리스)는 깨끗한 checks 를 넣도록 고쳤다(옛 관용 정책 없음). ④ 문서 — "재실행 필요/미실행"·"자동화하지 않음"·옛 수치(103/110·182 파일·HTML5 주 경로·release.mjs 변경 없음)를 현재 증거로 고쳤다. 도입 기록 재생성: webmaker 184 파일·수정 8·workingTreeDirty=true(의도 유지 — 참조 저장소 커밋 뒤 재도입).
|
||||
- **독립 검토 3차(최종 검토) 차단 항목 4건 수정** — ① **Suite 전용 · 승인된 릴리스 불변**: `hal/core.registerRelease` 가 승인된 버전을 다른 내용(artifact·checks·커밋·빌드 시각·메모)으로 재등록하면 `release_immutable` 로 거절하고 아무것도 바꾸지 않는다(같은 내용 재등록은 멱등, approve 요청이어도 그대로); 승인되지 않은 버전의 내용 변경 재등록은 registered 로 두고 승인을 물려받지 않으며 approve 는 세 검사 규칙(`releaseApprovalProblem`)을 지나야 한다. `hal/index.mjs register-release` 는 거부를 종료 1 로 알린다(DB 불변). ② **사이트 변경 잠금 전면 감사**(공유 코드, Suite 블록 보존): `/api/sites/<id>/…` 변경 요청은 본문을 잠금 밖에서 메모 읽기 → 잠금 안에서 기록 재적재 → 짧은 경로는 처리기 전체를 잠금 안에서, 긴 작업 경로는 재적재한 기록으로(claimJob 까지 await 없음); 재진입 잠금(AsyncLocalStorage)·`detached()`; `changeLanguages` 는 claimJob 먼저. Suite 의 rev 충돌 검사는 재적재된 최신 기록으로 이루어진다. ③ **트랜잭션 완결성·모호한 푸시 판정**: `recordChangeStrict` 의 준비 단계(initSite·스냅샷·원격 설정)도 트랜잭션 안(실패하면 onRollback·새로 만든 저장소 삭제·스냅샷 전 실패는 ok:false), 푸시 오류는 원격 ref 를 다시 읽어 성공/원격 불변(되돌림 + 원격 재검증)/모호(pending 유지, 200 + `remoteState:'ambiguous'`, rolledBack 주장 없음)로 판정. ④ 문서 합계 통일. ⑤ **배경 작업 저장 합치기(저장소 불변식, Suite 블록 포함)**: `modules/webmaker/lib/store.js` 가 읽은 객체마다 읽었을 때 상태(`__base`, 열거 안 됨)와 저장 표식 `saveId` 를 기억하고, 디스크 기록이 그 사이 다른 객체로 저장되었으면 이 객체가 바꾼 최상위 항목만 디스크 최신 기록 위에 합쳐 저장한다 — 배경 AI 작업의 종료 저장이 작업 중의 기본 사항·모델 등급 변경을 지우지 않는다(RED: 제목이 "VE site" 로 되돌아감 → GREEN). Suite 의 `rev` 는 합칠 때 디스크 값을 기준으로 올리고, 원격 반영 조정은 `keepRev`(합치되 rev 불변)로 저장한다(`restore` 는 스냅샷 그대로). upstream 대비 `lib/store.js` 차이는 5줄(rev·keepRev). ⑥ **같은 잎 동시 변경 fail-closed**: 합치기를 잎 단위 3자 병합으로 바꾸고(같은 최상위 항목의 다른 잎은 합침), 같은 잎을 둘 다 다르게 바꾼 경우만 `site_save_conflict`(409, fields)로 거절해 디스크의 먼저 쓴 값을 보존한다. 배경 작업의 모든 저장(실패·중단 기록 포함)은 `saveJob` 으로 가서 충돌 때 `store.update` 로 최신 기록을 다시 읽어 작업이 가진 항목만 다시 적용하고 객체를 맞춘다(`store.adopt`); 다시 푸시·배경 조정은 원격 ref 결과를 `store.update` 로 적용. RED: 작업 중 다시 푸시가 쓴 `verifiedAt` 이 작업 종료 저장에 지워지고 배경 재확인이 다른 시각을 씀 → GREEN. 상세·RED/GREEN: `docs/verification/webmaker-visual-editor-0.2.0.md`, upstream `modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md`(변경 이력 §10).
|
||||
- **독립 검토 4차 차단 항목 4건 수정** — ① **Suite 전용 · artifact 설치 불변·fail-closed**: `hal/core.installArtifacts` — 원본 바이트의 sha256·크기를 manifest 와 대조(불일치 `artifact_hash_mismatch`, 아무것도 쓰지 않음), 승인된 버전은 메타데이터·설치 파일 해시가 같을 때만 멱등(복사 없음)·다르면 `release_immutable` 로 파일·DB 불변 거절, 새·미승인 설치는 임시 파일 → 해시 재확인 → rename. `hal/index.mjs register-release`·`scripts/release.mjs register` 모두 설치 → 등록 순서로 이 함수를 쓰고 거부는 종료 1. ② **읽기 요청 부작용 제거**(공유 코드): GET/HEAD/OPTIONS 는 `x-ui-lang` 으로 저장하지 않는다(saveId·rev 불변) — 언어는 변경 요청의 잠금 안 재적재에서만 저장. ③ **폐기 잠금·표식 규약**(공유 코드, Suite 의 포털 영향 확인 블록 보존): 잠금 안 재검사 + 폐기 표식 원자적 claim → 잠금 밖 배포 서버 정리 → 다시 잠금·같은 표식 확인 뒤 삭제; 표식 중 모든 변경 요청·작업 시작 409; 실패는 표식 해제 + `store.update` 로 `deleteFailed`. **포털 연결 보상(잔여 위험 해소)**: 연결 해제(`ariweb.unlink`)를 정리 **전** 에 하던 것을 공유 `destroySite` 의 `beforeIrreversible` 훅으로 옮겨 배포 서버 정리 성공·사이트 폴더/기록을 임시 이름으로 옮긴 **뒤**(되돌릴 수 없는 기록 삭제 직전)에만 하고 readback 으로 확인한다; 정리가 실패하면 연결·파일·기록이 모두 그대로, 연결 해제가 실패하면 옮긴 것을 되돌리고 중단, 기록 삭제가 실패하면 `compensate`(`ariweb.link` 로 되살리기 + readback, `linkedAt` 보존) 뒤 실패로 알린다(되돌리지 못하면 `recoveryRequired` + 감사 `site.destroy.failed`). 뒷정리(폴더 등) 실패는 성공 응답의 `warnings` 와 감사 `site.destroy.cleanup` 으로 운영자에게 보인다. ④ **파서 차이(저장형 XSS)**(공유 코드): 스캐너가 svg/math 안에서는 title·textarea·style·script 를 원문으로 읽지 않아 `<svg><title><script>` 가 거절·비활성화된다. 상세·RED/GREEN: `docs/verification/webmaker-visual-editor-0.2.0.md`, upstream `modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md`(변경 이력 §11).
|
||||
|
||||
### 테스트
|
||||
- 독립 검토 4차 4건 수정 뒤(**현재 증거**): 참조 저장소 `npm test` **115/115**(단위 39 + 서비스 23 + 화면 전환 1 + 원본 52), `modules/webmaker` `npm test` **125/125**(Suite 전용 `test/ariweb.test.js` 10 — 폐기 보상 3 추가), Suite `node test/run.mjs` **24개 파일 실패 0**(`test/release-provenance.test.mjs` 11건), 실제 Chrome 게이트(Neo 호스트) 61/61 그대로. RED: Suite artifact 불변 2 실패(승인된 바이트가 B 로 덮임), 참조 단위 38/39(svg title script 통과)·서비스 21/23(읽기가 기록 파일을 바꿈, 폐기 중 저장 미거절), Suite 모듈 폐기 보상 7/10(정리 실패 뒤 연결이 풀림·연결 해제 실패인데 정리를 먼저 하지 않음·되살리기 없음).
|
||||
- 최종 검토(3차) 4건 + 잎 단위 저장소 합치기(fail-closed) 수정 뒤: 참조 저장소 `npm test` **112/112**(단위 38 + 서비스 21 + 화면 전환 1 + 원본 52), `modules/webmaker` `npm test` **119/119**, Suite `node test/run.mjs` **24개 파일 실패 0**(`test/release-provenance.test.mjs` 9건), 실제 Chrome 게이트(Neo 호스트) 61/61. RED: Suite 승인 불변 core·CLI 2 실패(재등록이 승인자를 바꿈), 참조 서비스 15/18(잠금 경쟁·모호한 푸시·준비 단계 실패).
|
||||
- 종결 검토 3건 + 원격 반영 보수 기록 수정 뒤: 참조 저장소 `npm test` **107/107**(단위 38 + 서비스 16 + 화면 전환 1 + 원본 52), `modules/webmaker` `npm test` **114/114**, Suite `node test/run.mjs` **24개 파일 실패 0**(`test/release-provenance.test.mjs` 7건 — 승인 규칙 3건 추가). RED: 참조 저장소 단위 37/38(SMIL 1 실패)·서비스 11/14(Git 실패 되돌리기 검증·원격 트랜잭션 실패), 그 뒤 서비스 13/16(푸시 순간의 기록이 `pushed:true` 를 주장 — 보수 기록 수정 전), Suite provenance 4/7(core 승인·운영자 API·release.mjs 3 실패). 실제 브라우저 게이트(Neo 호스트 실제 Chrome) 61/61.
|
||||
- 독립 검토(1차) 차단 항목 수정 뒤: 참조 저장소 `npm test` **103/103**(단위 37 + 서비스 13 + 화면 전환 1 + 원본 52), `modules/webmaker` `npm test` **110/110**, Suite `node test/run.mjs` **24개 파일 실패 0**(새 `test/release-provenance.test.mjs` 4건 포함, `test/webmaker-visual-editor.test.mjs` 5건은 rev 필수·교차 파일 409·되돌리기 rev 보존 검사를 더함). RED: 참조 저장소 단위 24/37(13 실패)·서비스 9/13(4 실패)·app 0/1, Suite provenance 0/4, Suite rev(rev 줄을 뺀 임시 서버 복사본) 4/5.
|
||||
- 포인터 끌기 다리 뒤: 참조 저장소 `npm test` **87/87**(단위 26 + 서비스 9 포함), `modules/webmaker` `npm test` **94/94**, Suite `node test/run.mjs` **23개 파일 실패 0**.
|
||||
- 끌어다 놓기 수정 뒤: 참조 저장소 `npm test` 84/84(단위 23 + 서비스 9 포함), `modules/webmaker` `npm test` 91/91, Suite `node test/run.mjs` 23개 파일 실패 0.
|
||||
- 도구 줄 수정 뒤: 참조 저장소 `npm test` 81/81(단위 20 + 서비스 9 포함), `modules/webmaker` `npm test` 88/88, Suite `node test/run.mjs` 23개 파일 실패 0.
|
||||
- 배치 수정 뒤: 참조 저장소 `npm test` 80/80(단위 19 + 서비스 9 포함), `modules/webmaker` `npm test` 87/87, Suite `node test/run.mjs` 23개 파일 실패 0.
|
||||
- 검토 수정 뒤: 참조 저장소 `npm test` 77/77(단위 16 + 서비스 9 포함), `modules/webmaker` `npm test` 84/84, Suite `node test/run.mjs` 23개 파일 실패 0. 아래는 첫 인도 때 수치.
|
||||
- Webmaker 참조 저장소(1.8.0): `test/visual-editor.test.js` 15건(문서 나누기·캔버스 변환·복원·저장 검증 허용/거절·hash·공용 핵심), `test/visual-editor-server.test.js` 7건(권한·출처·CSRF / 허용 페이지 / 캔버스 계약 / 저장·버전·캐시 미차감 / 오래된 저장 409·잘못된 본문 400 / AI 작업 중 409 — 가짜 claude 실행 파일로 작업을 멈춘 두 번째 서버 / 화면 접근성 조작·런타임 서빙). RED(모듈 없음·경로 404·버튼 없음) → GREEN 기록: `docs/verification/visual-editor-1.8.0-tdd.md`. 전체 `npm test` **74/74**(원본 52 + 22).
|
||||
- Suite: `test/webmaker-visual-editor.test.mjs` 5건(도입 파일·버전·IMPORTED_SOURCES upstream / 순수 모듈 / 공용 핵심 / 화면 접근성 + Suite 배너 보존 / **실제 서버로 rev 충돌 409 → 맞는 rev 저장 200·버전 visual-edit·rev 증가**). RED(0/5) → GREEN(5/5): `docs/verification/webmaker-visual-editor-0.2.0.md`. `modules/webmaker` 전체 `npm test` **81/81**(59 + 22). Suite 전체 `node test/run.mjs` **23개 파일 실패 0**(로컬 E2E 포함, `docs/verification/local-e2e-2026-10-28.md/.json` 생성). 모든 서버 테스트는 `CLAUDE_BIN` 을 즉시 실패하는 가짜 실행 파일로 두고 실행했다(개발 PC 의 진짜 claude 가 사이트 생성 작업으로 실행되지 않게 — 첫 RED 실행에서 한 번 실행되어 즉시 종료시켰고, 테스트를 고쳤다).
|
||||
|
||||
### 알려진 제한
|
||||
- 실제 브라우저 검증은 Neo 의 게이트(시각 게이트 1280×633·390×844 → Playwright 자동 게이트, 실제 Chrome)로 다섯 차례 수행되어 배치·도구 줄·끌어다 놓기 결함을 찾아 고쳤고, 독립 검토 수정 뒤 재실행(게이트 멱등성 수정 뒤)은 **Neo 호스트에서 61/61 통과** — 캔버스 안 실제 포인터 조작(contenteditable 입력·실행 취소/다시 실행·이동·팔레트 포인터 끌어다 놓기·저장·연결 규약·이동 활성화·위조 메시지)은 이 게이트로 자동 검증했다. Node 에서는 문법 검사·`eval` 부재·정적 구조·CSS 배치 계약·가짜 DOM 위 실제 런타임/app.js 실행을 확인. 자동화하지 않은 것: 포털 SSO 로 들어간 실제 화면·운영 서버 배포·Chrome 외 브라우저 — 사람 확인 목록은 Webmaker `Dev_Order_v1.8.0.md` 끝.
|
||||
- 본문은 브라우저 직렬화(innerHTML)로 정규화된다(속성 큰따옴표·`&`→`&`·태그 소문자·void 요소 `/>` 제거) — 의미는 같고 머리·doctype·꼬리는 글자 그대로. `</body>` 뒤 스크립트는 브라우저가 본문으로 옮기므로 저장 때 꼬리의 같은 스크립트를 제거해 중복을 막는다.
|
||||
- 캔버스는 페이지 스크립트를 실행하지 않으므로 스크립트가 만드는 동적 내용(슬라이더·모듈 위젯 목록)은 빈 틀로 보인다. 구성 요소는 7종의 첫 릴리스, 블록 이동은 같은 문서 안. 다국어 사이트는 페이지마다 따로 편집(자동 재번역 없음 — 기존 다국어 카드 흐름 사용).
|
||||
- 참조 저장소 1.8.0 은 `4b8c4d0de0f69025001a9e0a28a58a94e1a1f088` 으로 커밋·검증되어 `scripts/import-sources.mjs` 의 webmaker `pin` 을 그 커밋으로 올렸다. 이 작업 트리의 `IMPORTED_SOURCES.json/.md` 는 아직 개발 중에 만든 기록(기준 `3b10859`, `workingTreeDirty:true`)이며, 그 pin 으로 `node scripts/import-sources.mjs --only webmaker`(`--allow-dirty` 없이)를 돌려 다시 만들면 `commit = upstream.headCommit = 4b8c4d0`, `workingTreeDirty:false`, 184 파일·수정 8 의 깨끗한 기록이 되어 build·approve 가 가능하다(여기서는 참조 체크아웃이 그 커밋에 있지 않아 재생성하지 않았다). 0.1.4 지시(HAL AI 대시보드 등)는 이번 범위 밖으로 구현되지 않았다. 운영 서버 배포·검증·점검은 하지 않았다(출시 선언 없음).
|
||||
|
||||
### 미확정 정책값
|
||||
- 0.1.1 의 미확정 정책값(`alias_counting`·`large_attachment`·`ai_wallet_pricing`, 후속 상품정책 값)은 그대로다. 직접 편집은 AI Wallet 을 쓰지 않으므로 과금 정책에 새 항목이 없다. 운영자가 뒤에 정할 수 있는 값: 직접 편집 본문 상한(현재 소스 편집기와 같은 5MB 고정), 실행 취소 기록 수(화면 200개 고정), 구성 요소 목록 확장.
|
||||
|
||||
## 0.1.1 (2026-10-26)
|
||||
|
||||
- **작성자**: 개발 지시 — Hera(`Ariweb-Suitle_Dev-Order_v0.1.1.md`, 통합 검토: Hera v0.1.0·Neo v0.1.0·1차 개발계획서) · 구현 — Claude Fable 5.1(Ariweb Suite 통합 개발, 커밋 Co-Authored-By 표기)
|
||||
@@ -14,7 +129,7 @@
|
||||
- AriMail 쪽에도 같은 구간을 적용해 `/api/usage` 가 `warnLevel`·`limitedReasons`·`monthReset` 을 돌려준다.
|
||||
|
||||
### 재사용한 기존 기능(그대로 도입)
|
||||
기능별 상세 표는 `portal/webmaker/REUSE.md`(Webmaker, 원본 revision `3b10859`)와 `portal/mail/REUSE.md`(AriMail, 원본 revision `a07f69f`)에 있다. 두 모듈의 원본 회귀 테스트는 도입 뒤에도 모두 통과한다(Webmaker 59/59 — 원본 52 + 추가 7, AriMail 9개 묶음 모두 통과).
|
||||
기능별 상세 표는 `portal/webmaker/REUSE.md`(Webmaker, 원본 revision `4b8c4d0` — 1.8.0; 0.1.1 기준 `3b10859`)와 `portal/mail/REUSE.md`(AriMail, 원본 revision `a07f69f`)에 있다. 두 모듈의 원본 회귀 테스트는 도입 뒤에도 모두 통과한다(Webmaker 59/59 — 원본 52 + 추가 7, AriMail 9개 묶음 모두 통과).
|
||||
- **Webmaker(§0.1)**: `AI에게 맡기기`·`내가 고르기` 제작, Site Brief, Template 과 기존 Module, Page 생성·편집·미리보기·저장, AI 생성·수정과 Wallet/Cache 차감, 저장본 선택, Domain·DNS·TLS 관리(등록 업체·DNS 공급자·와일드카드 인증서), Gitea Source 연결과 변경 이력, 배포 결과 확인과 이전 Version 복구, AriMail Server 연결과 Mail 사용량 표시, 사용자·관리자 화면의 설정·상태·통계. 포털 사이트 상세 › 제작 탭의 "제작 화면 열기"(SSO) 로 진입한다.
|
||||
- **AriMail(§0.3)**: Multi-domain Mail Service, Webmail(수신함·발신함·작성·회신·전달·검색), SMTP 발신·수신, IMAP·POP3, Resend·Haraka·External SMTP 발송 방식, Mail Domain·Account 관리, Mailbox 용량, 자동 응답·전달, 일반 첨부·링크 방식 대용량 첨부, POP3·IMAP 설정, DNS 설정·도메인 확인, 사용량 API, Webmaker 연동, 초기 설정·관리자·진단. 포털 메일 화면의 "웹메일 열기"(SSO 티켓) 와 외부 프로그램 설정 안내로 진입한다.
|
||||
- **공통 라이브러리에 옮겨 적은 기존 방식**: Webmaker 의 원자적 JSON 저장·세션 세대·고정 관리자 비밀번호 폐지·scrypt 해시(→ `lib/db`·`lib/session`·`lib/crypto`), AriMail 의 의존성 없는 SMTP 클라이언트와 `node:sqlite` 운영(→ `portal/mailer.mjs`, `lib/db`), Webmaker `deploy/remote-update.sh` 의 갱신 순서(데이터 백업 → 교체 → 재시작 → 유닛 점검)와 AriMail `deploy/deploy.sh` 의 배포 순서(DB 백업 → 릴리스 복사 → 마이그레이션 → 재시작 → health → Haraka)를 HAL 모듈 설명자(`hal/modules/index.mjs`)와 Agent 모듈 설치기(`agent/jobs/module.mjs`)에 그대로 반영.
|
||||
@@ -124,6 +239,7 @@
|
||||
### 운영 Webmaker ↔ HAL 연동 점검 도구(2026-10-27, webmaker.ariweb.net)
|
||||
- **`scripts/webmaker-inventory.mjs`**(+ `test/webmaker-inventory.test.mjs` 5건): 운영 Webmaker 데이터(JSON)·포털 DB·HAL DB 를 읽기 전용으로 안정 식별자(프로젝트 32-hex id, 포털 `s_/g_/u_`, HAL `srv_`)로 대조해 연결 등록부 일치·충돌·중복·누락, SSO 회원(`aw-<g8>-<u8>`) 신원, 도메인, Gitea `sites/<id>/main` ref, HAL 모듈 등록·버전·건강을 판정한다. 비밀로 보이는 키는 출력 전에 거부(`assertNoSecrets`). 충돌·누락이 있으면 종료 코드 2.
|
||||
- 2026-10-27 hostinger 실데이터 결과: 프로젝트 10(포털 연결 일치 3·Webmaker 고유 7), 회원 12(SSO 3 일치·고유 9), 도메인 2, Gitea refs 13, HAL `module_targets.webmaker` 0.1.1 health ok — 충돌 0·누락 0, **적용 변경 없음**(교체도 불필요). 사전 스냅샷·복원 검증·사후 readback 은 `docs/verification/webmaker-hal-link-2026-10-27.md`, 절차는 RUNBOOK §8.
|
||||
- **HAL21 제어 평면 전환 완료**: bear HAL을 main `12958ab`로 갱신한 뒤 hostinger Node Agent를 `hal21.ariweb.net`의 기존 서버 `srv_01a17bd4dbcdb55877f2d9fcab`에 재등록·승인했다. Webmaker unit·데이터는 변경하거나 재시작하지 않았다. 명시적 `DATA_ROOT`·`DATA_DIR`·`SITES_ROOT`로 수집한 HAL 인벤토리는 프로젝트 10/10, 절단 없음, 오류 0이며 `module_targets.webmaker`는 0.1.1/health ok다. 사전 체크섬 32,608건 불일치 0, Git 7개 fsck 성공, Gitea ref 13개 동일. 결과와 롤백은 `docs/verification/webmaker-hal21-link-2026-10-27.md`.
|
||||
|
||||
### 기존 메일 서비스(AriMail 인스턴스) ↔ HAL 관리형 MailService/Node 가져오기(2026-10-27, mail.arihakdang.com · webmail.ibit.kr → bear `hal21.ariweb.net`)
|
||||
- **`hal/mailsvc.mjs`**(+ 마이그레이션 `hal_0003_mail_services`, `test/hal-mailimport.test.mjs` 7건): HAL 관리형 메일 서비스 등록부(`mail_services`, 도메인 단위 논리 리소스, 불변 id `mls_…`, 출처 권한 `arimail:<hostname>:<unit>`, 도메인별 active owner 정확히 하나 — 부분 유일 인덱스)와 가져오기 단위(`mail_imports`). 읽기 전용 discovery 문서만 입력으로 받고 **비밀값이 어디에든 있으면 거부**(비밀은 `secretRefs` 의 file:/dir:/vault:/env: 참조만 저장). dry-run 이 기본, 충돌(collision: 다른 서버/인스턴스가 이미 active)·중복·누락이 하나라도 있으면 아무것도 적용하지 않음, 적용은 한 트랜잭션 + 감사(immutable), 같은 출처·서버의 재적용은 update(멱등), 적용 단위 되돌리기(생성 행 삭제·갱신 행 복원). 소유 조직은 증거로 확인되기 전까지 `unassigned_legacy_managed`. Agent heartbeat 의 arimail 모듈 상태가 그 서버의 행에 `last_sync`·`health` 로 비친다(`core.heartbeat`). Data Plane(AriMail 송수신·계정·메일·DNS/MX·TLS·provider)은 바꾸지 않는다 — HAL 이 멈춰도 메일은 그대로다.
|
||||
@@ -131,6 +247,17 @@
|
||||
- **HAL CLI**(`hal/index.mjs`): `mail-import --discovery <json> (--server <id> | --create-server --name …) [--apply]`, `mail-import-rollback --import <id>`, `mail-services`(readback JSON), `server-token --server <id>`(운영자 콘솔이 닿지 않는 부트스트랩·연동 단계의 시스템 신원 토큰 발급, 감사 `cli:<by>`). **운영자 API**: `GET /ops/api/mail-services`(+readback: 총수·active·도메인 중복·서버별·비밀 누출 검사), `GET /ops/api/mail-services/:id`(감사 포함), `GET /ops/api/mail-services/imports`, `POST /ops/api/mail-services/import {discovery, serverId, apply}`(적용은 재인증), `POST /ops/api/mail-services/imports/:id/rollback`(재인증). **운영자 화면** `/ops/mail`: HAL 관리형 메일 서비스 표(도메인·공개 주소·서버/인스턴스·리비전·계정/메일함/메일·건강/동기화·TLS/DNS·비밀 참조 수)를 먼저 그리고 포털 메일 서비스는 그 아래 `#portal-mail` 에 중계로 채운다(`ops.js`).
|
||||
- 2026-10-27 실제 적용: bear HAL 을 커밋 79b45ca 릴리스로 교체(재시작 2.6초, 메일 서비스 프로브 무중단) → 서버 2(pending) · MailService 7(hostinger 인스턴스 6 도메인 + jitsi 1, 모두 `unassigned_legacy_managed`, duplicates 0, secretLeaks 0) · jitsi Node Agent 등록(heartbeat → `last_sync_source=agent`, 승인은 운영자 결정) · hostinger 는 기존 HAL(`hal.ariweb.net`) 제어 평면과의 이중 제어를 피해 Agent 미설치(관측 전용). 원본 메일 서비스 변경 0. 결과·백업 sha256·readback 은 `docs/verification/mail-hal-link-2026-10-27.md`, 절차·되돌리기는 `docs/RUNBOOK-0.1.1.md` §9.
|
||||
|
||||
### 포털·hostinger Node Agent 제어 평면 이전 — hal.ariweb.net → hal21.ariweb.net (2026-10-27, Hera)
|
||||
- HAL CLI(시스템 신원, 감사 actor `cli:<by>`·reason) 보완: `config-set`(운영 값만 data/config.json 에 기록, 비밀 키 거부, 서비스 재시작 뒤 반영) · `module-deploy`(운영자 API `POST /ops/api/modules/:name/deploy` 와 같은 멱등 키 규칙으로 `module.deploy` 작업을 큐에 넣고, 실행은 serve 프로세스의 작업 엔진이 맡는다) · `job --id`(작업·자식 readback). 운영자 콘솔 자격 증명이 없는 연동 단계용. 테스트 `test/hal-cli.test.mjs` 3건(별도 프로세스 실행·멱등·감사·서비스 측 엔진이 집어 실행).
|
||||
- 결과(docs/verification/portal-hal21-link-2026-10-27.md): bear HAL21 을 main `dfb708e` 릴리스(artifact 4e419a72…)로 교체·등록·승인, `portalUrl=https://my.ariweb.net`; `link-portal` 값을 파이프로만 포털 vault `service:hal` 에 넣고(구 `service:portal` 제거) 포털 `halUrl=https://hal21.ariweb.net`; HAL21 작업 `j_01a17cb5716c669450412967f7` 로 portal 0.1.1 재배포(이전 릴리스 `.prev-20261027093350` 보존, health ok). 포털→HAL21 인증 읽기(health·servers·jobs 계약) 실측, 포털 DB 집계·Webmaker 인벤토리·AriMail 전후 동일, 외부 URL/TLS 전부 정상. 구 HAL 은 server offline 상태로 잔존(중지는 사람 결정). blocker: hostinger 서버 행 role `mail` → `pickServer(web)` null(운영자가 control 로 변경 필요).
|
||||
|
||||
### 운영자 콘솔 부트스트랩 배너 결함 수정 — 완료 뒤에도 "부트스트랩 미완료" 경고(2026-10-27, bear `hal21.ariweb.net`, Hera)
|
||||
- **증상**: bear HAL21 `/api/health` 는 `bootstrap.complete=true · halRunningVersion=0.1.1 · firstAgent 있음 · manualInstallLocked=true` 인데, 운영자 콘솔 모든 화면 상단에 `! 부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)…` 경고가 계속 보이고, 배포 릴리스 화면도 `HAL 실행 버전: 확인 전 · 부트스트랩 미완료` 로 그려졌다.
|
||||
- **원인**: `hal/ui.mjs` 의 공통 틀(shell) 배너가 `core.selfHealth(d).bootstrap` 의 실제 계약(camelCase `complete`·`halRunningVersion`·`firstAgent`)이 아니라 존재하지 않는 snake_case 열 이름(`completed_at`·`hal_running_version`·`first_agent_id`)을 읽어 항상 `undefined` → 늘 "미완료·확인 전·미등록"으로 렌더링. 같은 파일의 `/ops/releases` 라우트는 `releasesView` 가 bootstrap 행(snake_case `hal_running_version`·`manual_install_locked`)을 기대하는데 camelCase `selfHealth().bootstrap` 을 넘겨 같은 증상. 서버 렌더(HTML) 결함이며 `ops.js`/`ops.css` 는 관여하지 않는다(브라우저 캐시 무관).
|
||||
- **수정(`hal/ui.mjs` 만, 2곳)**: 배너는 `health.bootstrap.complete` / `halRunningVersion` / `firstAgent` 를 쓰고, `/ops/releases` 는 `running: core.bootstrapState(d)` 를 넘긴다. 옛 snake_case fallback 은 두지 않았다(단일 계약).
|
||||
- **검증(strict TDD)**: `test/hal-ui.test.mjs` 에 회귀 1건 추가 — selfHealth.bootstrap 에 snake_case 필드가 없음을 고정하고, (1) 초기 `확인 전·미등록` (2) `claimLive` 뒤 `0.1.1·미등록` (3) 첫 Agent 등록·승인(완료) 뒤 운영 현황·서버·릴리스 화면에 경고 없음 + 릴리스 화면 `HAL 실행 버전: 0.1.1 · 수동 설치 잠김` (4) Agent 만 승인된 새 HAL 은 `확인 전·등록됨`. RED: 수정 전 (2)에서 `확인 전` 으로 실패(완료 상태에서도 경고·릴리스 화면 `확인 전` 잔존을 별도 스크립트로 기록). GREEN: 파일 7건·전체 22파일 150건 통과·실패 0. 배포 결과는 `docs/verification/hal21-ops-banner-fix-2026-10-27.md`.
|
||||
- **배포 결과(bear hal21, 2026-10-27 14:36Z)**: 커밋 `648247f` 릴리스(hal artifact `ed957a0a…`)를 `/srv/ariweb/hal/releases/0.1.1-648247f22415` 에 stage → 추출된 `hal/ui.mjs` sha·`node --check`·snake_case 참조 0 확인 → register-release(approved, by hera-ops-banner-fix)·bootstrap-release → current 전환·재시작(health 까지 1.08초, NRestarts 0). data/config(opsAllowCidrs 5항목)/vault/DB sha·크기 전후 동일, 사설 백업 `/srv/ariweb/backups/hal-pre-banner-fix-20261027T143608Z`. readback: `/api/health` bootstrap complete·mainCommit 648247f, Agent 2대 heartbeat 재개, 배포된 코드만 import 한 in-memory 서버 렌더에서 완료 상태 배너 없음·릴리스 화면 `HAL 실행 버전: 0.1.1 · 수동 설치 잠김`; 포털·Webmaker·메일 회귀 이상 없음. 되돌리기: current 를 `0.1.1-dfb708e933e5` 로 되돌리고 재시작.
|
||||
|
||||
### 도입 소스
|
||||
- 가져오기: `node scripts/import-sources.mjs` 가 고정 커밋(Webmaker `3b10859884ef2fd0f2af11934ab6c16370fd64b8`, AriMail `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`)을 검사하고 실행에 필요 없는 문서·보고서·샘플을 제외한 전체를 `modules/webmaker`(176 파일)·`modules/arimail`(88 파일)로 복사, 파일별 원본 경로·대상 경로·수정 여부·원본 해시·라이선스 검토를 `IMPORTED_SOURCES.md`/`.json` 에 기록한다.
|
||||
- 라이선스 검토: 두 저장소 모두 `private: true`, 별도 LICENSE 없음 — OZ-Group 내부 전용 소스로 같은 조직의 Ariweb Suite 에서 재사용 가능, 외부 배포 불가. AriMail `haraka/` 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* 이때는 모든 파일 작업(풀기·REVISION·current 전환·정리·DB 백업·마이그레이션)을 `sudo -n -u <계정> bash deploy/module-as-user.sh …` 로 그 계정이 수행한다
|
||||
* (sudoers 는 이 스크립트만 root 가 아닌 계정으로 허용). artifact 는 Agent 의 작업 디렉터리(<srv>/agent/work)에 받아 그 계정이 읽게 한다.
|
||||
* 마이그레이션은 유닛의 EnvironmentFile(DB_FILE 등)을 같은 계정으로 읽어 실행한다 — 없으면 릴리스 디렉터리 안에 빈 DB 를 만드는 잘못된 동작이 된다.
|
||||
* [실제 환경 결함 수정 0.1.1] 실제 버전: 도입 모듈의 package.json 버전(Webmaker 1.7.3·AriMail 1.0.11)은 Suite 릴리스 버전(0.1.1)과 다르다.
|
||||
* [실제 환경 결함 수정 0.1.1] 실제 버전: 도입 모듈의 package.json 버전(Webmaker 1.7.3·AriMail 1.0.11 — 0.2.0 부터 Webmaker 1.8.0)은 Suite 릴리스 버전(0.1.1·0.2.0)과 다르다.
|
||||
* HAL 은 "목표 릴리스 버전 = 실제 버전" 으로 판정하므로, 풀 때 적은 REVISION.json 의 version 을 실제 버전으로 읽고 package.json 버전은 upstreamVersion 으로 함께 보고한다.
|
||||
* [실제 환경 결함 수정 0.1.1] 기존 유닛 보존: 도입 모듈 기술서에 keepExistingUnit 이 있고 서버의 유닛이 이미 <appDir>/current 를 가리키면 유닛을 다시 쓰지 않는다
|
||||
* (운영 서버의 강화 지시문·ReadWritePaths 를 템플릿이 지워 버리지 않게). 유닛이 없거나 다른 곳을 가리킬 때만 템플릿으로 만든다.
|
||||
|
||||
@@ -25,7 +25,7 @@ lib/ 공통 라이브러리(ESM). db·http·session·status·jobs·qu
|
||||
hal/ HAL 서버(api, ops 콘솔 UI, 릴리스·서버·작업·복구)
|
||||
agent/ Node Agent(등록·heartbeat·작업 실행기·모듈 설치기·External Pull 모드)
|
||||
portal/ 고객 포털(api, 화면, 도메인 로직) — portal/<영역>/*.mjs
|
||||
modules/webmaker/ 도입: WebHosting_AI_agent @3b10859 (CommonJS)
|
||||
modules/webmaker/ 도입: WebHosting_AI_agent @4b8c4d0 (1.8.0, CommonJS; 0.1.1 기준 3b10859)
|
||||
modules/arimail/ 도입: AriMail @a07f69f (ESM)
|
||||
catalog/ 출시 검증용 버전 고정 상품 Catalog(JSON) — 운영자가 HAL/포털 화면에서 관리하는 ProductVersion 의 시드
|
||||
deploy/ 설치 스크립트·systemd 유닛·nginx 템플릿·부트스트랩(HAL 최초 설치)
|
||||
|
||||
@@ -121,3 +121,26 @@ node hal/index.mjs server-token --server <srv_id> --by hera # 1회 표시
|
||||
```
|
||||
되돌리기: `node hal/index.mjs mail-import-rollback --import <mimp_id>`(그 적용이 만든 행 삭제·갱신 행 복원), 서버 행은 운영자 콘솔 서버 삭제(3단계 확인). Agent 는 `systemctl disable --now ariweb-agent` + `/srv/ariweb/agent` 제거(메일 서비스 영향 없음). 원본 메일 데이터 복원은 각 백업의 RESTORE.md(운영자 승인 뒤에만 — 메일 중단 동반).
|
||||
hostinger 주의: `arihakdang-mail` 은 hostinger HAL(`hal.ariweb.net`)의 `module_targets.arimail` 로도 관리된다. hal21 에 같은 호스트의 두 번째 Agent 를 설치하면 두 제어 평면이 같은 유닛에 배포할 수 있으므로(§34 "하나의 HAL") **설치하지 않고** 서버 행을 pending(Agent 미연결)으로 두었다 — 제어 평면 이전은 사람 결정.
|
||||
|
||||
## 10. 포털·Node Agent 제어 평면 이전 — hostinger HAL(hal.ariweb.net) → bear HAL21 (2026-10-27)
|
||||
전제: 포털은 hostinger 에서 이미 active(신규 초기화·빈 DB 생성 금지), Agent 는 한 HAL 에만 연결(단일 제어 평면), 비밀값은 파이프로만 이동. 기록: `docs/verification/portal-hal21-link-2026-10-27.md`.
|
||||
```bash
|
||||
# 0) 읽기 전용 preflight: DNS(공개·권한 resolver)·TLS·login·/api/health, 유닛 PID/NRestarts/cwd/REVISION/리스너, Agent 의 halUrl(agent.json 의 비밀 아닌 값), 포털 config 값·vault 이름, DB integrity/집계, 구 HAL·HAL21 의 servers/agents/jobs/module_targets
|
||||
# 1) 사설 백업(각 호스트, root 0700/0600, RESTORE.md 먼저): 포털 DB VACUUM 사본(+wal/shm 크기 기록)·vault·config·유닛·nginx·cert 메타·Agent data·구 HAL DB/config, HAL21 DB/config/release — SHA256SUMS·stage 추출 readback·복원 경로 존재 검증
|
||||
# 2) HAL21(bear, HAL 데이터 소유자): main 릴리스 artifact 등록·승인, 포털 주소 설정(재시작 필요), current 전환
|
||||
cd /srv/ariweb/hal/releases/0.1.1-<commit12> && export ARIWEB_HAL_DATA=/srv/ariweb/hal/data
|
||||
node hal/index.mjs register-release --manifest <dist>/manifest.json --approve --by <who>
|
||||
node hal/index.mjs bootstrap-release --version 0.1.1 --commit <sha> --sha256 <hal artifact sha>
|
||||
node hal/index.mjs config-set --portalUrl https://my.ariweb.net --by <who> --reason '…' # 포털이 다른 호스트일 때
|
||||
ln -sfn $PWD /srv/ariweb/hal/current && sudo systemctl restart ariweb-hal && curl -s http://127.0.0.1:8900/api/health
|
||||
# 3) 포털 연동 키: 값을 화면에 두지 않고 host-to-host 파이프로 포털 vault 에 넣는다(service:hal 하나 — 포털은 양방향에 같은 이름을 쓴다)
|
||||
ssh bear 'cd /srv/ariweb/hal/current && ARIWEB_HAL_DATA=/srv/ariweb/hal/data node hal/index.mjs link-portal | sed -n "s/^ service:hal = //p"' \
|
||||
| ssh hostinger 'node -e "let s=\"\";process.stdin.on(\"data\",c=>s+=c).on(\"end\",async()=>{const m=await import(\"/srv/ariweb/portal/current/lib/vault.mjs\");m.openVault(\"/srv/ariweb/portal/data\").set(\"service:hal\",s.trim(),{kind:\"service-key\"})})"'
|
||||
# 4) 포털 주소(비밀 아님): portal/config.mjs 의 set 으로 config.json 기록(0600, 원자적). 포털은 기동 때 읽으므로 다음 단계의 재배포(재시작)로 반영된다
|
||||
ARIWEB_PORTAL_DATA=/srv/ariweb/portal/data node -e "import('/srv/ariweb/portal/current/portal/config.mjs').then(m=>m.loadConfig().set({publicUrl:'https://my.ariweb.net',halUrl:'https://hal21.ariweb.net'},'cli:<who>'))"
|
||||
# 5) HAL21 작업으로 portal 재배포(같은 0.1.1 이면 Agent 가 현재 릴리스를 <ver>.prev-<시각> 으로 보존하고 health 실패 시 자동 복귀). 실행은 ariweb-hal 서비스가 맡는다
|
||||
node hal/index.mjs module-deploy --module portal --version 0.1.1 --servers <srv_id> --by <who> --reason '…' # → jobId
|
||||
node hal/index.mjs job --id <j_id> # status done, children[].steps await.result.actualVersion/previousVersion/health
|
||||
# 6) readback: module_targets.portal target=actual=0.1.1 health ok · 포털의 실제 client+config+vault 로 /portal/v1/health·servers·jobs 계약 · bear nginx access log 의 포털 요청 200 · 포털 DB 집계·Webmaker 인벤토리 전후 비교 · 외부 URL/TLS · 유닛/NRestarts/journal
|
||||
```
|
||||
주의: 포털 vault 교체와 포털 재시작 사이(수 초)에는 포털→HAL 호출이 인증 실패한다(화면은 '정보 확인 지연'). 서버 행의 role 이 `mail` 이면 `pickServer(web)` 이 null 이라 포털 사이트 배포 대상이 없다 — 운영자 콘솔에서 role 을 control 로 바꾼다(운영자 API 만 있음). 구 HAL 은 Agent 가 떠나면 server offline 으로 남는다; 중지·폐기는 사람 결정.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# 운영자 콘솔 "부트스트랩 미완료" 배너 결함 수정·배포 검증 기록 — 2026-10-27 (bear hal21.ariweb.net, Hera)
|
||||
|
||||
비밀값(토큰·비밀번호·Vault 값·운영자 자격)은 이 문서에 없다. 시각은 UTC.
|
||||
|
||||
## 1. 증상과 원인
|
||||
- 오즈 보고: HAL21 운영 콘솔 모든 화면 상단에 `! 부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)…` 경고가 계속 보임.
|
||||
- 실제 readback(수정 전, 14:2xZ `curl http://127.0.0.1:8900/api/health`): `bootstrap.complete=true · halRunningVersion=0.1.1 · firstAgent=ag_01a17bd7201c9a73c157e29968 · manualInstallLocked=true`, Agent 2대 active.
|
||||
- 원인(코드): `hal/ui.mjs` shell 배너가 `core.selfHealth(d).bootstrap` 의 실제 계약(camelCase `complete`·`halRunningVersion`·`firstAgent`, `hal/core.mjs` selfHealth)이 아니라 존재하지 않는 snake_case `completed_at`·`hal_running_version`·`first_agent_id` 를 읽어 항상 `undefined` → "미완료·확인 전·미등록". 같은 파일 `/ops/releases` 라우트는 `releasesView`(`hal/ui/render.mjs`, bootstrap 행의 `hal_running_version`·`manual_install_locked` 기대)에 camelCase 객체를 넘겨 `HAL 실행 버전: 확인 전 · 부트스트랩 미완료` 로 그려짐.
|
||||
- **서버 렌더(HTML) 결함**이다. `hal/ui/public/ops.js`·`ops.css` 는 bootstrap 필드를 읽지 않으며(grep 0건) 변경하지 않았다 → 브라우저 캐시와 무관.
|
||||
|
||||
## 2. RED (수정 전 실패 증명)
|
||||
- 회귀 테스트 `test/hal-ui.test.mjs` "부트스트랩 배너는 selfHealth().bootstrap 의 실제 계약…": (0) `selfHealth.bootstrap` 에 snake_case 필드가 없음을 고정 (1) 초기 `확인 전·미등록` (2) `core.claimLive` 뒤 `HAL 실행 버전 0.1.1, 첫 Agent 미등록` (3) 첫 Agent 등록(`POST /agent/v1/register`)·`core.approveServer` → 완료 뒤 `/ops/`·`/ops/servers`·`/ops/releases` 에 경고 없음 + 릴리스 화면 `HAL 실행 버전: <b>0.1.1</b> · 수동 설치 잠김` (4) Agent 만 승인된 새 HAL 은 `확인 전·등록됨`.
|
||||
- 수정 전 실행 결과(`node --no-warnings test/hal-ui.test.mjs`): **통과 6 · 실패 1** — `Error: 실행 버전 0.1.1 표시: 부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록) (실제 false)` (test/hal-ui.test.mjs:50).
|
||||
- 완료 상태 증상도 수정 전 코드로 기록(scratchpad `g7/red-evidence.mjs`): `selfHealth.bootstrap.complete=true` 인데 `/ops/` 배너 `부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)`, `/ops/releases` `HAL 실행 버전: <b>확인 전</b>`.
|
||||
|
||||
## 3. GREEN (수정, `hal/ui.mjs` 만 2곳)
|
||||
- 배너: `!health.bootstrap.complete` / `health.bootstrap.halRunningVersion` / `health.bootstrap.firstAgent`.
|
||||
- `/ops/releases`: `running: core.bootstrapState(d)`(렌더러가 기대하는 bootstrap 행). snake_case fallback 없음, 다른 기능 변경 없음.
|
||||
- 수정 뒤 `test/hal-ui.test.mjs` 통과 7 · 실패 0.
|
||||
|
||||
## 4. 리비전·테스트·빌드
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 작업 시작 origin/main = HEAD(fetch 뒤 동일, worktree clean — e2e 테스트가 재생성한 `docs/verification/local-e2e-2026-10-27.*` 는 HEAD 로 복원) | `d8e7beeb861ae5edb45fd56d6950fd8a50ba204e` |
|
||||
| 수정 커밋(hal/ui.mjs + test/hal-ui.test.mjs + VERSION_HISTORY, author `Hera (헤라) <hera@mp.local>`) | `648247f2241579e947d61ab2928176f678f4b141` |
|
||||
| 전체 테스트 `node test/run.mjs`(수정 뒤, 2m37s) | 22파일 **150건 통과 · 실패 0**(기존 149 + 신규 1) |
|
||||
| release build(`scripts/release.mjs build`, 커밋 648247f, 테스트 재실행 150건) | 0.1.1, testsPassed=true, dirty=false; verify 5 artifact ✔ |
|
||||
| hal artifact sha256 | `ed957a0aeb8f065085e3163cf3f367a240e90b55f6221844c93fa4404de8512f` (portal `32c76a60…`, agent `cf2e52fe…`, webmaker `9490b59d…`, arimail `b22868c0…` 는 이전 빌드와 동일) |
|
||||
| `bash -n deploy/*.sh` | 4개 통과(agent-install·bootstrap-hal·hal-restore·module-as-user); shellcheck 미설치 |
|
||||
| 소스 `hal/ui.mjs` sha256 | `904059364106888cab17609e3e9f743d96ea239bae87c0af856980a673eed92d` |
|
||||
|
||||
## 5. bear 배포 (코드 릴리스만, data/config/DB/Vault 무변경)
|
||||
- 사전 상태: current `/srv/ariweb/hal/releases/0.1.1-dfb708e933e5`, PID 1792203(14:21:13Z 다른 세션의 opsAllowCidrs 변경·재시작, config.json `updatedBy cli:hera-ops-access` 14:21:12Z, `config.json.pre-ops-cidrs-20261027T142020Z` 존재).
|
||||
- 사설 백업 `/srv/ariweb/backups/hal-pre-banner-fix-20261027T143608Z/`(root 0700, 파일 0600, `sha256sum -c` OK, stage 추출 검증 halVault 4·integrity ok·servers 2·configKeys [publicUrl, portalUrl, opsAllowCidrs], 경로 검증 6/6): archive.tgz `5c0e91f8530403061adf108ec1dbcab684c58854ccd7b60eed20e08aa81490c3`(7: config.json·pre-ops-cidrs 사본·vault.json·vault.key·unit·nginx conf), db.tgz `5182f1a65bb99d8dc084590e3993408fc7dd2129998be14bd536ec03e21c7bd0`(hal.sqlite VACUUM 835,584 B, integrity ok, 28표: servers 2·agents 2·releases 1·jobs 4·agent_jobs 2·mail_services 7), code.tgz `0ec00cb9c4698e4e228d31ffa5432ab0a55e6847da1ff8db721700b1e165b73a`(81, 0.1.1-dfb708e933e5), meta.tgz `75d7ff23f1ebeeccb7ad8da8518c5bbe440c39bd4088197a2d3e236a7d466166`, RESTORE.md `78769ba25e4033a310d9b5c348863946603130fc430ed8356713758e3eaaa1f7`, hal-verify.json `2435b0eb87252cb42a589d98a83b55442f3720c898e51b08c3e81ce1e8d4148d`.
|
||||
- 적용(`~/hera-deploy/bear-apply-g7.sh`, 14:36:34Z): artifact sha 확인 OK → `/srv/ariweb/hal/releases/0.1.1-648247f22415` 추출 → **stage 검증**: 추출된 `hal/ui.mjs` sha256 = 소스(`90405936…`) OK, `node --check` OK, `health.bootstrap.complete` 참조 1, snake_case 참조 0 → `REVISION.json`(by hera-ops-banner-fix) → `register-release --manifest … --approve --by hera-ops-banner-fix`(릴리스 0.1.1 approved, 감사 release.register·release.approve 14:36:34.403Z) → `bootstrap-release --version 0.1.1 --commit 648247f… --sha256 ed957a0a…` → `ln -sfn` current 전환 → `systemctl restart ariweb-hal`. config-set 없음.
|
||||
- 결과: restart→health **1.08초**, PID 1816086, ActiveState active, NRestarts 0, ActiveEnter 14:36:35Z, 프로세스 cwd `/proc/<pid>/cwd` = `/srv/ariweb/hal/releases/0.1.1-648247f22415`, journal 오류 0, 기동 로그 "부트스트랩 완료".
|
||||
- 보존 확인(sha256 전=후): config.json `ae5963d29ae899bc77fcb7c03df7d5802bd493b61fc4b2ab2b7a263a525ba0b7`, vault.json `0e2dc9875f2779a0e7a3c1a56c00321dd2a45b0613a3952b62a8c7505be50bb7`, vault.key `345d92e77c3fcd7853b01024d4aaa3f07d27977ea04a30a287070048afe1bf81`; config readback `opsAllowCidrs=["127.0.0.1/32","::1/128","192.168.10.0/24","192.168.20.0/24","119.203.225.42/32"]`, publicUrl `https://hal21.ariweb.net`, portalUrl `https://my.ariweb.net`, updatedAt 14:21:12.623Z 그대로. data 디렉터리 0700, hal.sqlite 860,160 B·wal 4,132,392 B·shm 32,768 B 그대로. 이전 릴리스 디렉터리 전부 보존(0.1.1, -12958ab6952e, -79b45ca96102, -ac072e6f12a9, -dfb708e933e5).
|
||||
|
||||
## 6. readback
|
||||
- `/api/health`(로컬·`https://hal21.ariweb.net`): ok, version 0.1.1, db true, jobs true, bootstrap `complete=true · releaseVersion 0.1.1 · mainCommit 648247f2241579e947d61ab2928176f678f4b141 · artifactSha256 ed957a0a… · halRunningVersion 0.1.1 · firstAgent ag_01a17bd7201c9a73c157e29968 · manualInstallLocked=true`, instanceId `hal_01a17dcaa5d2cb7866a47b3fa3`.
|
||||
- HAL DB(읽기 전용): bootstrap_state `release_version 0.1.1 · main_commit 648247f… · hal_running_version 0.1.1 · hal_started_at 14:36:35.156Z · first_agent_id ag_01a17bd7… · completed_at 07:21:53.139Z · manual_install_locked 1`; releases 1행 0.1.1 approved by hera-ops-banner-fix(hal_deployed_at null — module.deploy hal 작업이 아닌 CLI bootstrap-release 경로, 이전과 동일); 열린 작업 0; mail_services 7.
|
||||
- Agent heartbeat: 서버 2대 ready, last_seen 14:36:59.073Z(hostinger ag_01a17c99…)·14:36:59.619Z(jitsi ag_01a17bd7…) — 재시작(14:36:35Z) 뒤 재개, 14:38:10Z 까지 heartbeat 6회(bear nginx log). 재시작 순간 Agent long-poll `GET /agent/v1/jobs/next?wait=25` 502 1회(14:36:35Z, 재시작 창) 외 Agent 요청 비-2xx 없음.
|
||||
- **배포된 코드 서버 렌더 검증**(운영자 자격 없음 → `~/hera-deploy/deployed-render.mjs` 가 `/srv/ariweb/hal/current` 의 app/deps/core 만 import, in-memory DB·임시 data dir, 운영 데이터·Vault 미접근): 초기 `부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)` → claimLive 뒤 `(HAL 실행 버전 0.1.1, 첫 Agent 미등록)` → 첫 Agent 등록·승인(complete=true) 뒤 `/ops/`·`/ops/servers`·`/ops/releases` 모두 **배너 없음**, 릴리스 화면 `HAL 실행 버전: <b>0.1.1</b> · 수동 설치 잠김(HAL 전용 운영)`.
|
||||
- 외부(14:37Z): `https://hal21.ariweb.net/api/health` 200 · `/ops/` 200(세션 없음 → 로그인 화면, 본문에 "부트스트랩 미완료" 0건) · `/ops/login` 200 · `/portal/v1/health` 무인증 401; TLS CN=hal21.ariweb.net 만료 2027-01-25, verify 0.
|
||||
- 회귀: `https://my.ariweb.net/api/health` 200 ok 0.1.1(로컬 8910 동일), `/` 302→/login, `/login` 200, http→https 301; hostinger 유닛 ariweb-portal 1500968·ariweb-agent 1483523·webmaker 1041361·arihakdang-mail 1046648·nginx 1414934·(구) ariweb-hal 1059540 모두 active NRestarts 0, `nginx -t` 성공, Agent/포털 journal 오류 0(14:36Z 이후); `https://webmaker.ariweb.net/` 302(첫 프로브 4.5~5.0초, 이후 0.29초 — hostinger 측 콜드 응답, HAL 무관); `https://mail.arihakdang.com/` 200·`/api/health` ok 1.0.11 domains 6; `https://webmail.ibit.kr/` 200·ok 1.0.11 domains 1.
|
||||
|
||||
## 7. 되돌리기
|
||||
- 코드만: `ln -sfn /srv/ariweb/hal/releases/0.1.1-dfb708e933e5 /srv/ariweb/hal/current && sudo systemctl restart ariweb-hal` (이전 릴리스 디렉터리 보존됨). 릴리스 행·bootstrap 행의 main_commit/artifact 는 `cd /srv/ariweb/hal/releases/0.1.1-dfb708e933e5 && ARIWEB_HAL_DATA=/srv/ariweb/hal/data node hal/index.mjs register-release --manifest ~/hera-deploy/dist-dfb708e/manifest.json --approve --by <who> && node hal/index.mjs bootstrap-release --version 0.1.1 --commit dfb708e933e524894822719bce0169008285f4fd --sha256 4e419a7293dc5be07936db58c8cf70dc1c7972f52c11787a9455029eff677377`.
|
||||
- 데이터까지: `/srv/ariweb/backups/hal-pre-banner-fix-20261027T143608Z/RESTORE.md`(검증 `sudo sha256sum -c SHA256SUMS`).
|
||||
|
||||
## 8. blocker / unknown
|
||||
- 운영자 자격 증명이 없어 실제 운영자 세션으로 콘솔 화면을 열어 보지는 못했다 — 배포된 코드의 서버 렌더 harness(§6)와 회귀 테스트, 배포 파일 sha 일치로 판단. 오즈가 로그인하면 경고가 보이지 않아야 한다(서버 렌더이므로 새로고침만으로 충분, 캐시 삭제 불필요).
|
||||
- `releases.hal_deployed_at` 은 null(HAL 자체는 module.deploy 작업이 아니라 CLI bootstrap-release + current 전환으로 교체) — 이전 배포와 같은 방식.
|
||||
@@ -0,0 +1,57 @@
|
||||
# 포털·hostinger Node Agent 제어 평면 이전 기록 — hal.ariweb.net → hal21.ariweb.net (2026-10-27, Hera)
|
||||
|
||||
대상: hostinger(`srv1953374`, 187.53.133.173)의 Ariweb Portal 0.1.1(`https://my.ariweb.net`)과 Node Agent 의 제어 평면을 hostinger 구 HAL(`hal.ariweb.net`)에서 bear HAL21(`https://hal21.ariweb.net`)로 옮기고 실제 readback 으로 증명한 기록. Webmaker·포털 데이터·고객 계정·사이트·세션·DB·Vault·DNS·TLS·메일은 변경하지 않았다. 비밀값은 어디에도 출력하지 않았다(파이프로만 이동).
|
||||
|
||||
## 1. 리비전
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 작업 시작 origin/main(rebase 뒤) | `0b9c70e90c7278e895de54e75e3d07f706c703bf` (Neo 의 Webmaker 연동 커밋 `12958ab`·`0b9c70e` 포함 확인, worktree clean) |
|
||||
| 코드 커밋 = 배포한 HAL / 등록 릴리스 | `dfb708e933e524894822719bce0169008285f4fd` — HAL CLI `config-set`·`module-deploy`·`job` + 테스트 3건 |
|
||||
| 릴리스 manifest | version 0.1.1, mainCommit `dfb708e…`, testsPassed=true, dirty=false. artifact sha256: hal `4e419a7293dc5be07936db58c8cf70dc1c7972f52c11787a9455029eff677377` · portal `32c76a6047b2a7d74de0929c993984040cb1263044af2593abd399ed7daff04e`(05:28Z 등록본과 동일) · agent `cf2e52fe23496b126248b65d3bb7e94106db573559392bd20f365f1b970e315f` · webmaker `9490b59dc1aca3d953f907bb7e5b447901a4558a7275913ffb1cb03e2c944caa` · arimail `b22868c08d2551e7d10891cd4858ee0525ac6529e6ece965881d601c20bde983` |
|
||||
| 포털 코드 | 운영 포털(2026-10-26 14:30:42Z 설치, 빌드 3951be5)과 HEAD 의 portal/lib/lang/catalog 경로 diff 없음 → 같은 0.1.1 재배포(제어 평면 이전 목적) |
|
||||
| HAL21 이전 | `/srv/ariweb/hal/releases/0.1.1-12958ab6952e`(Neo 09:02Z 수동 설치, PID 1314165), bootstrap mainCommit `12958ab…`, releases 행 main_commit `79b45ca…`(등록부와 실행본 불일치) |
|
||||
| HAL21 이후 | `/srv/ariweb/hal/releases/0.1.1-dfb708e933e5` → `current`, `ariweb-hal` 09:31:52Z 재시작(재시작→/api/health 1.11초, PID 1360348, NRestarts 0, instanceId `hal_01a17cb3add236d2c112f3df1a`), bootstrap mainCommit `dfb708e…`/artifact `4e419a72…`, releases 행 0.1.1 approved(by hera-portal-link 09:31:51.753Z), config `portalUrl` `http://127.0.0.1:8910` → `https://my.ariweb.net`(감사 config.update, actor cli:hera-portal-link) |
|
||||
| hostinger 포털 | `/srv/ariweb/portal/releases/0.1.1`(REVISION installedAt 2026-10-27T09:33:50.021Z, by master) → `current`; 이전 릴리스 `0.1.1.prev-20261027093350` 보존(.previous.json), 유닛 `ariweb-portal` PID 1500968(09:33:50Z), 127.0.0.1:8910 |
|
||||
| hostinger Agent | `/srv/ariweb/agent/releases/0.1.1-12958ab6952e`(Neo 09:03Z) 그대로, `ag_01a17c99f0102ce24409dc258a` → `https://hal21.ariweb.net`, PID 1483523, NRestarts 0 |
|
||||
|
||||
## 2. 읽기 전용 preflight(09:13~09:18Z)
|
||||
- DNS `my.ariweb.net` A 187.53.133.173 — 1.1.1.1·8.8.8.8·권한 서버 ns1/ns2.hosting.co.kr(TCP) 일치. `hal21.ariweb.net` 121.152.149.45. `hal.ariweb.net` 공개 A 없음(hostinger `/etc/hosts` 127.0.0.1 매핑만). TLS my: CN=`*.ariweb.net` 만료 2027-01-13T07:57:11Z verify 0; hal21: CN=hal21.ariweb.net 만료 2027-01-25T02:03:16Z.
|
||||
- hostinger 유닛(모두 active/enabled, NRestarts 0): ariweb-hal PID 1059540(10-26 14:33:18Z) · ariweb-portal PID 1059582(14:33:19Z, cwd `/srv/ariweb/portal/current`) · ariweb-agent PID 1483523(10-27 09:04:12Z) · webmaker PID 1041361(14:00:23Z) · arihakdang-mail PID 1046648(14:09:30Z). 리스너 127.0.0.1:8900/8910/8790/3080, nginx 80/443. `/etc/hosts`: `127.0.0.1 hal.ariweb.net my.ariweb.net webmail.ariweb.net mail.arihakdang.com`.
|
||||
- Agent(hostinger) 연결 HAL: 이미 hal21(Neo 09:03:45.729Z 재등록, label `srv1953374 (mail+webmaker)`, drop-in `90-hal21-webmaker.conf`). hal21 의 jobs/agent_jobs 0건. 구 HAL: server `srv_01a178829d5848f93a15f93a17` status offline(last_seen 09:03:14.274Z), agent `ag_01a178829f1c3d88381d2c0c75` active(heartbeat 없음), 열린 jobs 0·agent_jobs 0, module_targets 7(목표 0.1.1), 운영자 ariweb-ops 1, vault 3(service:agent, service:portal, service:hal).
|
||||
- 포털 config.json 값(비밀 아님): mailer·mailFrom·operatorEmail 만 → halUrl 은 코드 기본값 `https://hal.ariweb.net`(=/etc/hosts → 구 HAL). 포털 vault 이름 11: service:hal, service:portal, sso:webmaker, mailer:resend-key, arimail:* 7.
|
||||
- 포털 DB(portal.sqlite, wal): integrity ok, 표 53, users 7(active), groups 10, memberships 13, sites 3, domains 6, mail_services 4(cancelled), mail_servers 1, mailboxes 6, jobs 4(failed 2 backup.scheduled, needs_user 2 mail.activate), sessions 7(live 7), audit_log 71, mail_usage_log 899.
|
||||
- HAL21 DB: integrity ok; 서버 2 모두 ready(hostinger 승인 09:04:02.227Z by `cli:neo-webmaker-link`, jitsi 07:21:53.138Z by `op_01a17bae6c0b70acd18b16f029`), bootstrap complete 07:21:53.139Z(firstAgent `ag_01a17bd7201c9a73c157e29968`, manual_install_locked 1); agents 2 active; module_targets 12(hostinger: agent·arimail·backup·hal·observability·portal·webmaker 모두 actual 0.1.1 ok); 운영자 admin-hal 1(세션 live 1, 마지막 09:08:59Z — 다른 세션 소유); mail_services 7.
|
||||
- Webmaker baseline(`scripts/webmaker-inventory.mjs`, 읽기 전용): 회원 12(고유 9·SSO 3), 프로젝트 10(배포 3), 도메인 2, 연결 3; 포털 users 7·groups 10·sites 3(linked 3)·domains 6·mailServices 4; 판정 linked_consistent 3·unlinked_native 7, 충돌 0·누락 0. 공개 사이트 디렉터리 10.
|
||||
|
||||
## 3. 백업(Gate 2, root 0700/0600, git 밖, `sha256sum -c` OK, stage 추출·readback 검증)
|
||||
**hostinger `/srv/ariweb/backups/pre-hal21-migrate-20261027T092204Z/`** (RESTORE.md 먼저 작성, 경로 존재 검증 12/12 ok)
|
||||
- archive.tgz `dca689d8c0fcb5d38455351b5db102e31a5d846b4f4735992627edfa7751bf63` (21 entries: portal/agent/hal data 의 config·vault.json·vault.key·agent.json·jobs, 유닛 3+drop-in, nginx ariweb-portal.conf·ariweb-hal.conf, /etc/hosts)
|
||||
- db.tgz `d36000015028b6cea72173a8365cbd388f3b17c8b81e7daecb942dd0ac038e9c` (VACUUM INTO 일관 사본: portal.sqlite 1,028,096 B integrity ok 53표 — 원본 db 1,032,192 / wal 4,140,632 / shm 32,768 B; hal.sqlite 888,832 B ok 25표)
|
||||
- code.tgz `7a9c56e39a63ad79706eaa046869e122c135bb0aa85f7420d74d3536b2f7c08e` (296 entries: portal·agent·hal current 실경로 트리), meta.tgz `7be814eb0030d3ccfc4b1fe1e4d778f70e3a56868158ed5ce66805a4ded2b76c`, RESTORE.md `3954ce32299bc51c86c6ccb6e3a157730d4de11ec862ea10c12e404d578b1300`, portal-verify.json `aa212574b7d3dc1c7266c456379a89b0a71e3e1bf2eb00cfe92f248aae953788`, hal-verify.json `91c33cc8b0d6155bd481b02ca7258f4f589884bb841b4de07e6ed067106eee55`
|
||||
- stage 검증: portalVault 11 · agentVault 1 · halVault 3 · agent.json id `ag_01a17c99…` · 사본 integrity ok/ok.
|
||||
**bear `/srv/ariweb/backups/hal-pre-portal-link-20261027T092207Z/`**
|
||||
- archive.tgz `97b880370d4a94a64d712ba63d8b5ea072a847f893bf977435a1160d6dbcc9ed` (6), db.tgz `1deb62fdca1a77792c3367cb27668d297a4ec5d83e41c0d6c256d7c3b9425a32` (hal.sqlite 548,864 B ok 28표), code.tgz `e543244242541faadb067e66e48db32c128fcecfe23d3b1a15c830abb1f6d9ba` (80), meta.tgz `4c1078937387148e69ed602be66a528de14187eb04040d2908a7744a3c8cbe5c`, RESTORE.md `b1245a65fc82ad8a38866211a3cb261069bd77ef928725fc4921838dcc313ec6`, hal-verify.json `aebb63337c569abc821a9b88750b48d9cd0df45acedb80466c2d97f74135961f`; stage 검증 halVault 2 · servers 2 · configKeys publicUrl,portalUrl.
|
||||
**source**: origin/main `0b9c70e…` git bundle(작업 PC scratchpad, 0600) sha256 `d55c924495b08337efb6fb1a3385bf5648a07b97231284f4d123bba2080e67eb` + clone.
|
||||
구 HAL 등록 Agent 데이터는 Neo 백업 `/srv/ariweb/backups/agent-pre-hal21-20261027T090325Z`(RESTORE.md 의 B 절차가 가리킴).
|
||||
|
||||
## 4. 적용(모두 지원 경로, 직접 SQL 없음)
|
||||
1. 09:31:51Z bear: `register-release --manifest … --approve --by hera-portal-link`(artifact 5개 복사·등록부 갱신) → `bootstrap-release --version 0.1.1 --commit dfb708e… --sha256 4e419a72…` → `config-set --portalUrl https://my.ariweb.net`(감사) → current 전환·`ariweb-hal` 재시작(1.11초). 재시작 구간 외부 프로브 8회: my=200·mail=200·hal21=200·sample=200 전부, webmaker 302×6/000×2(프로브 4초 타임아웃; 직후 5회 재확인 302, 0.29초 — hostinger 호스트라 HAL 재시작과 무관). 두 Agent heartbeat 재개(09:32:29Z·09:32:45Z).
|
||||
2. 09:33:44Z `link-portal`(bear vault service:portal·service:hal 09:33:45.756Z 갱신) → stdout 을 `sed` 로 값만 추려 ssh 파이프로 hostinger `lib/vault.mjs` 에 전달 → 포털 vault `service:hal` 09:33:46.334Z 교체(replacedExisting true), 구 연동의 미사용 이름 `service:portal` 제거. 값은 화면·로그·셸 변수에 없음(두 node 프로세스 사이 파이프만).
|
||||
3. 09:33:46Z 포털 `config.set({publicUrl:'https://my.ariweb.net', halUrl:'https://hal21.ariweb.net'}, 'cli:hera-portal-link')`(config.json 198→292 B, 0600; webmakerUrl·arimailUrl 기본값 유지).
|
||||
4. 09:33:47Z bear `module-deploy --module portal --version 0.1.1 --servers srv_01a17bd4dbcdb55877f2d9fcab --by hera-portal-link --reason …` → HAL 작업 `j_01a17cb5716c669450412967f7`(module.deploy, created_by cli:hera-portal-link, actor operator) → 자식 `j_01a17cb5719cc5385804317f08`(module.deploy.target) → Agent 작업 `aj_01a17cb573916795ab7bc67a54`(module.install, 09:33:49.009→09:33:52.775Z). 결과: actualVersion 0.1.1, previousVersion `0.1.1.prev-20261027093350`, health ok(200, 10 ms), releaseDir `/srv/ariweb/portal/releases/0.1.1`, runUser master; 부모 done 09:33:53.325Z(precheck→fanout→collect, overall done 1/1). 감사 `deploy.module.request`·`deploy.module`. 포털 재시작 09:33:50Z(vault 교체 뒤 4초 — 그 사이 포털→HAL 호출 없음, 포털 journal 오류 0).
|
||||
|
||||
## 5. readback
|
||||
- HAL21 `module_targets.portal`(hostinger): target 0.1.1 · actual 0.1.1 · health ok · last_job `j_01a17cb5719cc5385804317f08` · last_check 09:34:45Z(heartbeat 로 계속 갱신). agent_jobs 1(done). jobs 2(done). outbox 0(운영자 작업은 포털 이벤트를 만들지 않음).
|
||||
- **Portal→HAL21 인증 읽기(09:35:09Z, 포털의 실제 `portal/hal/client.mjs` + 실제 config.json·vault 로)**: `/portal/v1/health` ok(version 0.1.1, instanceId `hal_01a17cb3…`, bootstrap complete, mainCommit dfb708e…) · `/portal/v1/servers` 2건(둘 다 ready/ok, hostinger modules webmaker·arimail·hal·portal·agent·observability·backup = 0.1.1/ok) · `POST /portal/v1/servers/pick {role:web}` → null(§7 blocker) · `GET /portal/v1/jobs/j_01a17cb5716c…` 200 · `GET /portal/v1/jobs/j_none` → `job_not_found` 404(작업 계약). bear nginx access log: 187.53.133.173 → 위 5요청 200/200/200/200/404, Agent heartbeat 67회/5분. HAL21 journal 오류 0(재시작 이후).
|
||||
- 바이트 보존(sha256, 배포 전=후): 유닛 `ef800cf0…1e59`(Agent 가 템플릿으로 다시 썼으나 내용 동일, diff 없음) · nginx ariweb-portal.conf `d7e8e1c7…7167` · fullchain `6650b903…b780` · portal vault.key `6a6af7d6…0094` · agent.json `335cc9e2…b524` · agent vault.json `393fe23d…4d64` · /etc/hosts `eafc0116…6c02`. config.json `a703ce78…`→`66fcb558…`, vault.json `8b3598bc…`→`a7e9e8ae…` 는 3·2단계의 의도된 변경(배포 자체는 data 디렉터리를 건드리지 않음). DB 원본 크기 동일(1,032,192/4,140,632/32,768).
|
||||
- 포털 DB 전후: integrity ok/ok, 표 53/53, 차이는 `mail_usage_log` 899→915(사용량 수집 주기 작업)뿐; users 7·groups 10·memberships 13·sites 3·domains 6·mail_services 4·mailboxes 6·jobs 4·sessions 7(live 7) 동일. vault 이름 11→10(service:portal 제거·service:hal 교체), config 키 +publicUrl·+halUrl.
|
||||
- Webmaker 전후 동일(회원 12·프로젝트 10·배포 3·연결 3, linked_consistent 3·unlinked_native 7, 충돌 0·누락 0, apply no-op), webmaker PID 1041361·NRestarts 0 유지, 공개 사이트 10. AriMail health ok 1.0.11 domains 6, webmail.ibit.kr ok domains 1.
|
||||
- 외부(09:35Z): my http→301 https, https / →302 /login, /login 200 `로그인 · Ariweb 고객 포털`, /api/health ok 0.1.1, TLS verify 0; hal21 /api/health ok·/ops/ 200·/portal/v1/health(무인증) 401; webmaker 302, sample 200, guide 200, mail.arihakdang.com 200, webmail.ibit.kr 200. 유닛 6개 active/enabled NRestarts 0, nginx -t 성공, journal 오류: 포털 0, Agent 1(09:31:52 HAL 재시작 중 502 1회), 구 HAL 0, webmaker 0, mail 0.
|
||||
- 구 HAL(hostinger): 계속 실행 중(PID 1059540)이나 서버 offline(last_seen 09:03:14Z, Agent heartbeat 없음), 열린 작업 0 — hostinger Agent 의 active owner 아님.
|
||||
|
||||
## 6. 테스트·빌드
|
||||
`node test/run.mjs` 22파일 149건 통과·실패 0(코드 커밋 뒤 release build 안에서 1회, 문서 커밋 뒤 재실행). 신규 `test/hal-cli.test.mjs` 3건. `scripts/release.mjs build/verify` 통과(artifact 5개 ✔). `bash -n deploy/*.sh` 통과, shellcheck 미설치.
|
||||
|
||||
## 7. 되돌리기·blocker
|
||||
- 되돌리기: hostinger `RESTORE.md`(A 포털 연결만 → B Agent 구 HAL 복귀(Neo 백업) → C 구 HAL 데이터 → D 유닛·nginx·hosts), bear `RESTORE.md`(vault·config·DB 사본·current `0.1.1-12958ab6952e`), 포털 코드 `ln -sfn /srv/ariweb/portal/releases/0.1.1.prev-20261027093350 /srv/ariweb/portal/current && systemctl restart ariweb-portal`, HAL 작업 되돌리기는 운영자 콘솔 모듈 되돌리기.
|
||||
- blocker/unknown: (1) hal21 운영자 자격 증명 없음 — 서버 승인·역할 변경·콘솔 readback 은 수행하지 않음(승인·부트스트랩은 이미 완료 상태를 readback). (2) **hostinger 서버 행 role 이 `mail`**(메일 가져오기 때 생성) → `pickServer(web)` null → 포털 관리형 사이트 신규 배포·백업 저장 대상이 없다. 운영자가 `PATCH /ops/api/servers/srv_01a17bd4dbcdb55877f2d9fcab {role:'control'}`(재인증 필요)로 바꿔야 한다. (3) 구 HAL `ariweb-hal`(hostinger)은 중지하지 않았다(폐기·보존은 사람 결정). `/etc/hosts` 의 `hal.ariweb.net` 매핑도 그대로. (4) Agent 의 3개 보존 정책으로 코드 디렉터리 `0.1.1.prev-20261026141643` 가 정리됨(데이터 아님). (5) HAL→포털 이벤트 경로(`/internal/hal-events`)는 포털 발 작업이 없어 실측하지 못함(portalUrl 은 설정됨). (6) jitsi 서버의 `module_targets.webmaker` actual null(Neo 작업 범위).
|
||||
@@ -0,0 +1,79 @@
|
||||
# webmaker.ariweb.net → HAL21 운영 연동 검증
|
||||
|
||||
**작성자:** Neo
|
||||
|
||||
**작성일:** 2026-10-27 UTC
|
||||
|
||||
**목적:** 운영 Webmaker의 데이터·계정·배포 사이트를 변경하지 않고 `hal21.ariweb.net`의 HAL 제어 평면에 연결한 결과와 롤백 근거를 기록한다.
|
||||
|
||||
**요약:** 운영 Webmaker는 이미 Ariweb Suite Webmaker 0.1.1(상류 1.7.3)이므로 애플리케이션 교체는 하지 않았다. 검증된 사전 백업을 유지한 채 Node Agent만 HAL21로 재등록했고, HAL21은 프로젝트 10개를 누락·절단·오류 없이 읽었다. 회원 12명(고유 9, SSO 3), 데이터 32,608개 파일, Gitea ref 13개와 서비스 프로세스는 보존됐다. 비밀값은 출력·문서화하지 않았다.
|
||||
|
||||
## 개정 이력
|
||||
|
||||
- v1.0 — 2026-10-27 — HAL21 연동, 인벤토리, 무결성, 회귀 및 롤백 검증 기록.
|
||||
|
||||
## 1. 범위와 결정
|
||||
|
||||
- 대상: `webmaker.ariweb.net` / `srv1953374` (`187.53.133.173`).
|
||||
- 제어 평면: `https://hal21.ariweb.net` / bear.
|
||||
- Webmaker 실행 버전은 이미 Ariweb Suite 모듈 `0.1.1`, 상류 Webmaker `1.7.3`이었다. 동일 코드를 다시 덮어쓰면 위험만 늘어나므로 Webmaker 코드·DB·데이터는 교체하지 않았다.
|
||||
- 기존 Webmaker 고유 계정의 비밀번호 해시나 세션을 Portal 계정으로 복제하지 않았다. 고유 회원 9명은 Webmaker 인증 정본으로 유지하고, 기존 SSO 회원 3명과 Portal 연결 3건도 유지했다.
|
||||
- 기존 hostinger HAL에 등록돼 있던 Node Agent를 HAL21로 재등록했다. Webmaker Data Plane은 작업 중 계속 실행됐다.
|
||||
|
||||
## 2. 배포 정본
|
||||
|
||||
- AriWeb Suite main: `12958ab6952e78159f4de22771ddf46a51441a34`.
|
||||
- HAL artifact SHA-256: `01c70d098d614fbd38195bde21c383c1dcaefc2891a4b907d0d268fc08f8f28f`.
|
||||
- HAL 설치: `/srv/ariweb/hal/releases/0.1.1-12958ab6952e` → `/srv/ariweb/hal/current`.
|
||||
- Agent 설치: `/srv/ariweb/agent/releases/0.1.1-12958ab6952e` → `/srv/ariweb/agent/current`.
|
||||
- HAL 백업: `bear:/srv/ariweb/backups/hal-pre-12958ab-20261027T090222Z`.
|
||||
- Agent 전환 백업: `srv1953374:/srv/ariweb/backups/agent-pre-hal21-20261027T090325Z`.
|
||||
- Webmaker 사전 백업·복원 시험: `/srv/webmaker/backups/pre-hal-link-20261027T045715Z` (`RESTORE_TEST_OK 32608 files`).
|
||||
|
||||
## 3. HAL21 연결
|
||||
|
||||
- HAL21 서버: `srv_01a17bd4dbcdb55877f2d9fcab`, 상태 `ready`.
|
||||
- HAL21 Agent: `ag_01a17c99f0102ce24409dc258a`, 상태 active.
|
||||
- Agent의 Webmaker 경로는 systemd drop-in으로 명시했다. 경로를 추측하거나 기존 Webmaker unit을 재작성하지 않았다.
|
||||
- `DATA_ROOT=/data/webmaker`
|
||||
- `DATA_DIR=/data/webmaker/sites`
|
||||
- `SITES_ROOT=/var/www/webmaker-sites`
|
||||
- HAL21 모듈 readback: Webmaker actual `0.1.1`, active, health `ok`, drift 없음.
|
||||
- HAL21 인벤토리 readback: total `10`, returned `10`, truncated `false`, errors `[]`.
|
||||
- 인벤토리에 포함된 도메인: `sample.ariweb.net`, `guide.ariweb.net`, `hs-company.ariweb.net`.
|
||||
- 사이트 본문, 생성 프롬프트, 연락처, 비밀번호·해시·토큰·키·세션은 HAL로 보내지 않는다.
|
||||
|
||||
## 4. 데이터 보존 검증
|
||||
|
||||
- `/data/webmaker`와 `/var/www/webmaker-sites` 사전 체크섬 32,608건 재검증: exit 0, 불일치 0.
|
||||
- 로컬 Git 저장소 7개: `git fsck` 성공 7, 실패 0.
|
||||
- Gitea heads: 사전 스냅샷과 동일한 13개 ref.
|
||||
- Webmaker 프로세스는 재시작하지 않았다.
|
||||
- MainPID `1041361`
|
||||
- NRestarts `0`
|
||||
- ActiveEnter `2026-10-26 14:00:23 UTC`
|
||||
- Webmaker 회원 12명(고유 9, SSO 3), 프로젝트 10개, Portal 연결 3개, 배포 이력 및 도메인 등록부는 변경하지 않았다.
|
||||
|
||||
## 5. 회귀 검증
|
||||
|
||||
- `webmaker.service`, `ariweb-agent`, `nginx`, `ariweb-hal`: active.
|
||||
- nginx 설정 검사: 성공. 기존 `server_name`/protocol 중복 경고는 남아 있으나 이번 작업에서 nginx 파일을 변경하지 않았다.
|
||||
- 외부 HTTP:
|
||||
- `https://webmaker.ariweb.net/` → 로그인 화면 HTTP 200.
|
||||
- `https://sample.ariweb.net/` → HTTP 200.
|
||||
- `https://guide.ariweb.net/` → HTTP 200.
|
||||
- Webmaker의 `572a1d…` 미리보기 경로 → HTTP 200.
|
||||
- `https://hal21.ariweb.net/api/health` → HTTP 200.
|
||||
- `https://hal21.ariweb.net/ops/` → HTTP 200.
|
||||
- 알려진 기존 상태: `https://hs-company.ariweb.net/`은 공개 TLS SNI 오류가 있으며 이번 HAL 연결 작업으로 새로 생긴 문제가 아니다. 프로젝트와 파일은 Webmaker 인벤토리 및 미리보기에서 보존됨을 확인했다.
|
||||
|
||||
## 6. 롤백
|
||||
|
||||
1. hostinger에서 `ariweb-agent`를 중지한다.
|
||||
2. `/srv/ariweb/backups/agent-pre-hal21-20261027T090325Z/data`를 `/srv/ariweb/agent/data`로 복원한다.
|
||||
3. `/srv/ariweb/agent/current`를 백업의 `current-target.txt` 값으로 되돌리고 `90-hal21-webmaker.conf`를 제거한다.
|
||||
4. daemon-reload 후 `ariweb-agent`를 시작하면 기존 hostinger HAL 등록으로 복귀한다.
|
||||
5. HAL21의 신규 Agent는 폐기 후 revoke하고, 서버 상태를 점검한다.
|
||||
6. HAL 자체 롤백이 필요하면 bear의 HAL 백업에서 data를 복원하고 `current-target.txt`의 이전 release로 되돌린 뒤 `ariweb-hal`을 기동한다.
|
||||
|
||||
Webmaker Data Plane은 HAL과 분리돼 있으므로 위 롤백 중에도 기존 공개 사이트와 Webmaker 자체 인증 데이터는 그대로 유지된다.
|
||||
@@ -0,0 +1,186 @@
|
||||
# Webmaker 1.8.0 직접 편집(시각 편집기) 도입 — Ariweb Suite 0.2.0 TDD RED→GREEN 기록
|
||||
|
||||
- 작업 트리: `/home/master/work/Ariweb-suite-visual-editor-v020`, 브랜치 `feature/visual-editor-v0.2.0`, 기준 `origin/main 4b3714e37c543fcfa8862dbc68c76f62a9b0bf97`
|
||||
- 참조 저장소 작업 트리: `/home/master/work/WebHoting_AI_Agent-visual-editor-v180`(`feature/visual-editor-v1.8.0`, 기준 `3b10859`) — 그쪽 RED→GREEN 은 `modules/webmaker/../docs/verification/visual-editor-1.8.0-tdd.md`(참조 저장소 문서)에 있다.
|
||||
- 날짜: 2026-10-28 · Node v24.22.1 · 커밋·push·병합·배포·운영 접근 없음(Neo 검토 전 인도). 출력은 실제 실행 결과를 그대로 옮겼다(스택 줄만 줄임).
|
||||
- 서버를 띄우는 테스트는 모두 `CLAUDE_BIN` 을 즉시 실패하는 가짜 실행 파일로 두고 실행했다(사이트 생성이 AI 작업을 시작하므로 개발 PC 의 진짜 claude 가 실행되지 않게). 첫 Suite 테스트 실행에서는 그 설정이 빠져 진짜 claude 프로세스가 하나 시작되어 즉시 종료시켰고, 테스트를 고쳤다.
|
||||
|
||||
## RED — `node --no-warnings test/webmaker-visual-editor.test.mjs` (도입 전: 모듈·화면·경로 없음)
|
||||
```
|
||||
✘ 도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.2.0 이다
|
||||
Error: lib/visual-editor.js (실제 false)
|
||||
✘ lib/visual-editor: 캔버스 변환은 스크립트를 실행하지 않게 하고, 저장 검증은 새 스크립트·핸들러를 거절한다
|
||||
Error: Cannot find module '/home/master/work/Ariweb-suite-visual-editor-v020/modules/webmaker/lib/visual-editor.js'
|
||||
✘ visual-editor-core: 실행 취소/다시 실행·저장 상태·메시지 검증·구성 요소가 Suite 체크아웃에서도 같다
|
||||
Error: Cannot find module '/home/master/work/Ariweb-suite-visual-editor-v020/modules/webmaker/public/visual-editor-core.js'
|
||||
✘ 화면(index.html)에 직접 편집 전환·저장·실행 취소·다시 실행·버리기·팔레트·속성 조작이 접근성 속성과 함께 있고 Suite 배너는 남아 있다
|
||||
Error: 직접 편집 전환 버튼 (실제 false)
|
||||
✘ server.js [Ariweb Suite 변경]: 직접 편집 저장도 동시 편집 rev 충돌(409)을 돌려주고, 맞는 rev 면 저장된다 (실제 서버)
|
||||
Error: 기대 200 실제 404
|
||||
✘ 통과 0 · 실패 5
|
||||
```
|
||||
|
||||
## 도입 작업
|
||||
1. 참조 저장소에서 1.8.0 을 TDD 로 구현(위 참조 문서). 새 파일 6개는 `cp` 로 그대로 가져옴(`lib/visual-editor.js`, `public/visual-editor-core.js`, `public/wm-ve-canvas.js`, `public/visual-editor-ui.js`, `test/visual-editor.test.js`, `test/visual-editor-server.test.js`), `public/admin.js` 는 Suite 변경이 없어 upstream 그대로.
|
||||
2. Suite 블록(`[Ariweb Suite 변경]`)이 있는 `server.js`·`public/app.js`·`public/index.html`·`public/style.css` 는 **같은 변경을 손으로 적용**(덮어쓰지 않음) + `server.js` 직접 편집 PUT 에 `revConflict` 추가. `diff` 로 upstream 과의 차이를 확인: server.js 107줄(모두 0.1.1 Suite 블록 + rev 검사 1줄), app.js 4줄, index.html 2줄, style.css 5줄(배너) — 1.8.0 공통 변경은 양쪽 동일.
|
||||
3. 중간 실행 1 — 4/5: rev 충돌 테스트가 "AI 작업 실행 중" 409 를 받음(진짜 claude 가 생성 작업으로 실행 중) → 테스트에 가짜 `CLAUDE_BIN`·작업 종료 대기 추가.
|
||||
4. `WEBMAKER_SRC=<참조 작업 트리> node scripts/import-sources.mjs --only webmaker --allow-dirty` 로 `IMPORTED_SOURCES.json/.md` 재생성(처음 182 파일 → 최종 184 파일: 0.1.1 의 176 + 신규 8, 수정 8 = 0.1.1 과 같은 Suite 블록 파일, upstream 1.8.0·workingTreeDirty).
|
||||
|
||||
## GREEN — `node --no-warnings test/webmaker-visual-editor.test.mjs`
|
||||
```
|
||||
✔ 도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.2.0 이다 (1ms)
|
||||
✔ lib/visual-editor: 캔버스 변환은 스크립트를 실행하지 않게 하고, 저장 검증은 새 스크립트·핸들러를 거절한다 (13ms)
|
||||
✔ visual-editor-core: 실행 취소/다시 실행·저장 상태·메시지 검증·구성 요소가 Suite 체크아웃에서도 같다 (2ms)
|
||||
✔ 화면(index.html)에 직접 편집 전환·저장·실행 취소·다시 실행·버리기·팔레트·속성 조작이 접근성 속성과 함께 있고 Suite 배너는 남아 있다 (3ms)
|
||||
✔ server.js [Ariweb Suite 변경]: 직접 편집 저장도 동시 편집 rev 충돌(409)을 돌려주고, 맞는 rev 면 저장된다 (실제 서버) (1323ms)
|
||||
✔ 통과 5 · 실패 0
|
||||
```
|
||||
|
||||
## 전체 묶음
|
||||
- `modules/webmaker`: `CLAUDE_BIN=<가짜 exit 1> npm test` → `ℹ tests 81 · pass 81 · fail 0` (0.1.1 의 59 + 새 22)
|
||||
- Suite: `CLAUDE_BIN=<가짜 exit 1> node test/run.mjs` → **`✔ 테스트 파일 23개 중 실패 0개`**(agent 9·auth 7·backup 8·commerce 11·dashboard 4·domains 3·e2e 11·groups 5·hal-cli 3·hal-mailimport 7·hal-ui 7·hal 16·lib 10·lifecycle 6·mail-discover 2·mail 9·relocation 6·sites 11·transfer 4·usage 4·webmaker-adapter 2·webmaker-inventory 5·webmaker-visual-editor 5). 로컬 E2E 하네스가 `docs/verification/local-e2e-2026-10-28.md/.json` 을 새로 썼다(결함 0).
|
||||
|
||||
## 요구 ↔ Suite 테스트 대응
|
||||
| 요구 | 테스트 |
|
||||
|---|---|
|
||||
| 도입 파일·버전(1.8.0/0.2.0)·IMPORTED_SOURCES 불변 기준 커밋·upstream 1.8.0 | 도입 모듈 파일·버전 |
|
||||
| 스크립트 비실행 변환·저장 검증(새 스크립트 거절·머리 보존) | lib/visual-editor |
|
||||
| 실행 취소/다시 실행·저장 상태·메시지 규약(세션 불일치 거절)·구성 요소 안전 | visual-editor-core |
|
||||
| 필수 접근성 조작 + Suite 배너·SSO 변경 보존 | 화면(index.html) |
|
||||
| Suite 전용 rev 충돌 409 → 맞는 rev 저장 200·버전 visual-edit·rev 증가(실제 서버) | server.js [Ariweb Suite 변경] |
|
||||
| 권한·CSRF·허용 페이지·오래된 저장·AI 작업 중·캐시 미차감 | `modules/webmaker/test/visual-editor-server.test.js`(81개 묶음 안) |
|
||||
|
||||
## 검토 수정(Neo 독립 검토, 2026-10-28) — 동시 저장 경쟁(출시 차단) · 원자적 저장(안전 강화)
|
||||
|
||||
참조 저장소에서 RED→GREEN 으로 고친 뒤(참조 문서 "검토 수정" 절: 경쟁 테스트 actual 200/expected 409, 원자성 테스트 actual 200/expected 500, helper 없음 TypeError → 모두 GREEN) Suite 에 반영했다.
|
||||
- 그대로 가져옴(`cp`, diff 동일): `modules/webmaker/lib/visual-editor.js`(`writeTextAtomic` 추가), `test/visual-editor.test.js`(16건), `test/visual-editor-server.test.js`(9건 — 경쟁·원자성 추가).
|
||||
- 손으로 반영: `modules/webmaker/server.js` 직접 편집 PUT — 본문을 읽은 뒤 `store.load` 로 사이트 기록과 파일을 다시 읽고(**Suite 의 `revConflict` 도 다시 읽은 최신 rev 로 비교**), hash 검사·검증·`writeTextAtomic` 까지 await 없이 이어 간다. 쓰기 실패 500 한국어 안내. upstream 과의 차이는 여전히 Suite 블록만(107줄).
|
||||
- Suite 전용 추가 테스트는 두지 않았다: 공유 테스트 파일(`modules/webmaker/test/visual-editor-server.test.js`)이 Suite 모듈 서버로도 실행되어 경쟁·원자성을 검증하고, `test/webmaker-visual-editor.test.mjs` 의 rev 충돌 테스트가 다시 읽은 rev 경로를 그대로 지나기 때문이다.
|
||||
|
||||
GREEN(Suite 체크아웃)
|
||||
```
|
||||
cd modules/webmaker && node --test test/visual-editor-server.test.js test/visual-editor.test.js
|
||||
ℹ tests 25
|
||||
ℹ pass 25
|
||||
ℹ fail 0
|
||||
node --no-warnings test/webmaker-visual-editor.test.mjs
|
||||
✔ server.js [Ariweb Suite 변경]: 직접 편집 저장도 동시 편집 rev 충돌(409)을 돌려주고, 맞는 rev 면 저장된다 (실제 서버) (1213ms)
|
||||
✔ 통과 5 · 실패 0
|
||||
```
|
||||
전체 묶음(수정 뒤, 모두 `CLAUDE_BIN=<가짜 exit 1>`):
|
||||
- 참조 저장소 `npm test` → `ℹ tests 77 · pass 77 · fail 0`
|
||||
- Suite `modules/webmaker` `npm test` → `ℹ tests 84 · pass 84 · fail 0`
|
||||
- Suite `node test/run.mjs` → `✔ 테스트 파일 23개 중 실패 0개`(webmaker-visual-editor 5/5 포함, 로컬 E2E 가 `local-e2e-2026-10-28.md/.json` 을 다시 씀, 결함 0)
|
||||
|
||||
## 실제 브라우저 시각 검증 수정(Neo 의 브라우저 게이트, 2026-10-28) — 직접 편집 중 배치(출시 차단)
|
||||
- 발견: 독립 Webmaker 코드의 실제 브라우저 검증(1280×633·390×844)에서 직접 편집을 켜도 옛 작업 칸(`.left` 460px)이 남아 캔버스 iframe 이 114.6px, 390px 에서는 캔버스가 y≈2228 로 밀림. 원인은 `body.ve-on` 의 CSS 배치 규칙 부재(JS 전환은 정상).
|
||||
- 참조 저장소에서 RED(`test/visual-editor.test.js` +3: CSS 계약 2건 실패 `직접 편집 중 옛 작업 칸 숨김`·`좁은 화면 body.ve-on .app 규칙`, JS 전환 동작 1건은 통과 — 진단 확인) → `public/style.css` 수정 → GREEN 19/19. Suite 에 **동일하게** 반영: `modules/webmaker/public/style.css` 같은 블록을 손으로 추가(upstream 과의 차이는 여전히 배너 5줄), 테스트 파일은 `cp`(diff 동일). Suite 모듈에서 `node --test test/visual-editor.test.js` → `ℹ tests 19 · pass 19 · fail 0`.
|
||||
- 전체 묶음(배치 수정 뒤, 모두 `CLAUDE_BIN=<가짜 exit 1>`): 참조 저장소 `npm test` → `ℹ tests 80 · pass 80 · fail 0` · Suite `modules/webmaker` `npm test` → `ℹ tests 87 · pass 87 · fail 0` · Suite `node test/run.mjs` → `✔ 테스트 파일 23개 중 실패 0개`(webmaker-visual-editor 5/5 포함). 하네스가 다시 쓴 `local-e2e-2026-10-28.*` 보고서 파일은 이번 작업의 산출물이 아니라 보존하지 않았다.
|
||||
- 문법·차이 확인: `node --check public/visual-editor-ui.js`(두 저장소) 통과, `style.css` upstream 대비 차이 5줄(포털 배너)만, `test/visual-editor.test.js` 두 저장소 동일.
|
||||
|
||||
## 실제 브라우저 시각 검증 수정 2(배치 수정 뒤 게이트, 2026-10-28) — 선택 전 도구 줄 노출(출시 차단)
|
||||
- 발견: 직접 편집을 켜고 아무 요소도 고르지 않았는데 어두운 선택 도구 줄 `.wm-ve-tools` 가 페이지 위에 이미 그려짐(DOM 은 `tools.hidden = true`, 속성 창은 "선택한 요소 없음"). 원인은 캔버스 런타임(`wm-ve-canvas.js`)의 작성자 규칙 `.wm-ve-tools{…display:flex…}` 가 UA 규칙 `[hidden]{display:none}` 을 덮어쓰는 것.
|
||||
- 참조 저장소에서 RED(`test/visual-editor.test.js` +1: 런타임 소스의 CSS 블록을 파싱해 `hidden` 인 `.wm-ve-tools`·`.wm-ve-drop` 의 최종 display 를 cascade 모형으로 계산 — `AssertionError: hidden 인 .wm-ve-tools 의 최종 display`, actual 'flex' / expected 'none', 20건 중 19 통과 1 실패) → `public/wm-ve-canvas.js` 런타임 CSS 에 한 줄 추가 `.wm-ve-tools[hidden],.wm-ve-drop[hidden]{display:none !important}` → GREEN 20/20. Suite 에는 `modules/webmaker/public/wm-ve-canvas.js`·`test/visual-editor.test.js` 를 `cp`(두 파일 모두 upstream 과 byte-identical, `cmp` 확인). Suite 모듈에서 `node --test test/visual-editor.test.js` → `ℹ tests 20 · pass 20 · fail 0`. 다른 동작 변경 없음(JS 의 hidden 전환·보일 때 flex 배치 그대로, 테스트가 함께 확인).
|
||||
- 전체 묶음(도구 줄 수정 뒤, 모두 `CLAUDE_BIN=<가짜 exit 1>`): 참조 저장소 `npm test` → `ℹ tests 81 · pass 81 · fail 0` · Suite `modules/webmaker` `npm test` → `ℹ tests 88 · pass 88 · fail 0` · Suite `node test/run.mjs` → `✔ 테스트 파일 23개 중 실패 0개`(webmaker-visual-editor 5/5 포함). 하네스가 다시 쓴 `local-e2e-2026-10-28.*` 보고서 파일은 보존하지 않았다.
|
||||
- 문법·차이 확인: `node --check public/wm-ve-canvas.js`(두 저장소) 통과, `wm-ve-canvas.js`·`test/visual-editor.test.js` 두 저장소 동일, `style.css` upstream 대비 차이는 여전히 배너 5줄.
|
||||
|
||||
## 실제 브라우저 게이트 3(Playwright 자동 게이트, 2026-10-28) — 팔레트 → 캔버스 끌어다 놓기 실패(출시 차단)
|
||||
- 발견: 실제 Chrome 에서 frameLocator 로 캔버스 iframe 을 직접 조작한 게이트가 선택·contenteditable 글 편집·실행 취소/다시 실행·Alt+↑/↓ 이동·팔레트 표시(19/19)를 통과시킨 뒤, 부모 팔레트 "문단" 의 실제 HTML5 끌어다 놓기에서 실패(`끌어다 놓기 뒤 요소 수 증가` 없음). 원인: 부모가 전용 MIME `text/wm-ve-component` 만 넣는데 캔버스는 `sandbox="allow-scripts"`(allow-same-origin 없음) 출처 'null' 문서라 Chromium 이 다른 출처의 전용 MIME 끌기 자료를 넘겨주지 않음. 제품 결함.
|
||||
- 참조 저장소에서 RED(`test/visual-editor.test.js` +3 — 순수 추출 규칙 `core.dragComponentId` 미존재, 팔레트 dragstart 에 text/plain 없음, **동작 테스트**: 가짜 DOM 위에서 실제 `wm-ve-canvas.js` 를 부트하고 전용 MIME 이 없는 DataTransfer 로 dragover/drop → `dragover preventDefault` actual false; 23건 중 20 통과 3 실패) → 수정 → GREEN 23/23. 수정: `public/visual-editor-core.js` 에 `COMPONENT_MIME`·`componentDragText`·`isComponentDrag`·`dragComponentId`(전용 MIME 우선, 없으면 `wm-ve-component:<id>` 정확 일치 + 알려진 id 만), `public/visual-editor-ui.js` dragstart 가 text/plain 도 넣음, `public/wm-ve-canvas.js` dragover/drop 이 종류로 인식하고 drop 때 검증된 id 만 삽입(아니면 삽입·표식 없음, 기본 drop 차단). sandbox·CSP·메시지 검증·저장 검증·"추가" 버튼 대안 불변.
|
||||
- Suite 에는 `modules/webmaker/public/visual-editor-core.js`·`visual-editor-ui.js`·`wm-ve-canvas.js`·`test/visual-editor.test.js` 를 `cp`(네 파일 모두 upstream 과 byte-identical, `cmp` 확인). Suite 모듈에서 `node --test test/visual-editor.test.js` → `ℹ tests 23 · pass 23 · fail 0`.
|
||||
- 전체 묶음(끌어다 놓기 수정 뒤, 모두 `CLAUDE_BIN=<가짜 exit 1>`): 참조 저장소 `npm test` → `ℹ tests 84 · pass 84 · fail 0` · Suite `modules/webmaker` `npm test` → `ℹ tests 91 · pass 91 · fail 0` · Suite `node test/run.mjs` → `✔ 테스트 파일 23개 중 실패 0개`(webmaker-visual-editor 5/5 포함). 하네스가 다시 쓴 `local-e2e-2026-10-28.*` 보고서 파일은 보존하지 않았다.
|
||||
- 문법·차이 확인: `node --check` 세 런타임 파일(두 저장소) 통과, 공유 파일 두 저장소 동일, `style.css` upstream 대비 차이는 여전히 배너 5줄, `server.js`·`app.js`·`index.html` 의 Suite 블록 차이 불변.
|
||||
|
||||
## 실제 브라우저 게이트 4(살균 DnD 진단, 2026-10-28) — HTML5 끌기가 sandbox iframe 으로 넘어가지 않음 → 포인터 끌기 다리(출시 차단)
|
||||
- 발견: MIME 보완 뒤 같은 게이트 재실행도 끌어다 놓기 단계에서 실패. 부모·캔버스 양쪽 살균 리스너(종류·플래그만) 진단: 부모 dragstart 2건(text/plain·전용 MIME 모두 유효), **캔버스 dragenter·dragover·drop 0건**. Chromium 은 HTML5 끌기 자체를 부모 → 출처 'null' sandbox iframe 으로 넘기지 않는다. 제품 결함.
|
||||
- 참조 저장소에서 RED(`test/visual-editor.test.js` +3 — 규약·좌표 순수 규칙 `paletteDragAt 는 부모→캔버스 종류`, 부모 포인터 순서 동작 `pointerdown/move/up 처리기`, 캔버스 수신 동작 `놓기 표시 보임`; 26건 중 23 통과 3 실패) → 수정 → GREEN 26/26. 수정(sandbox 그대로, allow-same-origin 없음): `public/visual-editor-core.js` 에 toCanvas 종류 `paletteDragAt`·`paletteDragCancel`·`paletteDropAt` + `isComponentId`·`POINTER_DRAG_THRESHOLD`(4px)·`pointInFrame`(테두리·스크롤바 보정 상대 좌표)·`canvasPoint`(유한 숫자·뷰포트 안); `public/visual-editor-ui.js` 팔레트 항목 포인터 다리(pointerdown — "추가" 버튼·터치·보조 버튼 제외 → 포인터 캡처 → 문턱 → iframe 안에서만 상대 좌표 메시지, 밖·Escape·pointercancel·끝내기 → 취소, 포인터 끌기 중 네이티브 dragstart 차단, HTML5 DnD 는 대체 경로); `public/wm-ve-canvas.js` 가 메시지 규약 뒤 id·좌표·모드를 다시 검사해 스스로 `dropTargetAt` → 놓기 표시 / `insertComponent`(잘못된 입력은 삽입·표식 없이 표시만 숨김); `public/style.css` 에 `.ve-comp { user-select:none; touch-action:pan-y }`·끌기 중 class 2줄.
|
||||
- Suite 에는 `modules/webmaker/public/visual-editor-core.js`·`visual-editor-ui.js`·`wm-ve-canvas.js`·`test/visual-editor.test.js` 를 `cp`(네 파일 모두 upstream 과 byte-identical, `cmp` 확인), `modules/webmaker/public/style.css` 에는 같은 2줄을 손으로 추가(upstream 대비 차이 여전히 배너 5줄). Suite 모듈에서 `node --test test/visual-editor.test.js` → `ℹ tests 26 · pass 26 · fail 0`.
|
||||
- 전체 묶음(포인터 다리 뒤, 모두 `CLAUDE_BIN=<가짜 exit 1>`): 참조 저장소 `npm test` → `ℹ tests 87 · pass 87 · fail 0` · Suite `modules/webmaker` `npm test` → `ℹ tests 94 · pass 94 · fail 0` · Suite `node test/run.mjs` → `✔ 테스트 파일 23개 중 실패 0개`(webmaker-visual-editor 5/5 포함). 하네스가 다시 쓴 `local-e2e-2026-10-28.*` 보고서 파일은 보존하지 않았다.
|
||||
- 문법·차이 확인: `node --check` 세 런타임 파일(두 저장소) 통과, 공유 파일 두 저장소 동일, `server.js`·`app.js`·`index.html` 의 Suite 블록 차이 불변.
|
||||
|
||||
## 독립 검토(읽기 전용) 차단 항목 12건 수정(2026-10-28) — Suite 쪽 기록
|
||||
참조 저장소 쪽 상세(항목 1~6·8~10 의 결함·수정·RED 증거 표)는 `modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md`(upstream 과 동일). 여기에는 Suite 반영과 Suite 전용 항목(7·11)을 적는다.
|
||||
|
||||
- **공유 파일 반영**: `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`·`test/visual-editor-server.test.js`·새 `test/visual-editor-app.test.js` 를 `cp`(모두 upstream 과 byte-identical, `cmp`). Suite 블록이 있는 `server.js`·`public/app.js`·`lib/store.js` 는 참조 저장소의 수정 전/후 diff 를 `patch` 로 적용하고(server.js 의 소스/직접 편집 PUT 두 hunk 는 Suite rev 줄 때문에 손으로 반영) Suite 블록을 보존했다. upstream 대비 차이: `server.js` 115줄(107 → 115: 항목 7 의 rev 필수 2줄·잠금 안으로 옮긴 `revConflict` 줄·주석), `app.js` 4줄·`style.css` 5줄·`index.html` 2줄 불변, `lib/store.js` 4줄(rev 증가 + restore 때 rev 보존).
|
||||
- **항목 7(Suite 전용) — 직접 편집 저장은 유한한 숫자 `rev` 필수**: `PUT /api/sites/<id>/visual/<페이지>` 는 `typeof rev === 'number' && Number.isFinite(rev)` 가 아니면 400 `{ revRequired: true }`(없음·문자열 "3"·null·"NaN"·""). 잠금 안에서 다시 읽은 사이트 기록의 rev 와 다르면(다른 파일 저장으로 오른 rev 포함) 409 `conflict{ yourRev, currentRev }`. 소스 편집기 PUT 의 옛 화면 호환 규칙(rev 없으면 통과)은 이 경로에 적용하지 않는다(fail-closed). 공유 서버 테스트는 GET 이 준 rev 를 모든 PUT 에 함께 보낸다(단독 Webmaker 는 0 을 주고 무시) — 처음 Suite 에서 `npm test` 를 돌리자 rev 없는 PUT 8건이 400 으로 떨어져 이를 드러냈고, 뒤처진 저장의 409 는 Suite 에서 hash(stale) 가 아니라 rev(conflict) 로 먼저 잡히므로 테스트가 둘 중 하나를 받는다(`expectStale`).
|
||||
- **항목 6 의 Suite 부분 — 되돌리기 저장은 rev 를 올리지 않는다**: `lib/store.js save(site, { restore: true })` 는 rev 를 그대로 둔다. 실패한 직접 편집 저장(사이트 기록 폴더 읽기 전용 → store 실패)은 500 뒤 페이지·rev 가 저장 전 값 그대로이고 같은 rev·baseHash 로 재시도하면 200.
|
||||
- **항목 11(Suite 전용) — 도입 소스 출처 fail-closed**: 새 `lib/provenance.mjs checkProvenance(manifest)`: 소스마다 40자리 `commit`·비어 있지 않은 파일 목록(from·to·origHash), `upstream` 이 있으면 version·branch·headCommit(= commit)·`workingTreeDirty === false` 를 요구하고, 모르는 형태·빈 목록은 불완전으로 거절. `upstream` 칸이 없는 0.1.x 기록(AriMail)은 깨끗한 고정 커밋만 받던 스크립트가 만든 것이라 legacy 로 받아들인다. `scripts/release.mjs build` 는 검사를 테스트보다 먼저 하고 깨끗하지 않으면 artifact 를 만들지 않고 종료 1(이유 출력), manifest.json 에 `checks.provenanceClean`·`provenanceProblems`·`imported[].upstream/legacy` 를 남긴다. `register --approve` 는 `checks.provenanceClean !== true`(false·옛 빌드의 없음)면 DB 를 열기 전에 종료 1. `hal/core.approveRelease` 는 `checks.provenanceClean === false` 면 `release_checks_failed`. 테스트용 `ARIWEB_IMPORTED_SOURCES` 로 manifest 경로를 바꿀 수 있다. 개발 중 기록(`workingTreeDirty: true`, --allow-dirty)으로는 build 가 멈췄다(의도). 참조 저장소 1.8.0 이 `4b8c4d0de0f69025001a9e0a28a58a94e1a1f088` 으로 커밋·검증된 뒤 `scripts/import-sources.mjs` 의 pin 을 그 커밋으로 올렸고, 그 pin 으로 `--allow-dirty` 없이 다시 만든 기록(`commit = headCommit = 4b8c4d0`, `workingTreeDirty:false`, 184 파일·수정 8)이면 build·approve 가 된다 — 이 작업 트리의 `IMPORTED_SOURCES` 파일은 아직 개발 중 기록이라 Neo 가 다시 만든다.
|
||||
- **항목 12** — 이 문서·VERSION_HISTORY·upstream 문서의 수치를 최종값으로, 가져온 파일 수는 생성된 manifest(`IMPORTED_SOURCES.json`)에서 읽어 적었다(VERSION_HISTORY).
|
||||
|
||||
- **수정 뒤 브라우저 게이트 재실행 조사(2026-10-28)**: Neo 의 재실행이 `edit-text` 에서 "저장 상태 dirty" 시간 초과. 원인은 게이트 멱등성 결함(같은 저장 폴더에 이전 실행의 같은 제목이 이미 있어 변경 없음 = clean, 제품 규칙은 옳음)이며 로컬에서 결정적으로 재현·수정(실행마다 다른 제목 + 사전 단언). 같은 로컬 실행에서 실제 틈 하나(가운데 클릭 `auxclick` 이 sandbox 에서도 새 페이지를 만듦)를 찾아 `wm-ve-canvas.js` 에 auxclick 차단을 더했다(단위 RED→GREEN, upstream 과 byte-identical 로 반영). 로컬 Chrome 156 게이트 61/61(연속 2회), 이어서 **Neo 호스트의 실제 Chrome 재실행 61/61 통과**(2026-10-28). 상세: `modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md`.
|
||||
|
||||
## 독립 검토 2차(종결 검토) 차단 항목 3건 + 문서 수정(2026-10-28) — Suite 쪽 기록
|
||||
참조 저장소 쪽 상세(항목 1·2 의 결함·수정·RED 증거)는 `modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md`(upstream 과 동일). 여기에는 Suite 반영과 Suite 전용 항목 3 을 적는다.
|
||||
- **공유 파일 반영**: `lib/visual-editor.js`·`lib/gitstore.js`·`public/visual-editor-core.js`·`test/visual-editor.test.js`·`test/visual-editor-server.test.js`·`docs/verification/visual-editor-1.8.0-tdd.md`·`README.md` 를 `cp`(모두 upstream 과 byte-identical, `cmp`). `server.js` 는 참조 저장소의 수정 전/후 diff 를 `patch` 로 적용하되 직접 편집 PUT 의 트랜잭션 hunk 는 Suite rev 주석 때문에 손으로 반영(되돌리기의 `store.save(prev,{restore:true})` 줄에 `[Ariweb Suite 변경]` 주석 — rev 보존). upstream 대비 차이: `server.js` 115줄(변함없음), `app.js` 4줄·`style.css` 5줄·`index.html` 2줄·`lib/store.js` 4줄 불변. 도입 기록 재생성: 184 파일·수정 8·workingTreeDirty=true(의도).
|
||||
- **항목 1 — SVG SMIL 저장형 XSS(공유 코드)**: Suite 는 같은 `lib/visual-editor.js` 를 쓰므로 그대로 적용된다 — 새 SMIL 요소(animate·set·animateMotion·animateTransform·animateColor·discard·mpath, 대소문자·`svg:` 접두어 포함)는 400, 원본에 있던 것은 그 수까지만(삭제 허용), 캔버스는 `wm-ve-x-` 비활성 이름. Suite 모듈 단위 테스트 38/38.
|
||||
- **항목 2 — Git 저장 트랜잭션(공유 코드 + Suite rev)**: `gitstore.recordChangeStrict` 가 로컬 커밋 → 사이트 기록(Suite 는 여기서 rev +1 한 번) → 원격 `push --atomic`(main + 변경 브랜치) 순서로 끝내고, 실패하면 `reset --mixed`·브랜치 삭제/복원·페이지 원본·`store.save(prev,{restore:true})`(rev 보존) 뒤 검증한 결과로만 `rolledBack` 을 준다. 1차 수정 경로가 하던 두 번의 `store.save`(manualEdits 저장 + gitRecord 안 저장)가 한 번으로 줄어 rev 는 성공한 저장마다 정확히 1 오른다(기존 `test/webmaker-visual-editor.test.mjs` 의 `currentRev >= …` 검사 그대로 통과). 푸시 전에 저장되는 기록은 `pushed:false, remote:'pending'` 이고(원격 반영 미주장 — 훅이 푸시 순간에 복사한 기록으로 확인), 트랜잭션 밖 `reconcileSite`(잠금 안, 저장 뒤·기동 때)가 `ls-remote` 로 확인한 뒤에만 `verified` 로 바꾸며 그 저장은 `{restore:true}` 라 rev 를 올리지 않는다(재기동 조정 테스트가 `saved.rev === site.rev` 를 확인). 공유 서비스 테스트(bare 원격 + 훅 실패 주입, 첫 커밋 전 되감기, 기동 조정, 모호한 푸시 판정, 잠금 경쟁)가 Suite 모듈에서도 그대로 돈다(18/18).
|
||||
- **항목 3(Suite 전용) — 릴리스 승인 fail-open**: `hal/core.approveRelease` 가 `checks.testsPassed === false`·`provenanceClean === false` 만 거절해 checks 가 없거나(옛 빌드·수동 등록) `testsPassed: null`(--skip-tests)·`dirty` 미기록인 릴리스를 승인했고, `registerRelease(approve:true)`·운영자 API `POST /ops/api/releases/:version/approve` 도 같은 함수를 지나 같은 구멍이 있었다. 새 `hal/core.releaseApprovalProblem(checks)` 한 규칙: `testsPassed === true`·`dirty === false`·`provenanceClean === true` 가 모두 있어야 하며(없음·null·문자열 "true"·1·"false" 모두 거절) 이유를 `release_checks_failed` 메시지로 돌려준다. `scripts/release.mjs register --approve` 도 같은 함수로 DB 를 열기 전에 종료 1(이유 출력). `--skip-tests` 빌드(testsPassed null)는 등록만 되고 승인은 불가. 기존 테스트 픽스처(hal-cli 2곳·hal 1곳 `registerRelease`, e2e `buildFakeRelease` manifest)는 깨끗한 checks 를 넣도록 고쳤다 — 현재 요구사항에 관용 정책이 없어 옛 정책은 남기지 않았다.
|
||||
- **문서 수정** — 이 문서·VERSION_HISTORY·upstream 문서의 "재실행 필요/미실행"·"자동화하지 않음"·옛 수치(103/110·182 파일·HTML5 주 경로·release.mjs 변경 없음)를 현재 증거(Neo 호스트 실제 Chrome 61/61, 106/113/24, 184 파일·수정 8, 포인터 다리 주 경로 + HTML5 대체, release.mjs·hal/core 변경됨)로 고쳤다.
|
||||
|
||||
## 독립 검토 3차(최종 검토) 차단 항목 4건(2026-10-28) — Suite 쪽 기록
|
||||
- **항목 1(Suite 전용) — 승인된 릴리스 불변**: `hal/core.registerRelease` 는 기존 행이 approved 면 같은 내용(main 커밋·빌드 시각·artifact·checks 를 키 순서 무관하게 비교, 메모는 준 경우)의 재등록만 멱등하게 돌려주고(approve:true 여도 승인자·시각 그대로), 다르면 `release_immutable` 로 거절하며 UPDATE 를 실행하지 않는다. 기존 행이 승인되지 않았으면 내용을 갱신하되 `status='registered', approved_by=NULL, approved_at=NULL` 로 두고, approve 는 `releaseApprovalProblem` 규칙을 지나야 한다. `hal/index.mjs register-release` 는 거부를 `릴리스 등록 거부 (release_immutable): …` 로 출력하고 종료 1(DB 불변). 운영자 API 의 승인 경로(`POST /ops/api/releases/:v/approve`)는 같은 규칙(앞 절 테스트). RED → GREEN: `test/release-provenance.test.mjs` +2 — core(8가지 변경 × approve true/false 모두 거절·readback 불변, 미승인 버전의 승인 미승계) · CLI(같은 manifest 재등록 멱등, 불량 checks·다른 artifact 재등록 종료 1 + DB readback 불변).
|
||||
- **항목 2·3(공유 코드)**: `modules/webmaker/server.js` 에 라우터 수준 잠금·재적재(본문 메모 읽기 → 잠금 안 재적재 → 짧은 경로 전체 잠금 / 긴 경로 재적재)와 재진입 잠금, `changeLanguages` 순서, 직접 편집 PUT 의 `remoteState:'ambiguous'` 응답을 upstream 과 같은 블록으로 반영(Suite 블록 보존). `lib/gitstore.js` 는 byte-identical. Suite 의 직접 편집 rev 충돌 검사는 잠금 안에서 재적재된 최신 기록으로 이루어진다.
|
||||
- **항목 4**: 이 문서·VERSION_HISTORY 의 합계(107/114 등)를 현재 증거로 바꿨다.
|
||||
- **항목 5(공유 코드 + Suite 블록) — 배경 작업 저장 합치기**: `modules/webmaker/lib/store.js` 의 `load` 가 읽은 상태를 `__base`(열거 안 됨)로 기억하고 `save` 가 디스크 `saveId` 변화를 보고 이 객체가 바꾼 최상위 항목만 합친다. Suite 블록: 합칠 때 `rev` 는 디스크 값을 기준으로 올리고, `keepRev`(원격 반영 조정)는 합치되 rev 를 올리지 않는다(`restore` 는 스냅샷 그대로, rev 불변). upstream 대비 차이 5줄. 되돌리기 검증(`server.js`)은 `updatedAt`·`saveId` 만 제외하고 기록을 비교한다. 공유 서비스 테스트 +1(작업 중 PATCH 제목·PUT 등급 → 중단 → 유지)이 Suite 모듈에서도 그대로 돈다(19/19).
|
||||
- **항목 6(공유 코드 + Suite 블록) — 같은 잎 동시 변경 fail-closed**: `store.js` 합치기를 잎 단위 3자 병합으로(일반 객체는 안으로, 배열·기본값은 잎), 같은 잎을 둘 다 다르게 바꾼 경우만 `SaveConflict`(code `site_save_conflict`, status 409, `fields`)로 거절·디스크 보존. `store.update(id, mutate, opts)`·`store.adopt(site, fresh)` 추가(Suite: `update` 도 `keepRev` 를 받아 조정 저장은 rev 불변). `server.js`: 배경 작업 저장 전부 `saveJob(site, reapply)`(충돌이면 최신 기록 위에 작업 항목만 재적용 + adopt), `gitRecord` 충돌 때 버전만 추가·옛 실패분 재시도는 잠금 안 조정으로, `/git/retry`·`reconcileSite` 는 `applyPushState` 를 `store.update` 로 적용. 그 밖의 화면 경로의 충돌은 409 로 드러난다(조용한 손실 없음). RED → GREEN: 공유 서비스 테스트 +2(저장소 규칙 6가지 / 실제 서버 — git 래퍼로 commit 2초 지연 → 작업 종료의 버전 기록 창에 다시 푸시 → 다시 푸시의 verifiedAt 그대로·작업의 새 버전도 기록); 중간 RED 에서 충돌이 다시 푸시 경로에 409 로 드러난 것을 확인한 뒤 그 경로를 update 로 옮겼다. Suite 모듈 21/21.
|
||||
|
||||
## 독립 검토 4차 차단 항목 4건(2026-10-28) — Suite 쪽 기록
|
||||
- **항목 1(Suite 전용) — artifact 설치 불변·fail-closed**: `hal/core.installArtifacts(d, {version, mainCommit, builtAt, checks, artifacts:{mod:{file, sha256, size, src}}})` — ① 원본 바이트 sha256·크기를 선언값과 대조(불일치 → `artifact_hash_mismatch`, 아무것도 쓰지 않음) ② 승인된 버전이면 `releaseImmutableProblem`(메타데이터) + 설치된 파일 해시 모두 같을 때만 멱등(복사 없음), 아니면 `release_immutable`(기존 파일·DB 불변) ③ 새·미승인 설치는 `.<file>.tmp-…` 에 쓰고 해시를 다시 확인한 뒤 rename. `hal/index.mjs register-release`·`scripts/release.mjs register` 는 설치 → `registerRelease` 순서로 바뀌었고 거부는 종료 1(release.mjs 는 DB 를 닫고 종료). RED → GREEN: `test/release-provenance.test.mjs` +2 — HAL CLI(A 승인 → B 유효 해시·B 바이트에 A 해시 거짓 선언 × approve 유무 → 종료 1, 대상 바이트/해시/DB 가 A, 임시 파일 없음, A 재등록 멱등, 새 버전 해시 불일치 미설치) · release.mjs(같은 4 경우 + 멱등).
|
||||
- **항목 2·3·4(공유 코드)**: `modules/webmaker/server.js` — 읽기 요청 부작용 제거(언어 저장은 잠금 안 재적재에서만), 폐기 규약(`destroySite(siteId, by, {ownerId, confirm})`: 잠금 안 재검사·표식 claim → 바깥 정리 → 같은 표식 확인 뒤 삭제; 라우터 재적재·`claimJob` 의 폐기 중 409; 실패는 `store.update` 로 deleteFailed) — 사용자 경로는 Suite 의 포털 영향 확인 블록(`ariweb.impactFromPortal`·`acknowledgeImpact`·`unlink`)을 그대로 두고 마지막 호출만 바꿨다(upstream 대비 118줄 차이 — 기존 Suite 블록 + 폐기 전 검사 2줄). `lib/visual-editor.js` 스캐너의 외래 콘텐츠 깊이는 byte-identical.
|
||||
|
||||
- **잔여 위험 해소 — 폐기 때 포털 연결 보상(Suite 블록 + 공유 훅)**: 포털 연결 등록부(`lib/ariweb.js` 의 `ariweb-links.json`, 로컬 파일)의 해제(`unlink`)는 되돌릴 수 있고(`link` 로 되살림, `linkedAt` 보존 인자 추가), 포털 자체에는 영향 조회(`impactFromPortal`, 읽기)만 한다. 공유 `destroySite` 의 최종 잠금 단계를 "임시 이름으로 옮김(rename) → `beforeIrreversible` → 기록 unlink(되돌릴 수 없는 한 단계) → 최선의 뒷정리" 로 바꾸고, Suite 사용자 경로는 `beforeIrreversible` 에서 `ariweb.unlink` + readback(남아 있으면 502), `compensate` 에서 `ariweb.link(원래 연결)` + readback 을 한다. 순서상 정리 실패·연결 해제 실패에는 되돌릴 수 없는 변경이 하나도 없고, 기록 삭제 실패에는 되살리기가 있으며 되살리지 못하면 `recoveryRequired`(응답·`deleteFailed`·감사 `site.destroy.failed`)로 운영자에게 보인다. 뒷정리 실패는 성공 응답의 `warnings` + 감사 `site.destroy.cleanup`(남은 `.deleting-…` 폴더). RED → GREEN: `modules/webmaker/test/ariweb.test.js` +3(가짜 ssh·가짜 배포 대상·가짜 포털) — ① 정리 실패(SSH_FAIL): 연결·파일·기록 그대로 + deleteFailed, 정리가 되면 폐기 성공·연결 없음 ② 정리 성공 뒤 연결 등록부 쓰기 실패(DATA_ROOT 읽기 전용): ssh 호출 있음, 연결·파일·기록 그대로(임시 폴더 없음), 쓸 수 있게 되면 성공 ③ 되살리기 readback + 뒷정리 실패(지울 수 없는 하위 폴더): 200 + warnings, 연결·기록·원래 폴더 없음, 남은 폴더 1개 확인 뒤 정리.
|
||||
|
||||
### RED — Suite(4차 검토)
|
||||
- `node test/release-provenance.test.mjs`(수정 전 CLI): `✘ 통과 9 · 실패 2` — `B 유효 해시 + approve: 대상 바이트 A 그대로: 기대 "artifact-A-bytes" 실제 "artifact-B-bytes-different"`, release.mjs `기대 "rel-A" 실제 "rel-B-other"`(승인된 artifact 바이트가 덮임).
|
||||
- 공유 테스트(참조 저장소, 수정 전): 단위 38/39(`거절되어야 함: …<svg><title><script>alert(1)</script></title></svg>`), 서비스 21/23(`읽기 요청은 기록 파일을 바꾸지 않는다`, 폐기 중 저장이 "폐기" 로 거절되지 않음).
|
||||
- Suite 모듈 `test/ariweb.test.js`(수정 전 순서 — unlink 먼저): `ℹ tests 10 · pass 7 · fail 3` — `포털 연결이 풀리지 않았다(연결 해제는 정리 성공 뒤에만): null`, `정리는 먼저 수행됐다`(연결 해제 실패가 정리 전에 남), `연결이 되살아났다(readback): null`.
|
||||
|
||||
### GREEN — Suite(4차 검토 뒤, 현재 증거)
|
||||
```
|
||||
node test/release-provenance.test.mjs ✔ 통과 11 · 실패 0
|
||||
modules/webmaker: CLAUDE_BIN=<가짜 exit 1> npm test ℹ tests 125 · pass 125 · fail 0 (단위 39 · 서비스 23 · 화면 전환 1 · 원본 59 + ariweb 폐기 보상 3)
|
||||
CLAUDE_BIN=<가짜 exit 1> node test/run.mjs ✔ 테스트 파일 24개 중 실패 0개 (생성된 local-e2e-2026-10-28.{md,json} 은 삭제)
|
||||
```
|
||||
참조 저장소: `npm test` 115/115(단위 39 + 서비스 23 + 화면 전환 1 + 원본 52). 실제 Chrome 게이트(Neo 호스트) 61/61 그대로(게이트가 보는 화면 동작 불변).
|
||||
|
||||
### RED — Suite(최종 검토)
|
||||
- `node test/release-provenance.test.mjs`(수정 전 `hal/core.mjs`): `✘ 통과 7 · 실패 2` — `Error: 승인자·시각도 그대로: 기대 {"status":"approved","by":"op",…} 실제 {"status":"approved","by":"op2",…}`(승인된 행이 재등록으로 바뀜), CLI 재등록 뒤 `approved_by` 가 "test" → "test2".
|
||||
- 공유 서비스 테스트(수정 전 코드, 참조 저장소): `ℹ tests 18 · pass 15 · fail 3` — 잠금 경쟁(`설정 저장이 직접 편집 버전을 덮어쓰지 않는다: ["1:create"]`), 모호한 푸시(`원격이 받았으면 성공: {…"rolledBack":true,"stage":"push"}`), 준비 단계 실패(`stage`·`rollback` 없음, 페이지 미복원). 항목 5(수정 전 store.js): `ℹ tests 19 · pass 18 · fail 1` — `작업의 종료 저장이 화면의 제목 변경을 덮어쓰지 않는다: {"title":"VE site","status":"idle"}`.
|
||||
|
||||
### GREEN — Suite(최종 검토 뒤)
|
||||
```
|
||||
node test/release-provenance.test.mjs ✔ 통과 9 · 실패 0
|
||||
modules/webmaker: CLAUDE_BIN=<가짜 exit 1> npm test ℹ tests 119 · pass 119 · fail 0 (단위 38 · 서비스 21 · 화면 전환 1 · 원본 59)
|
||||
CLAUDE_BIN=<가짜 exit 1> node test/run.mjs ✔ 테스트 파일 24개 중 실패 0개 (생성된 local-e2e-2026-10-28.{md,json} 은 삭제)
|
||||
```
|
||||
참조 저장소(그때): `npm test` 112/112(단위 38 + 서비스 21 + 화면 전환 1 + 원본 52). 현재 증거는 위 "4차 검토 뒤" 절.
|
||||
|
||||
### RED — Suite(종결 검토)
|
||||
- `node test/release-provenance.test.mjs`(수정 전 `hal/core.mjs`·`scripts/release.mjs`): `✘ 통과 4 · 실패 3` — `Error: checks 없음 (approveRelease)`(승인됨), `Error: 7.7.0: 거절 200 {"ok":true,"release":{…}}`(운영자 API 가 checks 없는 릴리스를 승인), `Error: tests-null: HAL 릴리스 등록: 0.2.0 (approved) … 기대 1 실제 0`(`register --approve` 가 testsPassed null 을 승인).
|
||||
- 공유 서비스 테스트(수정 전 코드, 참조 저장소에서 실행): `ℹ tests 14 · pass 11 · fail 3` — Git 실패 되돌리기 `rolledBack:false`(옛 boolean 은 상태를 검증하지 않음), 원격 트랜잭션(옛 코드는 pre-receive 거부를 200 + `rec.error` 로 삼킴). 단위: SMIL `거절되어야 함: …<animate attributeName="href" values="javascript:alert(1)"…`.
|
||||
|
||||
### GREEN — Suite(종결 검토 뒤)
|
||||
```
|
||||
node test/release-provenance.test.mjs ✔ 통과 7 · 실패 0 (checks 13가지 불량 × approveRelease/registerRelease 거절, 운영자 API 거절/승인, register --approve 4가지 거절 + 깨끗한 manifest 승인)
|
||||
modules/webmaker: CLAUDE_BIN=<가짜 exit 1> npm test ℹ tests 114 · pass 114 · fail 0 (단위 38 · 서비스 16 · 화면 전환 1 · 원본 59)
|
||||
CLAUDE_BIN=<가짜 exit 1> node test/run.mjs ✔ 테스트 파일 24개 중 실패 0개 (생성된 local-e2e-2026-10-28.{md,json} 은 삭제)
|
||||
```
|
||||
참조 저장소(그때): `npm test` 107/107(단위 38 + 서비스 16 + 화면 전환 1 + 원본 52). 현재 증거는 위 "최종 검토 뒤" 절.
|
||||
|
||||
### RED — Suite(1차 검토)
|
||||
- `node --no-warnings test/release-provenance.test.mjs`(구현 전): `✘ 통과 0 · 실패 4`(`lib/provenance.mjs` 없음 → 네 테스트 모두 실패).
|
||||
- `node --no-warnings test/webmaker-visual-editor.test.mjs` 를 **rev 필수 줄과 `{ restore: true }` 를 뺀 임시 서버 복사본** 으로 실행: `✘ 통과 4 · 실패 1` — `Error: rev 없음: {"path":"index.html",…,"rev":11,…}`(rev 없는 저장이 200 으로 파일을 바꿈). 실행 뒤 복사본은 수정본으로 되돌렸다(`cmp` 확인).
|
||||
- Suite `modules/webmaker` `npm test`(공유 테스트가 rev 를 보내기 전): `ℹ tests 110 · pass 102 · fail 8` — 모두 `{"error":"저장본 개정 번호(rev)가 없거나 올바르지 않습니다…","revRequired":true}`(항목 7 이 동작함을 보임) → 공유 테스트에 rev 추가 → `pass 108 · fail 2`(409 가 stale 대신 conflict) → `expectStale` → 110/110.
|
||||
|
||||
### GREEN — Suite(1차 검토 뒤)
|
||||
```
|
||||
node --no-warnings test/release-provenance.test.mjs ✔ 통과 4 · 실패 0
|
||||
node --no-warnings test/webmaker-visual-editor.test.mjs ✔ 통과 5 · 실패 0 (rev 없음/문자열/null/NaN/빈 → 400, 교차 파일 rev → 409, store 실패 → 500·rev 보존·재시도 200)
|
||||
modules/webmaker: CLAUDE_BIN=<가짜 exit 1> npm test ℹ tests 110 · pass 110 · fail 0
|
||||
CLAUDE_BIN=<가짜 exit 1> node test/run.mjs ✔ 테스트 파일 24개 중 실패 0개 (release-provenance 포함; 생성된 local-e2e-2026-10-28.{md,json} 은 삭제)
|
||||
```
|
||||
참조 저장소: `npm test` 103/103(단위 37 + 서비스 13 + 화면 전환 1 + 원본 52).
|
||||
|
||||
## 브라우저 검증 상태 · 자동화하지 않은 것
|
||||
- 실제 브라우저 검증(Neo 의 게이트, 실제 Chrome)은 다섯 차례 수행되었다: 배치 결함 → 선택 전 도구 줄 노출 → 끌어다 놓기 실패 → 살균 진단으로 "HTML5 끌기가 sandbox iframe 에 도달하지 않음" 확정(3·4차는 선택·글 편집·실행 취소/다시 실행·키보드 이동 통과) → 포인터 다리 뒤 네이티브 포인터 순서 게이트 **37/37**. 독립 검토 수정 뒤 재실행은 게이트 멱등성 결함을 고친 뒤 **Neo 호스트에서 61/61 통과**(연결 규약·이동 활성화 6종·위조 창 메시지 단언 포함) — 캔버스 안 실제 포인터 조작(글 입력·실행 취소/다시 실행·이동·팔레트 끌어다 놓기·저장·저장 파일·390px)은 이 게이트로 자동 검증했다. Suite 쪽 화면은 독립 Webmaker 와 같은 `style.css`·`visual-editor-ui.js`·`wm-ve-canvas.js` 를 쓰므로 같은 수정이 적용된다(style.css 포털 배너 5줄만 추가). 종결 검토 3건은 브라우저 동작을 바꾸지 않아 추가 재실행은 필요 없다.
|
||||
- 자동화하지 않은 것: 포털 SSO 로 들어간 실제 화면 확인, 운영 서버 배포, Chrome 외 브라우저. 사람 확인 목록은 Webmaker `Dev_Order_v1.8.0.md` 끝.
|
||||
+81
-6
@@ -3,6 +3,7 @@
|
||||
* app.mjs(라우트)와 jobs.mjs(작업 종류)가 함께 쓴다. 모든 함수는 deps(store·vault·config·log·jobs) 를 받는다.
|
||||
*/
|
||||
import { hostname } from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { newId, randomToken, sha256, hashPassword } from '../lib/crypto.mjs';
|
||||
import { nowIso, toJson, parseJson } from '../lib/db.mjs';
|
||||
import { audit } from '../lib/audit.mjs';
|
||||
@@ -10,8 +11,8 @@ import { notify } from '../lib/notify.mjs';
|
||||
import { redact, safeError } from '../lib/redact.mjs';
|
||||
import { serviceFetch } from '../lib/serviceauth.mjs';
|
||||
import { JobError } from '../lib/jobs.mjs';
|
||||
import { existsSync, statSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync, statSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
|
||||
import { join, basename } from 'node:path';
|
||||
import { syncFromHeartbeat } from './mailsvc.mjs';
|
||||
|
||||
export const EXTERNAL_KINDS = new Set(['site.deploy', 'site.remove', 'site.check', 'site.rollback']); // 외부 Agent 가 받을 수 있는 작업(§10.2)
|
||||
@@ -260,15 +261,89 @@ export async function awaitAgentJob(d, ctx, agentJobId, { timeoutMs = d.config.a
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 릴리스(§32.1)
|
||||
/** 키 순서에 무관한 비교용 JSON(릴리스 재등록의 "같은 내용" 판정) */
|
||||
function canonJson(v) { if (Array.isArray(v)) return '[' + v.map(canonJson).join(',') + ']'; if (v && typeof v === 'object') return '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonJson(v[k])).join(',') + '}'; return JSON.stringify(v === undefined ? null : v); }
|
||||
/**
|
||||
* 릴리스 등록(upsert). 0.2.0 검토 수정 3차 — **승인된 버전은 불변**: 같은 내용(main 커밋·빌드 시각·artifact·checks, 메모는 준 경우에만)의 재등록은 멱등하게 그대로 돌려주고(approve 요청이어도 이미 승인),
|
||||
* 어느 하나라도 다른 재등록은 approve 여부와 무관하게 `release_immutable` 로 거절하며 아무것도 바꾸지 않는다(새 내용은 새 버전 번호로). 승인되지 않은 버전의 내용 변경 재등록은 내용을 갱신하되
|
||||
* 상태를 registered 로 두고(승인자·시각 비움) 승인을 물려받지 않는다 — approve 가 참이면 releaseApprovalProblem 규칙을 지나야만 승인된다. CLI(hal/index.mjs register-release·scripts/release.mjs register)도 이 함수를 쓴다.
|
||||
*/
|
||||
/** 승인된 릴리스를 다른 내용으로 바꾸려는 등록인지(같은 내용이면 null). registerRelease·installArtifacts 가 같은 규칙을 쓴다 */
|
||||
export function releaseImmutableProblem(ex, { mainCommit, builtAt, artifacts, checks = null, notes = null }) {
|
||||
if (!ex || ex.status !== 'approved') return null;
|
||||
const same = ex.main_commit === mainCommit && ex.built_at === builtAt && canonJson(ex.artifacts) === canonJson(artifacts || {}) && canonJson(ex.checks) === canonJson(checks) && (notes == null || notes === ex.notes);
|
||||
return same ? null : `승인된 릴리스 ${ex.version} 은 바꿀 수 없습니다 — artifact·검사 기록·main 커밋·빌드 시각·메모가 다른 빌드는 새 버전 번호로 등록하세요`;
|
||||
}
|
||||
/**
|
||||
* artifact 설치(0.2.0 검토 수정 4차 — 불변·fail-closed). 등록(registerRelease) **전** 에 부른다.
|
||||
* 1) 원본 파일마다 바이트의 sha256 과 크기를 manifest 의 선언값과 대조한다 — 다르면 `artifact_hash_mismatch` 로 거절(아무것도 쓰지 않는다).
|
||||
* 2) 같은 버전이 이미 승인되어 있으면 메타데이터(releaseImmutableProblem)와 설치된 파일의 해시까지 같을 때만 멱등(복사하지 않음); 하나라도 다르면 `release_immutable` 로 거절하고 기존 파일·DB 를 건드리지 않는다.
|
||||
* 3) 새 버전·미승인 버전은 대상 폴더의 임시 파일(.tmp-…)에 쓰고 해시를 다시 확인한 뒤 rename 으로 승격한다(원자적) — 승인된 파일은 어떤 경로로도 잘리거나 덮이지 않는다.
|
||||
* @param {object} d HAL deps
|
||||
* @param {{version:string, mainCommit:string, builtAt:string, checks?:object, notes?:string, artifacts:Record<string,{file:string, sha256:string, size?:number, src:string}>}} m src = 원본 파일 경로
|
||||
* @returns {Record<string,{file:string, sha256:string, size:number}>} 등록에 넘길 artifact 목록(파일 이름만)
|
||||
*/
|
||||
export function installArtifacts(d, m) {
|
||||
const entries = Object.entries(m.artifacts || {});
|
||||
const declared = {}; const verified = {};
|
||||
for (const [mod, a] of entries) {
|
||||
if (!a || !a.file || !a.src) throw new JobError('artifact_missing', `${mod}: artifact 파일 정보가 없습니다`);
|
||||
if (!existsSync(a.src)) throw new JobError('artifact_missing', `${mod}: artifact 파일이 없습니다 (${basename(a.src)})`);
|
||||
const bytes = readFileSync(a.src); const sha = createHash('sha256').update(bytes).digest('hex');
|
||||
if (String(a.sha256 || '').toLowerCase() !== sha) throw new JobError('artifact_hash_mismatch', `${mod}: artifact 해시 불일치 — manifest ${String(a.sha256 || '').slice(0, 12)}… / 파일 ${sha.slice(0, 12)}… (설치·등록하지 않습니다)`);
|
||||
if (a.size !== undefined && a.size !== null && Number(a.size) !== bytes.length) throw new JobError('artifact_hash_mismatch', `${mod}: artifact 크기 불일치 — manifest ${a.size} / 파일 ${bytes.length}`);
|
||||
declared[mod] = { file: basename(a.file), sha256: sha, size: bytes.length }; verified[mod] = { bytes, ...declared[mod], src: a.src };
|
||||
}
|
||||
const ex = getRelease(d, m.version);
|
||||
const dir = join(artifactDir(d), m.version);
|
||||
if (ex && ex.status === 'approved') {
|
||||
const why = releaseImmutableProblem(ex, { mainCommit: m.mainCommit, builtAt: m.builtAt, artifacts: declared, checks: m.checks ?? null, notes: m.notes ?? null });
|
||||
if (why) throw new JobError('release_immutable', why);
|
||||
for (const [mod, v] of Object.entries(verified)) { // 설치된 바이트도 같아야 멱등 — 아니면 승인된 파일을 건드리지 않고 거절
|
||||
const p = join(dir, v.file); if (!existsSync(p)) throw new JobError('release_immutable', `승인된 릴리스 ${m.version} 의 ${mod} artifact 파일이 없습니다 — 설치 상태를 운영자가 확인하세요`);
|
||||
const cur = createHash('sha256').update(readFileSync(p)).digest('hex'); if (cur !== v.sha256) throw new JobError('release_immutable', `승인된 릴리스 ${m.version} 의 ${mod} artifact 는 바꿀 수 없습니다(설치된 파일과 바이트가 다름)`);
|
||||
}
|
||||
return declared; // 멱등 — 아무것도 쓰지 않는다
|
||||
}
|
||||
mkdirSync(dir, { recursive: true });
|
||||
for (const [mod, v] of Object.entries(verified)) {
|
||||
const target = join(dir, v.file);
|
||||
if (existsSync(target) && createHash('sha256').update(readFileSync(target)).digest('hex') === v.sha256) continue; // 이미 같은 바이트
|
||||
const tmp = join(dir, `.${v.file}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}`);
|
||||
try { writeFileSync(tmp, v.bytes, { mode: 0o644 }); const again = createHash('sha256').update(readFileSync(tmp)).digest('hex'); if (again !== v.sha256) throw new JobError('artifact_hash_mismatch', `${mod}: 임시 파일 해시 불일치`); renameSync(tmp, target); }
|
||||
catch (e) { try { unlinkSync(tmp); } catch (_) {} throw e; }
|
||||
}
|
||||
return declared;
|
||||
}
|
||||
export function registerRelease(d, { version, mainCommit, builtAt, artifacts, checks = null, notes = null, approve = false, by = null }) {
|
||||
const now = nowIso(); const ex = d.store.get('SELECT version FROM releases WHERE version=?', version);
|
||||
if (ex) d.store.run('UPDATE releases SET main_commit=?, built_at=?, artifacts_json=?, checks_json=?, notes=COALESCE(?, notes) WHERE version=?', mainCommit, builtAt, toJson(artifacts), toJson(checks), notes, version);
|
||||
else d.store.run('INSERT INTO releases(version, main_commit, built_at, artifacts_json, checks_json, status, notes, registered_at) VALUES(?,?,?,?,?,\'registered\',?,?)', version, mainCommit, builtAt, toJson(artifacts), toJson(checks), notes, now);
|
||||
const now = nowIso(); const ex = getRelease(d, version);
|
||||
if (ex) {
|
||||
const same = ex.main_commit === mainCommit && ex.built_at === builtAt && canonJson(ex.artifacts) === canonJson(artifacts || {}) && canonJson(ex.checks) === canonJson(checks) && (notes == null || notes === ex.notes);
|
||||
if (ex.status === 'approved') {
|
||||
const why = releaseImmutableProblem(ex, { mainCommit, builtAt, artifacts, checks, notes }); if (why) throw new JobError('release_immutable', why);
|
||||
audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'release.register', subjectType: 'release', subjectId: version, detail: { mainCommit, artifacts: Object.keys(artifacts || {}), idempotent: true, approved: true } });
|
||||
return ex;
|
||||
}
|
||||
if (!same) d.store.run('UPDATE releases SET main_commit=?, built_at=?, artifacts_json=?, checks_json=?, notes=COALESCE(?, notes), status=\'registered\', approved_by=NULL, approved_at=NULL WHERE version=?', mainCommit, builtAt, toJson(artifacts), toJson(checks), notes, version);
|
||||
} else d.store.run('INSERT INTO releases(version, main_commit, built_at, artifacts_json, checks_json, status, notes, registered_at) VALUES(?,?,?,?,?,\'registered\',?,?)', version, mainCommit, builtAt, toJson(artifacts), toJson(checks), notes, now);
|
||||
if (approve) approveRelease(d, version, by);
|
||||
audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'release.register', subjectType: 'release', subjectId: version, detail: { mainCommit, artifacts: Object.keys(artifacts || {}) } });
|
||||
return getRelease(d, version);
|
||||
}
|
||||
export function approveRelease(d, version, by) { const r = getRelease(d, version); if (!r) throw new JobError('release_not_found', '릴리스가 없습니다'); const checks = r.checks || {}; if (checks.testsPassed === false) throw new JobError('release_checks_failed', '검사에 실패한 릴리스는 승인할 수 없습니다'); d.store.run('UPDATE releases SET status=\'approved\', approved_by=?, approved_at=? WHERE version=?', by, nowIso(), version); audit(d.store, { actorType: 'operator', actorId: by, action: 'release.approve', subjectType: 'release', subjectId: version, immutable: true }); return getRelease(d, version); }
|
||||
/**
|
||||
* 릴리스 검사 기록이 승인 가능한지(0.2.0 검토 수정 2차 — fail-closed). 세 값이 **정확히** 있어야 한다: testsPassed === true(--skip-tests 빌드의 null·없음은 불가), dirty === false(작업 트리 깨끗함이 기록됨),
|
||||
* provenanceClean === true(도입 소스 출처가 깨끗하다고 확인됨). 기록이 없거나(옛 빌드) 명시적 false 가 아닌 "모름" 도 승인하지 않는다. 승인 경로(approveRelease·registerRelease approve:true·운영자 API·release.mjs register --approve)가 모두 이 한 규칙을 쓴다.
|
||||
* @returns {string|null} 승인할 수 없는 이유(없으면 null)
|
||||
*/
|
||||
export function releaseApprovalProblem(checks) {
|
||||
const c = checks && typeof checks === 'object' ? checks : null;
|
||||
if (!c) return '검사 기록(checks)이 없는 릴리스는 승인할 수 없습니다 — 테스트·작업 트리·도입 소스 출처가 확인된 빌드로 다시 등록하세요';
|
||||
if (c.testsPassed !== true) return `테스트 통과가 기록되지 않은 릴리스는 승인할 수 없습니다 (checks.testsPassed=${JSON.stringify(c.testsPassed ?? null)} — --skip-tests 빌드는 등록만 되고 승인은 불가)`;
|
||||
if (c.dirty !== false) return `작업 트리가 깨끗하다고 기록되지 않은 릴리스는 승인할 수 없습니다 (checks.dirty=${JSON.stringify(c.dirty ?? null)} — main 에 병합된 커밋에서 빌드하세요)`;
|
||||
if (c.provenanceClean !== true) return `도입 소스 출처(provenance)가 깨끗하다고 확인되지 않은 릴리스는 승인할 수 없습니다 (checks.provenanceClean=${JSON.stringify(c.provenanceClean ?? null)}${(c.provenanceProblems || []).length ? ': ' + c.provenanceProblems.join(' / ') : ''})`;
|
||||
return null;
|
||||
}
|
||||
export function approveRelease(d, version, by) { const r = getRelease(d, version); if (!r) throw new JobError('release_not_found', '릴리스가 없습니다'); const why = releaseApprovalProblem(r.checks); if (why) throw new JobError('release_checks_failed', why); d.store.run('UPDATE releases SET status=\'approved\', approved_by=?, approved_at=? WHERE version=?', by, nowIso(), version); audit(d.store, { actorType: 'operator', actorId: by, action: 'release.approve', subjectType: 'release', subjectId: version, immutable: true }); return getRelease(d, version); }
|
||||
export function getRelease(d, version) { const r = d.store.get('SELECT * FROM releases WHERE version=?', version); return r ? { ...r, artifacts: parseJson(r.artifacts_json, {}), checks: parseJson(r.checks_json, null) } : null; }
|
||||
export function listReleases(d) { return d.store.all('SELECT * FROM releases ORDER BY registered_at DESC').map((r) => ({ ...r, artifacts: parseJson(r.artifacts_json, {}), checks: parseJson(r.checks_json, null) })); }
|
||||
export const artifactDir = (d) => join(d.config.dataDir, 'artifacts');
|
||||
|
||||
+34
-3
@@ -12,6 +12,11 @@
|
||||
* 기존 AriMail 인스턴스를 HAL 관리형 메일 서비스로 가져오기(기본 dry-run, hal/mailsvc.mjs)
|
||||
* node hal/index.mjs mail-import-rollback --import <mimp_id> [--by <who>] 적용된 가져오기 되돌리기
|
||||
* node hal/index.mjs mail-services HAL 관리형 메일 서비스 readback(JSON, 비밀 없음)
|
||||
* node hal/index.mjs config-set [--publicUrl <u>] [--portalUrl <u>] [--offlineAfterMs <n>] [--metricsRetentionDays <n>] [--by <who>] [--reason <text>]
|
||||
* 운영 값(data/config.json) 기록 — 비밀값 거부, 감사 기록. 서비스 프로세스는 기동 때 읽으므로 재시작해야 반영된다
|
||||
* node hal/index.mjs module-deploy --module <m> --version <v> --servers <srv_id[,srv_id]> [--by <who>] [--reason <text>] [--nonce <n>]
|
||||
* 모듈 배포 작업(module.deploy)을 운영자 API 와 같은 멱등 규칙으로 큐에 넣는다(시스템 신원, 감사 기록). 실행은 serve 프로세스의 작업 엔진이 맡는다
|
||||
* node hal/index.mjs job --id <j_id> 작업·자식 작업 readback(JSON)
|
||||
*/
|
||||
import { loadConfig } from './config.mjs';
|
||||
import { createHalDeps, VERSION } from './deps.mjs';
|
||||
@@ -22,6 +27,9 @@ import * as emergency from './emergency.mjs';
|
||||
import * as dr from './dr.mjs';
|
||||
import * as mailsvc from './mailsvc.mjs';
|
||||
import { purgeSessions } from '../lib/session.mjs';
|
||||
import { audit } from '../lib/audit.mjs';
|
||||
import { describe } from '../lib/jobs.mjs';
|
||||
import { descriptor } from './modules/index.mjs';
|
||||
import { randomToken } from '../lib/crypto.mjs';
|
||||
import { writeFileSync, readFileSync, copyFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { join, dirname, basename } from 'node:path';
|
||||
@@ -38,9 +46,12 @@ if (cmd === 'setup-token') {
|
||||
} else if (cmd === 'grant') {
|
||||
const g = ops.issueGrant(d, { accountId: opt('login'), hours: Number(opt('hours', 8)), by: null, reason: opt('reason', 'cli') }); console.log(`접근권 URL(1회, ${g.expiresAt} 까지): ${g.url}`);
|
||||
} else if (cmd === 'register-release') {
|
||||
const mf = JSON.parse(readFileSync(opt('manifest'), 'utf8')); const dir = join(core.artifactDir(d), mf.version); mkdirSync(dir, { recursive: true });
|
||||
for (const [m, a] of Object.entries(mf.artifacts)) { const src = join(dirname(opt('manifest')), a.file); if (existsSync(src)) copyFileSync(src, join(dir, basename(a.file))); }
|
||||
const r = core.registerRelease(d, { version: mf.version, mainCommit: mf.mainCommit, builtAt: mf.builtAt, artifacts: Object.fromEntries(Object.entries(mf.artifacts).map(([m, a]) => [m, { file: basename(a.file), sha256: a.sha256, size: a.size }])), checks: mf.checks || null, approve: !!opt('approve'), by: opt('by', 'cli') });
|
||||
const mf = JSON.parse(readFileSync(opt('manifest'), 'utf8'));
|
||||
// 0.2.0 검토 수정 4차: artifact 설치는 불변·fail-closed — 원본 바이트를 manifest 해시·크기와 대조하고, 승인된 버전은 같은 바이트·메타데이터일 때만 멱등(복사 없음), 다르면 기존 파일·DB 를 건드리지 않고 거부. 새 설치는 임시 파일 → rename
|
||||
let r; try {
|
||||
const artifacts = core.installArtifacts(d, { version: mf.version, mainCommit: mf.mainCommit, builtAt: mf.builtAt, checks: mf.checks || null, artifacts: Object.fromEntries(Object.entries(mf.artifacts).map(([m, a]) => [m, { file: basename(a.file), sha256: a.sha256, size: a.size, src: join(dirname(opt('manifest')), a.file) }])) });
|
||||
r = core.registerRelease(d, { version: mf.version, mainCommit: mf.mainCommit, builtAt: mf.builtAt, artifacts, checks: mf.checks || null, approve: !!opt('approve'), by: opt('by', 'cli') });
|
||||
} catch (e) { console.error(`릴리스 등록 거부 (${e.code || 'error'}): ${e.message}`); process.exit(1); } // 해시 불일치(artifact_hash_mismatch)·승인된 버전 불변(release_immutable)·승인 규칙(release_checks_failed) — 종료 1, 파일·DB 불변
|
||||
console.log(`릴리스 ${r.version} 등록(${r.status}) — artifact ${Object.keys(r.artifacts).join(', ')}`);
|
||||
} else if (cmd === 'link-portal') {
|
||||
// [E2E 결함 수정] 포털은 양방향 모두 보관소 이름 'service:hal' 하나를 쓴다(포털→HAL 서명, HAL→포털 검증). 그래서 공유 비밀 하나를 HAL 의 두 이름(service:portal·service:hal)에 같이 둔다.
|
||||
@@ -65,6 +76,26 @@ if (cmd === 'setup-token') {
|
||||
try { const r = mailsvc.rollbackImport(d, opt('import'), `cli:${opt('by', 'cli')}`); console.log(JSON.stringify(r)); } catch (e) { console.error(`되돌리기 실패: ${e.code || ''} ${e.message}`); process.exit(2); }
|
||||
} else if (cmd === 'mail-services') {
|
||||
console.log(JSON.stringify(mailsvc.readback(d), null, 1));
|
||||
} else if (cmd === 'config-set') {
|
||||
// 운영 중 바꾸는 값만(hal/config.mjs 의 set — 비밀 키 이름은 거부). 받는 키를 고정해 비밀값이 설정 파일에 들어갈 길을 막는다. 감사 actor cli:<by>.
|
||||
const patch = {}; for (const k of ['publicUrl', 'portalUrl', 'offlineAfterMs', 'metricsRetentionDays']) { const v = opt(k); if (v != null && v !== true) patch[k] = String(v); }
|
||||
if (!Object.keys(patch).length) { console.error('바꿀 값이 없습니다: --publicUrl / --portalUrl / --offlineAfterMs / --metricsRetentionDays 중 하나 이상'); process.exit(1); }
|
||||
const by = `cli:${opt('by', 'cli')}`; cfg.set(patch, by);
|
||||
audit(d.store, { actorType: 'operator', actorId: by, action: 'config.update', subjectType: 'config', subjectId: 'hal', detail: { keys: Object.keys(patch), values: patch, reason: opt('reason', '') } });
|
||||
console.log(JSON.stringify({ updated: Object.keys(patch), file: cfg.file, note: '서비스 프로세스(ariweb-hal)는 기동 때 설정을 읽는다 — 재시작 뒤 반영' }));
|
||||
} else if (cmd === 'module-deploy') {
|
||||
// 운영자 API(POST /ops/api/modules/:name/deploy)와 같은 작업·멱등 키 규칙. 검증(모듈·릴리스·서버)은 여기서, 배포 전제 검사(승인·HAL 먼저·부트스트랩)는 작업의 precheck 단계가 한다.
|
||||
const module = opt('module'); const version = opt('version'); const serverIds = String(opt('servers', '') || '').split(',').map((x) => x.trim()).filter(Boolean); const by = `cli:${opt('by', 'cli')}`;
|
||||
if (!module || module === true || !version || version === true || !serverIds.length) { console.error('--module <name> --version <v> --servers <srv_id[,srv_id]> 가 필요합니다'); process.exit(1); }
|
||||
try { descriptor(module); } catch (e) { console.error(e.message); process.exit(1); }
|
||||
if (!core.getRelease(d, version)) { console.error(`릴리스 ${version} 이(가) 등록되어 있지 않습니다`); process.exit(1); }
|
||||
const missing = serverIds.filter((sid) => !core.getServer(d, sid)); if (missing.length) { console.error(`없는 서버: ${missing.join(', ')}`); process.exit(1); }
|
||||
const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module, version, serverIds }, idempotencyKey: `module.deploy:${module}:${version}:${serverIds.slice().sort().join(',')}:${opt('nonce', '') === true ? '' : opt('nonce', '')}`, createdBy: by, actorType: 'operator', subjectType: 'module', subjectId: module, label: `${module} ${version} 배포` });
|
||||
audit(d.store, { actorType: 'operator', actorId: by, action: 'deploy.module.request', subjectType: 'module', subjectId: module, detail: { version, serverIds, reason: opt('reason', ''), jobId: r.job.id, created: r.created } });
|
||||
console.log(JSON.stringify({ jobId: r.job.id, created: r.created, status: r.job.status, kind: r.job.kind, label: r.job.label, note: '실행은 ariweb-hal 서비스 프로세스의 작업 엔진이 맡는다 — `job --id` 로 확인' }));
|
||||
} else if (cmd === 'job') {
|
||||
const j = opt('id') && opt('id') !== true ? d.jobs.get(opt('id')) : null; if (!j) { console.error('--id <j_id> 가 필요합니다(없는 작업)'); process.exit(1); }
|
||||
console.log(JSON.stringify({ job: describe(j), children: d.jobs.list({ parentId: j.id, limit: 100 }).map(describe) }, null, 1));
|
||||
} else if (cmd === 'serve') {
|
||||
// "하나의 HAL" 임대(§34). 다른 인스턴스가 살아 있으면 기동 거부(--takeover 는 재해복구 절차에서만).
|
||||
try { core.claimLive(d, { instanceId: d.instanceId, version: VERSION, takeover: !!opt('takeover') }); } catch (e) { console.error(`[hal] 기동 거부: ${e.message}`); process.exit(2); }
|
||||
|
||||
@@ -22,9 +22,9 @@ export const MODULES = {
|
||||
health: { url: 'http://127.0.0.1:8910/api/health', expect: 'ok' }, version: { http: 'http://127.0.0.1:8910/api/health', field: 'version' }, dataDirs: ['/srv/ariweb/portal/data'] },
|
||||
agent: { name: 'agent', roles: ['control', 'web', 'mail', 'backup'], layout: 'releases', unit: 'ariweb-agent', unitTemplate: 'ariweb-agent.service.tmpl', user: 'master',
|
||||
health: { heartbeat: true }, version: { file: 'package.json' }, dataDirs: ['/srv/ariweb/agent/data'], selfUpdate: true },
|
||||
// 도입 모듈: Webmaker(WebHosting_AI_agent 1.7.3). 기존 서버 배치(/srv/webmaker/current, /data/webmaker, 유닛 webmaker, 포트 3080)를 존중한다 — install 은 releases 에 풀고 current 를 바꾸며,
|
||||
// 도입 모듈: Webmaker(WebHosting_AI_agent 1.8.0 — 0.2.0 에서 직접 편집(시각 편집기) 포함; 0.1.1 은 1.7.3). 기존 서버 배치(/srv/webmaker/current, /data/webmaker, 유닛 webmaker, 포트 3080)를 존중한다 — install 은 releases 에 풀고 current 를 바꾸며,
|
||||
// 유닛은 서버에 이미 있고 <appDir>/current 를 가리키면 그대로 둔다(keepExistingUnit — 운영 서버의 강화 지시문·ReadWritePaths 보존, 실제 환경 결함 수정). 없을 때만 deploy/units/webmaker.service.tmpl 로 만든다.
|
||||
// 원본 deploy/remote-update.sh 의 절차(백업→전송→교체→재시작→확인)를 Agent 가 같은 순서로 수행한다. 실제 버전은 REVISION.json 의 Suite 릴리스 버전(package.json 1.7.3 은 upstreamVersion).
|
||||
// 원본 deploy/remote-update.sh 의 절차(백업→전송→교체→재시작→확인)를 Agent 가 같은 순서로 수행한다. 실제 버전은 REVISION.json 의 Suite 릴리스 버전(package.json 1.8.0 은 upstreamVersion).
|
||||
webmaker: { name: 'webmaker', roles: ['web'], layout: 'releases', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, user: 'master', port: 3080, baseDir: '/srv/webmaker',
|
||||
health: { url: 'http://127.0.0.1:3080/api/auth/csrf', expect: 'any' }, version: { file: 'package.json' }, preStop: ['webmaker.backupData'], postStart: ['webmaker.unitCheck'], dataDirs: ['/data/webmaker', '/var/www/webmaker-sites'], extraUnits: ['webmaker-setup'] },
|
||||
// 도입 모듈: AriMail 1.0.11. 서버마다 유닛 이름이 다르다(arihakdang-mail / arimail / heart-mail) — 서버 facts.arimail.unit 이 정한다. 설치는 원본 deploy/deploy.sh 와 같은 절차(테스트→릴리스 복사→DB 백업·마이그레이션→재시작→health)를 Agent 가 수행한다.
|
||||
|
||||
+6
-4
@@ -7,6 +7,7 @@
|
||||
* - HAL 자체 데이터(서버·릴리스·작업·사건·감사·긴급 복구·재해복구·계정)는 서버에서 먼저 그린다(hal/ui/render.mjs). 포털 데이터(고객·주문·상품·사이트·메일·도메인·백업·이전)는
|
||||
* 브라우저가 /ops/api/portal/* 중계로 받아 같은 렌더러로 그리고, 중계 실패는 '정보 지연'으로 보인다(정상으로 보이지 않음).
|
||||
* - 모든 화면은 불러오는 중·비어 있음·정보 지연·부분 실패·전체 실패·권한 없음 상태와 360px 사용을 지원한다(hal/ui/public/ops.css).
|
||||
* - 전역 배너(.alerts)는 접근권 만료·HAL 비정상·긴급 복구·부트스트랩 미완료·비밀번호 변경만 그린다. 고위험 재인증 안내는 0.2.1 부터 거부 시점의 재인증 창 안에만 둔다(hal/ui/public/reauth.mjs).
|
||||
*/
|
||||
import { esc } from '../lib/http.mjs';
|
||||
import * as session from '../lib/session.mjs';
|
||||
@@ -39,14 +40,15 @@ export function registerUi(app, d) {
|
||||
/** 공통 틀. 운영자 세션이 없으면 로그인 화면(또는 최초 설정 화면)을 낸다. */
|
||||
function shell(ctx, { title, nav, body, page, id = '', scripts = [] }) {
|
||||
if (!ctx.operator) return ctx.html(loginPage(ctx, d));
|
||||
const recent = session.recentlyAuthed(ctx.session); const grant = ctx.opsVia === 'grant' ? ops.grantValid(d, ctx.cookies[ops.GRANT_COOKIE], ctx.headers['user-agent']) : null;
|
||||
const grant = ctx.opsVia === 'grant' ? ops.grantValid(d, ctx.cookies[ops.GRANT_COOKIE], ctx.headers['user-agent']) : null;
|
||||
const health = core.selfHealth(d); const emOpen = d.store.get('SELECT COUNT(*) AS n FROM emergency_sessions WHERE status<>\'closed\'').n;
|
||||
const banner = [];
|
||||
if (!recent) banner.push(`<a class="alert warning" href="#" data-act="reauth">! 고위험 작업(서버 삭제·되돌리기·인증 키 변경·복구)은 최근 10분 안의 재인증이 필요합니다 — 재인증</a>`);
|
||||
// [0.2.1] 재인증 만료(10분 경과)는 더 이상 전역 배너로 그리지 않는다. 고위험 안내는 서버가 요청을 reauth_required 로 거부한 그 때 ops.js 가 여는 재인증 창(hal/ui/public/reauth.mjs) 안에만 보인다. 거부 자체(requireRecent)는 그대로다.
|
||||
if (grant) banner.push(`<div class="alert info">ⓘ 기기 귀속 접근권으로 접속 중 · 만료 ${R.fmt(grant.expires_at)} (만료 후에는 로그인 화면도 열리지 않습니다)</div>`);
|
||||
if (!health.ok) banner.push(`<div class="alert critical">⚠ HAL 자체 건강 비정상 — 일반 배포 작업이 거부됩니다. 플랫폼 복구 화면을 확인하세요</div>`);
|
||||
if (emOpen) banner.push(`<a class="alert critical" href="/ops/recovery">⚠ 긴급 복구 세션 ${emOpen}건 진행 중</a>`);
|
||||
if (health.bootstrap && !health.bootstrap.completed_at) banner.push(`<div class="alert warning">! 부트스트랩 미완료(HAL 실행 버전 ${esc(health.bootstrap.hal_running_version || '확인 전')}, 첫 Agent ${health.bootstrap.first_agent_id ? '등록됨' : '미등록'}) — 포털 API·일반 모듈 배포는 완료 뒤 열립니다(§32.2)</div>`);
|
||||
// [실제 환경 결함 수정 2026-10-27] core.selfHealth().bootstrap 은 camelCase 계약(complete·halRunningVersion·firstAgent)만 낸다. 옛 snake_case 열 이름을 읽어 완료 뒤에도 늘 '미완료'로 그리던 결함.
|
||||
if (health.bootstrap && !health.bootstrap.complete) banner.push(`<div class="alert warning">! 부트스트랩 미완료(HAL 실행 버전 ${esc(health.bootstrap.halRunningVersion || '확인 전')}, 첫 Agent ${health.bootstrap.firstAgent ? '등록됨' : '미등록'}) — 포털 API·일반 모듈 배포는 완료 뒤 열립니다(§32.2)</div>`);
|
||||
if (ctx.operator.mustChangePassword) banner.push(`<a class="alert warning" href="/ops/account">! 비밀번호를 바꿔야 합니다</a>`);
|
||||
return ctx.html(`<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)} · Ariweb HAL 운영자 콘솔</title><link rel="stylesheet" href="/ops/ui/ops.css"><meta name="csrf" content="${esc(ctx.session.csrf)}"></head>
|
||||
<body><a class="skip" href="#content">본문으로</a><header class="top"><button class="menu-btn" data-act="toggle-nav" aria-label="메뉴 열기">☰</button><a class="brand" href="/ops/">Ariweb HAL</a><span class="ver">v${esc(d.version)} · ${health.ok ? R.badge('ok', 'HAL 정상') : R.badge('failed', 'HAL 비정상')}</span><nav class="top-right"><span class="muted">${esc(ctx.operator.displayName || ctx.operator.login)} · ${ctx.operator.securityLevel === 'mfa' ? 'MFA' : '<span class="err">MFA 없음</span>'}</span><a href="/ops/account">계정</a><button class="btn small" data-act="logout">로그아웃</button></nav></header>
|
||||
@@ -75,7 +77,7 @@ export function registerUi(app, d) {
|
||||
const actions = `<div class="actions">${ps.status === 'pending' ? `<button class="btn primary" data-act="server-token" data-id="${esc(s.id)}">등록 정보 발급</button> <button class="btn" data-act="server-approve" data-id="${esc(s.id)}">연결 확인 후 승인</button>` : ''}${ps.rawStatus === 'maintenance' ? `<button class="btn" data-act="server-maint-end" data-id="${esc(s.id)}">유지보수 종료·신규 배치 재개</button>` : `<button class="btn" data-act="server-maint-start" data-id="${esc(s.id)}">유지보수 시작(신규 배치 중지)</button>`} <button class="btn" data-act="server-accepts" data-id="${esc(s.id)}" data-v="${ps.acceptsNew ? 0 : 1}">${ps.acceptsNew ? '신규 배치 중지' : '신규 배치 허용'}</button> <button class="btn danger" data-act="server-isolate" data-id="${esc(s.id)}">격리</button> <button class="btn danger" data-act="server-delete" data-id="${esc(s.id)}" data-hosted="${ps.capacity.sitesHosted || 0}">서버 삭제</button></div>`;
|
||||
return shell(ctx, { title: `서버 · ${s.name}`, nav: 'servers', page: 'server', id: s.id, body: `${actions}${R.serverDetail(ps, { jobs, agentJobs })}<h2>지표(90일 이상 보존)</h2><div class="row"><label>지표 <select id="metric-key"><option value="cpu">CPU</option><option value="mem">메모리</option><option value="disk">디스크</option><option value="inode">inode</option><option value="net">네트워크 송신</option></select></label> <label>기간 <select id="metric-days"><option value="1">24시간</option><option value="7">7일</option><option value="30" selected>30일</option><option value="90">90일</option></select></label></div><div id="metrics">${R.statePanel('loading', '지표를 불러오는 중')}</div>` }); });
|
||||
// ---------------- 릴리스(§32.1)
|
||||
app.get('/ops/releases', (ctx) => shell(ctx, { title: '배포 릴리스', nav: 'releases', page: 'releases', body: R.releasesView({ releases: core.listReleases(d), running: core.selfHealth(d).bootstrap, modules: Object.keys(MODULES), deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }, changelog()) }));
|
||||
app.get('/ops/releases', (ctx) => shell(ctx, { title: '배포 릴리스', nav: 'releases', page: 'releases', body: R.releasesView({ releases: core.listReleases(d), running: core.bootstrapState(d), modules: Object.keys(MODULES), deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }, changelog()) }));
|
||||
// ---------------- 작업(§29)
|
||||
app.get('/ops/jobs', (ctx) => { const status = ctx.query.status ? ctx.query.status.split(',') : null; const jobs = d.jobs.list({ status, kind: ctx.query.kind || null, limit: 200 }).map((j) => ({ ...describe(j), subject: redact(j.subject), createdBy: j.created_by, actorType: j.actor_type, groupId: j.group_id }));
|
||||
const filters = `<div class="row"><a class="btn${!ctx.query.status ? ' on' : ''}" href="/ops/jobs">전체</a> <a class="btn${ctx.query.status === 'running,queued,awaiting_external' ? ' on' : ''}" href="/ops/jobs?status=running,queued,awaiting_external">진행·대기</a> <a class="btn${ctx.query.status === 'needs_user,failed' ? ' on' : ''}" href="/ops/jobs?status=needs_user,failed">확인 필요·실패</a> <a class="btn${ctx.query.kind === 'module.deploy' ? ' on' : ''}" href="/ops/jobs?kind=module.deploy">모듈 배포</a></div>`;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/* hal/ui/public/ops.js — 운영자 콘솔 브라우저 스크립트.
|
||||
* - /ops/api/* 호출(CSRF 자동), 재인증 필요(reauth_required) 시 비밀번호 창 → 재시도, 3단계 확인(고위험), 1회 표시 값(토큰·자격·복구 코드) 안내
|
||||
* - /ops/api/* 호출(CSRF 자동), 재인증 필요(reauth_required) 시 고위험 안내가 붙은 비밀번호 창 → 성공 시 1회 재시도(reauth.mjs), 3단계 확인(고위험), 1회 표시 값(토큰·자격·복구 코드) 안내
|
||||
* - [0.2.1] 고위험 재인증 안내는 전역 배너가 아니라 서버가 거부한 그 때의 재인증 창 안에만 보인다. 계정 화면의 수동 재인증 창에는 붙지 않는다.
|
||||
* - 포털 중계 화면(/ops/api/portal/*)은 같은 렌더러(render.mjs)로 그리고, 중계 실패는 '정보 지연'으로 보인다
|
||||
* - 진행 중 화면(운영 현황·작업·서버)은 15초마다 다시 그린다(탭이 숨겨지면 느리게). 화면을 떠나도 작업은 HAL 이 계속한다.
|
||||
*/
|
||||
import * as R from './render.mjs';
|
||||
import { callWithReauth, reauthPrompt } from './reauth.mjs';
|
||||
const $ = (s, el = document) => el.querySelector(s);
|
||||
const csrf = () => ($('meta[name=csrf]') || {}).content || '';
|
||||
async function api(method, path, body) {
|
||||
@@ -12,8 +14,8 @@ async function api(method, path, body) {
|
||||
if (!r.ok || (j && j.ok === false)) { const e = new Error((j && j.message) || `오류 ${r.status}`); e.code = (j && j.error) || 'http_' + r.status; e.detail = j && j.detail; e.status = r.status; throw e; }
|
||||
return j;
|
||||
}
|
||||
/** 재인증이 필요하면 비밀번호를 받아 재인증한 뒤 한 번 더 시도 */
|
||||
async function call(method, path, body) { try { return await api(method, path, body); } catch (e) { if (e.code === 'reauth_required') { if (await reauth()) return api(method, path, body); } throw e; } }
|
||||
/** 서버가 reauth_required 로 거부하면 고위험 안내가 붙은 재인증 창을 열고, 성공했을 때만 딱 한 번 다시 시도(취소·실패 시 원 요청 미실행 — hal/ui/public/reauth.mjs 계약) */
|
||||
const call = (method, path, body) => callWithReauth(() => api(method, path, body), reauth);
|
||||
const portal = (method, path, body) => call(method, '/ops/api/portal' + path, body);
|
||||
let toastT; function toast(t) { const el = $('#toast'); if (!el) return alert(t); el.textContent = t; el.classList.add('show'); clearTimeout(toastT); toastT = setTimeout(() => el.classList.remove('show'), 5000); }
|
||||
function modal(html) { const m = $('#modal'); m.innerHTML = `<div class="box" role="dialog" aria-modal="true">${html}</div>`; m.hidden = false; const close = () => { m.hidden = true; m.innerHTML = ''; }; m.onclick = (e) => { if (e.target === m) close(); }; return { el: m, close }; }
|
||||
@@ -37,7 +39,8 @@ function threeStep({ title, impacts = [], identifiers = [], revertible = '되돌
|
||||
render();
|
||||
});
|
||||
}
|
||||
async function reauth() { const v = await ask('재인증(최근 10분)', [{ name: 'password', label: '운영자 비밀번호', type: 'password', required: true }], { okLabel: '재인증' }); if (!v) return false; try { await api('POST', '/ops/api/reauth', { password: v.password }); toast('재인증했습니다'); return true; } catch (e) { toast(e.message); return false; } }
|
||||
/** 재인증 창. required=true(서버 거부 직후)일 때만 고위험 안내를 보이고, 계정 화면의 수동 재인증은 안내 없이 비밀번호만 묻는다 */
|
||||
async function reauth(opts = {}) { const p = reauthPrompt(opts); const v = await ask(p.title, [{ name: 'password', label: '운영자 비밀번호', type: 'password', required: true }], { okLabel: p.okLabel, note: p.note ? R.esc(p.note) : '' }); if (!v) return false; try { await api('POST', '/ops/api/reauth', { password: v.password }); toast('재인증했습니다'); return true; } catch (e) { toast(e.message); return false; } }
|
||||
const reload = () => location.reload();
|
||||
async function guarded(fn) { try { await fn(); } catch (e) { toast(e.message || String(e)); } }
|
||||
// ---------------- 페이지별 데이터 로드(포털 중계 포함)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* hal/ui/public/reauth.mjs — 운영자 콘솔 재인증 흐름의 순수 계약(0.2.1, 개발 지시 `Ariweb-Suitle_Dev-Order_v0.2.1.md`).
|
||||
*
|
||||
* 브라우저(ops.js)와 Node 테스트가 같은 파일을 쓴다. DOM·fetch 를 직접 만지지 않고, 호출자가 넘긴 함수만 부른다.
|
||||
*
|
||||
* 왜 따로 두나
|
||||
* - 0.2.0 까지는 재인증이 10분을 넘기면 모든 일반 화면 위에 "고위험 작업(…)은 최근 10분 안의 재인증이 필요합니다" 전역 배너가 늘 떠 있었다.
|
||||
* 단순 조회·화면 이동·로그인 10분 뒤에도 보여 운영자가 경고에 무뎌진다. 0.2.1 은 이 안내를 backend 가 실제로 고위험 요청을
|
||||
* `reauth_required` 로 거부한 **바로 그 때 여는 재인증 창 안**으로만 옮긴다. 수동 재인증(계정 화면 단추)에는 붙이지 않는다.
|
||||
* - 보안 경계는 그대로다: 거부·재인증 판단은 전부 서버(hal/app.mjs requireRecent·CSRF·3단계 확인)가 하고, 여기는 "거부됐을 때 어떻게 묻고 몇 번 다시 보내는가"만 정한다.
|
||||
*/
|
||||
|
||||
/** 서버가 고위험 요청을 거부할 때 쓰는 오류 코드(hal/app.mjs requireRecent 와 같은 값) */
|
||||
export const REAUTH_REQUIRED = 'reauth_required';
|
||||
/** 거부 시점 재인증 창에만 보이는 고위험 안내(옛 전역 배너 문구 그대로 — 위치만 옮겼다) */
|
||||
export const HIGH_RISK_NOTICE = '고위험 작업(서버 삭제·되돌리기·인증 키 변경·복구)은 최근 10분 안의 재인증이 필요합니다';
|
||||
|
||||
/**
|
||||
* 재인증 창의 문구. required=true(서버 거부 직후)일 때만 고위험 안내(note)를 붙이고, 수동 재인증(required 없음)은 안내 없이 비밀번호만 묻는다.
|
||||
* @returns {{ title: string, okLabel: string, note: string }}
|
||||
*/
|
||||
export function reauthPrompt({ required = false } = {}) {
|
||||
return { title: '재인증(최근 10분)', okLabel: '재인증', note: required ? HIGH_RISK_NOTICE : '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청을 보내고, 서버가 `reauth_required` 로 거부하면 재인증을 묻고(required=true) 성공했을 때만 **딱 한 번** 다시 보낸다.
|
||||
* - 재인증 창을 취소하거나 재인증이 실패하면 원 요청을 다시 보내지 않고 원래 오류를 그대로 올린다(고위험 작업이 몰래 실행되지 않음).
|
||||
* - 재시도도 거부되면 다시 묻지 않고 오류를 올린다(무한 반복 금지).
|
||||
* - `reauth_required` 가 아닌 오류(권한 없음·검증 실패·네트워크)는 재인증을 묻지 않는다.
|
||||
* @param {() => Promise<any>} run 실제 요청(같은 method·path·body 로 다시 부를 수 있어야 한다)
|
||||
* @param {(opts: { required: true }) => Promise<boolean>} reauth 재인증 창을 열어 성공 여부를 돌려주는 함수
|
||||
*/
|
||||
export async function callWithReauth(run, reauth) {
|
||||
try { return await run(); } catch (e) {
|
||||
if (e && e.code === REAUTH_REQUIRED && await reauth({ required: true })) return run();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -122,7 +122,7 @@ export function drView(data) {
|
||||
<h2>복구 훈련</h2>${table([{ l: '훈련', f: (x) => `<span class="mono">${esc(x.id)}</span>` }, { l: '시작·종료', f: (x) => `${fmt(x.started_at)} → ${fmt(x.finished_at)}` }, { l: '결과', f: (x) => x.result === 'running' ? badge('running', '진행 중') + ` <button class="btn small" data-act="dr-drill-finish" data-id="${esc(x.id)}">결과 입력</button>` : x.result === 'success' ? badge('ok', '성공') : badge('failed', x.result) }, { l: '측정 RTO/RPO', f: (x) => `${x.measured_rto_minutes ?? '—'} / ${x.measured_rpo_minutes ?? '—'}분` }, { l: '실패·조치 항목', f: (x) => x.findings_json ? `<pre class="pre">${esc(x.findings_json)}</pre>` : '—' }], data.drills || [], { empty: '훈련 기록이 없습니다' })}`;
|
||||
}
|
||||
export function accountsView(data) {
|
||||
return `<div class="cards"><div class="card"><h2>내 계정</h2>${kv([['아이디', esc(data.me.login)], ['보안 수준', data.me.securityLevel === 'mfa' ? badge('ok', '다중 인증') : badge('warn', '비밀번호만') + ` <span class="muted">${esc(data.me.securityHint || '')}</span>`], ['최근 재인증', data.recentlyAuthed ? badge('ok', '유효(10분)') : badge('warn', '재인증 필요(고위험 작업)')], ['접속 경로', data.via === 'grant' ? '기기 귀속 접근권' : '허용 네트워크']])}<div class="actions"><button class="btn" data-act="reauth">재인증</button> <button class="btn" data-act="password">비밀번호 변경</button> ${data.me.mfaEnabled ? `<button class="btn" data-act="mfa-disable">MFA 해제</button>` : `<button class="btn primary" data-act="mfa-begin">MFA 등록</button>`}</div></div>
|
||||
return `<div class="cards"><div class="card"><h2>내 계정</h2>${kv([['아이디', esc(data.me.login)], ['보안 수준', data.me.securityLevel === 'mfa' ? badge('ok', '다중 인증') : badge('warn', '비밀번호만') + ` <span class="muted">${esc(data.me.securityHint || '')}</span>`], ['최근 재인증', data.recentlyAuthed ? badge('ok', '유효(10분)') : badge('warn', '만료(10분 지남)')], ['접속 경로', data.via === 'grant' ? '기기 귀속 접근권' : '허용 네트워크']])}<div class="actions"><button class="btn" data-act="reauth">재인증</button> <button class="btn" data-act="password">비밀번호 변경</button> ${data.me.mfaEnabled ? `<button class="btn" data-act="mfa-disable">MFA 해제</button>` : `<button class="btn primary" data-act="mfa-begin">MFA 등록</button>`}</div></div>
|
||||
<div class="card"><h2>로그인 세션</h2>${table([{ l: '기기', f: (s) => `${esc(s.label || '')}${s.current ? ' <b>(현재)</b>' : ''}` }, { l: 'IP', k: 'ip' }, { l: '마지막 사용', f: (s) => fmt(s.last_seen_at) }, { l: '', f: (s) => s.current ? '' : `<button class="btn small" data-act="session-revoke" data-id="${esc(s.id)}">종료</button>` }], data.sessions || [])}<div class="actions"><button class="btn" data-act="session-revoke-all">다른 모든 세션 종료</button></div></div></div>
|
||||
<h2>접근권(허용 네트워크 밖 기기)</h2>${table([{ l: '계정', k: 'account_id' }, { l: '발급', f: (g) => `${esc(g.created_by || '')} ${fmt(g.created_at)}` }, { l: '만료', f: (g) => fmt(g.expires_at) }, { l: '기기 묶임', f: (g) => g.bound_at ? `${fmt(g.bound_at)} (${esc(g.ip || '')})` : '아직' }, { l: '상태', f: (g) => g.revoked_at ? badge('neutral', '회수') : Date.parse(g.expires_at) < Date.now() ? badge('neutral', '만료') : badge('ok', '유효') }, { l: '사유', k: 'reason' }, { l: '', f: (g) => g.revoked_at ? '' : `<button class="btn small" data-act="grant-revoke" data-id="${esc(g.id)}">회수</button>` }], data.grants || [], { empty: '발급된 접근권이 없습니다' })}<div class="actions"><button class="btn" data-act="grant-issue">접근권 발급(URL 1회 표시)</button></div>
|
||||
<h2>운영자 계정</h2>${table([{ l: '아이디', f: (a) => `<b>${esc(a.login)}</b> ${esc(a.displayName)}` }, { l: '보안', f: (a) => a.mfaEnabled ? badge('ok', 'MFA') : badge('warn', '비밀번호만') }, { l: '상태', f: (a) => a.lockedUntil ? badge('warn', `잠김 ${fmt(a.lockedUntil)}`) : badge('ok', a.status) }, { l: '마지막 로그인', f: (a) => fmt(a.lastLoginAt) }], data.accounts || [])}<div class="actions"><button class="btn" data-act="account-new">운영자 추가</button></div>`;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* lib/provenance.mjs — 도입 소스 출처(IMPORTED_SOURCES.json) 검사. 릴리스 build·register --approve·HAL 승인이 공유하는 순수 규칙(0.2.0 검토 수정, 차단 항목 11).
|
||||
*
|
||||
* 깨끗한 출처(clean provenance)란: 각 소스가 40자리 고정 커밋(commit)을 가지고, 파일 목록이 비어 있지 않으며(각 항목에 from·to·origHash),
|
||||
* upstream 기록이 있으면 완전하고(version·branch·headCommit) headCommit 이 commit 과 같고 workingTreeDirty 가 정확히 false 인 것.
|
||||
* - `--allow-dirty` 로 참조 저장소의 작업 트리 변경을 함께 가져온 기록(workingTreeDirty=true)은 "원본 저장소에 아직 없는 코드" 를 담고 있어 릴리스로 승인할 수 없다.
|
||||
* 원본 저장소에 커밋되면 pin 을 올려 `scripts/import-sources.mjs` 를 다시 실행해 깨끗한 기록을 만든다.
|
||||
* - upstream 칸이 없는 기록은 0.1.x 의 import-sources 가 만든 것이다(그 스크립트는 깨끗한 고정 커밋 체크아웃에서만 가져왔다) → legacy 로 표시하고 받아들인다.
|
||||
* 모르는 형태·빈 목록은 모두 "불완전" 으로 보아 거절한다(fail-closed).
|
||||
* @param {object} manifest IMPORTED_SOURCES.json 내용
|
||||
* @returns {{ ok: boolean, problems: string[], sources: { name: string, commit: string|null, upstream: object|null, legacy: boolean }[] }}
|
||||
*/
|
||||
export function checkProvenance(manifest) {
|
||||
const problems = []; const sources = [];
|
||||
if (!manifest || typeof manifest !== 'object' || !Array.isArray(manifest.sources)) return { ok: false, problems: ['IMPORTED_SOURCES: sources 목록이 없습니다(불완전한 기록)'], sources };
|
||||
if (!manifest.sources.length) return { ok: false, problems: ['IMPORTED_SOURCES: 도입 소스가 하나도 기록되지 않았습니다(불완전한 기록)'], sources };
|
||||
for (const s of manifest.sources) {
|
||||
const name = (s && typeof s.name === 'string' && s.name) || '(이름 없음)';
|
||||
const up = s && s.upstream && typeof s.upstream === 'object' ? s.upstream : null;
|
||||
const summary = { name, commit: s && typeof s.commit === 'string' ? s.commit : null, upstream: up ? { version: up.version ?? null, branch: up.branch ?? null, headCommit: up.headCommit ?? null, workingTreeDirty: up.workingTreeDirty } : null, legacy: !up };
|
||||
sources.push(summary);
|
||||
if (!s || typeof s !== 'object') { problems.push(`${name}: 기록 형식이 올바르지 않습니다`); continue; }
|
||||
if (!/^[0-9a-f]{40}$/.test(String(s.commit || ''))) problems.push(`${name}: 고정 커밋(commit)이 40자리 커밋 해시가 아닙니다 (${String(s.commit || '없음').slice(0, 12)})`);
|
||||
if (!Array.isArray(s.files) || !s.files.length) problems.push(`${name}: 가져온 파일 목록이 비어 있습니다(불완전한 기록)`);
|
||||
else if (s.files.some((f) => !f || typeof f.from !== 'string' || typeof f.to !== 'string' || typeof f.origHash !== 'string')) problems.push(`${name}: 파일 기록에 from·to·origHash 가 빠진 항목이 있습니다`);
|
||||
if (!up) continue; // 0.1.x 기록: 깨끗한 고정 커밋 체크아웃만 받던 스크립트가 만든 것(legacy)
|
||||
if (up.workingTreeDirty !== false) problems.push(`${name}: upstream.workingTreeDirty=${JSON.stringify(up.workingTreeDirty)} — 원본 저장소에 커밋되지 않은 작업 트리 변경을 함께 가져온 기록은 릴리스로 승인할 수 없습니다. 원본에 커밋한 뒤 pin 을 올려 scripts/import-sources.mjs 를 다시 실행하세요`);
|
||||
if (typeof up.version !== 'string' || !up.version) problems.push(`${name}: upstream.version 이 없습니다(불완전한 기록)`);
|
||||
if (typeof up.branch !== 'string' || !up.branch) problems.push(`${name}: upstream.branch 가 없습니다(불완전한 기록)`);
|
||||
if (!/^[0-9a-f]{40}$/.test(String(up.headCommit || ''))) problems.push(`${name}: upstream.headCommit 이 없거나 형식이 틀립니다(불완전한 기록)`);
|
||||
else if (up.headCommit !== s.commit) problems.push(`${name}: upstream.headCommit(${String(up.headCommit).slice(0, 7)}) ≠ 고정 커밋 commit(${String(s.commit).slice(0, 7)}) — 기록이 어긋났습니다. scripts/import-sources.mjs 를 다시 실행하세요`);
|
||||
}
|
||||
return { ok: problems.length === 0, problems, sources };
|
||||
}
|
||||
@@ -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')));
|
||||
});
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ariweb-suite",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.1",
|
||||
"private": true,
|
||||
"description": "Ariweb Suite — 고객 포털(사이트·호스팅·메일·도메인·백업·결제) + HAL 제어 평면 + Node Agent. Webmaker·AriMail 을 모듈로 도입한다.",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
# Webmaker 기존 기능 적용 기록 (Ariweb Suite 0.1.1, 개발 지시 §0.1·§0.2·§0.5)
|
||||
# Webmaker 기존 기능 적용 기록 (Ariweb Suite 0.1.1 → 0.2.0, 개발 지시 §0.1·§0.2·§0.5 · v0.2.0 직접 편집)
|
||||
|
||||
- 원본: https://source.ibit.kr/OZ-Group/WebHosting_AI_agent — revision `3b10859884ef2fd0f2af11934ab6c16370fd64b8` (v1.7.3), 도입 경로 `modules/webmaker/` (IMPORTED_SOURCES.md 참고)
|
||||
- 원본: https://source.ibit.kr/OZ-Group/WebHosting_AI_agent — 고정 revision `4b8c4d0de0f69025001a9e0a28a58a94e1a1f088` (Webmaker 1.8.0, 브랜치 `feature/visual-editor-v1.8.0`·main 원격 ref 검증됨; 0.1.1 의 불변 기준 revision `3b10859884ef2fd0f2af11934ab6c16370fd64b8`(v1.7.3) 위의 변경). 개발 중에는 커밋 전 작업 트리에서 나란히 도입했고(`IMPORTED_SOURCES.md` 의 upstream 칸, workingTreeDirty:true), 커밋 뒤 `scripts/import-sources.mjs` 의 pin 을 `4b8c4d0` 으로 올렸다 — 그 pin 으로 다시 만든 도입 기록은 workingTreeDirty:false, 184 파일(수정 8 = Suite 블록 파일). 도입 경로 `modules/webmaker/`
|
||||
- 접근 위치: 포털 사이트 상세 › 제작 탭 › "제작 화면 열기" (`/sso/webmaker?site=<siteId>`) → Webmaker `/api/auth/sso` → 기존 빌더 화면. 포털 사용자당 Webmaker 계정 `aw-<그룹>-<사용자>` 가 자동으로 생기며 별도 로그인은 없다.
|
||||
- 회귀 테스트: `cd modules/webmaker && node --test test/*.test.js` (원본 6개 파일 + 신규 `test/ariweb.test.js`). 실제 화면 확인 결과는 출시 검증 기록(VERSION_HISTORY.md 검증 절)에 적는다.
|
||||
- 회귀 테스트: `cd modules/webmaker && CLAUDE_BIN=<즉시 실패하는 가짜 실행 파일> node --test test/*.test.js` (원본 6개 파일 + 신규 `test/ariweb.test.js` + 0.2.0 `test/visual-editor.test.js`·`test/visual-editor-server.test.js`). Suite 쪽 회귀는 `test/webmaker-visual-editor.test.mjs`. 실제 화면 확인 결과는 출시 검증 기록(VERSION_HISTORY.md 검증 절)에 적는다.
|
||||
- 0.2.0 추가 기능 — **직접 편집(시각 편집기)**: 제작 화면(Webmaker 빌더)의 미리보기 도구 줄 "직접 편집". 그대로 도입(Webmaker 1.8.0 기능) + Suite 최소 연결 변경 1건(직접 편집 저장 `PUT /api/sites/<id>/visual/<페이지>` 에도 저장본 `rev` 충돌 검사(§9.3) 적용 — 소스 편집기와 같은 409 + 양쪽 요약). 직접 편집은 AI Wallet(캐시)을 쓰지 않으므로 포털 AI Wallet 표시에 영향이 없고, 저장본(버전 `visual-edit`)은 포털 배포 화면의 저장본 선택에 그대로 보인다.
|
||||
|
||||
| §0.1 기능 | 적용 구분 | 접근 위치 / 비고 |
|
||||
|---|---|---|
|
||||
| `AI에게 맡기기`·`내가 고르기` Site 제작 | 그대로 도입 | 제작 탭 → Webmaker 빌더. 포털에서 사이트를 만들 때 제작 방식(ai·manual·quick)을 골라 프로젝트를 만든다(내부 API `POST /api/internal/ariweb/projects`) |
|
||||
| Site Brief 작성과 확인 | 그대로 도입 | Webmaker 기획 단계 그대로 |
|
||||
| Template과 기존 제작 Module | 그대로 도입 | Webmaker 생성 탭 "템플릿에서 시작"·모듈 관리 그대로 |
|
||||
| Page 생성·편집·미리보기·저장 | 최소 연결 변경 | 저장(PATCH 기본 사항·PUT 소스 편집)에 `rev` 기반 동시 편집 충돌 감지(409 + 두 버전 요약)를 더함(§9.3). rev 를 보내지 않는 기존 화면은 그대로 동작 |
|
||||
| Page 생성·편집·미리보기·저장 | 최소 연결 변경 | 저장(PATCH 기본 사항·PUT 소스 편집·0.2.0 PUT 직접 편집)에 `rev` 기반 동시 편집 충돌 감지(409 + 두 버전 요약)를 더함(§9.3). rev 를 보내지 않는 기존 화면은 그대로 동작 |
|
||||
| 직접 편집(시각 편집기, Webmaker 1.8.0) | 그대로 도입 + 최소 연결 변경(rev 충돌) | 제작 화면 미리보기 도구 줄 "직접 편집" — 글 바로 고치기·블록 끌어 옮기기·구성 요소 7종 끌어다 놓기·속성 창·실행 취소/다시 실행·Ctrl+S 저장·저장하지 않은 변경 보호. 페이지 스크립트 비실행 캔버스(sandbox+CSP nonce), 서버 저장 검증(새 스크립트·핸들러·위험 주소·iframe 등 거절). AI 캐시 미사용 |
|
||||
| AI 생성·수정과 Wallet/Cache 차감 | 그대로 도입 | Webmaker 캐시(credits) 그대로. 포털은 `/api/internal/ariweb/credits/<login>` 으로 잔액을 읽어 AI Wallet 으로 표시(호스팅 사용량과 분리 §0.2) |
|
||||
| 이전 저장본과 배포할 저장본 선택 | 최소 연결 변경 | 저장본 목록은 Webmaker 버전 기록(git.changes) 그대로. 배포할 저장본 선택은 포털 배포 탭(`/api/internal/ariweb/projects/<id>/export?version=N`)에서 한다 |
|
||||
| Domain·DNS·TLS 관리 기능 | 그대로 도입 | 등록기관(Openprovider·Porkbun·Route 53)·DNS 공급자·네임서버 이전·레코드 관리는 Webmaker 도메인 탭 그대로(포털 도메인 화면의 "도메인 구매·DNS 자동화(Webmaker)" 링크). 포털은 연결 상태·소유 확인·HTTPS 검증만 가진다 |
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
* - 문서·보고서·샘플·PDF 처럼 실행에 필요 없는 파일은 가져오지 않는다(EXCLUDE).
|
||||
* - 이미 가져온 파일을 다시 실행해도 Ariweb Suite 쪽 수정본을 덮어쓰지 않는다(수정된 파일은 'modified' 로 표시하고 건너뜀). --force 로 원본으로 되돌릴 수 있다.
|
||||
*
|
||||
* 사용: node scripts/import-sources.mjs [--force] [--only webmaker|arimail]
|
||||
* 사용: node scripts/import-sources.mjs [--force] [--only webmaker|arimail] [--allow-dirty]
|
||||
* 원본 위치는 환경 변수 WEBMAKER_SRC / ARIMAIL_SRC (기본: ~/work/WebHoting_AI_Agent, ~/work/mail-gateway 의 git 체크아웃).
|
||||
* --allow-dirty (0.2.0): 원본 체크아웃이 고정 커밋 위에 아직 커밋되지 않은 변경(작업 트리)을 가진 상태도 받아들인다 — 참조 저장소와 Suite 를 같은 작업자가
|
||||
* 나란히 개발할 때(예: Webmaker 1.8.0 직접 편집) 쓴다. 추적되지 않은 새 파일도 가져오고, upstream{version·branch·headCommit·workingTreeDirty} 를 함께 기록한다.
|
||||
* commit 칸은 변하지 않는 기준(불변 baseline) 커밋이며, 작업 트리 변경이 원본 저장소에 커밋되면 pin 을 그 커밋으로 올리고 다시 실행한다.
|
||||
* --only 로 한 소스만 다시 만들 때 다른 소스의 기존 기록은 그대로 유지한다.
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, copyFileSync } from 'node:fs';
|
||||
@@ -17,21 +21,30 @@ import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const args = process.argv.slice(2); const force = args.includes('--force'); const only = args.includes('--only') ? args[args.indexOf('--only') + 1] : null;
|
||||
const args = process.argv.slice(2); const force = args.includes('--force'); const only = args.includes('--only') ? args[args.indexOf('--only') + 1] : null; const allowDirty = args.includes('--allow-dirty');
|
||||
const SOURCES = [
|
||||
{ name: 'webmaker', repo: 'https://source.ibit.kr/OZ-Group/WebHosting_AI_agent', src: process.env.WEBMAKER_SRC || join(homedir(), 'work', 'WebHoting_AI_Agent'), dest: 'modules/webmaker', pin: '3b10859884ef2fd0f2af11934ab6c16370fd64b8',
|
||||
{ name: 'webmaker', repo: 'https://source.ibit.kr/OZ-Group/WebHosting_AI_agent', src: process.env.WEBMAKER_SRC || join(homedir(), 'work', 'WebHoting_AI_Agent'), dest: 'modules/webmaker', pin: '4b8c4d0de0f69025001a9e0a28a58a94e1a1f088',
|
||||
exclude: [/^reports\//, /^sample\//, /\.pdf$/, /^Dev_Order/, /^WebHosting_AI_agent_/, /^CHANGELOG\.md$/, /^ERRORS\.md$/, /^\.gitignore$/], license: 'OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가.' },
|
||||
{ name: 'arimail', repo: 'https://source.ibit.kr/OZ-Group/AriMail', src: process.env.ARIMAIL_SRC || join(homedir(), 'work', 'mail-gateway'), dest: 'modules/arimail', pin: 'a07f69f1ffb6667de6e66b3d1ec43c1c66fab924',
|
||||
exclude: [/^CHANGELOG\.md$/, /^\.gitignore$/, /^deploy\/sites\//], license: 'OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. haraka/ 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.' },
|
||||
];
|
||||
const sha = (f) => createHash('sha256').update(readFileSync(f)).digest('hex').slice(0, 16);
|
||||
const prevManifest = existsSync(join(ROOT, 'IMPORTED_SOURCES.json')) ? JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')) : { sources: [] };
|
||||
const manifest = { generatedAt: new Date().toISOString(), sources: [] };
|
||||
for (const s of SOURCES) {
|
||||
if (only && only !== s.name) continue;
|
||||
if (only && only !== s.name) { const keep = prevManifest.sources.find((x) => x.name === s.name); if (keep) manifest.sources.push(keep); continue; } // --only: 다른 소스의 기존 기록 유지
|
||||
const head = execSync('git rev-parse HEAD', { cwd: s.src }).toString().trim();
|
||||
if (head !== s.pin) throw new Error(`${s.name}: 원본 체크아웃 커밋 ${head} 이 고정 커밋 ${s.pin} 과 다릅니다. git -C ${s.src} checkout ${s.pin}`);
|
||||
const files = execSync('git ls-files -z', { cwd: s.src }).toString().split('\0').filter(Boolean).filter((f) => !s.exclude.some((re) => re.test(f)));
|
||||
const entry = { name: s.name, repo: s.repo, commit: head, dest: s.dest, license: s.license, files: [] };
|
||||
const dirty = execSync('git status --porcelain', { cwd: s.src }).toString().trim().length > 0;
|
||||
if (dirty && !allowDirty) throw new Error(`${s.name}: 원본 체크아웃에 커밋되지 않은 변경이 있습니다. 커밋한 뒤 pin 을 올리거나, 나란히 개발 중이면 --allow-dirty 로 실행하세요`);
|
||||
// 추적 파일 + (--allow-dirty 일 때) 아직 추적되지 않은 새 파일. .gitignore 대상(data/·node_modules/ 등)은 제외
|
||||
const lsArgs = allowDirty ? 'git ls-files -z --cached --others --exclude-standard' : 'git ls-files -z';
|
||||
const files = [...new Set(execSync(lsArgs, { cwd: s.src }).toString().split('\0').filter(Boolean))].sort().filter((f) => !s.exclude.some((re) => re.test(f)));
|
||||
const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: s.src }).toString().trim();
|
||||
let version = null; try { version = JSON.parse(readFileSync(join(s.src, 'package.json'), 'utf8')).version || null; } catch (_) {}
|
||||
const entry = { name: s.name, repo: s.repo, commit: head, dest: s.dest, license: s.license,
|
||||
upstream: { version, branch, headCommit: head, workingTreeDirty: dirty, note: dirty ? '고정 커밋(commit) 위에 커밋되지 않은 작업 트리 변경을 함께 가져왔다. 원본 저장소에 커밋되면 pin 을 그 커밋으로 올려 다시 실행한다.' : '고정 커밋의 깨끗한 체크아웃' },
|
||||
files: [] };
|
||||
for (const f of files) {
|
||||
const from = join(s.src, f); const to = join(ROOT, s.dest, f);
|
||||
if (!statSync(from).isFile()) continue;
|
||||
@@ -50,7 +63,8 @@ writeFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), JSON.stringify(manifest, null
|
||||
let md = `# 도입 소스 기록(IMPORTED_SOURCES)\n\n생성: ${manifest.generatedAt} — \`node scripts/import-sources.mjs\` 가 만든다. 파일별 '수정' 은 원본 커밋과 현재 파일의 해시 비교 결과이며, 수정 이유는 VERSION_HISTORY.md 의 "도입 소스" 절과 각 파일 머리 주석의 \`[Ariweb Suite 변경]\` 표시로 설명한다.\n\n`;
|
||||
for (const e of manifest.sources) {
|
||||
const mod = e.files.filter((f) => f.status === 'modified').length;
|
||||
md += `## ${e.name}\n\n- 원본 저장소: ${e.repo}\n- 고정 커밋: \`${e.commit}\`\n- 대상 경로: \`${e.dest}/\`\n- 파일 수: ${e.files.length} (수정 ${mod})\n- 라이선스 검토: ${e.license}\n\n| 원본 경로 | 대상 경로 | 수정 | 원본 해시 |\n|---|---|---|---|\n`;
|
||||
const up = e.upstream ? `- 원본 버전(package.json): ${e.upstream.version || '-'} · 브랜치: \`${e.upstream.branch || '-'}\` · 작업 트리: ${e.upstream.workingTreeDirty ? '**커밋되지 않은 변경 포함(--allow-dirty)** — ' + e.upstream.note : '깨끗함'}\n` : '';
|
||||
md += `## ${e.name}\n\n- 원본 저장소: ${e.repo}\n- 고정 커밋(불변 기준): \`${e.commit}\`\n${up}- 대상 경로: \`${e.dest}/\`\n- 파일 수: ${e.files.length} (수정 ${mod})\n- 라이선스 검토: ${e.license}\n\n| 원본 경로 | 대상 경로 | 수정 | 원본 해시 |\n|---|---|---|---|\n`;
|
||||
for (const f of e.files) md += `| ${f.from} | ${f.to} | ${f.status === 'modified' ? '**수정**' : f.status === 'restored' ? '원본 복원' : '그대로'} | ${f.origHash} |\n`;
|
||||
md += '\n';
|
||||
}
|
||||
|
||||
+21
-8
@@ -7,12 +7,16 @@
|
||||
* 버전은 package.json, main 커밋은 git rev-parse HEAD(작업 트리가 깨끗하지 않으면 dirty 표시 — 승인 불가 검사 기록).
|
||||
* 모듈별 포함 경로: hal(hal, lib, deploy, lang, package.json) portal(portal, lib, lang, package.json) agent(agent, lib, deploy, package.json)
|
||||
* webmaker(modules/webmaker 전체 - data) arimail(modules/arimail 전체 - data). .git·node_modules·data·dist·test 제외.
|
||||
* 도입 소스 출처(0.2.0 검토 수정): build 는 IMPORTED_SOURCES.json 을 lib/provenance.checkProvenance 로 검사해 깨끗하지 않으면(workingTreeDirty=true·불완전·불일치)
|
||||
* artifact 를 만들지 않고 종료 1 — fail-closed. manifest.json 의 checks.provenanceClean/provenanceProblems 와 imported[].upstream 에 결과를 남기고,
|
||||
* register --approve 와 HAL approveRelease 는 provenanceClean 이 true 가 아닌 릴리스를 승인하지 않는다. (테스트용: ARIWEB_IMPORTED_SOURCES 로 manifest 경로를 바꿀 수 있다.)
|
||||
*/
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, rmSync, readdirSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join, dirname, basename, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { checkProvenance } from '../lib/provenance.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const args = process.argv.slice(2); const cmd = args[0] || 'build'; const opt = (n, d = null) => { const i = args.indexOf('--' + n); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : d; };
|
||||
@@ -20,11 +24,16 @@ const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); const
|
||||
const MODULES = { hal: ['hal', 'lib', 'deploy', 'lang', 'package.json', 'README.md'], portal: ['portal', 'lib', 'lang', 'catalog', 'package.json'], agent: ['agent', 'lib', 'deploy', 'package.json'], webmaker: ['modules/webmaker'], arimail: ['modules/arimail'] };
|
||||
const sha = (f) => createHash('sha256').update(readFileSync(f)).digest('hex');
|
||||
const git = (a) => { try { return execFileSync('git', a, { cwd: ROOT, encoding: 'utf8' }).trim(); } catch (_) { return ''; } };
|
||||
const IMPORTED_FILE = process.env.ARIWEB_IMPORTED_SOURCES || join(ROOT, 'IMPORTED_SOURCES.json');
|
||||
|
||||
if (cmd === 'build') {
|
||||
// 도입 소스 출처 검사 — 깨끗하지 않으면 아무것도 만들지 않는다(fail-closed). 테스트보다 먼저 보아 시간을 아낀다
|
||||
const importedManifest = existsSync(IMPORTED_FILE) ? JSON.parse(readFileSync(IMPORTED_FILE, 'utf8')) : null;
|
||||
const prov = checkProvenance(importedManifest);
|
||||
if (!prov.ok) { console.error(`도입 소스 출처(provenance)가 깨끗하지 않아 릴리스 artifact 를 만들지 않습니다 (${IMPORTED_FILE}):`); for (const p of prov.problems) console.error(` - ${p}`); process.exit(1); }
|
||||
const out = join(resolve(ROOT, opt('out', 'dist')), VERSION); rmSync(out, { recursive: true, force: true }); mkdirSync(out, { recursive: true });
|
||||
const commit = git(['rev-parse', 'HEAD']); const dirty = git(['status', '--porcelain']).length > 0; const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']);
|
||||
let checks = { testsPassed: null, dirty, branch };
|
||||
let checks = { testsPassed: null, dirty, branch, provenanceClean: prov.ok, provenanceProblems: prov.problems };
|
||||
if (!opt('skip-tests')) { const r = spawnSync(process.execPath, ['test/run.mjs'], { cwd: ROOT, stdio: 'inherit' }); checks.testsPassed = r.status === 0; if (r.status !== 0) { console.error('테스트 실패 — artifact 를 만들지 않습니다'); process.exit(1); } }
|
||||
const artifacts = {};
|
||||
for (const [m, paths] of Object.entries(MODULES)) {
|
||||
@@ -34,7 +43,7 @@ if (cmd === 'build') {
|
||||
if (m === 'webmaker' || m === 'arimail') { tarArgs.push('--transform', `s,^modules/${m},.,`); } tarArgs.push(...present);
|
||||
execFileSync('tar', tarArgs); artifacts[m] = { file, sha256: sha(abs), size: statSync(abs).size, paths: present };
|
||||
}
|
||||
const manifest = { version: VERSION, mainCommit: commit, branch, builtAt: new Date().toISOString(), artifacts, checks, imported: existsSync(join(ROOT, 'IMPORTED_SOURCES.json')) ? JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')).sources.map((s) => ({ name: s.name, repo: s.repo, commit: s.commit })) : [] };
|
||||
const manifest = { version: VERSION, mainCommit: commit, branch, builtAt: new Date().toISOString(), artifacts, checks, imported: importedManifest.sources.map((s) => ({ name: s.name, repo: s.repo, commit: s.commit, upstream: s.upstream ? { version: s.upstream.version ?? null, branch: s.upstream.branch ?? null, headCommit: s.upstream.headCommit ?? null, workingTreeDirty: s.upstream.workingTreeDirty } : null, legacy: !s.upstream })) };
|
||||
writeFileSync(join(out, 'manifest.json'), JSON.stringify(manifest, null, 1));
|
||||
console.log(`릴리스 ${VERSION} (${commit.slice(0, 7)}${dirty ? ', dirty' : ''}) → ${out}`); for (const [m, a] of Object.entries(artifacts)) console.log(` ${m.padEnd(9)} ${a.file} ${(a.size / 1024).toFixed(0)}KB sha256 ${a.sha256.slice(0, 16)}…`);
|
||||
} else if (cmd === 'verify') {
|
||||
@@ -43,11 +52,15 @@ if (cmd === 'build') {
|
||||
process.exit(bad ? 1 : 0);
|
||||
} else if (cmd === 'register') {
|
||||
const halData = opt('hal-data'); if (!halData) { console.error('--hal-data 가 필요합니다'); process.exit(1); } const dist = resolve(ROOT, opt('dist', join('dist', VERSION))); const mf = JSON.parse(readFileSync(join(dist, 'manifest.json'), 'utf8'));
|
||||
if (mf.checks?.dirty && opt('approve')) { console.error('작업 트리가 깨끗하지 않은(dirty) 빌드는 승인할 수 없습니다 — main 에 병합된 커밋에서 빌드하세요'); process.exit(1); }
|
||||
const { openDb } = await import('../lib/db.mjs'); const { openVault } = await import('../lib/vault.mjs'); const { MIGRATIONS } = await import('../hal/schema.mjs'); const core = await import('../hal/core.mjs'); const { loadConfig } = await import('../hal/config.mjs');
|
||||
// 0.2.0 검토 수정(2차): 승인은 HAL 과 같은 한 규칙(core.releaseApprovalProblem) — testsPassed === true(--skip-tests 빌드는 등록만, 승인 불가) · dirty === false · provenanceClean === true 가 모두 기록된 manifest 만. DB 를 열기 전에 멈춘다(fail-closed)
|
||||
const core = await import('../hal/core.mjs');
|
||||
if (opt('approve')) { const why = core.releaseApprovalProblem(mf.checks); if (why) { console.error(`${why}${(mf.checks?.provenanceProblems || []).map((p) => `\n - ${p}`).join('')}\n테스트를 건너뛰지 않고(--skip-tests 없이) main 에 병합된 깨끗한 커밋에서, 깨끗한 IMPORTED_SOURCES.json(원본 저장소에 커밋된 고정 커밋, workingTreeDirty=false)으로 다시 build 하세요`); process.exit(1); } }
|
||||
const { openDb } = await import('../lib/db.mjs'); const { openVault } = await import('../lib/vault.mjs'); const { MIGRATIONS } = await import('../hal/schema.mjs'); const { loadConfig } = await import('../hal/config.mjs');
|
||||
process.env.ARIWEB_HAL_DATA = halData; const config = loadConfig(); const d = { store: openDb(join(halData, 'hal.sqlite'), MIGRATIONS), vault: openVault(halData), config, log: () => {} };
|
||||
const dir = join(core.artifactDir(d), mf.version); mkdirSync(dir, { recursive: true });
|
||||
const artifacts = {}; for (const [m, a] of Object.entries(mf.artifacts)) { const src = join(dist, a.file); if (sha(src) !== a.sha256) { console.error(`${m} artifact 해시 불일치`); process.exit(1); } const { copyFileSync } = await import('node:fs'); copyFileSync(src, join(dir, basename(a.file))); artifacts[m] = { file: basename(a.file), sha256: a.sha256, size: a.size }; }
|
||||
const r = core.registerRelease(d, { version: mf.version, mainCommit: mf.mainCommit, builtAt: mf.builtAt, artifacts, checks: mf.checks, approve: !!opt('approve'), by: opt('by', 'release-script') });
|
||||
console.log(`HAL 릴리스 등록: ${r.version} (${r.status}) main ${r.main_commit.slice(0, 7)} — artifact ${Object.keys(artifacts).join(', ')}`); d.store.close();
|
||||
// 0.2.0 검토 수정 4차: artifact 설치는 HAL 과 같은 불변·fail-closed 규칙(core.installArtifacts) — 바이트 해시·크기 검증 → 승인된 버전은 같을 때만 멱등, 다르면 파일·DB 불변 거부 → 새 설치는 임시 파일 → rename
|
||||
let r; try {
|
||||
const artifacts = core.installArtifacts(d, { version: mf.version, mainCommit: mf.mainCommit, builtAt: mf.builtAt, checks: mf.checks, artifacts: Object.fromEntries(Object.entries(mf.artifacts).map(([m, a]) => [m, { file: basename(a.file), sha256: a.sha256, size: a.size, src: join(dist, a.file) }])) });
|
||||
r = core.registerRelease(d, { version: mf.version, mainCommit: mf.mainCommit, builtAt: mf.builtAt, artifacts, checks: mf.checks, approve: !!opt('approve'), by: opt('by', 'release-script') });
|
||||
} catch (e) { console.error(`릴리스 등록 거부 (${e.code || 'error'}): ${e.message}`); d.store.close(); process.exit(1); }
|
||||
console.log(`HAL 릴리스 등록: ${r.version} (${r.status}) main ${r.main_commit.slice(0, 7)} — artifact ${Object.keys(r.artifacts).join(', ')}`); d.store.close();
|
||||
} else { console.error(`알 수 없는 명령: ${cmd}`); process.exit(1); }
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ export function buildFakeRelease(base, version, { withWebmaker = false } = {}) {
|
||||
const file = `${m}-${version}.tar.gz`; tarGz(src, join(dist, file));
|
||||
artifacts[m] = { file, sha256: createHash('sha256').update(readFileSync(join(dist, file))).digest('hex'), size: statSync(join(dist, file)).size };
|
||||
}
|
||||
const manifest = { version, mainCommit: 'e2e0000000000000000000000000000000000000', branch: 'e2e', builtAt: new Date().toISOString(), artifacts, checks: { testsPassed: true, dirty: false } };
|
||||
const manifest = { version, mainCommit: 'e2e0000000000000000000000000000000000000', branch: 'e2e', builtAt: new Date().toISOString(), artifacts, checks: { testsPassed: true, dirty: false, provenanceClean: true, provenanceProblems: [] } }; // register-release --approve 는 세 검사가 모두 깨끗해야 승인한다(0.2.0)
|
||||
writeFileSync(join(dist, 'manifest.json'), JSON.stringify(manifest, null, 1)); return { dist, manifest: join(dist, 'manifest.json'), artifacts };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/** test/hal-cli.test.mjs — HAL CLI(시스템 신원) 회귀 테스트: config-set / module-deploy / job.
|
||||
* 운영자 콘솔이 닿지 않는 연동 단계에서 HAL 데이터 소유자가 쓰는 명령이다. 별도 프로세스(node hal/index.mjs …)로 실행해 실제 CLI 경로를 검증하고,
|
||||
* 같은 데이터 디렉터리를 연 서비스 측 작업 엔진이 CLI 가 넣은 작업을 집어 실행하는지(실행은 serve 프로세스 몫) 확인한다. 비밀값은 출력하지 않는다. */
|
||||
import { test, eq, ok, tmpDir, done } from '../lib/test.mjs';
|
||||
import { createHalDeps } from '../hal/deps.mjs';
|
||||
import * as core from '../hal/core.mjs';
|
||||
import { listAudit } from '../lib/audit.mjs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, mkdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
function makeDataDir() { const dir = join(tmpDir('hal-cli-'), 'data'); mkdirSync(dir, { recursive: true, mode: 0o700 }); return dir; }
|
||||
/** CLI 를 별도 프로세스로. 실패(종료 코드≠0)는 { code, stdout, stderr } 로 돌려준다. */
|
||||
function cli(dataDir, args) {
|
||||
try { const out = execFileSync(process.execPath, ['--no-warnings', join(ROOT, 'hal', 'index.mjs'), ...args], { env: { ...process.env, ARIWEB_HAL_DATA: dataDir, PORT: '0' }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30000 }); return { code: 0, stdout: out, stderr: '' }; }
|
||||
catch (e) { return { code: e.status, stdout: String(e.stdout || ''), stderr: String(e.stderr || '') }; }
|
||||
}
|
||||
const deps = (dataDir) => createHalDeps({ config: { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'https://hal.example.test', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: [], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 1000, emergencyTtlMinutes: 30, agentPollMs: 10, values: {}, set() {} } });
|
||||
|
||||
await test('config-set: 운영 값만 data/config.json 에 기록(actor cli:<by>, 감사), 인자 없으면 거부, 비밀 키는 받지 않음', () => {
|
||||
const dir = makeDataDir();
|
||||
eq(cli(dir, ['config-set']).code, 1, '바꿀 값이 없으면 실패');
|
||||
const r = cli(dir, ['config-set', '--portalUrl', 'https://my.example.test', '--publicUrl', 'https://hal21.example.test', '--by', 'hera', '--reason', 'control-plane move']);
|
||||
eq(r.code, 0, r.stderr); const out = JSON.parse(r.stdout.trim().split('\n').pop()); eq(out.updated.sort(), ['portalUrl', 'publicUrl']);
|
||||
const cfg = JSON.parse(readFileSync(join(dir, 'config.json'), 'utf8')); eq(cfg.values.portalUrl, 'https://my.example.test'); eq(cfg.values.publicUrl, 'https://hal21.example.test'); eq(cfg.updatedBy, 'cli:hera');
|
||||
const d = deps(dir); const a = listAudit(d.store, { action: 'config.update' }); eq(a.length, 1); eq(a[0].actor_id, 'cli:hera'); eq(JSON.parse(a[0].detail_json).keys.sort(), ['portalUrl', 'publicUrl']); eq(JSON.parse(a[0].detail_json).reason, 'control-plane move');
|
||||
// 비밀값 이름은 config-set 이 받는 키 목록에 없다 — 무시되어 '바꿀 값 없음' 으로 거부된다(설정 파일에 비밀을 두지 않는다)
|
||||
const s = cli(dir, ['config-set', '--apiKey', 'x']); eq(s.code, 1); ok(!readFileSync(join(dir, 'config.json'), 'utf8').includes('apiKey'));
|
||||
});
|
||||
|
||||
await test('module-deploy: 운영자 API 와 같은 멱등 규칙으로 module.deploy 작업을 큐에 넣는다(실행은 서비스 프로세스), 검증 실패는 종료 코드 1, job 명령으로 readback', () => {
|
||||
const dir = makeDataDir(); const d = deps(dir);
|
||||
const srv = core.createServer(d, { name: 'web1', role: 'control', by: 'test' });
|
||||
eq(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id]).code, 1, '릴리스가 없으면 거부');
|
||||
core.registerRelease(d, { version: '0.1.1', mainCommit: 'abc', builtAt: 'now', artifacts: { portal: { file: 'portal-0.1.1.tar.gz', sha256: 'x', size: 1 } }, checks: { testsPassed: true, dirty: false, provenanceClean: true, provenanceProblems: [] }, approve: true, by: 'test' }); // 승인은 세 검사가 모두 깨끗해야 한다(0.2.0)
|
||||
eq(cli(dir, ['module-deploy', '--module', 'nope', '--version', '0.1.1', '--servers', srv.id]).code, 1, '알 수 없는 모듈 거부');
|
||||
eq(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', 'srv_none']).code, 1, '없는 서버 거부');
|
||||
const r = cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera', '--reason', 'portal → hal21 link']);
|
||||
eq(r.code, 0, r.stderr); const out = JSON.parse(r.stdout.trim().split('\n').pop()); ok(out.jobId.startsWith('j_')); eq(out.created, true); eq(out.status, 'queued');
|
||||
const j = d.jobs.get(out.jobId); eq(j.kind, 'module.deploy'); eq(j.subject, { module: 'portal', version: '0.1.1', serverIds: [srv.id] }); eq(j.created_by, 'cli:hera'); eq(j.actor_type, 'operator'); eq(j.idempotency_key, `module.deploy:portal:0.1.1:${srv.id}:`);
|
||||
const again = JSON.parse(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera']).stdout.trim().split('\n').pop()); eq(again.jobId, out.jobId); eq(again.created, false, '같은 멱등 키 → 같은 작업');
|
||||
const nonce = JSON.parse(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera', '--nonce', '2']).stdout.trim().split('\n').pop()); ok(nonce.jobId !== out.jobId && nonce.created, 'nonce 로 새 작업');
|
||||
const a = listAudit(d.store, { action: 'deploy.module.request' }); eq(a.length, 3); eq(a[0].actor_id, 'cli:hera'); eq(JSON.parse(a.find((x) => JSON.parse(x.detail_json).jobId === out.jobId && JSON.parse(x.detail_json).created).detail_json).reason, 'portal → hal21 link');
|
||||
const rb = cli(dir, ['job', '--id', out.jobId]); eq(rb.code, 0, rb.stderr); const view = JSON.parse(rb.stdout); eq(view.job.id, out.jobId); eq(view.job.status, 'queued'); eq(view.children, []);
|
||||
eq(cli(dir, ['job', '--id', 'j_none']).code, 1);
|
||||
});
|
||||
|
||||
await test('CLI 가 넣은 작업을 같은 데이터의 서비스 측 작업 엔진이 집어 실행한다(artifact 없음·부트스트랩 미완료 → precheck 실패로 끝남, 큐에 남지 않음)', async () => {
|
||||
const dir = makeDataDir(); const d = deps(dir); const srv = core.createServer(d, { name: 'web1', role: 'control', by: 'test' });
|
||||
core.registerRelease(d, { version: '0.1.1', mainCommit: 'abc', builtAt: 'now', artifacts: { portal: { file: 'portal-0.1.1.tar.gz', sha256: 'x', size: 1 } }, checks: { testsPassed: true, dirty: false, provenanceClean: true, provenanceProblems: [] }, approve: true, by: 'test' }); // 승인은 세 검사가 모두 깨끗해야 한다(0.2.0)
|
||||
const out = JSON.parse(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera']).stdout.trim().split('\n').pop());
|
||||
d.jobsRunning = true; const n = await d.jobs.drain(); ok(n >= 1, '서비스 측 엔진이 큐 작업을 집었다');
|
||||
const j = d.jobs.get(out.jobId); eq(j.status, 'failed'); ok(['artifact_missing', 'bootstrap_incomplete'].includes(j.error_code), `precheck 실패로 끝남: ${j.error_code}`); eq(d.jobs.list({ status: 'queued' }).length, 0);
|
||||
});
|
||||
|
||||
done();
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* test/hal-reauth-banner.test.mjs — Ariweb Suite 0.2.1(개발 지시 `Ariweb-Suitle_Dev-Order_v0.2.1.md`): 운영자 콘솔 고위험 재인증 안내 위치 회귀 테스트.
|
||||
*
|
||||
* 계약
|
||||
* 1) 일반 화면(운영 현황·서버·계정 …)에는 "고위험 작업(…)은 최근 10분 안의 재인증이 필요합니다" 전역 배너가 **없다** — 로그인 직후에도, 10분이 지난 뒤에도, 화면을 옮겨 다녀도.
|
||||
* 2) 그 안내는 backend 가 고위험 요청을 `reauth_required` 로 거부한 바로 그 때 여는 재인증 창(modal) 안에만 보인다. 계정 화면의 수동 "재인증" 단추는 그대로 두되 고위험 안내를 붙이지 않는다.
|
||||
* 3) backend 의 requireRecent·CSRF·3단계 확인은 그대로다: 10분이 지나면 고위험 API 는 403 reauth_required, 재인증 뒤에만 통과.
|
||||
* 4) 재인증 창을 취소하면 원래 고위험 요청은 실행되지 않고, 재인증에 성공하면 원 요청을 **딱 한 번** 재시도한다(두 번째도 거부되면 다시 묻지 않고 오류를 그대로 올린다).
|
||||
*
|
||||
* 브라우저 스크립트(hal/ui/public/ops.js)는 DOM 이 있어야 돌므로, 재인증 흐름·창 문구는 순수 helper(hal/ui/public/reauth.mjs)로 분리해 Node 에서 검사하고,
|
||||
* ops.js 가 실제로 그 helper 를 쓰는지는 정적 계약(소스 텍스트)으로 못 박는다.
|
||||
*/
|
||||
import { test, eq, ok, tmpDir, done } from '../lib/test.mjs';
|
||||
import { invoke } from '../lib/http.mjs';
|
||||
import { createHalDeps } from '../hal/deps.mjs';
|
||||
import { buildApp } from '../hal/app.mjs';
|
||||
import * as core from '../hal/core.mjs';
|
||||
import * as ops from '../hal/ops.mjs';
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
/** 실행마다 다른 운영자 비밀번호 fixture(저장소에 정적 비밀번호 literal 을 두지 않는다). correct 와 wrong 은 서로 다름이 보장된다 */
|
||||
const makeFixturePasswords = () => { const correct = 'fx-' + randomBytes(12).toString('hex'); let wrong; do { wrong = 'fx-' + randomBytes(12).toString('hex'); } while (wrong === correct); return { correct, wrong }; };
|
||||
const BANNER = '고위험 작업(서버 삭제·되돌리기·인증 키 변경·복구)은 최근 10분 안의 재인증이 필요합니다';
|
||||
|
||||
function makeHal() {
|
||||
const dataDir = join(tmpDir(), 'hal'); mkdirSync(dataDir, { recursive: true });
|
||||
const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'https://hal.example.test', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: ['127.0.0.1/32'], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 3600000, emergencyTtlMinutes: 30, agentPollMs: 20, agentWaitTimeoutMs: 2000, fanoutTimeoutMs: 4000, values: {}, set(p) { Object.assign(this, p); } };
|
||||
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: async () => { throw new Error('ECONNREFUSED'); } });
|
||||
return { d, app: buildApp(d) };
|
||||
}
|
||||
async function loginOp(h) {
|
||||
const pw = makeFixturePasswords();
|
||||
ops.createAccount(h.d, { login: 'ozops', password: pw.correct, displayName: '오즈' });
|
||||
const r = await invoke(h.app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: pw.correct } }); ok(r.json.ok, JSON.stringify(r.json));
|
||||
return { pw, as: (method, path, body) => invoke(h.app, method, path, { body, cookies: { aw_ops: r.cookies.aw_ops }, headers: { 'x-csrf': r.json.csrf } }) };
|
||||
}
|
||||
/** 화면 HTML 의 전역 배너 영역(<div class="alerts">…</div>)만 떼어 낸다 */
|
||||
const alerts = (html) => (html.match(/<div class="alerts">([\s\S]*?)<\/div><div id="body">/) || ['', ''])[1];
|
||||
|
||||
await test('일반 화면에는 고위험 재인증 전역 배너가 없다 — 로그인 직후·10분 경과·화면 이동 모두', async () => {
|
||||
const h = makeHal(); const op = await loginOp(h);
|
||||
// 로그인 직후(재인증 유효): 배너 없음
|
||||
let r = await op.as('GET', '/ops/'); eq(r.status, 200); ok(!r.text.includes(BANNER), '로그인 직후 운영 현황에 고위험 배너 없음'); ok(!alerts(r.text).includes('data-act="reauth"'), '배너 영역에 재인증 링크 없음');
|
||||
// 10분 경과(재인증 만료): 그래도 일반 화면에는 배너 없음
|
||||
h.d.store.run('UPDATE sessions SET reauth_at=?', new Date(Date.now() - 11 * 60000).toISOString());
|
||||
for (const path of ['/ops/', '/ops/servers', '/ops/releases', '/ops/jobs', '/ops/recovery', '/ops/customers']) {
|
||||
r = await op.as('GET', path); eq(r.status, 200, path);
|
||||
ok(!r.text.includes(BANNER), `${path}: 10분 경과 뒤에도 고위험 배너 없음`);
|
||||
ok(!alerts(r.text).includes('data-act="reauth"'), `${path}: 배너 영역에 재인증 링크 없음`);
|
||||
ok(!r.text.includes('고위험 작업('), `${path}: 단순 조회 화면에 고위험 안내 문구 없음`);
|
||||
}
|
||||
// 계정 화면: 수동 재인증 단추는 유지, 고위험 안내는 붙이지 않는다
|
||||
r = await op.as('GET', '/ops/account'); eq(r.status, 200);
|
||||
ok(r.text.includes('data-act="reauth"'), '계정 화면의 수동 재인증 단추 유지');
|
||||
ok(!r.text.includes(BANNER) && !r.text.includes('고위험 작업'), '계정 화면에 고위험 안내 없음');
|
||||
// 다른 배너(부트스트랩 미완료)는 그대로 — 배너 영역 자체를 없앤 것이 아님
|
||||
ok(alerts(r.text).includes('부트스트랩 미완료'), '다른 전역 배너는 유지');
|
||||
});
|
||||
|
||||
await test('backend 보안 경계는 그대로: 10분 경과 뒤 고위험 API 는 403 reauth_required, CSRF 없는 재인증 거부, 재인증 뒤에만 통과', async () => {
|
||||
const h = makeHal(); const op = await loginOp(h);
|
||||
const s = core.createServer(h.d, { name: '웹1', role: 'web' });
|
||||
h.d.store.run('UPDATE sessions SET reauth_at=?', new Date(Date.now() - 11 * 60000).toISOString());
|
||||
let r = await op.as('POST', `/ops/api/servers/${s.id}/delete`, { confirmImpact: true, confirmIdentifiers: [s.id], confirmExecute: true }); eq(r.status, 403); eq(r.json.error, 'reauth_required'); eq(r.json.detail && r.json.detail.action, 'reauth');
|
||||
ok(core.getServer(h.d, s.id), '거부된 고위험 요청은 실행되지 않음(서버 그대로)');
|
||||
r = await op.as('PATCH', `/ops/api/servers/${s.id}`, { status: 'isolated' }); eq(r.json.error, 'reauth_required');
|
||||
// 재인증 자체도 CSRF 가 필요하다
|
||||
r = await invoke(h.app, 'POST', '/ops/api/reauth', { body: { password: op.pw.correct }, cookies: { aw_ops: (await invoke(h.app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: op.pw.correct } })).cookies.aw_ops } }); eq(r.status, 403); eq(r.json.error, 'csrf');
|
||||
r = await op.as('POST', '/ops/api/reauth', { password: op.pw.wrong }); ok(r.status >= 400, '틀린 비밀번호 재인증 거부');
|
||||
r = await op.as('POST', '/ops/api/reauth', { password: op.pw.correct }); eq(r.status, 200);
|
||||
r = await op.as('GET', '/ops/api/me'); ok(r.json.recentlyAuthed, '재인증 뒤 recentlyAuthed');
|
||||
// 3단계 확인은 재인증과 별개로 여전히 요구된다
|
||||
r = await op.as('POST', `/ops/api/servers/${s.id}/delete`, {}); ok(r.status >= 400 && r.json.error !== 'reauth_required', `3단계 확인 없이는 거부(${r.json.error})`); ok(core.getServer(h.d, s.id), '서버 그대로');
|
||||
});
|
||||
|
||||
await test('재인증 helper(순수): 고위험 안내는 reauth_required 로 거부된 때의 재인증 창에만, 수동 재인증 창에는 없음', async () => {
|
||||
const H = await import('../hal/ui/public/reauth.mjs');
|
||||
eq(H.REAUTH_REQUIRED, 'reauth_required');
|
||||
eq(H.HIGH_RISK_NOTICE, BANNER, '안내 문구는 옛 전역 배너 문구와 같다(위치만 옮김)');
|
||||
const manual = H.reauthPrompt(); ok(manual && manual.title && manual.okLabel, '수동 재인증 창 제목·단추'); eq(manual.note, '', '수동 재인증 창에는 고위험 안내 없음');
|
||||
const forced = H.reauthPrompt({ required: true }); eq(forced.note, BANNER, '거부된 때의 재인증 창에는 고위험 안내'); eq(forced.title, manual.title);
|
||||
});
|
||||
|
||||
await test('재인증 helper(순수): 취소하면 원 요청 미실행·오류 전파, 성공하면 딱 한 번 재시도, 다른 오류는 재인증을 묻지 않음', async () => {
|
||||
const H = await import('../hal/ui/public/reauth.mjs');
|
||||
const denied = () => Object.assign(new Error('고위험 작업은 최근 10분 안의 재인증이 필요합니다'), { code: 'reauth_required', status: 403 });
|
||||
// 1) 취소 → 원 요청은 1번(거부된 호출)만, 재인증 창은 required 로 1번, 오류는 그대로 전파
|
||||
let runs = 0; let prompts = [];
|
||||
let err = null; try { await H.callWithReauth(() => { runs++; throw denied(); }, async (o) => { prompts.push(o); return false; }); } catch (e) { err = e; }
|
||||
eq(runs, 1, '취소하면 원 요청을 다시 보내지 않음'); eq(prompts.length, 1); eq(prompts[0].required, true); eq(err && err.code, 'reauth_required', '원래 오류가 호출자에게 전파');
|
||||
// 2) 성공 → 원 요청 2번(거부 + 재시도 1번), 결과는 재시도 값
|
||||
runs = 0; prompts = [];
|
||||
const out = await H.callWithReauth(() => { runs++; if (runs === 1) throw denied(); return { ok: true, n: runs }; }, async (o) => { prompts.push(o); return true; });
|
||||
eq(runs, 2, '재인증 성공 시 딱 한 번 재시도'); eq(out.n, 2); eq(prompts.length, 1);
|
||||
// 3) 재시도도 거부 → 다시 묻지 않고 오류 전파(무한 반복 금지)
|
||||
runs = 0; prompts = [];
|
||||
err = null; try { await H.callWithReauth(() => { runs++; throw denied(); }, async (o) => { prompts.push(o); return true; }); } catch (e) { err = e; }
|
||||
eq(runs, 2); eq(prompts.length, 1, '재시도가 또 거부돼도 재인증 창을 다시 열지 않음'); eq(err && err.code, 'reauth_required');
|
||||
// 4) 다른 오류(권한 없음·검증 실패)는 재인증을 묻지 않는다
|
||||
runs = 0; prompts = [];
|
||||
err = null; try { await H.callWithReauth(() => { runs++; throw Object.assign(new Error('x'), { code: 'bad_request' }); }, async (o) => { prompts.push(o); return true; }); } catch (e) { err = e; }
|
||||
eq(runs, 1); eq(prompts.length, 0); eq(err && err.code, 'bad_request');
|
||||
// 5) 정상 응답은 그대로
|
||||
eq((await H.callWithReauth(async () => 7, async () => { throw new Error('부르면 안 됨'); })), 7);
|
||||
});
|
||||
|
||||
await test('정적 계약: ui.mjs 에 전역 고위험 배너가 없고, ops.js 는 reauth helper 로 거부 시점 재인증·수동 재인증(안내 없음)을 처리한다', () => {
|
||||
const ui = readFileSync(join(ROOT, 'hal', 'ui.mjs'), 'utf8'); const js = readFileSync(join(ROOT, 'hal', 'ui', 'public', 'ops.js'), 'utf8');
|
||||
ok(!ui.includes('고위험 작업('), 'hal/ui.mjs 는 고위험 배너 문구를 더 이상 그리지 않음');
|
||||
ok(!/if \(!recent\) banner\.push/.test(ui), '재인증 만료를 전역 배너로 바꾸는 코드가 없음');
|
||||
ok(js.includes("from './reauth.mjs'"), 'ops.js 가 순수 helper 를 가져온다');
|
||||
ok(js.includes('callWithReauth('), 'API 호출 재시도 흐름은 helper 가 담당');
|
||||
ok(js.includes('reauthPrompt('), '재인증 창 문구는 helper 가 결정');
|
||||
ok(/\breauth: \(\) => reauth\(\)/.test(js), '계정 화면 수동 재인증은 required 없이 호출');
|
||||
ok(!js.includes('고위험 작업('), 'ops.js 는 안내 문구를 직접 품지 않는다(helper 단일 출처)');
|
||||
});
|
||||
done();
|
||||
@@ -39,6 +39,29 @@ await test('화면 접근: 허용 네트워크 밖 403, 세션 없음/고객 쿠
|
||||
// 로그인 화면은 고객 로그인과 분리된 문구
|
||||
r = await invoke(h.app, 'GET', '/ops/login'); ok(r.text.includes('고객 계정으로는 로그인할 수 없습니다'));
|
||||
});
|
||||
await test('부트스트랩 배너는 selfHealth().bootstrap 의 실제 계약(complete·halRunningVersion·firstAgent)만 본다 — 완료 뒤에는 경고가 사라지고, 미완료 때는 실행 버전·첫 Agent 상태가 정확하다', async () => {
|
||||
const h = makeHal(); const op = await loginOp(h);
|
||||
// 계약 고정: selfHealth 는 camelCase 만 내고 옛 snake_case 열 이름은 없다(배너가 어떤 필드를 읽는지 못 박는다)
|
||||
const b0 = core.selfHealth(h.d).bootstrap; eq(b0.complete, false); ok(!('completed_at' in b0) && !('hal_running_version' in b0) && !('first_agent_id' in b0), 'selfHealth.bootstrap 에 snake_case 필드가 없다');
|
||||
// 1) 아무것도 안 된 상태: 실행 버전 확인 전 · 첫 Agent 미등록
|
||||
let r = await op.as('GET', '/ops/'); eq(r.status, 200); ok(r.text.includes('부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)'), '초기 상태 문구');
|
||||
// 2) HAL 기동(실행 버전 기록)만 된 상태: 실행 버전은 보이고 첫 Agent 는 아직 미등록
|
||||
core.claimLive(h.d, { instanceId: h.d.instanceId, version: '0.1.1' });
|
||||
r = await op.as('GET', '/ops/'); ok(r.text.includes('부트스트랩 미완료(HAL 실행 버전 0.1.1, 첫 Agent 미등록)'), `실행 버전 0.1.1 표시: ${(r.text.match(/부트스트랩 미완료\([^)]*\)/) || [''])[0]}`);
|
||||
// 3) 첫 Agent 등록·승인 → 부트스트랩 완료(core.approveServer → maybeCompleteBootstrap). 경고는 사라져야 한다
|
||||
const s = core.createServer(h.d, { name: '웹1', role: 'web' }); const t = core.issueRegistrationToken(h.d, { serverId: s.id });
|
||||
const reg = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'web1' }, version: '0.1.1' } }); ok(reg.json.ok, JSON.stringify(reg.json));
|
||||
core.approveServer(h.d, s.id, 'ozops');
|
||||
const b1 = core.selfHealth(h.d).bootstrap; eq(b1.complete, true); eq(b1.halRunningVersion, '0.1.1'); eq(b1.firstAgent, reg.json.agentId); eq(b1.manualInstallLocked, true);
|
||||
r = await op.as('GET', '/ops/'); eq(r.status, 200); ok(!r.text.includes('부트스트랩 미완료'), `완료 뒤 운영 현황에 경고 없음: ${(r.text.match(/부트스트랩 미완료\([^)]*\)/) || [''])[0]}`);
|
||||
r = await op.as('GET', '/ops/servers'); ok(!r.text.includes('부트스트랩 미완료'), '완료 뒤 다른 화면(서버)에도 경고 없음');
|
||||
r = await op.as('GET', '/ops/releases'); ok(!r.text.includes('부트스트랩 미완료'), '완료 뒤 배포 릴리스 화면에도 경고 없음'); ok(r.text.includes('HAL 실행 버전: <b>0.1.1</b>') && r.text.includes('수동 설치 잠김'), '릴리스 화면의 실행 버전·수동 설치 잠김은 실제 bootstrap 행 값');
|
||||
// 4) 첫 Agent 는 등록됐지만 HAL 실행 버전이 없는 새 HAL(재해복구 등): 실행 버전 확인 전 · 첫 Agent 등록됨
|
||||
const h2 = makeHal(); const op2 = await loginOp(h2); const s2 = core.createServer(h2.d, { name: '웹2', role: 'web' }); const t2 = core.issueRegistrationToken(h2.d, { serverId: s2.id });
|
||||
const reg2 = await invoke(h2.app, 'POST', '/agent/v1/register', { body: { token: t2.token, facts: { hostname: 'web2' }, version: '0.1.1' } }); ok(reg2.json.ok); core.approveServer(h2.d, s2.id, 'ozops');
|
||||
eq(core.selfHealth(h2.d).bootstrap.complete, false); eq(core.selfHealth(h2.d).bootstrap.firstAgent, reg2.json.agentId);
|
||||
r = await op2.as('GET', '/ops/'); ok(r.text.includes('부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 등록됨)'), `첫 Agent 등록됨 표시: ${(r.text.match(/부트스트랩 미완료\([^)]*\)/) || [''])[0]}`);
|
||||
});
|
||||
await test('주요 화면 렌더: 서버(등록·용량·모듈 차이)·서버 상세·릴리스(HAL 먼저)·작업(부분 실패)·사건·감사·복구·계정', async () => {
|
||||
const h = makeHal(); const op = await loginOp(h);
|
||||
const s = core.createServer(h.d, { name: '웹1', role: 'web', location: '서울' }); const t = core.issueRegistrationToken(h.d, { serverId: s.id });
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ await test('모듈 배포: 릴리스 승인·HAL 먼저·대상별 자식 작업
|
||||
const h = makeHal(); bootstrapDone(h.d); const a1 = await registerAgent(h, { name: 'w1', role: 'control' }); const a2 = await registerAgent(h, { name: 'w2', role: 'web' });
|
||||
const art = join(h.dataDir, 'artifacts', '0.1.1'); mkdirSync(art, { recursive: true }); writeFileSync(join(art, 'portal-0.1.1.tar.gz'), 'x'); writeFileSync(join(art, 'hal-0.1.1.tar.gz'), 'y');
|
||||
const sum = (f) => createHash('sha256').update(f).digest('hex');
|
||||
core.registerRelease(h.d, { version: '0.1.1', mainCommit: 'abc', builtAt: new Date().toISOString(), artifacts: { portal: { file: 'portal-0.1.1.tar.gz', sha256: sum('x') }, hal: { file: 'hal-0.1.1.tar.gz', sha256: sum('y') } } });
|
||||
core.registerRelease(h.d, { version: '0.1.1', mainCommit: 'abc', builtAt: new Date().toISOString(), artifacts: { portal: { file: 'portal-0.1.1.tar.gz', sha256: sum('x') }, hal: { file: 'hal-0.1.1.tar.gz', sha256: sum('y') } }, checks: { testsPassed: true, dirty: false, provenanceClean: true, provenanceProblems: [] } }); // 승인(아래)은 세 검사가 모두 깨끗해야 한다(0.2.0)
|
||||
const enq = (module, version = '0.1.1', ids = [a1.serverId, a2.serverId]) => h.d.jobs.enqueue({ kind: 'module.deploy', subject: { module, version, serverIds: ids }, createdBy: 'op', actorType: 'operator' }).job;
|
||||
let j = enq('portal'); await h.d.jobs.drain(); eq(h.d.jobs.get(j.id).error_code, 'release_not_approved');
|
||||
core.approveRelease(h.d, '0.1.1', 'op'); core.setBootstrap(h.d, { hal_running_version: '0.1.0' });
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* test/release-provenance.test.mjs — Ariweb Suite 0.2.0 검토 수정(독립 검토 차단 항목 11): 릴리스 build·register --approve·HAL 승인은
|
||||
* IMPORTED_SOURCES 의 도입 소스 출처(provenance)가 깨끗하지 않으면(작업 트리 변경을 함께 가져온 workingTreeDirty=true, 고정 커밋 ≠ upstream.headCommit,
|
||||
* 불완전한 기록) **fail-closed** 로 멈춘다. --allow-dirty 로 나란히 개발하는 동안 만든 Suite 체크아웃이 그대로 릴리스로 승인되는 길을 막는다.
|
||||
* 1) lib/provenance.mjs checkProvenance(manifest) 순수 규칙
|
||||
* 2) scripts/release.mjs build 가 더러운 manifest 로는 artifact 를 만들지 않고 종료 코드 1, 깨끗한 manifest 로는 manifest.json 에 checks.provenanceClean=true
|
||||
* 3) scripts/release.mjs register --approve 가 provenanceClean≠true 인 dist manifest 를 승인하지 않는다(DB 를 열기 전에 멈춤)
|
||||
* 4) hal/core.approveRelease 가 checks.provenanceClean=false 릴리스를 승인하지 않는다
|
||||
*/
|
||||
import { test, eq, ok, tmpDir, done, rejects } from '../lib/test.mjs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const HEX = 'a'.repeat(40);
|
||||
const clean = () => ({ generatedAt: 'x', sources: [
|
||||
{ name: 'webmaker', repo: 'r', commit: HEX, dest: 'modules/webmaker', license: 'l', upstream: { version: '1.8.0', branch: 'main', headCommit: HEX, workingTreeDirty: false, note: '' }, files: [{ from: 'a.js', to: 'modules/webmaker/a.js', status: 'unchanged', origHash: '0123456789abcdef' }] },
|
||||
{ name: 'arimail', repo: 'r', commit: 'b'.repeat(40), dest: 'modules/arimail', license: 'l', files: [{ from: 'b.js', to: 'modules/arimail/b.js', status: 'modified', origHash: '0123456789abcdef' }] }, // 0.1.x 기록(upstream 칸 없음 — 깨끗한 고정 커밋만 받던 스크립트가 만든 것)
|
||||
] });
|
||||
|
||||
await test('checkProvenance: 깨끗한 기록은 ok, workingTreeDirty·headCommit 불일치·불완전한 upstream·잘못된 commit·빈 파일 목록은 문제로 나열(fail-closed)', async () => {
|
||||
const { checkProvenance } = await import('../lib/provenance.mjs');
|
||||
const c = checkProvenance(clean()); eq(c.ok, true, JSON.stringify(c.problems)); eq(c.problems, []); eq(c.sources.map((s) => s.name), ['webmaker', 'arimail']); eq(c.sources[1].legacy, true, '0.1.x 기록은 legacy 표시');
|
||||
const dirty = clean(); dirty.sources[0].upstream.workingTreeDirty = true; const r1 = checkProvenance(dirty); eq(r1.ok, false); ok(r1.problems.some((p) => /webmaker/.test(p) && /workingTreeDirty|작업 트리/.test(p)), JSON.stringify(r1.problems));
|
||||
const mism = clean(); mism.sources[0].upstream.headCommit = 'c'.repeat(40); const r2 = checkProvenance(mism); eq(r2.ok, false); ok(r2.problems.some((p) => /headCommit/.test(p)));
|
||||
const inc = clean(); delete inc.sources[0].upstream.version; const r3 = checkProvenance(inc); eq(r3.ok, false); ok(r3.problems.some((p) => /version/.test(p)));
|
||||
const badc = clean(); badc.sources[0].commit = 'abc'; const r4 = checkProvenance(badc); eq(r4.ok, false); ok(r4.problems.some((p) => /commit/.test(p)));
|
||||
const nof = clean(); nof.sources[0].files = []; const r5 = checkProvenance(nof); eq(r5.ok, false); ok(r5.problems.some((p) => /파일/.test(p)));
|
||||
eq(checkProvenance(null).ok, false); eq(checkProvenance({ sources: [] }).ok, false, '소스가 하나도 없으면 불완전');
|
||||
const dirtyNote = clean(); dirtyNote.sources[0].upstream = { ...dirtyNote.sources[0].upstream, workingTreeDirty: 'true' }; eq(checkProvenance(dirtyNote).ok, false, '불리언 아닌 값도 깨끗함으로 보지 않는다');
|
||||
});
|
||||
|
||||
await test('release.mjs build: 더러운 출처 manifest 로는 artifact 를 만들지 않고 종료 1(이유 출력), 깨끗한 manifest 로는 manifest.json 에 checks.provenanceClean=true 와 imported[].upstream 요약', () => {
|
||||
const dir = tmpDir('aw-prov-'); const dirtyM = clean(); dirtyM.sources[0].upstream.workingTreeDirty = true; writeFileSync(join(dir, 'dirty.json'), JSON.stringify(dirtyM)); writeFileSync(join(dir, 'clean.json'), JSON.stringify(clean()));
|
||||
const out1 = join(dir, 'dist1'); const r1 = spawnSync(process.execPath, [join(ROOT, 'scripts', 'release.mjs'), 'build', '--skip-tests', '--out', out1], { cwd: ROOT, encoding: 'utf8', env: { ...process.env, ARIWEB_IMPORTED_SOURCES: join(dir, 'dirty.json') } });
|
||||
eq(r1.status, 1, r1.stdout + r1.stderr); ok(/출처|provenance/i.test(r1.stderr + r1.stdout), '이유 출력'); ok(/webmaker/.test(r1.stderr + r1.stdout));
|
||||
ok(!existsSync(out1) || readdirSync(out1).every((d) => !readdirSync(join(out1, d)).some((f) => f.endsWith('.tar.gz'))), 'artifact 없음');
|
||||
const out2 = join(dir, 'dist2'); const r2 = spawnSync(process.execPath, [join(ROOT, 'scripts', 'release.mjs'), 'build', '--skip-tests', '--out', out2], { cwd: ROOT, encoding: 'utf8', env: { ...process.env, ARIWEB_IMPORTED_SOURCES: join(dir, 'clean.json') } });
|
||||
eq(r2.status, 0, r2.stdout + r2.stderr);
|
||||
const ver = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version; const mf = JSON.parse(readFileSync(join(out2, ver, 'manifest.json'), 'utf8'));
|
||||
eq(mf.checks.provenanceClean, true); eq(mf.checks.provenanceProblems, []); eq(mf.imported[0].name, 'webmaker'); eq(mf.imported[0].upstream.workingTreeDirty, false); eq(mf.imported[0].upstream.headCommit, HEX); ok(mf.artifacts.webmaker && mf.artifacts.webmaker.sha256);
|
||||
});
|
||||
|
||||
await test('release.mjs register --approve: dist manifest 의 checks.provenanceClean 이 true 가 아니면(false·없음) 승인하지 않고 종료 1', () => {
|
||||
const dir = tmpDir('aw-prov-reg-'); const ver = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
|
||||
for (const [label, checks] of [['false', { testsPassed: true, dirty: false, provenanceClean: false, provenanceProblems: ['webmaker: workingTreeDirty'] }], ['없음', { testsPassed: true, dirty: false }]]) {
|
||||
const dist = join(dir, 'dist-' + label, ver); mkdirSync(dist, { recursive: true }); writeFileSync(join(dist, 'manifest.json'), JSON.stringify({ version: ver, mainCommit: HEX, branch: 'main', builtAt: 'x', artifacts: {}, checks, imported: [] }));
|
||||
const r = spawnSync(process.execPath, [join(ROOT, 'scripts', 'release.mjs'), 'register', '--hal-data', join(dir, 'hal-' + label), '--dist', dist, '--approve'], { cwd: ROOT, encoding: 'utf8' });
|
||||
eq(r.status, 1, label + ': ' + r.stdout + r.stderr); ok(/출처|provenance/i.test(r.stderr + r.stdout), label + ': 이유 출력'); ok(!existsSync(join(dir, 'hal-' + label, 'hal.sqlite')), label + ': DB 를 열기 전에 멈춘다');
|
||||
}
|
||||
});
|
||||
|
||||
await test('hal/core.approveRelease: checks.provenanceClean=false 인 릴리스는 승인할 수 없다(release_checks_failed); 기록은 되며 provenanceClean=true 는 승인된다', async () => {
|
||||
const { openDb } = await import('../lib/db.mjs'); const { MIGRATIONS } = await import('../hal/schema.mjs'); const core = await import('../hal/core.mjs');
|
||||
const dir = tmpDir('aw-prov-hal-'); const d = { store: openDb(join(dir, 'hal.sqlite'), MIGRATIONS), log: () => {} };
|
||||
core.registerRelease(d, { version: '9.9.1', mainCommit: HEX, builtAt: 'x', artifacts: { hal: { file: 'hal-9.9.1.tar.gz', sha256: 'z', size: 1 } }, checks: { testsPassed: true, dirty: false, provenanceClean: false, provenanceProblems: ['webmaker: workingTreeDirty'] } });
|
||||
eq(core.getRelease(d, '9.9.1').status, 'registered');
|
||||
await rejects(async () => core.approveRelease(d, '9.9.1', 'op'), 'release_checks_failed', '더러운 출처 승인 거절'); eq(core.getRelease(d, '9.9.1').status, 'registered');
|
||||
core.registerRelease(d, { version: '9.9.2', mainCommit: HEX, builtAt: 'x', artifacts: { hal: { file: 'hal-9.9.2.tar.gz', sha256: 'z', size: 1 } }, checks: { testsPassed: true, dirty: false, provenanceClean: true, provenanceProblems: [] } });
|
||||
eq(core.approveRelease(d, '9.9.2', 'op').status, 'approved');
|
||||
d.store.close();
|
||||
});
|
||||
|
||||
const CLEAN = { testsPassed: true, dirty: false, provenanceClean: true, provenanceProblems: [] };
|
||||
const ART = { hal: { file: 'hal-x.tar.gz', sha256: 'z', size: 1 } };
|
||||
await test('hal/core.approveRelease·registerRelease(approve:true): testsPassed===true · provenanceClean===true · dirty===false 를 모두 확인해야 승인한다(fail-closed) — checks 없음·testsPassed null/없음/문자열·dirty true/없음/null·provenanceClean 없음/null/false 는 모두 release_checks_failed 이고 상태는 registered 그대로', async () => {
|
||||
const { openDb } = await import('../lib/db.mjs'); const { MIGRATIONS } = await import('../hal/schema.mjs'); const core = await import('../hal/core.mjs');
|
||||
const dir = tmpDir('aw-approve-'); const d = { store: openDb(join(dir, 'hal.sqlite'), MIGRATIONS), log: () => {} };
|
||||
const without = (k) => { const c = { ...CLEAN }; delete c[k]; return c; };
|
||||
const bads = [['checks 없음', null], ['checks 빈 객체', {}], ['testsPassed null(--skip-tests 빌드)', { ...CLEAN, testsPassed: null }], ['testsPassed 없음', without('testsPassed')], ['testsPassed 문자열 "true"', { ...CLEAN, testsPassed: 'true' }], ['testsPassed 1', { ...CLEAN, testsPassed: 1 }],
|
||||
['dirty true', { ...CLEAN, dirty: true }], ['dirty 없음', without('dirty')], ['dirty null', { ...CLEAN, dirty: null }], ['dirty "false"(문자열)', { ...CLEAN, dirty: 'false' }],
|
||||
['provenanceClean 없음(옛 빌드)', without('provenanceClean')], ['provenanceClean null', { ...CLEAN, provenanceClean: null }], ['provenanceClean false', { ...CLEAN, provenanceClean: false, provenanceProblems: ['webmaker: workingTreeDirty'] }]];
|
||||
let i = 0;
|
||||
for (const [label, checks] of bads) {
|
||||
const v = `8.8.${i++}`;
|
||||
core.registerRelease(d, { version: v, mainCommit: HEX, builtAt: 'x', artifacts: ART, checks });
|
||||
await rejects(async () => core.approveRelease(d, v, 'op'), 'release_checks_failed', label + ' (approveRelease)'); eq(core.getRelease(d, v).status, 'registered', label + ': 상태 그대로');
|
||||
await rejects(async () => core.registerRelease(d, { version: v, mainCommit: HEX, builtAt: 'x', artifacts: ART, checks, approve: true, by: 'op' }), 'release_checks_failed', label + ' (registerRelease approve:true)'); eq(core.getRelease(d, v).status, 'registered', label + ': approve:true 로 다시 등록해도 승인되지 않음');
|
||||
}
|
||||
core.registerRelease(d, { version: '8.9.0', mainCommit: HEX, builtAt: 'x', artifacts: ART, checks: CLEAN }); eq(core.approveRelease(d, '8.9.0', 'op').status, 'approved', '정확히 깨끗·통과한 검사만 승인');
|
||||
eq(core.registerRelease(d, { version: '8.9.1', mainCommit: HEX, builtAt: 'x', artifacts: ART, checks: CLEAN, approve: true, by: 'op' }).status, 'approved', 'registerRelease approve:true 도 같은 규칙으로 승인');
|
||||
d.store.close();
|
||||
});
|
||||
|
||||
await test('hal/core.registerRelease 불변 승인: 승인된 버전은 같은 내용의 재등록만 멱등하게 받고(상태 approved 유지), artifact·checks·커밋·빌드 시각이 다른 재등록은 approve 여부와 무관하게 release_immutable 로 거절하며 아무것도 바꾸지 않는다; 승인되지 않은 버전의 내용 변경 재등록은 registered 로 남고 승인을 물려받지 않는다', async () => {
|
||||
const { openDb } = await import('../lib/db.mjs'); const { MIGRATIONS } = await import('../hal/schema.mjs'); const core = await import('../hal/core.mjs');
|
||||
const dir = tmpDir('aw-immutable-'); const d = { store: openDb(join(dir, 'hal.sqlite'), MIGRATIONS), log: () => {} };
|
||||
const base = { version: '6.6.0', mainCommit: HEX, builtAt: '2026-10-28T00:00:00Z', artifacts: ART, checks: CLEAN };
|
||||
const snap = (v) => { const r = core.getRelease(d, v); return JSON.stringify({ status: r.status, by: r.approved_by, at: r.approved_at, main: r.main_commit, built: r.built_at, artifacts: r.artifacts, checks: r.checks, notes: r.notes }); };
|
||||
eq(core.registerRelease(d, { ...base, approve: true, by: 'op' }).status, 'approved'); const s0 = snap('6.6.0');
|
||||
eq(core.registerRelease(d, { ...base }).status, 'approved', '같은 내용 재등록(approve:false) 은 멱등'); eq(snap('6.6.0'), s0); eq(core.registerRelease(d, { ...base, approve: true, by: 'op2' }).status, 'approved', '같은 내용 재등록(approve:true) 도 멱등'); eq(snap('6.6.0'), s0, '승인자·시각도 그대로');
|
||||
const bad = { ...CLEAN, testsPassed: null }; const otherArt = { hal: { file: 'hal-x.tar.gz', sha256: 'DIFFERENT', size: 2 } };
|
||||
for (const [label, p] of [['checks 변경(불량) approve:false', { ...base, checks: bad }], ['checks 변경(불량) approve:true', { ...base, checks: bad, approve: true, by: 'op' }], ['checks 변경(깨끗) approve:true', { ...base, checks: { ...CLEAN, provenanceProblems: ['x'] }, approve: true, by: 'op' }], ['artifact 변경', { ...base, artifacts: otherArt }], ['artifact 변경 approve:true', { ...base, artifacts: otherArt, approve: true, by: 'op' }], ['커밋 변경', { ...base, mainCommit: 'b'.repeat(40) }], ['빌드 시각 변경', { ...base, builtAt: '2026-10-29T00:00:00Z' }], ['notes 변경', { ...base, notes: '다른 메모' }]]) {
|
||||
await rejects(async () => core.registerRelease(d, p), 'release_immutable', label); eq(snap('6.6.0'), s0, label + ': 읽기 결과 불변');
|
||||
}
|
||||
// 승인되지 않은 버전: 내용이 바뀐 재등록은 받되 registered 로 남고, approve:true 여도 검사 규칙을 지나야 승인된다
|
||||
core.registerRelease(d, { ...base, version: '6.6.1' }); eq(core.getRelease(d, '6.6.1').status, 'registered');
|
||||
eq(core.registerRelease(d, { ...base, version: '6.6.1', checks: bad }).status, 'registered', '불량 checks 로 재등록: registered'); eq(core.getRelease(d, '6.6.1').checks.testsPassed, null, '내용은 갱신됨');
|
||||
await rejects(async () => core.registerRelease(d, { ...base, version: '6.6.1', checks: bad, approve: true, by: 'op' }), 'release_checks_failed', '불량 checks + approve:true'); eq(core.getRelease(d, '6.6.1').status, 'registered'); eq(core.getRelease(d, '6.6.1').approved_by, null, '승인을 물려받지 않는다');
|
||||
eq(core.registerRelease(d, { ...base, version: '6.6.1', approve: true, by: 'op' }).status, 'approved', '깨끗한 내용으로 재등록 + approve:true 는 승인');
|
||||
await rejects(async () => core.registerRelease(d, { ...base, version: '6.6.1', checks: bad }), 'release_immutable', '승인 뒤에는 불변'); eq(core.getRelease(d, '6.6.1').status, 'approved'); eq(core.getRelease(d, '6.6.1').checks.testsPassed, true);
|
||||
d.store.close();
|
||||
});
|
||||
|
||||
await test('HAL CLI register-release: 승인된 버전을 다른 manifest(불량 checks·다른 artifact)로 다시 등록하면 --approve 유무와 무관하게 종료 코드 1 이고 DB 의 릴리스(상태 approved·checks·artifact)는 그대로다; 같은 manifest 재등록은 멱등', async () => {
|
||||
const { openDb } = await import('../lib/db.mjs'); const { MIGRATIONS } = await import('../hal/schema.mjs'); const { spawnSync: run } = await import('node:child_process');
|
||||
const dir = tmpDir('aw-immutable-cli-'); const halData = join(dir, 'hal'); mkdirSync(halData, { recursive: true });
|
||||
const cli = (args) => run(process.execPath, ['--no-warnings', join(ROOT, 'hal', 'index.mjs'), ...args], { env: { ...process.env, ARIWEB_HAL_DATA: halData, PORT: '0' }, encoding: 'utf8', timeout: 30000 });
|
||||
const mf = (name, over = {}) => { const p = join(dir, name); mkdirSync(p, { recursive: true }); writeFileSync(join(p, 'hal-5.5.0.tar.gz'), 'tgz'); const sha = createHash('sha256').update('tgz').digest('hex'); writeFileSync(join(p, 'manifest.json'), JSON.stringify({ version: '5.5.0', mainCommit: HEX, builtAt: '2026-10-28T00:00:00Z', artifacts: { hal: { file: 'hal-5.5.0.tar.gz', sha256: sha, size: 3 } }, checks: CLEAN, ...over })); return join(p, 'manifest.json'); };
|
||||
const m1 = mf('m1'); let r = cli(['register-release', '--manifest', m1, '--approve', '--by', 'test']); eq(r.status, 0, r.stdout + r.stderr); ok(/approved/.test(r.stdout), r.stdout);
|
||||
const read = () => { const db = openDb(join(halData, 'hal.sqlite'), MIGRATIONS); const row = db.get('SELECT status, approved_by, checks_json, artifacts_json, main_commit FROM releases WHERE version=?', '5.5.0'); db.close(); return JSON.stringify(row); };
|
||||
const s0 = read(); ok(/approved/.test(s0));
|
||||
r = cli(['register-release', '--manifest', m1, '--approve', '--by', 'test2']); eq(r.status, 0, '같은 manifest 재등록은 멱등: ' + r.stdout + r.stderr); eq(read(), s0);
|
||||
r = cli(['register-release', '--manifest', m1]); eq(r.status, 0, r.stdout + r.stderr); eq(read(), s0);
|
||||
const m2 = mf('m2', { checks: { ...CLEAN, testsPassed: null } });
|
||||
for (const args of [['register-release', '--manifest', m2, '--approve', '--by', 'test'], ['register-release', '--manifest', m2]]) { r = cli(args); eq(r.status, 1, args.join(' ') + ': ' + r.stdout + r.stderr); ok(/불변|immutable|승인된/.test(r.stderr + r.stdout), args.join(' ') + ': 이유 출력: ' + (r.stderr + r.stdout).slice(0, 300)); eq(read(), s0, args.join(' ') + ': DB 불변'); }
|
||||
const m3 = mf('m3'); writeFileSync(join(dir, 'm3', 'hal-5.5.0.tar.gz'), 'other'); writeFileSync(m3, JSON.stringify({ ...JSON.parse(readFileSync(m3, 'utf8')), artifacts: { hal: { file: 'hal-5.5.0.tar.gz', sha256: createHash('sha256').update('other').digest('hex'), size: 5 } } }));
|
||||
r = cli(['register-release', '--manifest', m3, '--approve', '--by', 'test']); eq(r.status, 1, 'artifact 변경: ' + r.stdout + r.stderr); eq(read(), s0, 'artifact 변경: DB 불변');
|
||||
});
|
||||
|
||||
await test('artifact 설치 불변·fail-closed(HAL CLI register-release): 승인된 버전 A 가 있을 때 같은 버전으로 다른 바이트 B(선언 해시가 B 로 맞는 경우·A 로 거짓인 경우)를 --approve 유무와 무관하게 등록하면 종료 1 이고 대상 파일의 바이트·해시와 DB 가 A 그대로다; 새 버전의 선언 해시 불일치는 파일을 설치하지 않으며, 같은 A 재등록은 멱등', async () => {
|
||||
const { openDb } = await import('../lib/db.mjs'); const { MIGRATIONS } = await import('../hal/schema.mjs'); const { spawnSync: run } = await import('node:child_process');
|
||||
const dir = tmpDir('aw-artifact-cli-'); const halData = join(dir, 'hal'); mkdirSync(halData, { recursive: true });
|
||||
const cli = (args) => run(process.execPath, ['--no-warnings', join(ROOT, 'hal', 'index.mjs'), ...args], { env: { ...process.env, ARIWEB_HAL_DATA: halData, PORT: '0' }, encoding: 'utf8', timeout: 30000 });
|
||||
const sha = (b) => createHash('sha256').update(b).digest('hex');
|
||||
const mf = (name, bytes, { declaredSha, version = '4.4.0', checks = CLEAN } = {}) => { const p = join(dir, name); mkdirSync(p, { recursive: true }); writeFileSync(join(p, `hal-${version}.tar.gz`), bytes); writeFileSync(join(p, 'manifest.json'), JSON.stringify({ version, mainCommit: HEX, builtAt: '2026-10-28T00:00:00Z', artifacts: { hal: { file: `hal-${version}.tar.gz`, sha256: declaredSha || sha(bytes), size: bytes.length } }, checks })); return join(p, 'manifest.json'); };
|
||||
const target = join(halData, 'artifacts', '4.4.0', 'hal-4.4.0.tar.gz');
|
||||
const read = () => { const db = openDb(join(halData, 'hal.sqlite'), MIGRATIONS); const row = db.get('SELECT status, approved_by, artifacts_json FROM releases WHERE version=?', '4.4.0'); db.close(); return JSON.stringify(row); };
|
||||
const A = Buffer.from('artifact-A-bytes'); const B = Buffer.from('artifact-B-bytes-different');
|
||||
let r = cli(['register-release', '--manifest', mf('mA', A), '--approve', '--by', 'test']); eq(r.status, 0, r.stdout + r.stderr); eq(readFileSync(target).toString(), 'artifact-A-bytes'); const s0 = read(); ok(/approved/.test(s0) && s0.includes(sha(A)));
|
||||
for (const [label, manifest, args] of [['B 유효 해시 + approve', mf('mB1', B), ['--approve', '--by', 'test']], ['B 유효 해시', mf('mB2', B), []], ['B 바이트에 A 해시(거짓 선언) + approve', mf('mB3', B, { declaredSha: sha(A) }), ['--approve', '--by', 'test']], ['B 바이트에 A 해시(거짓 선언)', mf('mB4', B, { declaredSha: sha(A) }), []]]) {
|
||||
r = cli(['register-release', '--manifest', manifest, ...args]); eq(r.status, 1, label + ': ' + r.stdout + r.stderr); ok(/불변|immutable|해시|hash|승인된/.test(r.stderr + r.stdout), label + ': 이유: ' + (r.stderr + r.stdout).slice(0, 300));
|
||||
eq(readFileSync(target).toString(), 'artifact-A-bytes', label + ': 대상 바이트 A 그대로'); eq(sha(readFileSync(target)), sha(A), label + ': 대상 해시 A'); eq(read(), s0, label + ': DB 그대로');
|
||||
ok(!readdirSync(join(halData, 'artifacts', '4.4.0')).some((f) => /\.tmp/.test(f)), label + ': 임시 파일 없음');
|
||||
}
|
||||
r = cli(['register-release', '--manifest', mf('mA2', A), '--approve', '--by', 'test2']); eq(r.status, 0, '같은 A 재등록 멱등: ' + r.stdout + r.stderr); eq(readFileSync(target).toString(), 'artifact-A-bytes'); eq(read(), s0);
|
||||
// 새(미승인) 버전: 선언 해시가 바이트와 다르면 설치도 등록도 하지 않는다
|
||||
r = cli(['register-release', '--manifest', mf('mN', B, { version: '4.4.1', declaredSha: sha(A) })]); eq(r.status, 1, '새 버전 해시 불일치: ' + r.stdout + r.stderr); ok(!existsSync(join(halData, 'artifacts', '4.4.1', 'hal-4.4.1.tar.gz')), '파일 미설치'); ok(!existsSync(join(halData, 'artifacts', '4.4.1')) || !readdirSync(join(halData, 'artifacts', '4.4.1')).length, '임시 파일 없음');
|
||||
const db = openDb(join(halData, 'hal.sqlite'), MIGRATIONS); eq(db.get('SELECT version FROM releases WHERE version=?', '4.4.1'), undefined); db.close();
|
||||
});
|
||||
|
||||
await test('artifact 설치 불변·fail-closed(scripts/release.mjs register): 승인된 버전 A 위에 다른 바이트 B(유효 해시·거짓 해시) 등록은 --approve 유무와 무관하게 종료 1, 대상 바이트·DB 는 A 그대로; 같은 A 는 멱등', () => {
|
||||
const dir = tmpDir('aw-artifact-rel-'); const ver = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version; const halData = join(dir, 'hal');
|
||||
const sha = (b) => createHash('sha256').update(b).digest('hex');
|
||||
const dist = (name, bytes, declaredSha) => { const p = join(dir, name, ver); mkdirSync(p, { recursive: true }); writeFileSync(join(p, `hal-${ver}.tar.gz`), bytes); writeFileSync(join(p, 'manifest.json'), JSON.stringify({ version: ver, mainCommit: HEX, branch: 'main', builtAt: 'x', artifacts: { hal: { file: `hal-${ver}.tar.gz`, sha256: declaredSha || sha(bytes), size: bytes.length } }, checks: CLEAN, imported: [] })); return p; };
|
||||
const reg = (d, extra = []) => spawnSync(process.execPath, [join(ROOT, 'scripts', 'release.mjs'), 'register', '--hal-data', halData, '--dist', d, ...extra, '--by', 'test'], { cwd: ROOT, encoding: 'utf8' });
|
||||
const target = join(halData, 'artifacts', ver, `hal-${ver}.tar.gz`); const A = Buffer.from('rel-A'); const B = Buffer.from('rel-B-other');
|
||||
let r = reg(dist('dA', A), ['--approve']); eq(r.status, 0, r.stdout + r.stderr); eq(readFileSync(target).toString(), 'rel-A');
|
||||
const { openDb } = { openDb: null }; void openDb;
|
||||
for (const [label, d, extra] of [['B 유효 + approve', dist('dB1', B), ['--approve']], ['B 유효', dist('dB2', B), []], ['B 에 A 해시 + approve', dist('dB3', B, sha(A)), ['--approve']], ['B 에 A 해시', dist('dB4', B, sha(A)), []]]) {
|
||||
r = reg(d, extra); eq(r.status, 1, label + ': ' + r.stdout + r.stderr); eq(readFileSync(target).toString(), 'rel-A', label + ': 대상 바이트 A 그대로'); ok(!readdirSync(join(halData, 'artifacts', ver)).some((f) => /\.tmp/.test(f)), label + ': 임시 파일 없음');
|
||||
}
|
||||
r = reg(dist('dA2', A), ['--approve']); eq(r.status, 0, '같은 A 멱등: ' + r.stdout + r.stderr); eq(readFileSync(target).toString(), 'rel-A');
|
||||
});
|
||||
|
||||
await test('HAL 운영자 API POST /ops/api/releases/:version/approve 도 같은 규칙 — 검사 기록이 없는 릴리스는 release_checks_failed 로 거절, 깨끗한 릴리스는 승인', async () => {
|
||||
const { createHalDeps } = await import('../hal/deps.mjs'); const { buildApp } = await import('../hal/app.mjs'); const core = await import('../hal/core.mjs'); const ops = await import('../hal/ops.mjs'); const { invoke } = await import('../lib/http.mjs');
|
||||
const dataDir = join(tmpDir('aw-approve-app-'), 'hal'); mkdirSync(dataDir, { recursive: true });
|
||||
const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'https://hal.example.test', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: ['127.0.0.1/32'], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 3600000, emergencyTtlMinutes: 30, agentPollMs: 20, agentWaitTimeoutMs: 2000, fanoutTimeoutMs: 4000, values: {}, set(p) { Object.assign(this, p); } };
|
||||
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: async () => { throw new Error('ECONNREFUSED'); } }); const app = buildApp(d);
|
||||
const opsPw = randomBytes(12).toString('base64url'); // 실행마다 만드는 비밀번호(소스에 고정 문자열 없음)
|
||||
ops.createAccount(d, { login: 'ozops', password: opsPw, displayName: '오즈' }); const login = await invoke(app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: opsPw } }); ok(login.json.ok, JSON.stringify(login.json));
|
||||
const as = (method, path, body) => invoke(app, method, path, { body, cookies: { aw_ops: login.cookies.aw_ops }, headers: { 'x-csrf': login.json.csrf } });
|
||||
core.registerRelease(d, { version: '7.7.0', mainCommit: HEX, builtAt: 'x', artifacts: ART, checks: null });
|
||||
core.registerRelease(d, { version: '7.7.1', mainCommit: HEX, builtAt: 'x', artifacts: ART, checks: { testsPassed: true, dirty: false } });
|
||||
core.registerRelease(d, { version: '7.7.2', mainCommit: HEX, builtAt: 'x', artifacts: ART, checks: CLEAN });
|
||||
for (const v of ['7.7.0', '7.7.1']) { const r = await as('POST', `/ops/api/releases/${v}/approve`); ok(r.status >= 400 && r.status < 500, `${v}: 거절 ${r.status} ${r.text.slice(0, 200)}`); ok(/release_checks_failed/.test(r.text), `${v}: 이유 코드: ${r.text.slice(0, 200)}`); eq(core.getRelease(d, v).status, 'registered'); }
|
||||
const okr = await as('POST', '/ops/api/releases/7.7.2/approve'); eq(okr.status, 200, okr.text.slice(0, 200)); eq(core.getRelease(d, '7.7.2').status, 'approved');
|
||||
d.store.close();
|
||||
});
|
||||
|
||||
await test('release.mjs register --approve: testsPassed 가 true 가 아니면(--skip-tests 빌드의 null·없음) · dirty 가 false 가 아니면(true·없음) 승인하지 않고 DB 를 열기 전에 종료 1; 세 검사가 모두 깨끗한 manifest 만 승인된다', () => {
|
||||
const dir = tmpDir('aw-approve-reg-'); const ver = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
|
||||
const run = (label, checks) => { const dist = join(dir, 'dist-' + label, ver); mkdirSync(dist, { recursive: true }); writeFileSync(join(dist, 'manifest.json'), JSON.stringify({ version: ver, mainCommit: HEX, branch: 'main', builtAt: 'x', artifacts: {}, checks, imported: [] })); return { r: spawnSync(process.execPath, [join(ROOT, 'scripts', 'release.mjs'), 'register', '--hal-data', join(dir, 'hal-' + label), '--dist', dist, '--approve', '--by', 'test'], { cwd: ROOT, encoding: 'utf8' }), hal: join(dir, 'hal-' + label) }; };
|
||||
for (const [label, checks, re] of [['tests-null', { ...CLEAN, testsPassed: null }, /테스트|testsPassed/], ['tests-missing', (() => { const c = { ...CLEAN }; delete c.testsPassed; return c; })(), /테스트|testsPassed/], ['dirty-true', { ...CLEAN, dirty: true }, /dirty|깨끗/], ['dirty-missing', (() => { const c = { ...CLEAN }; delete c.dirty; return c; })(), /dirty|깨끗/]]) {
|
||||
const { r, hal } = run(label, checks); eq(r.status, 1, label + ': ' + r.stdout + r.stderr); ok(re.test(r.stderr + r.stdout), label + ': 이유 출력: ' + (r.stderr + r.stdout).slice(0, 300)); ok(!existsSync(join(hal, 'hal.sqlite')), label + ': DB 를 열기 전에 멈춘다');
|
||||
}
|
||||
const { r, hal } = run('clean', CLEAN); eq(r.status, 0, 'clean: ' + r.stdout + r.stderr); ok(/approved/.test(r.stdout), 'clean: 승인 등록: ' + r.stdout); ok(existsSync(join(hal, 'hal.sqlite')));
|
||||
});
|
||||
|
||||
done();
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* test/webmaker-visual-editor.test.mjs — Ariweb Suite 0.2.0: 도입 모듈 Webmaker 1.8.0 의 직접 편집(시각 편집기) 회귀 검증.
|
||||
* Suite 쪽에서 확인할 것: ① 도입된 순수 모듈(modules/webmaker/lib/visual-editor.js · public/visual-editor-core.js)이 Suite 체크아웃에서 그대로 동작하고,
|
||||
* ② Suite 전용 변경(동시 편집 rev 충돌 409, [Ariweb Suite 변경])이 직접 편집 저장 경로에도 실제로 적용되며(서버를 띄워 확인), ③ 화면에 필수 접근성 조작이 있다.
|
||||
* 브라우저 없이 Node 로만 검사한다. Webmaker 자체 전체 테스트(modules/webmaker: npm test)는 별도로 실행한다.
|
||||
*/
|
||||
import { test, eq, ok, done, sleep } from '../lib/test.mjs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFileSync, existsSync, writeFileSync, mkdirSync, mkdtempSync, chmodSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
// 비밀값(설정 토큰·비밀번호)은 실행마다 만들고 변수로만 넘긴다 — 소스에 고정 문자열로 두지 않고 출력하지도 않는다
|
||||
const SETUP_TOKEN = randomBytes(24).toString('base64url'); const PW = { alice: randomBytes(12).toString('base64url'), root: randomBytes(12).toString('base64url') };
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const WM = join(ROOT, 'modules', 'webmaker');
|
||||
|
||||
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.2.1(HAL 재인증 안내 위치 수정, 도입 모듈 변경 없음) 이다', () => {
|
||||
for (const f of ['lib/visual-editor.js', 'public/visual-editor-core.js', 'public/wm-ve-canvas.js', 'public/visual-editor-ui.js']) ok(existsSync(join(WM, f)), f);
|
||||
eq(JSON.parse(readFileSync(join(WM, 'package.json'), 'utf8')).version, '1.8.0');
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.2.1');
|
||||
const imported = JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')).sources.find((s) => s.name === 'webmaker');
|
||||
const pin = (readFileSync(join(ROOT, 'scripts', 'import-sources.mjs'), 'utf8').match(/name: 'webmaker'[^\n]*pin: '([0-9a-f]{40})'/) || [])[1]; ok(pin, '도입 스크립트의 webmaker pin');
|
||||
if (imported.upstream && imported.upstream.workingTreeDirty) eq(imported.commit, '3b10859884ef2fd0f2af11934ab6c16370fd64b8', '개발 중 기록(--allow-dirty)은 0.1.1 기준 커밋 위의 작업 트리');
|
||||
else { eq(imported.commit, pin, '깨끗한 기록은 스크립트의 고정 커밋과 같다'); eq(imported.upstream && imported.upstream.headCommit, pin, 'headCommit = pin'); }
|
||||
eq(imported.upstream && imported.upstream.version, '1.8.0');
|
||||
ok(imported.files.some((f) => f.from === 'lib/visual-editor.js'), '도입 기록에 새 파일');
|
||||
});
|
||||
|
||||
await test('lib/visual-editor: 캔버스 변환은 스크립트를 실행하지 않게 하고, 저장 검증은 새 스크립트·핸들러를 거절한다', () => {
|
||||
const ve = require(join(WM, 'lib', 'visual-editor.js'));
|
||||
const page = '<!doctype html><html><head><title>t</title><script>boot()</script></head><body><h1 onclick="a()">안녕</h1><p>본문</p></body></html>';
|
||||
const canvas = ve.prepareCanvas(page, { baseHref: 'http://h/x/', session: 's', nonce: 'n', parentOrigin: 'http://h', runtime: '1' });
|
||||
ok(/<script type="text\/x-wm-ve-disabled">boot\(\)<\/script>/.test(canvas)); ok(canvas.includes('data-wm-ve-on-click="a()"') && !/\sonclick=/.test(canvas));
|
||||
const body = ve.splitDocument(canvas).body.replace('본문', '본문 수정');
|
||||
const r = ve.validateVisualSave({ original: page, body, maxBytes: 1e6 });
|
||||
ok(r.html.includes('<h1 onclick="a()">안녕</h1><p>본문 수정</p>') && r.html.startsWith('<!doctype html><html><head><title>t</title><script>boot()</script></head><body>'));
|
||||
let err = null; try { ve.validateVisualSave({ original: page, body: body + '<script>x()</script>', maxBytes: 1e6 }); } catch (e) { err = e; } eq(err && err.status, 400); ok(/스크립트/.test(err.message));
|
||||
});
|
||||
|
||||
await test('visual-editor-core: 실행 취소/다시 실행·저장 상태·메시지 검증·구성 요소가 Suite 체크아웃에서도 같다', () => {
|
||||
const core = require(join(WM, 'public', 'visual-editor-core.js'));
|
||||
const h = core.createHistory(10); h.reset('a'); h.push('b'); eq(h.undo(), 'a'); eq(h.redo(), 'b');
|
||||
const s = core.createSaveState(); s.markDirty(); ok(s.beginSave()); s.saveOk(); eq(s.state(), 'saved');
|
||||
const win = {}; ok(core.validateMessage({ source: win, origin: 'null', data: core.makeMessage('s', 'changed', {}) }, { expectedSource: win, expectedOrigin: 'null', session: 's', direction: 'fromCanvas' }));
|
||||
ok(!core.validateMessage({ source: win, origin: 'null', data: core.makeMessage('t', 'changed', {}) }, { expectedSource: win, expectedOrigin: 'null', session: 's', direction: 'fromCanvas' }));
|
||||
for (const id of ['section', 'heading', 'paragraph', 'button', 'image', 'divider', 'spacer']) ok(core.isSafeComponentHtml(core.componentHtml(id)), id);
|
||||
});
|
||||
|
||||
await test('화면(index.html)에 직접 편집 전환·저장·실행 취소·다시 실행·버리기·팔레트·속성 조작이 접근성 속성과 함께 있고 Suite 배너는 남아 있다', () => {
|
||||
const html = readFileSync(join(WM, 'public', 'index.html'), 'utf8');
|
||||
ok(/<button id="btnVisual"[^>]*aria-pressed="false"/.test(html), '직접 편집 전환 버튼');
|
||||
for (const id of ['veSave', 'veUndo', 'veRedo', 'veDiscard', 'veClose', 'veState', 'vePalette', 'veInspector']) ok(html.includes(`id="${id}"`), id);
|
||||
ok(/id="veState"[^>]*aria-live="polite"/.test(html), '저장 상태는 aria-live');
|
||||
ok(html.includes('/visual-editor-core.js') && html.includes('/visual-editor-ui.js'));
|
||||
ok(html.includes('id="ariwebBanner"'), 'Suite 전용 배너 보존');
|
||||
const app = readFileSync(join(WM, 'public', 'app.js'), 'utf8'); ok(app.includes('[Ariweb Suite 변경] 포털 SSO 진입') && app.includes('VisualEditor'), 'app.js 에 Suite 변경과 편집기 연결이 함께');
|
||||
});
|
||||
|
||||
await test('server.js [Ariweb Suite 변경]: 직접 편집 저장도 동시 편집 rev 충돌(409)을 돌려주고, 맞는 rev 면 저장된다 (실제 서버)', async () => {
|
||||
const PORT = 4500 + Math.floor(Math.random() * 90); const tmp = mkdtempSync(join(tmpdir(), 'aw-wm-ve-')); const B = `http://127.0.0.1:${PORT}`; const jar = {};
|
||||
writeFileSync(join(tmp, 'setup.token'), SETUP_TOKEN + '\n', { mode: 0o600 });
|
||||
// 사이트 생성이 띄우는 AI 작업에 개발 PC 의 진짜 claude 가 쓰이지 않게(비용·시간) 바로 실패하는 가짜 실행 파일을 쓴다
|
||||
const fake = join(tmp, 'fake-claude.sh'); writeFileSync(fake, '#!/bin/sh\nexit 1\n', { mode: 0o755 });
|
||||
const child = spawn(process.execPath, [join(WM, 'server.js')], { env: { ...process.env, CLAUDE_BIN: fake, PORT: String(PORT), HOST: '127.0.0.1', DATA_ROOT: tmp, DATA_DIR: join(tmp, 'sites'), SITES_ROOT: join(tmp, 'www'), PUBLIC_BASE_URL: B, WEBMAKER_SETUP_PORT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let out = ''; child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { out += c; });
|
||||
try {
|
||||
for (let i = 0; i < 100 && !/listening on/.test(out); i++) await sleep(100);
|
||||
ok(/listening on/.test(out), out.slice(-300));
|
||||
const api = async (p, init = {}, who = 'a') => { const headers = { 'Content-Type': 'application/json', referer: `${B}/`, ...(init.headers || {}) }; if (jar[who]) headers.cookie = jar[who]; const r = await fetch(B + p, { ...init, headers, redirect: 'manual' }); const sc = r.headers.get('set-cookie'); if (sc) jar[who] = sc.split(';')[0]; let b = {}; try { b = await r.json(); } catch (_) {} return { status: r.status, b }; };
|
||||
eq((await api('/api/auth/signup', { method: 'POST', body: JSON.stringify({ id: 'alice', password: PW.alice, name: 'Alice' }) })).status, 200);
|
||||
eq((await api('/api/setup/login', { method: 'POST', body: JSON.stringify({ token: SETUP_TOKEN }) }, 's')).status, 200);
|
||||
ok((await api('/api/setup/admin/create', { method: 'POST', body: JSON.stringify({ id: 'root1', name: 'Root', password: PW.root }) }, 's')).status < 300);
|
||||
eq((await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ id: 'root1', password: PW.root }) }, 'r')).status, 200);
|
||||
eq((await api('/api/admin/credits/alice', { method: 'POST', body: JSON.stringify({ type: 'add', amount: 50000, memo: 'test' }) }, 'r')).status, 200);
|
||||
const mk = await api('/api/sites', { method: 'POST', body: JSON.stringify({ title: 'VE', purpose: 'p', content: 'c' }) }); ok(mk.status < 300, JSON.stringify(mk.b).slice(0, 200)); const id = mk.b.id;
|
||||
mkdirSync(join(tmp, 'www', id), { recursive: true }); writeFileSync(join(tmp, 'www', id, 'index.html'), '<!doctype html><html><head><title>t</title></head><body><h1>제목</h1></body></html>');
|
||||
for (let i = 0; i < 100 && (await api(`/api/sites/${id}`)).b.running; i++) await sleep(100); // 생성 때 시작된(실패하는) AI 작업이 끝날 때까지
|
||||
const g = await api(`/api/sites/${id}/visual/index.html`); eq(g.status, 200); ok(typeof g.b.rev === 'number', '캔버스 응답에 rev');
|
||||
const stale = await api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1>충돌</h1>', baseHash: g.b.hash, rev: g.b.rev + 5 }) });
|
||||
eq(stale.status, 409, JSON.stringify(stale.b)); ok(/다른 사용자|먼저 저장/.test(stale.b.error)); ok(stale.b.conflict && stale.b.conflict.currentRev === g.b.rev, 'rev 충돌 요약');
|
||||
const okPut = await api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1>저장</h1>', baseHash: g.b.hash, rev: g.b.rev }) });
|
||||
eq(okPut.status, 200, JSON.stringify(okPut.b)); ok(readFileSync(join(tmp, 'www', id, 'index.html'), 'utf8').includes('<h1>저장</h1>'));
|
||||
const s = await api(`/api/sites/${id}`); eq(s.b.versions[s.b.versions.length - 1].kind, 'visual-edit'); ok(s.b.rev > g.b.rev, '저장 뒤 rev 증가');
|
||||
|
||||
// ---- 검토 수정(독립 검토 차단 항목 7): 직접 편집 저장은 유한한 숫자 rev 가 필수 — 없음·문자열·null·NaN 문자열은 400(fail-closed), 파일은 그대로 ----
|
||||
const g2 = await api(`/api/sites/${id}/visual/index.html`); eq(g2.status, 200); const before = readFileSync(join(tmp, 'www', id, 'index.html'), 'utf8');
|
||||
for (const [label, body] of [['rev 없음', { body: '<h1>rev 없음</h1>', baseHash: g2.b.hash }], ['문자열 rev', { body: '<h1>문자열</h1>', baseHash: g2.b.hash, rev: String(g2.b.rev) }], ['null rev', { body: '<h1>null</h1>', baseHash: g2.b.hash, rev: null }], ['NaN 문자열', { body: '<h1>NaN</h1>', baseHash: g2.b.hash, rev: 'NaN' }], ['빈 문자열', { body: '<h1>빈</h1>', baseHash: g2.b.hash, rev: '' }]]) {
|
||||
const r = await api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
eq(r.status, 400, `${label}: ${JSON.stringify(r.b)}`); ok(/rev/.test(r.b.error), label + ' 안내에 rev'); eq(r.b.revRequired, true, label);
|
||||
}
|
||||
eq(readFileSync(join(tmp, 'www', id, 'index.html'), 'utf8'), before, 'rev 없는 저장은 파일을 바꾸지 않는다');
|
||||
// 다른 파일의 저장으로 rev 가 오르면(cross-file) 옛 rev 로 보낸 저장은 409 — 파일 hash 가 맞아도 조용히 덮어쓰지 않는다
|
||||
writeFileSync(join(tmp, 'www', id, 'about.html'), '<!doctype html><html><head><title>a</title></head><body><p>소개</p></body></html>');
|
||||
const ga = await api(`/api/sites/${id}/visual/about.html`); eq(ga.status, 200); eq(ga.b.rev, g2.b.rev, '같은 사이트 rev');
|
||||
const saveA = await api(`/api/sites/${id}/visual/about.html`, { method: 'PUT', body: JSON.stringify({ body: '<p>소개 수정</p>', baseHash: ga.b.hash, rev: ga.b.rev }) }); eq(saveA.status, 200, JSON.stringify(saveA.b)); ok(saveA.b.rev > ga.b.rev);
|
||||
const cross = await api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1>교차</h1>', baseHash: g2.b.hash, rev: g2.b.rev }) });
|
||||
// currentRev 는 saveA 응답의 rev 이상(원격 저장소가 없으면 gitRecord 가 뒤늦게 retryPush → store.save 로 rev 를 한 번 더 올릴 수 있다)
|
||||
eq(cross.status, 409, 'about.html 저장으로 오른 rev: ' + JSON.stringify(cross.b)); ok(cross.b.conflict && cross.b.conflict.currentRev >= saveA.b.rev && cross.b.conflict.yourRev === g2.b.rev, '충돌 요약(현재 rev·내 rev): ' + JSON.stringify(cross.b.conflict));
|
||||
eq(readFileSync(join(tmp, 'www', id, 'index.html'), 'utf8'), before, '교차 충돌 저장은 파일을 바꾸지 않는다');
|
||||
|
||||
// ---- 검토 수정(차단 항목 6, Suite 쪽): 사이트 기록 저장이 실패하면 되돌리고 500 — rev 도 저장 전 값 그대로(되돌리기 저장은 rev 를 올리지 않는다), 같은 rev·baseHash 로 재시도 성공 ----
|
||||
const g3 = await api(`/api/sites/${id}/visual/index.html`); const rev3 = (await api(`/api/sites/${id}`)).b.rev; eq(g3.b.rev, rev3);
|
||||
chmodSync(join(tmp, 'sites'), 0o555);
|
||||
let fail; try { fail = await api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1>기록 실패</h1>', baseHash: g3.b.hash, rev: g3.b.rev }) }); } finally { chmodSync(join(tmp, 'sites'), 0o755); }
|
||||
eq(fail.status, 500, JSON.stringify(fail.b)); ok(/되돌/.test(fail.b.error));
|
||||
eq(readFileSync(join(tmp, 'www', id, 'index.html'), 'utf8'), before, '페이지 되돌림'); eq((await api(`/api/sites/${id}`)).b.rev, rev3, '실패한 저장은 rev 를 바꾸지 않는다');
|
||||
const retry = await api(`/api/sites/${id}/visual/index.html`, { method: 'PUT', body: JSON.stringify({ body: '<h1>재시도</h1>', baseHash: g3.b.hash, rev: g3.b.rev }) }); eq(retry.status, 200, '같은 rev·baseHash 로 재시도: ' + JSON.stringify(retry.b)); ok(retry.b.rev > rev3);
|
||||
} finally { try { child.kill('SIGTERM'); } catch (_) {} }
|
||||
});
|
||||
|
||||
done();
|
||||
Reference in New Issue
Block a user