diff --git a/.env.example b/.env.example index 49db91b8..5ad19038 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ APP_ENV=production APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.10 +APP_VERSION=7.0.11 # 리버스 프록시(AWS ALB, CloudFront, Cloudflare, nginx, ngrok) 뒤에서 HTTPS 를 인식하려면 # 신뢰할 프록시를 지정합니다. 미설정 시 아무 프록시도 신뢰하지 않습니다(기존 동작). @@ -143,6 +143,16 @@ G7_UPDATE_PENDING_PATH= # 상태·수동 복구: php artisan ext-static:status / 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」 # G7_STATIC_CACHE=true +# SEO 봇 캐시 상한 (config/core.php seo_cache_limits). 봇 판정은 User-Agent 뿐이라 위장이 +# 가능하고, 캐시 키에 쿼리가 들어가므로 값만 바꾼 반복 요청이 매번 렌더·저장을 유발한다. +# 렌더 예산을 넘긴 요청은 오류가 아니라 일반 SPA 응답(X-SEO-Cache: BYPASS)을 받는다. +# G7_SEO_CACHE_MAX_QUERY_PARAMS=10 +# G7_SEO_CACHE_MAX_QUERY_LENGTH=512 +# G7_SEO_CACHE_MAX_VARIANTS_PER_PATH=50 +# G7_SEO_CACHE_MAX_ENTRIES=20000 +# G7_SEO_RENDER_MISSES_PER_MINUTE=60 +# G7_SEO_STATS_RECORDS_PER_MINUTE=300 + # 코어 업데이트 경로 목록 (선택). 재정의 시 **전체 목록을 다시 적는다** — 부분값은 기본 항목을 # 통째로 대체한다 (예: excludes 에서 build/ext 가 빠지면 --prune 이 정적 게시본을 지운다). # 기본값은 config/app.php 의 update 절과 같다. diff --git a/.env.testing.example b/.env.testing.example index dea1ab52..b5706dca 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -3,7 +3,7 @@ APP_ENV=testing APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.10 +APP_VERSION=7.0.11 # 리버스 프록시(AWS ALB, CloudFront, Cloudflare, nginx, ngrok) 뒤에서 HTTPS 를 인식하려면 # 신뢰할 프록시를 지정합니다. 미설정 시 아무 프록시도 신뢰하지 않습니다(기존 동작). diff --git a/AGENTS.md b/AGENTS.md index 8d6901f5..2cf633b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ | [service-provider.md](docs/backend/service-provider.md) | 서비스 프로바이더 안전성 | DB 접근 전 .env 파일 존재 확인 필수 | | [service-repository.md](docs/backend/service-repository.md) | Service-Repository 패턴 | RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지) | | [settings-multilingual-enrichment.md](docs/backend/settings-multilingual-enrichment.md) | Settings 카탈로그 다국어 자동 보강 | settings JSON 의 다국어 카탈로그 라벨(_cached_name 등)은 카탈로그 빌드 시점에 보강 | -| [static-asset-publishing.md](docs/backend/static-asset-publishing.md) | 부트스트랩 리소스 정적 게시 (Static Asset Publishing) | 게시물: public/build/ext/{cache_version}/ — 수명주기 이벤트와 운영자 cu... | +| [static-asset-publishing.md](docs/backend/static-asset-publishing.md) | 부트스트랩 리소스 정적 게시 (Static Asset Publishing) | 게시물: public/build/ext/{cache_version}/ — 수명주기 이벤트·자산 URL ... | | [translatable-seeders.md](docs/backend/translatable-seeders.md) | 다국어 시더 인터페이스 (Translatable Seeders) | 다국어 JSON 컬럼(name 등)을 시드하는 확장 entity 시더는 TranslatableSeede... | | [user-overrides.md](docs/backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array $trackable... | | [validation.md](docs/backend/validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) | @@ -149,7 +149,7 @@ | 대상 | 진입점 | 문서/엔드포인트 | |------|--------|----------------| -| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 325 | +| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 328 | ### 확장 API 레퍼런스 (14개 확장, 자동 스캔) @@ -198,7 +198,7 @@ | `sirsoft-verification_nhnkcp` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-verification_nhnkcp/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/README.md) | 훅 0 · 라우트 2 · 모델 2 · 레이아웃 1 | | `gnuboard7-hello_admin_template` | 템플릿 | [AGENTS.md](templates/_bundled/gnuboard7-hello_admin_template/AGENTS.md) | [docs/](templates/_bundled/gnuboard7-hello_admin_template/docs/README.md) | 훅 0 · 라우트 1 · 모델 0 · 레이아웃 8 | | `gnuboard7-hello_user_template` | 템플릿 | [AGENTS.md](templates/_bundled/gnuboard7-hello_user_template/AGENTS.md) | [docs/](templates/_bundled/gnuboard7-hello_user_template/docs/README.md) | 훅 0 · 라우트 1 · 모델 0 · 레이아웃 8 | -| `sirsoft-admin_basic` | 템플릿 | [AGENTS.md](templates/_bundled/sirsoft-admin_basic/AGENTS.md) | [docs/](templates/_bundled/sirsoft-admin_basic/docs/README.md) | 훅 0 · 라우트 29 · 모델 0 · 레이아웃 145 | +| `sirsoft-admin_basic` | 템플릿 | [AGENTS.md](templates/_bundled/sirsoft-admin_basic/AGENTS.md) | [docs/](templates/_bundled/sirsoft-admin_basic/docs/README.md) | 훅 0 · 라우트 29 · 모델 0 · 레이아웃 146 | | `sirsoft-basic` | 템플릿 | [AGENTS.md](templates/_bundled/sirsoft-basic/AGENTS.md) | [docs/](templates/_bundled/sirsoft-basic/docs/README.md) | 훅 0 · 라우트 40 · 모델 0 · 레이아웃 166 | @@ -278,6 +278,57 @@ | `"item"`, `"index"` | `"item_var"`, `"index_var"` | | iteration 내 if 순서 무시 | if가 iteration보다 먼저 평가됨 | +### 위젯 값 형태 ↔ apply 경로 + +편집기 위젯 중 **값이 스칼라가 아닌 것**(`image` → `{url,size,repeat,position}` 객체)을 값 슬롯이 하나뿐인 apply 경로에 연결하면, 객체가 그대로 `props[key]` 에 저장되어 소비 컴포넌트가 `[object Object]` 를 URL 로 받는다. 이 결함은 예외도 콘솔 오류도 서버 로그도 남기지 않는다 — 깨진 이미지 요청은 SPA catch-all 때문에 404 조차 아니라 **200(HTML)** 이고, 편집기 위젯의 미리보기는 정상이라 조작 중에는 이상이 보이지 않는다. 화면의 엑박이 유일한 증상이다. + +| ❌ 금지 | ✅ 올바른 사용 | +|--------|---------------| +| `image` 위젯을 `classToken`·`cssVar` 에 연결하거나 `apply` 를 생략 | 값이 스칼라로 축약되는 경로만 — `propValue`(맨 url 문자열) 또는 `backgroundImage` 를 포함한 `styleProp` 묶음(4속성 분해) | +| 축약 판정을 **값 형태 sniffing** 으로 게이트 | `widget === 'image'` 게이트 — `isImageValueObject` 는 4키 중 **하나만** 있어도 참이라 `props.tooltip = {position:'left'}` 같은 정당한 객체 prop 을 이미지로 오인해 삭제한다 | +| 축약 분기를 writer 마다 복붙 | 공용 헬퍼 `scalarizeImageValue` 단일 지점 — 이번 결함의 원인이 정확히 "방어가 `applyStyleProp` 안에만 있었다" 이다 | +| 쓰기만 축약하고 읽기는 그대로 | `propValue` 역해석이 저장 문자열을 `{url}` 로 되감는다. **표현식 문자열도 감싼다** — 감싸지 않으면 빈 피커로 보이고 업로드 1클릭에 그 표현식이 소리 없이 소실된다 | +| 저장되지 않는 컨트롤을 `disabled` 로 남김 | 단일 값 슬롯이면 표시모드 버튼을 **컨테이너째 미렌더** — `disabled` 는 *일시적* 비활성의 시각 언어라 "URL 을 넣으면 살아나겠지" 라는 거짓 정보를 준다 | +| 저장되지 않는 `size` 를 미리보기에 반영 | 단일 슬롯 미리보기는 `contain` 고정 — 실제 표시 방식은 소비 컴포넌트의 클래스가 정하므로 편집기가 흉내내면 거짓 미리보기다 | +| 코어 엔진의 느슨한 판정식을 **백필**에 이식 | 백필·런타임 방어는 **엄격 판정식**(키 집합 ⊆ 4키 **AND** `url` 키 존재). 느슨한 판정식은 레이아웃 전수에서 2,219건을 매치하고 그 대부분이 정상 props 다(`{className,name,size}` 674건 · `{name,size}` 552건) — 엄격 판정식의 매치는 0건이었다 | +| 백필 순회 범위를 **노드 키 allowlist** 로 정의 | `props` 키 진입 시 모드 ON / `style` 키 진입 시 OFF 인 **모드 플래그 전역 재귀** — 실측상 `props` 안에 컴포넌트 노드가 1,150건 살아 allowlist 는 원리상 완결 불가다 | +| 두 방어선(런타임 `Img` / 백필)의 판정 강도를 따로 정함 | 완전히 같은 엄격도 — 어긋나면 한쪽만 통과하는 값이 생긴다 | + +> 상세: [editor-spec.md](docs/extension/editor-spec.md) "controls — 재사용 스타일 컨트롤" +> 정적 검사가 두 축을 함께 본다 — editor-spec 선언과 코어 엔진의 축약 분기 실존. 선언 축만 보면 코어 분기가 삭제돼도 통과하는데 결함은 부활한다. 기설치본 보정은 DB 데이터 상태라 정적 검사 대상이 아니며, 업그레이드 스텝의 회귀 테스트가 그 축을 잠근다 + +### 편집기 컨트롤의 데이터 연결 값 보호 + +레이아웃의 prop 자리에는 `{{_global.settings?.general?.site_logo_url}}` 같은 **표현식 문자열**이 저장돼 있을 수 있다. 위젯은 그 값을 해석하지 못해 **빈 컨트롤**로 보이고, 조작하는 순간 그 연결이 사라진다 — 값 하나가 아니라 **환경설정과의 연결**이 끊기고, 원문이 화면 어디에도 남지 않아 되돌릴 수단조차 없다. 예외도 콘솔 오류도 남지 않는다. + +| ❌ 금지 | ✅ 올바른 사용 | +|--------|---------------| +| 바인딩 판정·배지·잠금을 위젯마다 구현 | `ControlRenderer` 의 **공용 게이트 한 곳** — 새 위젯을 등록해도 자동 적용된다 | +| 해제 경로 없이 잠그기만 | 「직접 지정으로 바꾸기」로 **명시적으로만** 연다 | +| 「직접 지정으로 바꾸기」를 편도로 두기 | 「되돌리기」 동반 — 해제 직후엔 취소로, 값을 이미 넣은 뒤엔 원문 복구로 동작한다 | +| 파괴적 조작 표면 중 일부만 잠그기 | 그 위젯의 **전 표면** — 업로드·제거·목록 선택뿐 아니라 **관리 모달 진입**까지. 같은 동작이 두 곳에 렌더되면 하나만 잠근 것은 판단이 아니라 누락이다 | +| 위젯이 자체 처리를 가지면서 공용 게이트도 통과 | 둘 중 하나 — 자체 처리 위젯은 제외 목록에 등재하고, 그 위젯이 **원문 표시·해제·복구 셋을 모두** 제공하는지 확인한다 | +| 자체 분기가 공용 해제 경로를 막음 | 해제된 뒤에는 위젯이 평소대로 편집 가능해야 한다 | +| 해석 못 하는 값을 위젯이 흉내내 표시 | 원문 배지로 대체 — 흉내내면 거짓 컨트롤이다 | + +> 상세: [editor-spec.md](docs/extension/editor-spec.md) "데이터 연결 값 보호" + +### 상속·주입 노드의 편집 표면 + +저장 마스킹(`stripInheritedFromLayoutContent`)은 상속(base)·주입(extension) 출처 노드를 **정상 저장에서도 항상 폐기**한다. 그래서 그 노드를 편집할 수 있게 열어 두면 편집분이 오류도 경고도 없이 사라진다 — 저장은 200 으로 성공하고 `history.clear()` 로 undo 도 불가능하다. + +| ❌ 금지 | ✅ 올바른 사용 | +|--------|---------------| +| 잠금 판정에 편집 모드 예외를 둠 (`editMode !== 'route' && isNodeLocked(...)`) | 출처 잠금이 `data_bound` 보다 **항상 우선** — "route 는 종전 동작을 한 줄도 바꾸지 않는다" 는 계약이 아니라 보수성 선언이었고, 그 보수성이 곧 결함이었다 | +| `data_bound` 의 **의미**를 좁혀 해결 | 의미는 그대로 두고(= 편집 가능, 텍스트만 잠금) **어느 노드가 그렇게 분류되는가**만 좁힌다 — 형제 경로(DnD·오버레이)가 그 계약에 실제로 의존한다 | +| 게이트 조건을 표면마다 복사 | 단일 판정 헬퍼 `isEditableLockKind` / `resolveDndDenial` 만 호출 — 한 곳만 빠져도 같은 소실 결함이 재발한다 | +| ⓘ 메뉴와 드래그 핸들만 막고 끝냄 | 인라인 편집(더블클릭)·복제·키보드 `Delete`·잘라내기까지 전 표면 — 키보드 경로는 ⓘ 메뉴를 거치지 않아 무방비였다 | +| 드래그 거부를 `zone === null` 에 기댄 간접 방어로 | commit 직전 최종 가드 — stale 슬롯이나 유효 zone 이 들어오면 그대로 이동 commit 된다 | +| 거부된 드래그가 `activeDragPath` 를 남김 | 거부 시 즉시 비운다 — 남으면 DragOverlay 가 잡힌 노드를 따라다녀 "옮길 수 있다" 는 거짓 어포던스를 준다 | +| 조상 산출 구현을 파일마다 복제 | `collectAncestors` 단일 출처 — 잠금 판정의 입력이 갈리면 "핸들은 있는데 드래그는 거부" 같은 어긋남이 조용히 생긴다 | + +차단된 노드에는 「🔒 공통 레이아웃 편집」·「🔒 확장 편집」 진입 어포던스가 대신 뜬다. 상속 노드의 「데이터 영역」 라벨은 사라지지만 후자가 행동 가능한 정보이므로 순증이다. + ### 컴포넌트 Props | 금지 | 올바른 사용 | @@ -476,6 +527,24 @@ catch-all shadow 는 보호처럼 보인다는 점이 위험하다. 가려진 > 상세: [validation.md](docs/backend/validation.md), [service-repository.md](docs/backend/service-repository.md), [frontend/security.md](docs/frontend/security.md) +### 서버가 조건에 따라 다른 형태의 200 을 돌려주는 엔드포인트 + +같은 엔드포인트가 설정·상태에 따라 **다른 형태의 2xx** 를 낸다면, 프론트는 형태를 판별한 뒤에 읽어야 한다. 한 형태만 가정하면 다른 형태에서 필드 접근이 그 자리에서 던지고, 그 원문이 오류 박스에 영문으로 노출된다. 서버는 정상 응답했으므로 **서버 로그에는 흔적이 없다** — 깨진 것은 클라이언트뿐이다. + +| ❌ 금지 | ✅ 올바른 사용 | +|--------|---------------| +| 응답 타입을 한 형태로 고정 선언하고 `response.data.user.*` 를 바로 읽기 | 판별 유니온으로 두 형태를 표현 (`LoginResult` = `{status:'authenticated', user}` \| `{status:'two_factor_required', challenge}`) | +| 저장 지점에 형태 가드 없이 `setToken(response.data.token)` | 비어 있지 않은 문자열만 저장 — `localStorage` 는 무엇을 넣든 문자열로 바꾸므로 `undefined` 가 `"undefined"`(truthy)로 남아 이후 모든 요청이 `Bearer undefined` 로 나가 401 이 된다 | +| 대체 형태 분기를 사용자 경로에만 두고 관리자 경로는 그대로 | 관리자 경로가 먼저 500 이 되면 설정을 되돌릴 수단까지 사라진다 — 두 경로 동시 적용 | +| `onSuccess` 후속 액션에 조건 없이 성공 처리를 나열 | 대체 형태에서 실행되면 안 되는 액션마다 `if:"{{!response.대체형태플래그}}"` | +| `onSuccess`·시퀀스 안에서 방금 저장한 상태(`_global.*`/`_local.*`)를 형제 액션의 `if`·값으로 재독 | 그 시점 컨텍스트는 아직 갱신 전이다 — `{{response.*}}` 만 읽는다 (`onSuccess` 결과는 `handleSequence` 의 상태 동기화 대상이 아니다) | +| 서버가 제공하는 기능의 프론트 화면 부재를 "미사용" 으로 간주 | 토글을 켠 사이트에서만 드러나는 미구현이다 — 서버 토글 ↔ 화면 존재를 전수 대조 | + +착수 전 전수조사 축은 **"서버가 대체 형태 2xx 를 내는 엔드포인트 ↔ 프론트 처리 여부"** 다. 그리고 **"서버 토글 ON 시 프론트 화면 존재 여부"** 를 함께 본다 — 2단계 인증은 도입 후 여러 버전 동안 입력 화면이 없었고, 그 토글을 켠 사이트에서만 전원 로그인 불가로 나타났다(공개 #133). + +> 상세: [auth-system.md "2단계 인증 로그인"](docs/frontend/auth-system.md) +> 정적 검사로는 잡히지 않는다 — 응답 변종은 서버 분기의 의미 판정이므로 코드 리뷰에서 확인한다. + ### 제3자 라이브러리는 쓰기 경로를 지정받는다 제3자 라이브러리는 캐시·임시파일 경로를 설정하지 않으면 **자기 설치 폴더**(vendor 안)나 시스템 temp 에 쓴다. 표준 Laravel 배포는 웹서버에 `storage/` 와 `bootstrap/cache` 만 쓰기 권한을 주므로 그 쓰기는 실패하는데, 실패가 예외가 아니라 PHP 경고라 Laravel `HandleExceptions` 가 `ErrorException` 으로 승격시켜 요청이 500 이 된다. 해시당 1회만 기록하는 라이브러리라면 캐시가 영영 생기지 않아 **매 요청이 같은 실패를 반복**한다 — 개발 머신에서는 vendor 가 쓰기 가능해 한 번 성공하고 끝나므로 재현되지 않는다 (공개 #125). @@ -495,6 +564,25 @@ catch-all shadow 는 보호처럼 보인다는 점이 위험하다. 가려진 > 상세: [storage-driver.md](docs/extension/storage-driver.md) "제3자 라이브러리에 절대 경로를 넘길 때", [service-repository.md](docs/backend/service-repository.md) "서비스가 제3자 라이브러리를 붙일 때" +### 공개 자산 직접 URL 은 운영자 선언으로만 열린다 + +저장된 파일의 주소를 직접 URL(CDN)로 내보내면 서버 스트리밍 경로가 통째로 건너뛰어진다. 그 경로에 게이트가 있으면 게이트가 사라지고, 대상 저장소가 실제로는 비공개면 발급된 주소가 403 이 된다. 둘 다 예외도 로그도 남기지 않는다 — 그 이미지들만 조용히 깨지거나, 막아야 할 파일이 조용히 열린다. + +| ❌ 금지 | ✅ 올바른 사용 | +|--------|---------------| +| `filesystems.disks.{disk}.url` 이 설정돼 있다는 이유로 그 디스크를 공개로 간주해 직접 URL 발급 | 운영자가 명시 선언한 `core.storage.public_asset_disk` 와 **행 disk 가 일치할 때만** 직접 URL. `url` 설정 유무는 "URL 을 만들 수 있는가" 이지 "익명 읽기가 되는가" 가 아니다 | +| 권한·게이트(비밀글·소유권·발행 상태·본인인증·다운로드 카운트)가 걸린 첨부 경로를 직접 URL 로 전환 | 게이트가 없는 완전 공개 자산 카테고리에만 배선. 게이트가 있는 경로의 응답 `url` 칸은 그 게이트를 통과하는 서빙 URL 로 채운다 | +| 공개 판정을 `PublicAssetDisk::resolve()` 밖에서 사본으로 재작성 | 판정은 그 단일 지점 경유. `''`·`'none'`·config 에 없는 고아 디스크가 등가 비교만으로 통과하는 것을 막는다 | +| 행 disk 로 `withDisk()` 를 무검증 호출 | 존재 확인 후 폴백 — 공개 자산 디스크가 플러그인 등록 디스크일 수 있고, 그 플러그인 비활성화 시 무인증 공개 서빙 라우트가 **500** 이 된다 | +| 직접 URL 로 전환하면서 "행 삭제 = 접근 차단" 전제를 그대로 둠 | 직접 URL 은 행과 무관하게 파일을 가리킨다 — 회수 수단(파일 삭제)과 그 한계를 문서에 남긴다 | +| 서버가 스스로 발급하는 자산 주소(프록시 서빙 URL)를 절대 URL 로 만듦 | 사이트 상대 경로 — 절대면 저장 규칙(외부 URL 차단)이 자기 주소를 외부로 차단해 image 위젯의 업로드 → 저장이 422 가 되고(배경은 `style` 이라 스캔되지 않아 로고에서만 드러났다), 저장된 레이아웃이 발급 시점 도메인·스킴에 묶인다 | +| 저장 규칙의 "외부" 판정을 스킴 접두 문자열로만 두고 자기 주소를 예외 없이 차단 | 사이트 자기 host(`app.url`)·선언된 공개 자산 디스크 host 는 외부가 아니다 — `SiteAssetHosts` 단일 판정(정규화 후 host 등가 비교). 접두 비교는 `host.evil.com`·`host@evil.com` 을 통과시키고, 요청 `Host` 헤더는 위조 가능해 근거가 아니다 | + +이 결함군의 유일한 증상은 화면이다: 직접 URL 이 403 을 돌려주면 그 이미지들만 깨지고, 게이트가 우회되면 정상 200 이 나간다. 서버 로그에는 어느 쪽도 흔적이 없다. + +> 상세: [storage-driver.md](docs/extension/storage-driver.md) "공개 자산 전용 디스크 분리" +> 이 규칙은 정적 검사로 판정할 수 없다 — "이 경로에 게이트가 있는가"·"행 disk 와 설정이 같은 축인가" 는 의미 판정이므로, 회귀 테스트(특히 비공개 S3 행 + 공개 URL 설정 조합에서 서빙 라우트가 나오는 케이스)가 잠근다 + ### 직접 전송로는 자격증명을 스스로 싣는다 레이아웃의 `globalHeaders` 는 **데이터소스(DataSourceManager)와 `apiCall` 핸들러(ActionDispatcher)** 에만 적용된다. 코어 ApiClient(`G7Core.api.*`)를 직접 부르거나 `fetch` 를 쓰는 경로는 그 배선을 타지 않아 `Authorization` 과 `Accept-Language` 만 실린다. 게이트된 엔드포인트를 그렇게 부르면 서버는 정당한 사용자를 거부하는데, 화면은 버튼·썸네일을 이미 내준 뒤라 **예외도 콘솔 오류도 없이 그 자리만 비는 것**이 유일한 증상이다. @@ -642,7 +730,7 @@ TLS 가 앞단에서 종단되고 앱에는 HTTP 로 전달되는 구성(AWS ALB | 버전 키·서명 키를 기본 TTL 로 `put()` | `PERSISTENT_TTL_SECONDS`(10년) 명시 — `forever()` 는 `CacheInterface` 밖(공개 표면 변경), `put(…, 0)` 은 forget | | 서명 스코프를 렌더 템플릿만으로 나눔 | `{템플릿}@{호스트명}` — 다중 서버 공유 캐시에서 서버 간 mtime 차이로 요청마다 재게시가 왕복한다 | | `config:cache` / `route:cache` / `event:cache` / `optimize` 를 헬퍼 밖에서 `Artisan::call` | `ConfigCacheHelper::rebuild()` / `RouteCacheHelper::rebuild()` (내부가 `withPreservedContainer`) — 이 명령들은 새 Application 을 부팅하며 전역 `Container` 를 일회용 앱으로 바꿔 놓아, 그 뒤 등록되는 `app()->terminating()` 재게시 예약이 종료되지 않는 앱에 걸려 사라진다 | -| 코어 업데이트 흐름에서 현재 프로세스의 버전·update 목록을 `config('app.version')`·`config('app.update.*')` 로 판독 | spawn 자식은 부모가 비우지 않은 이전 버전 config 캐시로 부팅한다 — 버전은 `CoreVersionChecker::getCoreVersion()`(env 우선), update 목록은 캐시 부팅이면 `CoreUpdateService::freshDiskUpdateConfig()`, 부모는 spawn 직전 `ConfigCacheHelper::clear()` | +| 코어 업데이트 흐름에서 현재 프로세스의 버전·update 목록을 `config('app.version')`·`config('app.update.*')` 로 판독 | spawn 자식은 부모가 비우지 않은 이전 버전 config 캐시로 부팅한다 — 버전은 `CoreVersionChecker::getCoreVersion()`(업데이트 트리 안에서만 env 우선), update 목록은 캐시 부팅이면 `CoreUpdateService::freshDiskUpdateConfig()`, 부모는 spawn 직전 `ConfigCacheHelper::clear()` + `PackageManifestCacheHelper::clear()` | 이 결함군은 예외도 로그도 남기지 않는다 — 게시본이 정상 200 으로 옛 내용을 내보내는 것, 또는 매일 전체 재생성이 일어나는 것이 유일한 증상이다. 재게시 누락의 안전망은 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」의 [지금 다시 만들기](`POST /api/admin/settings/static-cache/republish`)이며, 상태 판정은 `ExtensionStaticCacheService::statusReport()` 한 곳이 CLI·API·화면에 공급한다. @@ -664,6 +752,27 @@ TLS 가 앞단에서 종단되고 앱에는 HTTP 로 전달되는 구성(AWS ALB > 상세: [module-settings.md](docs/extension/module-settings.md) "카탈로그 병합 설정의 공개 응답" +### vendor 를 교체한 뒤 새 PHP 프로세스를 띄우기 전에는 패키지 매니페스트를 비운다 + +Laravel 은 `bootstrap/cache/packages.php` 가 있으면 stale 여부를 검사하지 않고 그대로 읽어 provider 를 `new` 한다. 그래서 vendor 를 바꾼 뒤 그 파일을 남겨 두면 다음에 부팅하는 프로세스가 새 vendor 에 없는 클래스를 찾다 부팅 단계에서 죽고, 예외는 부팅 전이라 앱 로그에 남지 않는다. + +| 금지 | 올바른 사용 | +|------|------------| +| vendor 교체 뒤 `proc_open`·`config:cache`·`route:cache` 등 새 부팅을 `bootstrap/cache/{packages,services}.php` 정리 없이 실행 | 부팅 직전 `PackageManifestCacheHelper::clear()`(재생성까지 필요하면 `rebuild()`) — `ConfigCacheHelper::clear()` 와 짝으로 | +| 정리 로직을 호출부마다 `@unlink` 로 복제 | 헬퍼 단일 지점 — `clearAllCaches()` 도 같은 헬퍼를 쓴다 | +| 자식 프로세스 보호를 부모 코드에만 두기 | 부모는 이미 배포된 옛 코드일 수 있다 — 새 버전의 `bootstrap/app.php` 가 `G7_UPDATE_IN_PROGRESS` 를 보고 스스로 비운다(App\ 클래스 미참조·실패 무시) | +| 코어 버전 판정에서 프로세스 env `APP_VERSION` 을 무조건 우선 | env 우선은 `CoreUpdateContext::isInProgress()` 인 프로세스 트리 안에서만 — 업데이트 전에 뜬 `artisan serve`·큐 워커는 옛 값을 물고 있다 | +| 업데이트 커맨드 argv 판정을 SAPI 게이트 없이 두기 | argv 는 명령줄 SAPI(`cli`·`phpdbg`)에서만 읽는다 — CGI/FPM 은 `register_argc_argv=On` 이면 `$_SERVER['argv']` 를 쿼리스트링에서 채워(`?x+core:update`) 비인증 웹 요청이 업데이트 트리로 판정되고, 자가 치유가 요청마다 매니페스트를 지운다. env 플래그 채널은 웹에서 주입할 수 없으므로 그대로 둔다 | +| 매니페스트 삭제 실패를 `@unlink` 로 삼키기 | `clear()` 가 지우지 못한 경로를 돌려주고 spawn 직전 호출부가 업그레이드 로그에 경고로 남긴다 — 권한·소유권 불일치면 자식도 같은 이유로 실패해 증상은 제보와 같고, 이 경고가 원인을 가리키는 유일한 흔적이다 | +| 업데이트 트리 판정을 지점마다 다시 작성 | `App\Support\CoreUpdateContext` 단일 SSoT — `CoreServiceProvider::isCoreUpdateInProgress()` 도 위임이다. `bootstrap/app.php` 의 복제본은 부팅 전이라 불가피한 예외이며 주석으로 상호 참조한다 | +| 자동 비활성화 로그의 `core_version` 을 `config('app.version')` 으로 적기 | 판정과 같은 `CoreVersionChecker::getCoreVersion()` — 로그와 판정 근거가 갈리면 운영자가 원인을 특정할 수 없다 | +| "이미 있으니 건너뛴다" 분기(인스톨러 vendor 재사용)가 산출물의 출처를 보지 않음 | `installed.json` 의 `dev`/`dev-package-names` 로 출처를 보고 경고 카드·로그를 남기며, 재사용 경로에서도 컴파일 캐시를 정리한다 | +| 코어 업데이트를 마치고 상주 워커에 신호를 보내지 않음 | Step 11·핸드오프·단독 재개 사후 단계에서 `signalQueueRestart()` | + +이 결함군은 예외도 로그도 남기지 않는다 — 자식 프로세스가 부팅 단계에서 죽어 부모가 핸드오프로 멈추는 것, 또는 업데이트 직후 확장이 `incompatible_core` 로 꺼지는 것이 유일한 증상이다. 후자는 관리자 템플릿이 대상이면 복구 UI 자체에 도달할 수 없어 자가 회복 경로가 없다. + +> 상세: [core-update-system.md](docs/backend/core-update-system.md) "spawn 전 캐시 정리 계약(3계층)" · [extension-update-system.md](docs/extension/extension-update-system.md) "판정의 단일 출처와 이 플래그가 게이트하는 것" + ### 설정 주입과 `.env` 우선 관리자 환경설정(`storage/app/settings/*.json`)은 `SettingsServiceProvider` 를 거쳐 `config()` 에 주입되어 `.env` 유래 값을 덮는다. `.env` 를 배포 기준값으로 관리하는 설치(컨테이너·IaC·다중 서버)를 위해 그 소유권을 **키 단위**로 되돌리는 옵트인 스위치(`G7_ENV_PRIORITY`)가 있고, 판정은 `App\Support\EnvPriority` 가 단독으로 소유한다. @@ -727,6 +836,19 @@ TLS 가 앞단에서 종단되고 앱에는 HTTP 로 전달되는 구성(AWS ALB > 상세: [pagination.md](docs/backend/pagination.md) +### 입력 크기에 비례해 커지는 메모리는 PHP 기본 한계 안에서 잰다 + +브라우저에서 잘 돌던 알고리즘·상수를 PHP 로 옮길 때 시간 상한(O(n·m) 가드)만 함께 오고 **메모리 상한은 오지 않는다.** PHP 배열은 원소당 수십 바이트라 (줄 수)² 표는 2,350줄에서 약 150MB 이고, 운영 서버의 기본 `memory_limit` 은 128M 이다. 개발 머신(512M)에서는 통과하고 서버에서만 500 이 되며, 예외는 `FatalError` 한 줄뿐이라 어느 요청의 어떤 입력이었는지 로그에 남지 않는다. + +| ❌ 금지 | ✅ 올바른 사용 | +|--------|---------------| +| 카운트·길이만 쓰는 LCS/DP 에 전체 표 + backtrack | 두 행(또는 한 행) DP 로 길이만 구한다 — 추가 = 새 줄 − LCS, 삭제 = 옛 줄 − LCS 라 숫자가 같다 | +| 브라우저 구현의 임계값(`DIFF_MAX_LINES` 등)을 그대로 이식하고 "가드가 있다" 고 간주 | 그 임계에서의 PHP 메모리를 실측하고 128M 아래인지 확인 — 임계가 시간 축만 막는 경우가 있다 | +| "인접 버전 비교는 변경 영역이 작다" 는 가정으로 최악 경로를 비워 둠 | 변경 영역은 양끝이 동시에 바뀌면 파일 전체다 — 편집기가 `comment` 키를 떼어내는 첫 저장이 정확히 그 형태 | +| 메모리 회귀 테스트를 "통과했다" 로만 잠금 | `memory_get_peak_usage()` 증가량 상한을 단언하고, 수정 전 값(146MB)을 테스트 메시지에 남긴다 | + +> 상세: [service-repository.md "입력 크기에 비례하는 메모리"](docs/backend/service-repository.md) + ### 검색 인덱스 재생성(리인덱싱) | ❌ 금지 | ✅ 올바른 사용 | @@ -982,6 +1104,7 @@ Added/Changed/Fixed 내 항목이 10개를 초과하면 `####` 서브 헤딩으 - `## [버전] - YYYY-MM-DD` 헤더 필수 - `### Added` / `### Changed` / `### Fixed` / `### Removed` 카테고리 사용 - 최신 버전이 파일 상단 +- 한 버전 섹션 안에 같은 카테고리 헤딩은 한 번만 — 브랜치마다 섹션 머리에 자기 `### Fixed` 블록을 얹고 병합이 양쪽을 이어 붙이면 같은 제목이 두 번 남는다. 항목은 기존 카테고리 블록 끝에 추가하고, 병합 뒤 두 번째 블록이 보이면 그 항목을 첫 블록에 합치고 헤딩만 지운다(항목 삭제 금지). 같은 버전 헤더 중복·허용 밖 카테고리도 같은 결함군이며 정적 검사가 working 버전 섹션에서 차단한다. 공개 배포된 과거 섹션은 소급 수정하지 않는다 --- @@ -1414,6 +1537,8 @@ lazy 번들(편집기/devtools)이 코어 런타임(DynamicRenderer·엔진 싱 - 확장 에셋 절대경로는 `getBuiltAssetAbsolutePaths()`(=`getModulePath()`/`getPluginPath()`) 만 쓴다. `base_path("modules"|"plugins")` 직접 조립은 `_bundled` 경로 오해석 → 빈 번들. - concat 루프는 확장별 try/catch — 실패 확장만 skip 하고 나머지 병합을 지속한다. - 번들 파일명에 확장 캐시 버전을 포함(`{type}.{version}.{js,css}`). 조합 변경 시 version bump → 새 파일명 → 자동 재생성. 구파일 GC 는 `ext-bundles:cleanup` + `{module,plugin,template}:cache-clear` 가 담당한다. prod 은 version-in-path 디스크 캐시, 비프로덕션은 매 요청 concat. +- 프로덕션은 캐시 파일 존재를 **빌드보다 먼저** 확인한다. 캐시 키는 `(type, kind, version)` 만으로 계산되는데 빌드를 앞세우면 캐시 적중에도 매 요청 활성 확장 열거·파일 읽기가 일어나고, 원본이 소실되면 멀쩡한 캐시를 두고 503 이 된다. 캐시 미스는 같은 키의 잠금으로 1회 빌드에 수렴하고 잠금 뒤 캐시를 재확인하며, 잠금 대기 초과·저장소 장애는 실패가 아니라 각자 빌드로 폴백한다. +- 병합 결과가 비어도 선언 산출물이 전부 존재하거나 선언이 0이면 0바이트 캐시 파일을 만들어 정적 게시까지 간다. 만들지 않으면 그 구성의 자산 URL 이 API 로 폴백해 방문자의 모든 페이지 로드가 PHP 를 거친다. 캐시하지 않는 것은 산출물 소실(503 판정 보존)·병합 단계에서 건너뛴 확장이 있는 결과(굳지 않도록 매 요청 재시도)·디스크 쓰기 실패뿐이다. ### 빌드 명령어 (Artisan) diff --git a/CHANGELOG.md b/CHANGELOG.md index f393be09..2aaa9852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,57 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [7.0.11] - 2026-09-09 + +### Added + +- 2단계 인증을 켠 사이트의 로그인 화면에 인증번호 입력 단계가 추가되었습니다. 비밀번호를 확인하면 같은 카드 안에서 인증번호 입력으로 넘어가고, 「인증번호 다시 받기」로 새 번호를 받거나 「처음부터」로 되돌아갈 수 있습니다. 관리자 로그인 화면도 같은 흐름으로 동작합니다. (#133 @keidichoi-gif 님께서 제보해주셨습니다.) + +### Changed + +- 인증번호를 보내지 못해 로그인을 마칠 수 없을 때의 응답이 「인증에 실패했습니다」(401)에서 「인증번호를 보내지 못했습니다」(503)로 바뀌었습니다. 자격 증명은 올바른데도 로그인 실패로 안내되어 사용자는 비밀번호를 의심하며 같은 시도를 반복하고, 운영자는 메일 설정이 깨진 사실을 알 방법이 없었습니다. (#133 @keidichoi-gif 님께서 제보해주셨습니다.) +- 설치 마법사가 이미 준비된 `vendor/` 에 개발용 패키지가 섞여 있으면 설치 환경 확인 단계와 설치 로그에서 이를 알리고 정리 명령을 안내합니다. 설치는 그대로 진행되며, 종전에는 아무 표시 없이 그대로 사용해 이후 코어 업데이트 중단의 원인이 되었습니다. +- 설치 안내서의 Composer 설치 명령이 운영용 구성(`--no-dev`)으로 바뀌었고, `vendor/` 가 없으면 설치 마법사가 자동 설치하므로 이 단계를 생략해도 된다는 안내가 추가되었습니다. +- 설치 중 Composer 단계가 실패했을 때 안내하는 수동 명령도 운영용 구성(`--no-dev`)으로 바뀌었습니다. 종전 안내를 그대로 따르면 개발용 패키지가 섞인 상태로 설치되어 이후 코어 업데이트가 중단될 수 있었습니다. +- 코어 업데이트가 운영 `vendor/` 에 개발용 패키지가 있는지 확인해 로그에 남깁니다. 의존성 변경이 없어 재설치를 건너뛰는 경우에는 개발용 패키지가 그대로 남는다는 경고와 정리 명령을 함께 안내합니다. +- 코어 업데이트가 끝나면 실행 중인 큐 워커에 재시작 신호를 보내, 워커를 손수 재시작하지 않아도 새 코드가 반영됩니다. +- 「공개 자산 스토리지」를 지정하면 레이아웃 배경 이미지가 그 저장소에 저장됩니다. 이미 올라간 파일은 옮기지 않으며 각자 저장된 위치에서 그대로 서비스됩니다. 기존 첨부 저장소와 공개 자산 저장소를 같은 곳으로 지정한 경우에는 설정을 켜는 순간 이전에 올린 이미지도 저장소 주소로 바뀝니다. 자세한 사항은 관리자 > 환경설정 > 드라이버의 안내를 참고하세요. +- PHP 의존성 패키지를 최신 안정 버전으로 갱신했습니다 (Laravel 12.69, Reverb 1.11, Scout 11.6, Monolog 3.11, Symfony 7.4.18 등). 각 패키지의 보안 수정과 오류 수정이 함께 반영되며, Composer 를 실행할 수 없는 환경을 위한 코어와 이커머스 모듈의 `vendor-bundle.zip` 도 같은 구성으로 다시 만들었습니다. + +### Fixed + +- 2단계 인증을 켠 사이트에서 로그인이 되지 않던 문제를 수정했습니다. 로그인 화면에 영문 오류(`Cannot read properties of undefined`)가 표시되고 그 뒤로는 진행할 방법이 없었으며, 관리자 로그인은 서버 오류(500)가 되어 설정을 되돌릴 수단까지 사라졌습니다. 이제 인증번호 입력 단계가 표시되고 관리자도 같은 흐름으로 로그인할 수 있습니다. (#133 @keidichoi-gif 님께서 제보해주셨습니다.) +- 로그인 응답을 잘못 해석해 사용할 수 없는 인증 정보가 브라우저에 저장되던 문제를 수정했습니다. 그 뒤로는 화면을 새로 열 때마다 「세션이 만료되었습니다」 안내와 함께 로그인 화면으로 되돌아갔습니다. +- 본인인증 화면에서 로그인용 인증 요청을 처리할 수 있어, 그 요청으로는 다시 로그인할 수 없게 되던 문제를 수정했습니다. 로그인용 인증 요청은 이제 로그인 화면에서만 처리됩니다. +- 로그인 시도가 많아 잠시 차단될 때 영문 안내(`Too Many Attempts.`)가 그대로 표시되던 문제를 수정했습니다. 이제 다시 시도할 수 있는 시각과 함께 사이트 언어로 안내합니다. +- 로그인 중 네트워크가 끊겼을 때 영문 원문(`Network Error`)이 표시되던 문제를 수정했습니다. +- 로그인 시도 초과로 계정이 잠겼을 때 해제 시각이 화면에 표시되지 않던 문제를 수정했습니다. 언제 다시 시도할 수 있는지 알 수 없어 계속 눌러 보게 되었습니다. +- 안내 문구 안에 날짜·시각 서식을 넣으면 그 값만 비어 보이던 문제를 수정했습니다. +- 확장 스크립트·스타일을 합쳐 주는 공개 주소가 이미 만들어 둔 파일이 있어도 요청마다 다시 합치던 문제를 수정했습니다. 같은 주소를 반복해서 부르면 활성 확장 수에 비례하는 파일 읽기와 처리가 매번 일어나 서버 부하로 이어질 수 있었습니다. 이제 만들어 둔 파일이 있으면 그것을 바로 내보내고, 처음 만드는 순간에만 한 번 합칩니다. 합친 결과가 비어 있는 경우(스타일이 없는 확장만 설치된 기본 구성)도 파일로 두어 웹서버가 직접 내보냅니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2191) +- 검색엔진 봇에게 대신 그려 주는 페이지가 주소의 물음표 뒤 값만 바꿔 계속 요청하면 매번 새로 그려지고 그 결과가 무한정 저장되던 문제를 수정했습니다. 봇으로 위장한 요청이 서버 부하와 저장 공간 증가로 이어질 수 있었습니다. 이제 한 IP 가 분당 일정 횟수를 넘겨 새 페이지를 요청하면 그 초과분에는 일반 페이지를 주고, 저장 개수에도 상한을 둡니다. 상한값은 서버 설정으로 바꿀 수 있습니다. +- 관리자 SEO 통계와 `seo:stats` 명령이 항상 0 으로 표시되던 문제를 수정했습니다. 캐시 적중·미적중이 기록되지 않고 있었습니다. +- 확장의 스크립트·스타일 파일을 읽지 못하는 상태가 되면 그 확장의 자산이 빠진 결과가 저장되어, 파일 문제가 풀린 뒤에도 확장을 다시 설치하거나 설정을 바꿀 때까지 계속 빠진 채로 남던 문제를 수정했습니다. 이제 읽지 못한 확장이 있으면 그 결과를 저장하지 않아 원인이 사라지는 즉시 정상으로 돌아오며, 어느 확장을 읽지 못했는지 서버 기록에 남습니다. +- 개발용 패키지가 포함된 상태로 설치된 사이트에서 코어 업데이트가 업그레이드 스텝 단계에서 「Class ... not found」 오류와 「수동 재개」 안내로 멈추던 문제를 수정했습니다. 이전 설치본이 만든 패키지 목록 캐시가 새 `vendor/` 로 교체된 뒤에도 남아 있던 것이 원인이며, 이제 업데이트는 스텝 실행 전에 그 캐시를 함께 비웁니다. 비우지 못한 파일이 있으면 업데이트 로그에 남겨 권한 문제를 바로 확인할 수 있습니다. (sir.kr 커뮤니티에서 제보해주신 내용입니다.) +- 이미 배포된 이전 버전(7.0.9·7.0.10)에서 업데이트를 시작하는 경우에도 같은 중단이 나지 않도록, 새 버전의 스텝 프로세스가 시작할 때 이전 패키지 목록 캐시를 스스로 정리합니다. +- `php artisan serve` 나 큐 워커처럼 오래 떠 있는 프로세스가 기동 시점의 버전 값을 계속 쓰면서, 업데이트 직후 확장이 「코어 버전 미달」로 잘못 비활성화되던 문제를 수정했습니다. 업데이트 진행 중이 아닐 때는 프로세스 환경값이 아니라 설정의 버전을 기준으로 판정합니다. +- 코어 업데이트가 마지막 정리 단계에서 「Target class [...] does not exist」 오류로 실패하고 백업으로 되돌아가던 문제를 수정했습니다. 설정 캐시를 다시 만드는 과정이 내부적으로 띄우는 일회용 애플리케이션이 그 뒤의 모든 작업까지 넘겨받은 채로 남아 있던 것이 원인입니다. +- 업데이트 진행 중 판정이 명령줄에서 실행될 때만 커맨드 이름을 참고하도록 좁혔습니다. 일부 PHP 설정(`register_argc_argv` 활성)에서는 웹 주소의 쿼리만으로 업데이트가 진행 중인 것처럼 보이게 할 수 있었습니다. +- 레이아웃 편집기에서 올린 배경 이미지의 주소가 「공개 자산 스토리지」 설정과 무관하게 항상 사이트 서버를 거쳐 전달되던 문제를 수정했습니다. 파일은 설정한 저장소에 정상 저장되지만 방문자 요청은 매번 사이트 서버가 저장소에서 받아 다시 내보내고 있어, CDN 을 쓰도록 설정해도 그 효과를 볼 수 없었습니다. 이제 「공개 자산 스토리지」를 지정하면 그 저장소에 올라간 배경 이미지가 저장소 주소로 직접 전달됩니다. 설정하지 않은 경우와 설정 이전에 올린 이미지는 종전과 똑같이 동작합니다. (#134 @lyg-kaban 님께서 제보해주셨습니다.) +- 확장이 카테고리별로 다른 저장소를 쓰도록 설정했을 때, 확장 개발용 파일 주소·경로 조회가 그 설정을 반영하지 않고 언제나 기본 저장소를 보던 문제를 수정했습니다. +- 화면 편집기에서 로고처럼 이미지 주소를 직접 받는 항목에 이미지를 지정하면 그림이 깨져 보이던 문제를 수정했습니다. 지정한 주소 대신 알 수 없는 값이 저장된 것이 원인이며, 편집기 화면에서는 미리보기가 정상으로 보여 저장 전에는 알아챌 수 없었습니다. 이제 주소가 그대로 저장되고, 이미 잘못 저장된 사이트도 업데이트하면 자동으로 정정됩니다. 업데이트 전에도 화면은 정상 표시됩니다. (#135 @lyg-kaban 님께서 제보해주셨습니다.) +- 화면 편집기의 「탭 표시 게시판 수」처럼 숫자를 받는 항목이 「지원하지 않는 컨트롤」로 표시되어 편집할 수 없던 문제를 수정했습니다. 이제 숫자 입력칸이 나타납니다. +- 화면 편집기에서 공통 레이아웃이나 확장이 넣은 요소를 고쳐도 저장되지 않던 문제를 수정했습니다. 편집은 되는데 저장 후 되돌아가 있었고 실행 취소로도 되살릴 수 없었습니다. 이제 그런 요소는 편집 대신 「공통 레이아웃 편집」·「확장 편집」 진입 안내가 표시되며, 옮기기·복제·삭제도 같은 기준으로 막힙니다. +- 화면 편집기가 열리자마자 빈 화면이 되는 경우가 있던 문제를 수정했습니다. 편집기가 잠깐 보였다가 사라졌고, 새로고침에 따라 나타났다 사라졌다 했습니다. +- 화면 편집기에서 환경설정과 연결된 항목(예: 헤더 로고)을 무심코 조작하면 그 연결이 소리 없이 끊기던 문제를 수정했습니다. 이제 그런 항목은 연결된 값이라는 안내와 원래 값이 표시되고, 「직접 지정으로 바꾸기」를 눌러야 편집할 수 있으며 「되돌리기」로 언제든 원래 연결로 복구할 수 있습니다. +- `vendor-bundle:build-all --check` 가 설치되지 않은 번들 확장을 「소스 경로 없음」으로 건너뛰어, 그 확장의 vendor 번들이 갱신되지 않은 상태가 점검을 통과하던 문제를 수정했습니다. 이제 설치 여부와 무관하게 배포 원본 기준으로 판정합니다. +- 레이아웃 편집기의 헤더 「로고 이미지」에서 파일을 올린 뒤 저장하면 「HTTPS 프로토콜 URL은 허용되지 않습니다」로 거부되던 문제를 수정했습니다. 업로드한 이미지의 주소가 사이트 전체 주소 형태로 발급되어 레이아웃 저장의 외부 주소 차단에 걸리고 있었으며, 배경 이미지는 검사 대상이 아니라 증상이 없었습니다. 이제 업로드 주소는 사이트 상대 경로로 발급되고, 사이트 자신의 주소와 「공개 자산 스토리지」로 지정한 저장소의 주소는 외부로 취급하지 않습니다. +- 큰 공통 레이아웃(예: 사용자 화면 공통 레이아웃)을 레이아웃 편집기에서 처음 저장할 때 「저장 중 네트워크 오류가 발생했습니다 / Server Error」로 끝나던 문제를 수정했습니다. 버전 이력의 변경량(+N/-N 줄)을 계산하는 과정이 레이아웃 크기의 제곱에 비례하는 메모리를 써서 PHP 기본 메모리 한도(128MB)인 서버에서 실패했습니다. 이제 메모리가 레이아웃 크기에 비례하며 표시되는 변경량 숫자는 그대로입니다. +- 레이아웃 편집기의 「확장 편집」 모드에서 저장하면 그 확장이 화면에 끼워 넣던 요소(예: 헤더의 통화 선택기)가 사라지던 문제를 수정했습니다. 내용을 바꾸지 않고 저장해도 일어났고 오류나 안내가 없어 사이트에서 그 요소가 없어진 뒤에야 알 수 있었습니다. 이제 끼워 넣은 요소가 그대로 보존되며, 되돌릴 자리를 확인할 수 없는 요소가 있으면 저장을 막고 그 사실을 안내합니다. 이미 사라진 사이트는 「버전 기록」에서 직전 버전을 복원하면 됩니다. +- 레이아웃 편집기에서 저장이나 복원을 두 번 이상 한 뒤 「초기화」나 충돌 안내의 「최신 불러오기」를 누르면 최신이 아닌 옛 내용이 올라오던 문제를 수정했습니다. 그 화면을 다시 저장하면 최신 내용을 옛 내용이 덮을 수 있었고, 충돌 안내 뒤에는 새로고침 전까지 저장이 계속 거부됐습니다. +- 레이아웃 편집기의 충돌 안내가 「최신 버전: -1」 로 표시되던 문제를 수정했습니다. 이제 실제 최신 버전 번호가 표시됩니다. +- 레이아웃·레이아웃 확장의 버전 기록에 저장자가 항상 「알 수 없음」으로 표시되던 문제를 수정했습니다. 이제 저장한 관리자가 기록됩니다. +- 버전 기록에서 복원한 직후, 복원 전 화면을 열어 둔 다른 관리자가 저장하면 복원 결과가 충돌 안내 없이 덮이던 문제를 수정했습니다. 이제 복원도 저장과 같이 충돌 검사의 기준 번호를 올립니다. + ## [7.0.10] - 2026-09-06 ### Added diff --git a/INSTALL.md b/INSTALL.md index aedfdf78..6c8f5f10 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -148,12 +148,16 @@ git clone https://github.com/gnuboard/g7.git cd g7 ``` -### 3단계: Composer 의존성 설치 +### 3단계: Composer 의존성 설치 (선택) ```bash -composer install +composer install --no-dev --optimize-autoloader ``` +> 이 단계는 생략해도 됩니다 — `vendor/` 가 없으면 설치 마법사가 운영용 구성(`--no-dev`)으로 자동 설치합니다. +> +> 개발용 패키지가 포함되는 `composer install`(옵션 없음)의 결과를 운영 사이트에 그대로 쓰지 마세요. 이미 그렇게 설치했다면 위 명령을 한 번 더 실행하면 개발용 패키지만 제거됩니다. + ### 4단계: 환경 설정 파일 생성 ```bash @@ -200,15 +204,17 @@ https://github.com/gnuboard/g7 다운로드한 ZIP 파일을 원하는 위치에 압축 해제합니다. -### 4단계: Composer 의존성 설치 +### 4단계: Composer 의존성 설치 (선택) -터미널에서 압축 해제된 디렉토리로 이동한 후 실행합니다. +터미널에서 압축 해제된 디렉토리로 이동한 후 실행합니다. 이 단계는 생략해도 됩니다 — `vendor/` 가 없으면 설치 마법사가 운영용 구성(`--no-dev`)으로 자동 설치합니다. ```bash cd g7-버전명 -composer install +composer install --no-dev --optimize-autoloader ``` +> 개발용 패키지가 포함되는 `composer install`(옵션 없음)의 결과를 운영 사이트에 그대로 쓰지 마세요. 이미 그렇게 설치했다면 위 명령을 한 번 더 실행하면 개발용 패키지만 제거됩니다. + ### 5단계: 환경 설정 파일 생성 ```bash @@ -289,7 +295,7 @@ unzip g7-release.zip # 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경 ls -la -# (필요 시) mv g7-7.0.10 g7 +# (필요 시) mv g7-7.0.11 g7 # ZIP 파일 정리 (선택) rm g7-release.zip @@ -343,13 +349,23 @@ http://도메인/install |------|------| | 0. 환영 | 언어 선택 및 `storage` 권한 검증 — 권한 부족 시 인스톨러가 상대경로 기반 명령을 자동 안내 | | 1. 라이선스 | 동의 | -| 2. 요구사항 | PHP 8.2+ 및 필수 확장 확인 | +| 2. 요구사항 | PHP 8.2+ 및 필수 확장 확인 + 권장 항목(OPcache · Composer 의존성 구성) 안내 | | 3. 환경 설정 | DB 정보 + 관리자 계정 + **Vendor 설치 방식** 선택 | | 4. 확장 선택 | 템플릿/모듈/플러그인 선택 (의존성 자동 해결) | | 5. 설치 실행 | "설치 시작" 버튼 클릭 후 진행 상황 모니터링 | `.env` 파일 생성과 `storage` / `bootstrap/cache` / `public/build` 쓰기 권한 부여는 인스톨러가 환경(소유자 일치 여부 등)에 맞춰 Step 0 및 이후 단계에서 자동 안내하므로, 사전 SSH 작업으로 수행할 필요 없습니다. +**Step 2 — Composer 의존성 구성 (선택 항목):** + +이미 `vendor/` 가 준비되어 있으면 설치 마법사는 그것을 그대로 씁니다. 이때 그 `vendor/` 가 개발용 패키지까지 포함한 설치(`composer install`, 옵션 없음)인지 확인해 알려 줍니다. + +- **운영용 구성 (개발용 패키지 없음)**: 권장 상태입니다. 조치가 필요 없습니다. +- **개발용 패키지 N개 포함**: 설치는 그대로 진행됩니다. 운영에 사용할 서버라면 설치를 마친 뒤 프로젝트 루트에서 `composer install --no-dev --optimize-autoloader` 를 한 번 실행하세요. 그대로 두면 이후 코어 업데이트가 `vendor/` 를 교체할 때 이전 패키지 목록 캐시와 어긋나 업데이트가 중단될 수 있습니다. +- **vendor 없음**: 설치 마법사가 운영용 구성으로 자동 설치하므로 조치가 필요 없습니다. + +이 항목은 설치를 차단하지 않습니다. + **Step 3 — Vendor 설치 방식 선택:** - **자동 (권장)**: 환경을 자동 감지하여 번들 모드로 폴백합니다. diff --git a/README.ko.md b/README.ko.md index 28e085cf..62759593 100644 --- a/README.ko.md +++ b/README.ko.md @@ -6,7 +6,7 @@ A modern, extensible CMS platform built with Laravel + React
-
+
@@ -369,6 +369,8 @@ cp .env.example .env
# 3. 브라우저에서 /install 접속 → 설치 마법사 진행
```
+> Composer 의존성은 설치 마법사가 운영용 구성(`--no-dev`)으로 자동 설치합니다. 직접 설치하려면 `composer install --no-dev --optimize-autoloader` 를 사용하세요.
+
> 상세 설치 가이드는 [INSTALL.md](INSTALL.md)를 참조하세요.
---
@@ -521,6 +523,7 @@ cp .env.example .env
+
diff --git a/README.md b/README.md
index e6ded2ed..1b4483b4 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
The next generation of Gnuboard — Korea's most widely used open-source CMS
-
-
- Ze?(ut=Ne,Ne=null):ut=Ne.sibling;var wt=J(K,Ne,Y[Ze],oe);if(wt===null){Ne===null&&(Ne=ut);break}t&&Ne&&wt.alternate===null&&a(K,Ne),F=g(wt,F,Ze),St===null?Be=wt:St.sibling=wt,St=wt,Ne=ut}if(Ze===Y.length)return o(K,Ne),ht&&ya(K,Ze),Be;if(Ne===null){for(;Ze
+
@@ -369,8 +369,11 @@ Every verification point — signup, password reset, sensitive operations, the m
git clone https://github.com/gnuboard/g7.git
cd g7
-# 2. Install PHP dependencies
-composer install
+# 2. (Optional) Install PHP dependencies — you can skip this step:
+# the setup wizard installs them automatically with the production
+# configuration when vendor/ is absent. Do not use a plain composer
+# install on a production site; it pulls in development packages.
+composer install --no-dev --optimize-autoloader
# 3. Copy the environment file
cp .env.example .env
@@ -535,6 +538,7 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
+
diff --git a/app/Console/Commands/Core/Concerns/BundledExtensionUpdatePrompt.php b/app/Console/Commands/Core/Concerns/BundledExtensionUpdatePrompt.php
index 768695de..a9ee600d 100644
--- a/app/Console/Commands/Core/Concerns/BundledExtensionUpdatePrompt.php
+++ b/app/Console/Commands/Core/Concerns/BundledExtensionUpdatePrompt.php
@@ -330,6 +330,10 @@ trait BundledExtensionUpdatePrompt
// ENV 합집합 (G7_UPDATE_IN_PROGRESS 등 핵심 플래그 자식에 전달)
$env = array_merge(getenv(), $_ENV);
+ // audit:allow spawn-after-vendor-swap-clears-package-manifest 이 spawn 은 vendor 를 바꾸지 않고,
+ // 두 호출처가 모두 clearAllCaches() 뒤다 — CoreUpdateCommand Step 11(패키지 매니페스트 재생성)
+ // 다음의 Step 12, ExecuteUpgradeStepsCommand 단독 실행의 캐시 정리 다음 단계. 따라서 자식이
+ // 읽는 매니페스트는 이미 현재 vendor 기준으로 다시 만들어진 것이다.
$process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
if (! is_resource($process)) {
return null;
diff --git a/app/Console/Commands/Core/CoreUpdateCommand.php b/app/Console/Commands/Core/CoreUpdateCommand.php
index 9dca2f67..47565d05 100644
--- a/app/Console/Commands/Core/CoreUpdateCommand.php
+++ b/app/Console/Commands/Core/CoreUpdateCommand.php
@@ -16,7 +16,9 @@ use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Vendor\Exceptions\VendorInstallException;
use App\Extension\Vendor\VendorMode;
use App\Services\CoreUpdateService;
+use App\Support\ComposerInstallInfo;
use App\Support\ConfigCacheHelper;
+use App\Support\PackageManifestCacheHelper;
use App\Support\RouteCacheHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -320,6 +322,22 @@ class CoreUpdateCommand extends Command
$composerSkipped = $vendorMode !== VendorMode::Bundled
&& $service->isComposerUnchangedForCore($pendingPath);
+ // 운영 vendor 에 개발용(require-dev) 패키지가 섞여 있는지 알린다. 그대로 두면
+ // 이전 설치본이 만든 패키지 매니페스트가 새 vendor 와 어긋나 이후 부팅이 깨지고,
+ // 그 사실은 오류 메시지 어디에도 "dev 설치본" 으로 드러나지 않는다.
+ $devPackages = ComposerInstallInfo::devPackageNames(base_path('vendor'));
+ if ($devPackages !== []) {
+ $devCount = count($devPackages);
+ $devSample = implode(', ', array_slice($devPackages, 0, 5)).($devCount > 5 ? ' …' : '');
+ if ($composerSkipped) {
+ $this->warn("운영 vendor 에 개발용 패키지 {$devCount}개 감지 — composer.json/lock 변경이 없어 vendor 를 재설치하지 않으므로 그대로 남습니다. 업데이트 후 'composer install --no-dev --optimize-autoloader' 실행을 권장합니다.");
+ $log("운영 vendor 개발용 패키지 {$devCount}개 잔존 (composer 스킵): {$devSample}");
+ } else {
+ $this->line("운영 vendor 에 개발용 패키지 {$devCount}개 감지 — 이번 업데이트가 --no-dev vendor 로 교체합니다.");
+ $log("운영 vendor 개발용 패키지 {$devCount}개 감지 — --no-dev vendor 로 교체: {$devSample}");
+ }
+ }
+
if ($composerSkipped) {
$bar->setMessage('Composer 의존성 변경 없음 — 스킵');
$bar->advance();
@@ -511,6 +529,11 @@ class CoreUpdateCommand extends Command
$service->updateVersionInEnv($toVersion);
$service->clearAllCaches();
+ // 상주 큐 워커는 부팅이 한 번뿐이라 옛 코드를 물고 있다 — 캐시가 새 코드 기준으로
+ // 정리된 직후 재시작 신호를 보낸다.
+ $service->signalQueueRestart();
+ $log('큐 워커 재시작 신호 전송 (queue:restart)');
+
// 코어 업데이트 후 프론트엔드가 새 lang/routes/layout 자원으로 fetch 하도록
// `ext.cache_version` bump. 코어 lang JSON 변경이 프론트엔드 캐시에
// 반영되지 않는 회귀를 차단한다 (core-frontend-i18n-infrastructure 계획서).
@@ -636,6 +659,9 @@ class CoreUpdateCommand extends Command
try {
$service->updateVersionInEnv($toVersion);
$service->clearAllCaches();
+ // 핸드오프로 멈춰도 파일은 이미 toVersion 이므로 워커는 새 코드로 재기동해야 한다.
+ $service->signalQueueRestart();
+ $log('큐 워커 재시작 신호 전송 (queue:restart)');
// 핸드오프 cleanup 에서도 프론트엔드 캐시 버전 bump — 사용자가 resume 명령
// (execute-upgrade-steps) 을 실행하기 전이라도 이미 toVersion 으로 반영된
// 코어 lang/routes/layout 자원이 프론트엔드 캐시 stale 로 가려지지 않도록.
@@ -887,6 +913,24 @@ class CoreUpdateCommand extends Command
// Step 11 의 `ConfigCacheHelper::rebuild()` 가 모든 파일이 안착한 뒤 다시 만든다.
ConfigCacheHelper::clear();
+ // 같은 이유로 패키지 매니페스트(bootstrap/cache/packages.php · services.php)도 비운다.
+ // vendor 는 Step 6/8 에서 이미 교체됐지만 두 파일은 Step 11 clearAllCaches 까지 이전
+ // 설치본의 것이 남고, Laravel 은 packages.php 가 "없을 때만" 다시 만든다. 이전 설치본이
+ // dev composer(require-dev 전이 의존성 laravel/mcp 의 McpServiceProvider 포함)로 깔렸다면
+ // 자식은 새 vendor 에 없는 provider 를 new 하다 부팅 단계에서 죽는다 (7.0.9→7.0.10 실사례).
+ // 부모 메모리의 매니페스트는 영향받지 않고, Step 11 이 package:discover 로 다시 만든다.
+ //
+ // 지우지 못한 파일(권한·소유권 불일치)은 로그에 남긴다. 그 상태면 자식의 자가 치유도 같은
+ // 권한으로 실패하므로 증상은 이전 설치본 provider 의 「Class not found」 그대로이고, 이 기록이
+ // 권한이 원인이라는 유일한 흔적이다.
+ $remainingManifests = PackageManifestCacheHelper::clear();
+ if ($remainingManifests !== []) {
+ $manifestWarning = 'spawn 직전 패키지 매니페스트 삭제 실패 — 자식이 이전 설치본의 provider 목록으로 부팅할 수 있습니다 (권한·소유권 확인): '
+ .implode(', ', $remainingManifests);
+ $log($manifestWarning);
+ $this->warn($manifestWarning);
+ }
+
$process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
if (! is_resource($process)) {
return $this->failSpawnWithMode(
diff --git a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php
index 69140be7..480f71d7 100644
--- a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php
+++ b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php
@@ -261,6 +261,10 @@ class ExecuteUpgradeStepsCommand extends Command
if (! $this->option('skip-cache-clear') && ! $isSpawnChild && ! $stepsOnly) {
$this->info('캐시 정리 (config/route/view/services/packages)');
$service->clearAllCaches();
+ // 상주 큐 워커는 부팅이 한 번뿐이라 옛 코드를 물고 있다 — 캐시 정리 직후 재시작 신호.
+ // spawn 자식·steps-only 는 이 블록을 스킵하고 부모가 보낸다.
+ $service->signalQueueRestart();
+ $this->info('큐 워커 재시작 신호 전송 (queue:restart)');
// clearAllCaches() 는 config:clear 만 하므로, 단독 실행 흐름에서는 config 캐시가
// 비활성 상태로 남는다. 모든 upgrade step + 번들 확장 업데이트가 config 소스를
// 변경했을 수 있으니, 캐시 정리 세트의 마지막에 config 캐시를 재생성한다.
diff --git a/app/Console/Commands/PlaywrightSeedTwoFactor.php b/app/Console/Commands/PlaywrightSeedTwoFactor.php
new file mode 100644
index 00000000..e5060f90
--- /dev/null
+++ b/app/Console/Commands/PlaywrightSeedTwoFactor.php
@@ -0,0 +1,242 @@
+ --code=135790
+ */
+class PlaywrightSeedTwoFactor extends Command
+{
+ protected $signature = 'playwright:seed-two-factor
+ {--ensure-user= : 이 접미사로 Active 테스트 사용자를 생성/갱신하고 이메일을 출력한다}
+ {--password= : --ensure-user 가 설정할 비밀번호 (기본값 Passw0rd!2fa)}
+ {--admin : --ensure-user 계정에 admin 역할을 부여한다}
+ {--plant= : 이 challenge 에 알려진 인증 코드의 해시를 심는다 (challenge UUID)}
+ {--code=135790 : --plant 가 심을 인증 코드}
+ {--gc-hours=6 : 이 시간(시)보다 오래된 playwright 2FA 테스트 계정을 정리. 0 이면 정리 안 함}
+ {--purge-users : 나이와 무관하게 playwright 2FA 테스트 계정을 전부 제거 (실측 종료 직후 호출)}';
+
+ protected $description = 'Playwright E2E 용 2단계 인증 픽스처 (테스트 계정 준비 / 인증 코드 심기)';
+
+ /** 테스트 전용 계정 이메일 접두사 */
+ private const TEST_EMAIL_PREFIX = 'playwright_2fa_';
+
+ public function handle(): int
+ {
+ // ① CLI 한정 — production 웹 요청에서 절대 도달 불가
+ if (php_sapi_name() !== 'cli') {
+ $this->error('CLI 전용 커맨드입니다. (현재 SAPI: '.php_sapi_name().')');
+
+ return self::FAILURE;
+ }
+
+ // ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단.
+ // 여기의 `env()` 는 `.env` 유래 값이 아니라 호출자가 그 자리에서 넘기는 프로세스
+ // 환경변수이므로 config:cache 의 영향을 받지 않는다.
+ if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
+ $this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')');
+
+ return self::FAILURE;
+ }
+
+ // ③ APP_DEBUG 강제 — production + debug=false 환경에서도 픽스처 조작 허용
+ Config::set('app.debug', true);
+
+ $gcHours = (int) $this->option('gc-hours');
+ if ($gcHours > 0) {
+ $this->pruneStaleTestUsers($gcHours);
+ }
+
+ $did = false;
+
+ // 알려진 비밀번호를 가진 계정(관리자 포함)을 실측 뒤에 남기지 않는다 —
+ // 나이 기준 정리(gc-hours)는 그 사이의 창을 닫지 못한다.
+ if ($this->option('purge-users')) {
+ $this->purgeTestUsers();
+ $did = true;
+ }
+
+ if ($suffix = $this->option('ensure-user')) {
+ $this->ensureUser((string) $suffix);
+ $did = true;
+ }
+
+ if ($challengeId = $this->option('plant')) {
+ if (! $this->plantCode((string) $challengeId, (string) $this->option('code'))) {
+ return self::FAILURE;
+ }
+ $did = true;
+ }
+
+ if (! $did) {
+ $this->error('--ensure-user · --plant · --purge-users 중 하나는 지정해야 합니다.');
+
+ return self::FAILURE;
+ }
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * 알려진 비밀번호를 가진 Active 테스트 사용자를 만들거나 갱신하고 이메일을 출력합니다.
+ *
+ * @param string $suffix 계정 구분 접미사 (예: user / nonadmin)
+ */
+ private function ensureUser(string $suffix): void
+ {
+ $email = self::TEST_EMAIL_PREFIX.$suffix.'@example.test';
+ $password = (string) ($this->option('password') ?: 'Passw0rd!2fa');
+
+ $user = User::where('email', $email)->first();
+
+ if ($user === null) {
+ $user = User::factory()->create([
+ 'email' => $email,
+ 'password' => Hash::make($password),
+ 'status' => UserStatus::Active->value,
+ ]);
+ } else {
+ // 이전 실행이 남긴 계정을 재사용한다 — 매 실행마다 계정이 늘면 회원 목록이
+ // 테스트 잔재로 뒤덮인다. 잠금·실패 카운트도 함께 초기화해 앞선 spec 의
+ // 잠금 상태가 다음 실행으로 새지 않게 한다.
+ $user->forceFill([
+ 'password' => Hash::make($password),
+ 'status' => UserStatus::Active->value,
+ 'locked_until' => null,
+ 'failed_login_attempts' => 0,
+ ])->save();
+ }
+
+ if ($this->option('admin')) {
+ $adminRole = Role::firstOrCreate(
+ ['identifier' => 'admin'],
+ [
+ 'name' => ['ko' => '관리자', 'en' => 'Admin'],
+ 'description' => ['ko' => '시스템 관리자', 'en' => 'System Admin'],
+ 'extension_type' => ExtensionOwnerType::Core,
+ 'extension_identifier' => 'core',
+ 'type' => 'admin',
+ 'is_active' => true,
+ ]
+ );
+
+ if (! $user->roles()->where('roles.id', $adminRole->id)->exists()) {
+ $user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
+ }
+ } else {
+ // 관리자 거절 경로를 재현하려면 관리자 역할이 없어야 한다 — 재사용 계정에
+ // 앞선 실행의 admin 역할이 남아 있으면 403 을 측정할 수 없다.
+ $user->roles()->detach();
+ }
+
+ $this->line($email);
+ }
+
+ /**
+ * challenge 에 알려진 인증 코드의 해시를 심습니다.
+ *
+ * @param string $challengeId challenge UUID
+ * @param string $code 심을 인증 코드
+ * @return bool 성공 여부
+ */
+ private function plantCode(string $challengeId, string $code): bool
+ {
+ $log = IdentityVerificationLog::find($challengeId);
+
+ if ($log === null) {
+ $this->error("challenge 를 찾을 수 없습니다: {$challengeId}");
+
+ return false;
+ }
+
+ $metadata = $log->metadata ?? [];
+ $metadata['code_hash'] = Hash::make($code);
+
+ $log->metadata = $metadata;
+ $log->save();
+
+ $this->line($code);
+
+ return true;
+ }
+
+ /**
+ * playwright 2FA 테스트 계정을 나이와 무관하게 전부 제거합니다.
+ *
+ * 이 계정들은 알려진 비밀번호를 갖고, `--admin` 으로 만든 것은 관리자 역할까지 갖는다.
+ * 실측이 끝난 뒤에도 남아 있으면 그 자체가 열린 문이므로 즉시 지운다.
+ */
+ private function purgeTestUsers(): void
+ {
+ $removed = 0;
+
+ User::where('email', 'like', self::TEST_EMAIL_PREFIX.'%')
+ ->chunkById(100, function ($chunk) use (&$removed) {
+ foreach ($chunk as $user) {
+ $user->tokens()->delete();
+ $user->roles()->detach();
+ $user->delete();
+ $removed++;
+ }
+ });
+
+ $this->info("[purge] playwright 2FA 테스트 계정 제거: {$removed}건");
+ }
+
+ /**
+ * 임계 시간보다 오래된 playwright 2FA 테스트 계정을 정리합니다.
+ *
+ * chunkById(키셋 순회) 필수 — 콜백이 순회 대상 행을 삭제하므로 OFFSET 기반
+ * chunk()/each() 는 줄어든 결과 집합만큼 커서가 밀려 일부를 건너뛴다.
+ *
+ * @param int $hours 이 시간보다 오래된 계정만 정리
+ */
+ private function pruneStaleTestUsers(int $hours): void
+ {
+ $threshold = now()->subHours($hours);
+ $removed = 0;
+
+ User::where('email', 'like', self::TEST_EMAIL_PREFIX.'%')
+ ->where('created_at', '<', $threshold)
+ ->chunkById(100, function ($chunk) use (&$removed) {
+ foreach ($chunk as $user) {
+ $user->tokens()->delete();
+ $user->roles()->detach();
+ $user->delete();
+ $removed++;
+ }
+ });
+
+ if ($removed > 0) {
+ $this->info("[gc] playwright 2FA 테스트 계정 정리: {$removed}건");
+ }
+ }
+}
diff --git a/app/Console/Commands/Vendor/Concerns/RunsVendorBundleAction.php b/app/Console/Commands/Vendor/Concerns/RunsVendorBundleAction.php
index 6b17bdb2..0fa1796f 100644
--- a/app/Console/Commands/Vendor/Concerns/RunsVendorBundleAction.php
+++ b/app/Console/Commands/Vendor/Concerns/RunsVendorBundleAction.php
@@ -3,8 +3,8 @@
namespace App\Console\Commands\Vendor\Concerns;
use App\Extension\Vendor\Exceptions\VendorInstallException;
-use App\Extension\Vendor\VendorBundleResult;
use App\Extension\Vendor\VendorBundler;
+use App\Extension\Vendor\VendorBundleResult;
use App\Extension\Vendor\VendorIntegrityChecker;
use Illuminate\Support\Facades\File;
@@ -210,14 +210,17 @@ trait RunsVendorBundleAction
$sourcePath = $this->resolveSourcePath($target);
$outputPath = $this->resolveOutputPath($target);
- if (! is_dir($sourcePath)) {
- $this->warn("- $label: 소스 경로 없음 ($sourcePath)");
+ // 판정 기준 파일은 빌드(VendorBundler::build / isStale)와 같은 규칙 — 출력(_bundled) 우선,
+ // 없으면 활성 디렉토리 폴백. 활성 디렉토리만 보면 미설치 확장을 "소스 경로 없음" 으로
+ // 보고해 _bundled 의 stale 번들이 --check 를 통과한다.
+ $composerJsonPath = $this->resolveCheckFile($sourcePath, $outputPath, 'composer.json');
+ if ($composerJsonPath === null) {
+ if (! is_dir($sourcePath) && ! is_dir($outputPath)) {
+ $this->warn("- $label: 경로 없음 ($outputPath)");
- return false;
- }
+ return false;
+ }
- $composerJsonPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.json';
- if (! is_file($composerJsonPath)) {
$this->line("- $label: SKIPPED (composer.json 없음)");
return false;
@@ -240,6 +243,24 @@ trait RunsVendorBundleAction
return $stale;
}
+ /**
+ * --check 가 읽을 composer 파일 경로 — 출력(_bundled) 우선, 없으면 활성 디렉토리 폴백.
+ *
+ * VendorBundler::resolveHashTarget() 과 같은 규칙. 판정 파일이 빌드·해시 기준과
+ * 다르면 --check 결과가 실제 stale 여부와 어긋난다.
+ */
+ private function resolveCheckFile(string $sourcePath, string $outputPath, string $filename): ?string
+ {
+ $outputFile = $outputPath.DIRECTORY_SEPARATOR.$filename;
+ if (is_file($outputFile)) {
+ return $outputFile;
+ }
+
+ $sourceFile = $sourcePath.DIRECTORY_SEPARATOR.$filename;
+
+ return is_file($sourceFile) ? $sourceFile : null;
+ }
+
/**
* composer.json 에 외부 패키지 의존성이 있는지 확인합니다.
*
diff --git a/app/Contracts/Repositories/LayoutExtensionVersionRepositoryInterface.php b/app/Contracts/Repositories/LayoutExtensionVersionRepositoryInterface.php
index 1800a6b0..e2554a5d 100644
--- a/app/Contracts/Repositories/LayoutExtensionVersionRepositoryInterface.php
+++ b/app/Contracts/Repositories/LayoutExtensionVersionRepositoryInterface.php
@@ -14,9 +14,10 @@ interface LayoutExtensionVersionRepositoryInterface
* @param int $extensionId 레이아웃 확장 ID
* @param array $oldContent 이전 콘텐츠
* @param array|null $newContent 새 콘텐츠 (null이면 현재 확장 content 사용)
+ * @param int|null $createdBy 저장자 ID (null 이면 현재 인증 사용자)
* @return TemplateLayoutExtensionVersion 생성된 버전 모델
*/
- public function saveVersion(int $extensionId, array $oldContent, ?array $newContent = null): TemplateLayoutExtensionVersion;
+ public function saveVersion(int $extensionId, array $oldContent, ?array $newContent = null, ?int $createdBy = null): TemplateLayoutExtensionVersion;
/**
* 특정 확장의 모든 버전 조회 (최신순)
diff --git a/app/Contracts/Repositories/LayoutVersionRepositoryInterface.php b/app/Contracts/Repositories/LayoutVersionRepositoryInterface.php
index 0c3d7e47..7842a24d 100644
--- a/app/Contracts/Repositories/LayoutVersionRepositoryInterface.php
+++ b/app/Contracts/Repositories/LayoutVersionRepositoryInterface.php
@@ -14,9 +14,10 @@ interface LayoutVersionRepositoryInterface
* @param int $layoutId 레이아웃 ID
* @param array $oldContent 이전 콘텐츠
* @param array|null $newContent 새 콘텐츠 (null이면 현재 레이아웃 content 사용)
+ * @param int|null $createdBy 저장자 ID (null 이면 현재 인증 사용자)
* @return TemplateLayoutVersion 생성된 버전 모델
*/
- public function saveVersion(int $layoutId, array $oldContent, ?array $newContent = null): TemplateLayoutVersion;
+ public function saveVersion(int $layoutId, array $oldContent, ?array $newContent = null, ?int $createdBy = null): TemplateLayoutVersion;
/**
* 특정 레이아웃의 모든 버전 조회 (최신순)
diff --git a/app/Exceptions/Auth/TwoFactorDeliveryFailedException.php b/app/Exceptions/Auth/TwoFactorDeliveryFailedException.php
new file mode 100644
index 00000000..e973a070
--- /dev/null
+++ b/app/Exceptions/Auth/TwoFactorDeliveryFailedException.php
@@ -0,0 +1,25 @@
+getStorage()->getBasePath($category);
+ return $this->getStorageFor($category)->getBasePath($category);
}
/**
* 파일의 공개 URL 반환
*
- * public disk인 경우 직접 URL을 반환하고,
- * private disk인 경우 null을 반환합니다 (별도 API 엔드포인트 사용).
+ * 카테고리에 배선된 디스크(getStorageDiskFor)가 직접 URL 을 지원하면 그 URL 을,
+ * 아니면 null 을 반환합니다 (별도 API 엔드포인트 사용). 기본 디스크를 보면
+ * 공개 자산 디스크로 옮긴 카테고리가 항상 null 을 받습니다.
*
* @param string $category 카테고리
* @param string $path 파일 경로
- * @return string|null 파일 URL (private disk인 경우 null)
+ * @return string|null 파일 URL (직접 URL 불가 디스크인 경우 null)
*/
public function getStorageUrl(string $category, string $path): ?string
{
- return $this->getStorage()->url($category, $path);
+ return $this->getStorageFor($category)->url($category, $path);
}
}
diff --git a/app/Extension/AbstractPlugin.php b/app/Extension/AbstractPlugin.php
index 236cc313..e11f3e09 100644
--- a/app/Extension/AbstractPlugin.php
+++ b/app/Extension/AbstractPlugin.php
@@ -10,6 +10,7 @@ use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\Cache\PluginCacheDriver;
use App\Extension\Storage\PluginStorageDriver;
use App\Extension\Traits\ReportsLifecycleFailure;
+use App\Support\PublicAssetDisk;
use Illuminate\Database\Seeder;
use ReflectionClass;
@@ -1313,15 +1314,7 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
*/
protected function resolvePublicAssetDisk(?string $override = null): ?string
{
- $disk = ($override !== null && $override !== '')
- ? $override
- : (string) config('core.storage.public_asset_disk', '');
-
- if ($disk === '' || $disk === 'none' || config("filesystems.disks.{$disk}") === null) {
- return null;
- }
-
- return $disk;
+ return PublicAssetDisk::resolve($override);
}
/**
@@ -1359,26 +1352,30 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
/**
* 카테고리별 스토리지 기본 경로 반환
*
+ * 카테고리가 다른 디스크로 배선돼 있으면(getStorageDiskFor 오버라이드) 그 디스크
+ * 기준 경로를 돌려줍니다. 기본 디스크를 보면 배선한 카테고리의 경로가 어긋납니다.
+ *
* @param string $category 카테고리 (settings, data, temp)
* @return string 전체 파일 시스템 경로
*/
public function getStorageBasePath(string $category): string
{
- return $this->getStorage()->getBasePath($category);
+ return $this->getStorageFor($category)->getBasePath($category);
}
/**
* 파일의 공개 URL 반환
*
- * public disk인 경우 직접 URL을 반환하고,
- * private disk인 경우 null을 반환합니다 (별도 API 엔드포인트 사용).
+ * 카테고리에 배선된 디스크(getStorageDiskFor)가 직접 URL 을 지원하면 그 URL 을,
+ * 아니면 null 을 반환합니다 (별도 API 엔드포인트 사용). 기본 디스크를 보면
+ * 공개 자산 디스크로 옮긴 카테고리가 항상 null 을 받습니다.
*
* @param string $category 카테고리
* @param string $path 파일 경로
- * @return string|null 파일 URL (private disk인 경우 null)
+ * @return string|null 파일 URL (직접 URL 불가 디스크인 경우 null)
*/
public function getStorageUrl(string $category, string $path): ?string
{
- return $this->getStorage()->url($category, $path);
+ return $this->getStorageFor($category)->url($category, $path);
}
}
diff --git a/app/Extension/CoreVersionChecker.php b/app/Extension/CoreVersionChecker.php
index e7b679d1..d3726ab2 100644
--- a/app/Extension/CoreVersionChecker.php
+++ b/app/Extension/CoreVersionChecker.php
@@ -5,6 +5,7 @@ namespace App\Extension;
use App\Contracts\Extension\CacheInterface;
use App\Exceptions\CoreVersionMismatchException;
use App\Extension\Cache\CoreCacheDriver;
+use App\Support\CoreUpdateContext;
use Composer\Semver\Semver;
use Exception;
use Illuminate\Support\Facades\Log;
@@ -31,10 +32,11 @@ class CoreVersionChecker
* 현재 설치된 그누보드7 코어 버전 반환
*
* 반환 우선순위:
- * 1. 환경변수 `APP_VERSION` (getenv / $_ENV / $_SERVER)
+ * 1. 코어 업데이트 프로세스 트리 안(`CoreUpdateContext::isInProgress()`)이면
+ * 환경변수 `APP_VERSION` (getenv / $_ENV / $_SERVER)
* 2. `config('app.version')`
*
- * 왜 env 를 우선 읽는가:
+ * 왜 업데이트 트리 안에서만 env 를 우선 읽는가:
* 코어 업그레이드 중 `core:update` 는 `core:execute-upgrade-steps` / 각 업그레이드 스텝의
* inline 스크립트를 `proc_open` 으로 spawn 한다. 디스크 `.env` 의 `APP_VERSION` 은
* `updateVersionInEnv()` 가 최종 단계(Step 11)에서 기록하므로, spawn 이 부팅되는
@@ -44,23 +46,28 @@ class CoreVersionChecker
* env 오버라이드가 반영되지 않는 회귀가 있었다 (확장이 `>= 신버전` 요구 시
* `validateAndDeactivateIncompatibleExtensions` 가 전 확장을 자동 비활성화).
*
- * env 를 우선 읽는 본 구현은 config cache 유무와 무관하게 spawn 이 전달한 버전을 그대로
- * 신뢰한다. 일반 요청 경로에서는 `APP_VERSION` 이 `.env` 에 기록된 값 그대로이므로
- * 동작에 차이가 없다.
+ * 왜 트리 밖에서는 env 를 읽지 않는가:
+ * `php artisan serve` · 큐 워커 · Horizon 처럼 오래 사는 프로세스는 기동 시점의
+ * `APP_VERSION` 을 프로세스 환경 테이블에 물고 있다. 업데이트가 끝나 `.env` 와 config 가
+ * 새 버전이 되어도 그 프로세스만 옛 버전으로 판정해, 새 코어를 요구하는 확장을
+ * `incompatible_core` 로 자동 비활성화한다 (관리자 템플릿이 꺼지면 복구 UI 에도 도달할 수
+ * 없다 — 2026-09-07 실측). 업데이트 트리 밖에서는 config 가 유일한 근거다.
*
- * 규정 예외: "env() 는 config 파일에서만 사용" 규칙의 본문 예외. 정당성은 버전 판정이
- * config cache 우회를 요구하기 때문이다.
+ * 규정 예외: "env() 는 config 파일에서만 사용" 규칙의 본문 예외. 정당성은 업데이트 중
+ * 버전 판정이 config cache 우회를 요구하기 때문이다.
*
* @return string 코어 버전 문자열 (예: "7.0.0-beta.4")
*/
public static function getCoreVersion(): string
{
- $envVersion = $_ENV['APP_VERSION'] ?? $_SERVER['APP_VERSION'] ?? getenv('APP_VERSION');
- if (is_string($envVersion) && $envVersion !== '') {
- return $envVersion;
+ if (CoreUpdateContext::isInProgress()) {
+ $envVersion = $_ENV['APP_VERSION'] ?? $_SERVER['APP_VERSION'] ?? getenv('APP_VERSION');
+ if (is_string($envVersion) && $envVersion !== '') {
+ return $envVersion;
+ }
}
- return config('app.version');
+ return (string) config('app.version');
}
/**
diff --git a/app/Http/Controllers/Api/Admin/AuthController.php b/app/Http/Controllers/Api/Admin/AuthController.php
index f6bdc66a..4979f348 100644
--- a/app/Http/Controllers/Api/Admin/AuthController.php
+++ b/app/Http/Controllers/Api/Admin/AuthController.php
@@ -3,9 +3,13 @@
namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\Auth\AccountLockedException;
+use App\Exceptions\Auth\TwoFactorDeliveryFailedException;
use App\Http\Controllers\Api\Base\AdminBaseController;
+use App\Http\Controllers\Concerns\BuildsAuthFailureResponses;
use App\Http\Requests\Auth\AuthenticatedRequest;
use App\Http\Requests\Auth\LoginRequest;
+use App\Http\Requests\Auth\TwoFactorChallengeRequest;
+use App\Http\Requests\Auth\TwoFactorResendRequest;
use App\Http\Resources\UserResource;
use App\Services\AuthService;
use Illuminate\Http\JsonResponse;
@@ -13,10 +17,21 @@ use Illuminate\Validation\ValidationException;
class AuthController extends AdminBaseController
{
+ use BuildsAuthFailureResponses;
+
public function __construct(
private AuthService $authService
) {
- parent::__construct();
+ // 부모 생성자 호출하지 않음 - 인증 미들웨어를 수동으로 설정
+ // parent::__construct();
+
+ // 2단계 인증 확인·재발송은 아직 토큰이 없는 상태에서 호출된다 — 주체는 challenge 가
+ // 식별하며, 관리자 여부는 코드 확인에 성공한 뒤 서비스가 돌려준 사용자로 판정한다.
+ $this->middleware(['auth:sanctum', 'admin'])->except([
+ 'login',
+ 'verifyTwoFactor',
+ 'resendTwoFactor',
+ ]);
}
/**
@@ -33,10 +48,21 @@ class AuthController extends AdminBaseController
$request->validated()['password']
);
+ // 2단계 인증이 켜져 있으면 아직 토큰도 사용자도 없다 — 관리자 판정은 코드 확인
+ // 뒤로 미룬다. 이 분기가 없으면 $data['user'] 가 없어 500 이 되고, 관리자까지
+ // 로그인할 수 없어 설정을 되돌릴 수단이 사라진다.
+ if ($data['two_factor_required'] ?? false) {
+ return $this->success('auth.two_factor_required', $data);
+ }
+
$user = $data['user'];
// 관리자 권한 확인
if (! $user->isAdmin()) {
+ // 이 시점에는 이미 토큰과 web 세션이 발급되어 있다 — 거절하면서 남겨 두면
+ // 관리자가 아닌 사용자가 응답만 403 을 받을 뿐 세션은 그대로 유효해진다.
+ $this->authService->revokeIssuedSession($user, $data['token']);
+
return $this->forbidden('auth.admin_required');
}
@@ -45,22 +71,92 @@ class AuthController extends AdminBaseController
return $this->success('auth.admin_login_success', $data);
} catch (AccountLockedException $e) {
- // 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출.
- return $this->error(
- $e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
- 423,
- [
- 'locked_until' => $e->lockedUntil?->toIso8601String(),
- 'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
- 'permanent' => $e->isPermanent(),
- ],
- ['minutes' => $e->remainingMinutes]
- );
+ return $this->lockedResponse($e);
+ } catch (TwoFactorDeliveryFailedException $e) {
+ return $this->deliveryFailedResponse();
} catch (ValidationException $e) {
return $this->unauthorized('auth.login_failed');
}
}
+ /**
+ * 관리자의 2단계 인증 코드를 확인하고 로그인을 완료합니다.
+ *
+ * 비밀번호 확인 단계(`login`)는 토큰 대신 challenge 를 돌려주며, 이 엔드포인트가
+ * 코드 확인에 성공해야 비로소 토큰이 발급됩니다.
+ *
+ * @param TwoFactorChallengeRequest $request challenge 확인 요청
+ * @return JsonResponse 로그인 결과와 관리자 정보, 토큰을 포함한 JSON 응답
+ */
+ public function verifyTwoFactor(TwoFactorChallengeRequest $request): JsonResponse
+ {
+ $validated = $request->validated();
+
+ try {
+ $data = $this->authService->completeTwoFactor(
+ $validated['challenge_id'],
+ ['code' => $validated['code']]
+ );
+
+ $user = $data['user'];
+
+ if (! $user->isAdmin()) {
+ // completeTwoFactor() 는 코드 확인에 성공한 시점에 토큰을 발급한다.
+ // 관리자 판정으로 거절하면서 그 발급분을 회수하지 않으면 관리자가 아닌
+ // 사용자가 유효한 세션을 손에 쥔 채 응답만 403 을 받는다.
+ $this->authService->revokeIssuedSession($user, $data['token']);
+
+ return $this->forbidden('auth.admin_required');
+ }
+
+ $data['user'] = new UserResource($user);
+
+ return $this->success('auth.admin_login_success', $data);
+ } catch (AccountLockedException $e) {
+ // 세션을 여는 지점이므로 `login` 과 같은 423 계약을 따른다.
+ return $this->lockedResponse($e);
+ } catch (TwoFactorDeliveryFailedException $e) {
+ return $this->deliveryFailedResponse();
+ } catch (ValidationException $e) {
+ return $this->unauthorized('auth.two_factor_failed');
+ }
+ }
+
+ /**
+ * 관리자의 2단계 인증 코드를 재발송합니다.
+ *
+ * 기존 challenge 는 취소되고 새 challenge 가 발행되므로, 앞서 받은 인증번호는
+ * 더 이상 통하지 않습니다.
+ *
+ * @param TwoFactorResendRequest $request challenge 재발송 요청
+ * @return JsonResponse 새 challenge 정보를 포함한 JSON 응답
+ */
+ public function resendTwoFactor(TwoFactorResendRequest $request): JsonResponse
+ {
+ try {
+ $resolvedUser = null;
+
+ $data = $this->authService->resendTwoFactorChallenge(
+ $request->validated()['challenge_id'],
+ $resolvedUser
+ );
+
+ // 이 단계는 토큰을 발급하지 않으므로 회수할 것이 없다 — 다만 완료할 수 없는
+ // 상대에게 새 인증번호를 계속 보내지는 않는다.
+ if ($resolvedUser === null || ! $resolvedUser->isAdmin()) {
+ return $this->forbidden('auth.admin_required');
+ }
+
+ return $this->success('auth.two_factor_required', $data);
+ } catch (AccountLockedException $e) {
+ return $this->lockedResponse($e);
+ } catch (TwoFactorDeliveryFailedException $e) {
+ return $this->deliveryFailedResponse();
+ } catch (ValidationException $e) {
+ return $this->validationError($e->errors(), 'auth.two_factor_invalid_challenge');
+ }
+ }
+
/**
* 관리자를 로그아웃시킵니다.
*
@@ -113,16 +209,7 @@ class AuthController extends AdminBaseController
} catch (AccountLockedException $e) {
// 재발급도 세션을 여는 지점이다 — 사용자 경로와 같은 423 계약을 따른다.
// 이 catch 가 없으면 잠긴 계정의 재발급 시도가 500 으로 새어 나간다.
- return $this->error(
- $e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
- 423,
- [
- 'locked_until' => $e->lockedUntil?->toIso8601String(),
- 'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
- 'permanent' => $e->isPermanent(),
- ],
- ['minutes' => $e->remainingMinutes]
- );
+ return $this->lockedResponse($e);
} catch (ValidationException $e) {
return $this->unauthorized('auth.unauthenticated');
}
diff --git a/app/Http/Controllers/Api/Auth/AuthController.php b/app/Http/Controllers/Api/Auth/AuthController.php
index d66a9007..d5085011 100644
--- a/app/Http/Controllers/Api/Auth/AuthController.php
+++ b/app/Http/Controllers/Api/Auth/AuthController.php
@@ -3,13 +3,16 @@
namespace App\Http\Controllers\Api\Auth;
use App\Exceptions\Auth\AccountLockedException;
+use App\Exceptions\Auth\TwoFactorDeliveryFailedException;
use App\Http\Controllers\Api\Base\AuthBaseController;
+use App\Http\Controllers\Concerns\BuildsAuthFailureResponses;
use App\Http\Requests\Auth\AuthenticatedRequest;
use App\Http\Requests\Auth\ForgotPasswordRequest;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\RegisterRequest;
use App\Http\Requests\Auth\ResetPasswordRequest;
use App\Http\Requests\Auth\TwoFactorChallengeRequest;
+use App\Http\Requests\Auth\TwoFactorResendRequest;
use App\Http\Requests\Auth\ValidateResetTokenRequest;
use App\Http\Resources\UserResource;
use App\Services\AuthService;
@@ -18,6 +21,8 @@ use Illuminate\Validation\ValidationException;
class AuthController extends AuthBaseController
{
+ use BuildsAuthFailureResponses;
+
public function __construct(
private AuthService $authService
) {
@@ -27,8 +32,9 @@ class AuthController extends AuthBaseController
// 공개 인증 엔드포인트를 제외한 나머지에만 인증 미들웨어 적용
$this->middleware('auth:sanctum')->except([
'login',
- // 2단계 인증 확인은 아직 토큰이 없는 상태에서 호출된다 — 주체는 challenge 가 식별한다
+ // 2단계 인증 확인·재발송은 아직 토큰이 없는 상태에서 호출된다 — 주체는 challenge 가 식별한다
'verifyTwoFactor',
+ 'resendTwoFactor',
'register',
'forgotPassword',
'resetPassword',
@@ -61,6 +67,8 @@ class AuthController extends AuthBaseController
return $this->success('auth.login_success', $data);
} catch (AccountLockedException $e) {
return $this->lockedResponse($e);
+ } catch (TwoFactorDeliveryFailedException $e) {
+ return $this->deliveryFailedResponse();
} catch (ValidationException $e) {
return $this->unauthorized('auth.login_failed');
}
@@ -92,32 +100,37 @@ class AuthController extends AuthBaseController
// 세션을 여는 지점이므로 `login` 과 같은 423 계약을 따른다 — 화면은 두 경로를
// 구분하지 않으므로 한쪽만 다른 모양이면 잠금 안내가 깨진다.
return $this->lockedResponse($e);
+ } catch (TwoFactorDeliveryFailedException $e) {
+ return $this->deliveryFailedResponse();
} catch (ValidationException $e) {
return $this->unauthorized('auth.two_factor_failed');
}
}
/**
- * 계정 잠금 응답(423)을 구성합니다.
+ * 2단계 인증 코드를 재발송합니다.
*
- * 세션을 발급하는 모든 엔드포인트가 같은 페이로드를 돌려주도록 단일 지점에서 만든다.
+ * 기존 challenge 는 취소되고 새 challenge 가 발행되므로, 앞서 받은 인증번호는
+ * 더 이상 통하지 않습니다.
*
- * @param AccountLockedException $e 잠금 예외
- * @return JsonResponse 423 응답
+ * @param TwoFactorResendRequest $request challenge 재발송 요청
+ * @return JsonResponse 새 challenge 정보를 포함한 JSON 응답
*/
- private function lockedResponse(AccountLockedException $e): JsonResponse
+ public function resendTwoFactor(TwoFactorResendRequest $request): JsonResponse
{
- // 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출.
- return $this->error(
- $e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
- 423,
- [
- 'locked_until' => $e->lockedUntil?->toIso8601String(),
- 'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
- 'permanent' => $e->isPermanent(),
- ],
- ['minutes' => $e->remainingMinutes]
- );
+ try {
+ $data = $this->authService->resendTwoFactorChallenge(
+ $request->validated()['challenge_id']
+ );
+
+ return $this->success('auth.two_factor_required', $data);
+ } catch (AccountLockedException $e) {
+ return $this->lockedResponse($e);
+ } catch (TwoFactorDeliveryFailedException $e) {
+ return $this->deliveryFailedResponse();
+ } catch (ValidationException $e) {
+ return $this->validationError($e->errors(), 'auth.two_factor_invalid_challenge');
+ }
}
/**
diff --git a/app/Http/Controllers/Api/Identity/IdentityVerificationController.php b/app/Http/Controllers/Api/Identity/IdentityVerificationController.php
index 89fc494e..573f6336 100644
--- a/app/Http/Controllers/Api/Identity/IdentityVerificationController.php
+++ b/app/Http/Controllers/Api/Identity/IdentityVerificationController.php
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\Identity;
use App\Enums\IdentityOriginType;
+use App\Enums\IdentityVerificationPurpose;
use App\Extension\IdentityVerification\IdentityVerificationManager;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Requests\Identity\CancelChallengeRequest;
@@ -96,6 +97,15 @@ class IdentityVerificationController extends PublicBaseController
*/
public function verify(VerifyChallengeRequest $request, IdentityVerificationLog $challenge): JsonResponse
{
+ // 로그인 challenge 는 이 공개 경로로 다루지 않는다. 여기서 검증·취소되면
+ // 바로 뒤의 `auth/login/two-factor` 가 INVALID_STATE 로 거절해, 그 challenge 로는
+ // 영영 로그인할 수 없게 된다(자기 DoS). 로그인 전용 엔드포인트만 사용한다.
+ if ($challenge->purpose === IdentityVerificationPurpose::Login->value) {
+ return $this->error('identity.errors.purpose_not_allowed', 403, [
+ 'failure_code' => 'PURPOSE_NOT_ALLOWED',
+ ]);
+ }
+
$result = $this->service->verify(
challengeId: $challenge->id,
input: $request->validated(),
@@ -142,6 +152,15 @@ class IdentityVerificationController extends PublicBaseController
*/
public function cancel(CancelChallengeRequest $request, IdentityVerificationLog $challenge): JsonResponse
{
+ // 로그인 challenge 는 이 공개 경로로 다루지 않는다. 여기서 검증·취소되면
+ // 바로 뒤의 `auth/login/two-factor` 가 INVALID_STATE 로 거절해, 그 challenge 로는
+ // 영영 로그인할 수 없게 된다(자기 DoS). 로그인 전용 엔드포인트만 사용한다.
+ if ($challenge->purpose === IdentityVerificationPurpose::Login->value) {
+ return $this->error('identity.errors.purpose_not_allowed', 403, [
+ 'failure_code' => 'PURPOSE_NOT_ALLOWED',
+ ]);
+ }
+
$ok = $this->service->cancel($challenge->id);
if (! $ok) {
diff --git a/app/Http/Controllers/Api/Public/PublicLayoutController.php b/app/Http/Controllers/Api/Public/PublicLayoutController.php
index cecd2d5e..ca651cb5 100644
--- a/app/Http/Controllers/Api/Public/PublicLayoutController.php
+++ b/app/Http/Controllers/Api/Public/PublicLayoutController.php
@@ -63,20 +63,18 @@ class PublicLayoutController extends PublicBaseController
}
try {
- // 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화.
+ // 서버 캐시 키는 **서버 현재** 확장 캐시 버전으로만 조립한다. 클라이언트 `?v=` 는 브라우저
+ // HTTP 캐시 우회용 좌표일 뿐 서버 키의 근거가 아니다.
//
- // 서버 캐시 키에는 **정수 버전만** 쓴다(소수 nonce 제거). 레이아웃 편집기는 같은 세션의
- // 저장·버전 복원 직후 브라우저 HTTP 캐시를 우회하려고 `?v={cacheVersion}.{nonce}` 형식으로
- // 요청한다(클라이언트 cache-bust nonce). 그런데 `serve` 가 이 문자열을 그대로 캐시 키에
- // 쓰면 키가 `...v{cacheVersion}.{nonce}.meta` 가 되는데, 저장 경로
- // `LayoutService::clearPublicServingCache` 는 `(int) ext.cache_version` 으로 nonce 없는
- // 키만 forget 하므로 키 형식이 어긋나 무효화가 빗나간다(저장/복원 후 편집기 캔버스만
- // stale). nonce 는 브라우저 HTTP 캐시 우회용(URL·ETag 차이로 이미 달성)이고, 서버 캐시 키
- // 정합은 정수 버전이 SSoT 다. `(int)` 캐스팅은 PHP 가 소수점에서 절단해 정수부만 남긴다.
- // `?v` 생략 시 현재 버전으로 폴백 — 리터럴 0 폴백은 워밍/무효화 어느 경로에도
- // 걸리지 않는 `.v0` 영구 사각 키를 만든다 (#588).
- $rawVersion = request()->query('v');
- $cacheVersion = $rawVersion !== null ? (int) $rawVersion : self::getExtensionCacheVersion();
+ // 종전엔 `?v` 의 정수부를 키에 썼다(#588 — nonce 제거). 그런데 레이아웃 편집기는 부팅
+ // 시점 `window.G7Config.cache_version` 에 nonce 만 붙여 계속 요청하고, 저장·복원은
+ // `ext.cache_version` 을 `time()` 으로 올리며 `clearPublicServingCache` 는 **현재** 버전
+ // 키만 지운다. 그래서 두 번째 bump 부터 부팅 버전 키가 영영 지워지지 않아 초기화·복원·
+ // 409 「최신 불러오기」가 옛 content 를 받았고(실측: 초기화 직후 lock 4 응답, DB 는 lock 7),
+ // 그 화면을 다시 저장하면 옛 내용이 최신을 덮을 수 있었다. 서버 버전으로 키를 고정하면
+ // 무효화(현재 버전 키 forget)와 굽기(현재 버전 키 remember)가 같은 키를 본다. `?v` 가 어떤
+ // 값이든 결과는 같고, 이전 버전 키는 bump 로 자연 이탈한다(TTL 만료).
+ $cacheVersion = self::getExtensionCacheVersion();
// 편집기 출처 메타 옵션
// - 옵션이 truthy 면 각 노드에 `__source` 메타를 부여한 응답을 반환
diff --git a/app/Http/Controllers/Concerns/BuildsAuthFailureResponses.php b/app/Http/Controllers/Concerns/BuildsAuthFailureResponses.php
new file mode 100644
index 00000000..f5588287
--- /dev/null
+++ b/app/Http/Controllers/Concerns/BuildsAuthFailureResponses.php
@@ -0,0 +1,53 @@
+error(
+ $e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
+ 423,
+ [
+ 'locked_until' => $e->lockedUntil?->toIso8601String(),
+ 'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
+ 'permanent' => $e->isPermanent(),
+ ],
+ ['minutes' => $e->remainingMinutes]
+ );
+ }
+
+ /**
+ * 인증번호 발송 실패 응답(503)을 구성합니다.
+ *
+ * 자격 증명은 올바르므로 401 로 답하지 않는다 — 사용자는 비밀번호를 의심하며 같은
+ * 실패를 반복하고, 운영자는 메일 설정이 깨진 사실을 알 방법이 없다.
+ *
+ * @return JsonResponse 503 응답
+ */
+ protected function deliveryFailedResponse(): JsonResponse
+ {
+ return $this->error('auth.two_factor_delivery_failed', 503);
+ }
+}
diff --git a/app/Http/Requests/Auth/TwoFactorResendRequest.php b/app/Http/Requests/Auth/TwoFactorResendRequest.php
new file mode 100644
index 00000000..8ba3f3cf
--- /dev/null
+++ b/app/Http/Requests/Auth/TwoFactorResendRequest.php
@@ -0,0 +1,54 @@
+ 검증 규칙
+ */
+ public function rules(): array
+ {
+ $rules = [
+ 'challenge_id' => ['required', 'string', 'uuid'],
+ ];
+
+ // 확장이 자체 2단계 수단을 붙일 때 재발송 입력을 추가할 수 있도록 개방한다
+ return HookManager::applyFilters('core.auth.two_factor_resend_validation_rules', $rules, $this);
+ }
+
+ /**
+ * 검증 실패 메시지를 반환합니다.
+ *
+ * @return array` 로 직접 사용하므로 CORS 설정은
필요하지 않습니다. 다만 그 URL 을 자바스크립트로 읽는 커스텀 확장을 만든다면 그때는
버킷/CDN 에 CORS 규칙이 필요합니다.
@@ -1651,6 +1675,13 @@ class AttachmentServiceTest extends TestCase
직접 URL 은 서버 스트리밍 경로의 권한 검사를 우회하므로, 완전 공개 자산 카테고리에만
공개 자산 디스크를 적용합니다.
+**알려진 한계** — 상품 리뷰 이미지는 공개 자산으로 배선되어 있습니다. 리뷰를 노출에서
+숨김으로 바꾸면 목록·상세 응답에서 이미지가 사라지지만, 공개 자산 디스크를 켠 상태에서
+이미 발급된 직접 URL 을 알고 있는 사람은 그 주소로 파일에 계속 접근할 수 있습니다.
+직접 URL 은 저장소의 파일을 직접 가리키므로 응답에서 감추는 것만으로는 회수되지 않습니다.
+숨김을 접근 차단으로 쓰려면 그 카테고리에 공개 자산 디스크를 적용하지 않거나, 저장소에서
+파일 자체를 지워야 합니다.
+
---
## 관련 문서
diff --git a/docs/extension/template-security.md b/docs/extension/template-security.md
index 6afe8f6d..b13572a3 100644
--- a/docs/extension/template-security.md
+++ b/docs/extension/template-security.md
@@ -37,7 +37,7 @@ Custom Rule: ComponentExists
| JSON 구조 유효성 | 올바른 JSON 형식인지 검증 |
| 최대 중첩 깊이 | 10단계 제한 |
| 엔드포인트 화이트리스트 | `/api/(admin\|auth\|public)/` 패턴만 허용 |
-| 외부 URL 금지 | 외부 도메인 URL 차단 |
+| 외부 URL 금지 | 외부 도메인 URL 차단 (사이트 자기 host 와 「공개 자산 스토리지」로 선언한 디스크의 host 는 외부가 아니다) |
| 컴포넌트 존재 여부 | components.json 기준으로 검증 |
---
diff --git a/docs/frontend/actions-handlers-ui.md b/docs/frontend/actions-handlers-ui.md
index 7ca56f08..bb0f335b 100644
--- a/docs/frontend/actions-handlers-ui.md
+++ b/docs/frontend/actions-handlers-ui.md
@@ -20,6 +20,8 @@
12. [callExternal](#callexternal)
13. [실전 예시](#실전-예시)
+> `login` 절에 `loginTwoFactor` · `loginTwoFactorResend` 가 함께 설명되어 있습니다.
+
---
## login / logout
@@ -61,6 +63,110 @@
| `admin` | 관리자 인증 |
| `user` | 사용자 인증 |
+### login 의 반환값 — 2단계 인증이 켜진 사이트
+
+서버는 보안 환경설정에 따라 **두 가지 형태의 200** 을 돌려줍니다. `onSuccess` 는 두 형태 모두에서
+실행되므로, 후속 액션은 `response.two_factor_required` 로 분기해야 합니다.
+
+| 필드 | 타입 | 설명 |
+|------|------|------|
+| `user` | object \| null | 로그인한 사용자. 인증번호 확인이 남았으면 `null` |
+| `two_factor_required` | boolean | `true` 면 아직 로그인이 끝나지 않았습니다 |
+| `challenge_id` | string | 인증번호 확인에 그대로 전달할 식별자 |
+| `provider_id` | string | 인증번호를 보낸 프로바이더 |
+| `expires_at` | string \| null | 인증 요청 만료 시각 (ISO8601) |
+
+분기를 두지 않으면 인증이 끝나기 전에 홈으로 이동하거나, 빈 사용자 정보가 상태에 실립니다.
+
+```json
+{
+ "handler": "login",
+ "target": "user",
+ "params": { "body": { "email": "{{form.email}}", "password": "{{form.password}}" } },
+ "onSuccess": [
+ {
+ "handler": "setState",
+ "if": "{{response.two_factor_required}}",
+ "params": {
+ "target": "global",
+ "twoFactor": {
+ "required": true,
+ "challenge_id": "{{response.challenge_id}}",
+ "expires_at": "{{response.expires_at}}",
+ "code": ""
+ }
+ }
+ },
+ {
+ "handler": "navigate",
+ "if": "{{!response.two_factor_required}}",
+ "params": { "path": "/" }
+ }
+ ]
+}
+```
+
+같은 시퀀스·`onSuccess` 안에서는 방금 저장한 상태(`_global.twoFactor.*`)를 다시 읽지 않습니다 —
+그 자리의 상태는 아직 갱신 전이므로 `{{response.*}}` 만 사용합니다.
+
+### loginTwoFactor
+
+인증번호를 확인해 로그인을 완료합니다. 성공 시 토큰이 발급되고 `{ user }` 를 돌려줍니다.
+
+```json
+{
+ "handler": "loginTwoFactor",
+ "target": "user",
+ "params": {
+ "body": {
+ "challenge_id": "{{_global.twoFactor?.challenge_id}}",
+ "code": "{{_global.twoFactor?.code}}"
+ }
+ },
+ "onSuccess": [
+ { "handler": "setState", "params": { "target": "global", "currentUser": "{{response.user}}", "twoFactor": null } },
+ { "handler": "navigate", "params": { "path": "/" } }
+ ],
+ "onError": [
+ { "handler": "setState", "params": { "target": "global", "twoFactor.error": "{{error.message}}" } }
+ ]
+}
+```
+
+`params.body` 의 `challenge_id` 와 `code` 는 필수입니다. `target` 은 `login` 과 같은 값을 씁니다
+(`user` / `admin`) — 관리자 대상은 관리자 전용 엔드포인트를 호출하고, 관리자가 아니면 `403` 이 됩니다.
+
+### loginTwoFactorResend
+
+인증번호를 다시 보냅니다. 서버가 **기존 인증 요청을 취소하고 새로 발행**하므로, 반환된
+`challenge_id` 로 반드시 교체하고 입력란을 비워야 합니다 — 앞서 받은 번호는 더 이상 통하지 않습니다.
+
+```json
+{
+ "handler": "loginTwoFactorResend",
+ "target": "user",
+ "params": { "body": { "challenge_id": "{{_global.twoFactor?.challenge_id}}" } },
+ "onSuccess": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "twoFactor": {
+ "required": true,
+ "challenge_id": "{{response.challenge_id}}",
+ "expires_at": "{{response.expires_at}}",
+ "code": "",
+ "error": null,
+ "resent": true
+ }
+ }
+ }
+ ]
+}
+```
+
+반환값은 `{ two_factor_required, challenge_id, provider_id, expires_at }` 입니다.
+
### logout
로그아웃하고 토큰을 삭제합니다.
diff --git a/docs/frontend/actions-handlers.md b/docs/frontend/actions-handlers.md
index fb178a25..e7210490 100644
--- a/docs/frontend/actions-handlers.md
+++ b/docs/frontend/actions-handlers.md
@@ -56,6 +56,9 @@
### UI 인터랙션 핸들러 → [상세 문서](actions-handlers-ui.md)
14. [login / logout](actions-handlers-ui.md#login--logout) - 인증
+ - [login 의 반환값 — 2단계 인증이 켜진 사이트](actions-handlers-ui.md#login-의-반환값--2단계-인증이-켜진-사이트)
+ - [loginTwoFactor](actions-handlers-ui.md#logintwofactor) - 인증번호 확인
+ - [loginTwoFactorResend](actions-handlers-ui.md#logintwofactorresend) - 인증번호 다시 받기
15. [openModal / closeModal](actions-handlers-ui.md#openmodal--closemodal) - 모달
16. [showAlert / toast](actions-handlers-ui.md#showalert--toast) - 알림
17. [confirm (액션 속성)](actions-handlers-ui.md#confirm-액션-속성) - 실행 전 확인 대화상자
diff --git a/docs/frontend/auth-system.md b/docs/frontend/auth-system.md
index 4cea9ad1..f4d6fa09 100644
--- a/docs/frontend/auth-system.md
+++ b/docs/frontend/auth-system.md
@@ -98,14 +98,15 @@ private getAuthType(route: Route, pathname: string): AuthType {
## API 엔드포인트
-| 구분 | 로그인 | 사용자 정보 | 로그아웃 | 토큰 갱신 |
-|------|--------|------------|---------|----------|
-| **관리자** | `/auth/login` | `/admin/auth/user` | `/admin/auth/logout` | `/admin/auth/refresh` |
-| **일반사용자** | `/auth/login` | `/user/auth/user` | `/user/auth/logout` | `/user/auth/refresh` |
+| 구분 | 로그인 | 인증번호 확인 | 인증번호 재발송 | 사용자 정보 | 로그아웃 | 토큰 갱신 |
+|------|--------|--------------|----------------|------------|---------|----------|
+| **관리자** | `/auth/admin/login` | `/auth/admin/login/two-factor` | `/auth/admin/login/two-factor/resend` | `/admin/auth/user` | `/admin/auth/logout` | `/admin/auth/refresh` |
+| **일반사용자** | `/auth/login` | `/auth/login/two-factor` | `/auth/login/two-factor/resend` | `/user/auth/user` | `/user/auth/logout` | `/user/auth/refresh` |
**공통**:
-- 로그인 엔드포인트는 관리자/일반사용자 동일 (`/auth/login`)
+- 로그인 엔드포인트는 관리자/일반사용자가 다릅니다 (`AuthConfig.loginEndpoint`)
+- `updateConfig({ loginEndpoint })` 로 템플릿이 재정의한 값이 그대로 사용됩니다
- 인증 후 작업은 각각 분리된 엔드포인트 사용
- API 인증은 Bearer 토큰 전용 (세션 기반 인증 미사용)
- 401 응답 시 서버가 세션 쿠키 만료 헤더를 자동 전송 (잔존 쿠키 정리)
@@ -161,11 +162,12 @@ user.language 확인
UI 즉시 업데이트
```
-**구현 위치**: `AuthManager.login()`
+**구현 위치**: `AuthManager.establishSession()` — 일반 로그인과 2단계 인증 완료가 같은 후처리를
+공유합니다. 갈라지면 한쪽 경로에서만 로케일 전환이나 이벤트 발행이 빠집니다.
```typescript
// 로케일 변경 감지 및 처리
-const userLanguage = response.data.user.language;
+const userLanguage = user.language;
const currentLocale = localStorage.getItem('g7_locale');
const localeChanged = userLanguage && userLanguage !== currentLocale;
@@ -187,6 +189,51 @@ if (localeChanged && window.__templateApp) {
---
+## 2단계 인증 로그인 (engine-v1.65.0+)
+
+보안 환경설정의 「2단계 인증」이 켜져 있으면 서버는 비밀번호가 맞아도 토큰을 발급하지 않고
+인증 요청(challenge)만 돌려줍니다. 즉, **로그인 응답은 두 가지 형태의 200** 입니다.
+
+```typescript
+export type LoginResult =
+ | { status: 'authenticated'; user: AuthUser }
+ | { status: 'two_factor_required'; challenge: TwoFactorChallenge };
+```
+
+`AuthManager.login()` 은 이 판별 유니온을 돌려줍니다. 한 형태만 가정하면 challenge 응답에서
+`data.user.*` 접근이 예외가 되고, `data.token`(undefined)을 저장하면 `"undefined"` 문자열이 남아
+이후 모든 요청이 401 로 튕깁니다.
+
+| 메서드 | 하는 일 |
+|--------|---------|
+| `login(type, credentials, options?)` | 비밀번호 확인. `LoginResult` 반환 |
+| `completeTwoFactor(type, { challengeId, code }, options?)` | 인증번호 확인 → 토큰 발급 → `AuthUser` 반환 |
+| `resendTwoFactor(type, { challengeId }, options?)` | 인증번호 재발송 → **새** `TwoFactorChallenge` 반환 |
+
+레이아웃에서는 액션 핸들러 `login` / `loginTwoFactor` / `loginTwoFactorResend` 로 사용합니다
+(→ [actions-handlers-ui.md](actions-handlers-ui.md)).
+
+### 레이아웃 작성 규칙
+
+- 1단계 블록과 2단계 블록의 `if` 는 **상보적**이어야 합니다. 두 블록이 동시에 보이면 인증번호
+ 단계에서 이메일·비밀번호가 함께 노출됩니다.
+- 제출 시퀀스의 `login` 과 `loginTwoFactor` 도 상호배타 `if` 를 갖습니다. `if` 가 빠지면 인증번호
+ 단계에서 Enter 를 누를 때 새 challenge 가 발급되어 흐름이 깨집니다. `if` 는 시퀀스 시작 시점
+ 스냅샷으로 평가되므로 한 번의 제출에 정확히 하나만 실행됩니다.
+- **같은 시퀀스·`onSuccess` 안에서 방금 저장한 상태를 다시 읽지 않습니다.** 그 자리의 상태는 아직
+ 갱신 전이므로 `{{response.*}}` 만 사용합니다.
+- 인증번호 입력은 자동바인딩이 아니라 `value` + `onChange` 로 상태가 값을 소유해야 합니다 —
+ 재발송 시 입력값을 비워야 하기 때문입니다.
+- 인증 단계 상태는 화면을 떠나도 남으므로, 로그인 화면 진입 시 `init_actions` 에서 초기화합니다.
+
+### 재발송의 계약
+
+서버는 기존 challenge 를 **취소하고 새로 발행**합니다. 유효한 코드를 여러 개 동시에 살려 두면
+대입 시도의 표적이 넓어지기 때문입니다. 따라서 클라이언트는 응답의 `challenge_id` 로 반드시
+교체하고 입력란을 비워야 합니다.
+
+---
+
## 사용 예시
### routes.json 설정
diff --git a/docs/frontend/identity-verification-ui.md b/docs/frontend/identity-verification-ui.md
index e5beb01b..5f2c0709 100644
--- a/docs/frontend/identity-verification-ui.md
+++ b/docs/frontend/identity-verification-ui.md
@@ -316,6 +316,17 @@ IDV 모달 파셜 (`_identity_challenge_modal.json`) 및 동일 패턴을 따르
- code Input 의 `actions[]` 에 `event:"onChange"` + `handler:"setState"` + `target:"global"` 항목이 존재할 것
- 재전송 setState (resendCooldown=30) 의 params 에 `identityChallenge.code: ""` 가 포함될 것
+로그인 2단계 인증 화면도 같은 규칙을 따르며, 회귀 테스트는
+`templates/_bundled/{template}/__tests__/layouts/{admin-,}login-two-factor-step.test.tsx` 가 담당합니다.
+
+### 5. 로그인 challenge 는 이 화면으로 처리하지 않는다
+
+`purpose = login` challenge 는 로그인 전용 엔드포인트(`POST /api/auth/login/two-factor`,
+`.../resend`)만 사용합니다. 공개 본인인증 엔드포인트(`POST /api/identity/challenges/{id}/verify`,
+`.../cancel`)는 이 목적을 `403 PURPOSE_NOT_ALLOWED` 로 거부합니다 — 여기서 검증·취소되면 그
+challenge 로는 더 이상 로그인을 마칠 수 없게 되고(자기 DoS), 이 화면은 로그인 흐름을 모르므로
+되돌릴 방법도 없기 때문입니다.
+
## 관련 문서
- [identity-guard-interceptor.md](identity-guard-interceptor.md) — 코어 인터셉터 API 레퍼런스
diff --git a/docs/frontend/security.md b/docs/frontend/security.md
index 72d666e4..859992de 100644
--- a/docs/frontend/security.md
+++ b/docs/frontend/security.md
@@ -55,7 +55,7 @@
| JSON 구조 | ValidLayoutStructure | 필수 필드, 깊이 10단계 제한, 타입 검증 |
| 컴포넌트 | ComponentExists | components.json 매니페스트 대조 |
| API 엔드포인트 | WhitelistedEndpoint | `/api/(admin\|auth\|public)/` 패턴만 허용 |
-| 외부 URL | NoExternalUrls | http, data, javascript 등 7개 위험 스킴 차단 |
+| 외부 URL | NoExternalUrls | http, data, javascript 등 7개 위험 스킴 차단. 사이트 자기 host·선언된 공개 자산 디스크 host 의 http(s) 절대 URL 은 외부가 아니다(서버가 발급하는 첨부 주소) |
| 상속 | ValidParentLayout | 순환 참조 방지, 상속 깊이 10 제한 |
| 슬롯 | ValidSlotStructure | 부모에서 정의된 슬롯만 허용 |
| 데이터소스 | ValidDataSourceMerge | 상속 체인 ID 고유성 |
diff --git a/docs/requirements.md b/docs/requirements.md
index 83e6aa08..2d577991 100644
--- a/docs/requirements.md
+++ b/docs/requirements.md
@@ -174,6 +174,11 @@ sudo chmod -R 755 storage bootstrap/cache vendor modules plugins templates publi
두 도구 모두 필요하지 않다.** 소스에서 직접 빌드하거나 개발 환경을 구성할 때, 또는
Composer 설치 방식을 선택할 때만 필요하다.
+Composer 로 직접 설치한다면 `composer install --no-dev --optimize-autoloader` 를 쓴다.
+옵션 없는 `composer install` 은 개발용 패키지까지 설치하며, 그 상태로 운영하면
+이후 코어 업데이트가 vendor 를 교체할 때 이전 패키지 목록 캐시와 어긋나 부팅이 깨진다.
+개발용 패키지가 섞여 있으면 설치 마법사의 「설치 환경 확인」 단계가 경고한다.
+
---
## 2. 데이터베이스
diff --git a/docs/testing/e2e-testing.md b/docs/testing/e2e-testing.md
index bf1b404a..44ed0554 100644
--- a/docs/testing/e2e-testing.md
+++ b/docs/testing/e2e-testing.md
@@ -132,6 +132,7 @@ UA 를 실제 브라우저 값으로 고정해도 그 검증은 그대로 동작
| 공유 상태 | 같은 관리자 설정 화면을 건드리는 spec 이 병렬로 돌면 서로의 저장 상태를 덮어써 실패할 수 있다. 실행 옵션에 맡기지 않고 그 `describe` 에 `test.describe.configure({ mode: 'serial' })` 를 둔다 |
| 워커 수 | 관리자 SPA 는 번들이 크고 레이아웃을 여러 번 받아온다. 개발 머신에서 2워커 이상이면 `page.waitForLoadState` 가 30초를 넘겨 **비결정적으로** 실패한다(실측: 같은 스위트가 회차마다 다른 5~7건 실패, 테스트당 8초 → 25초). 판정은 `--workers=1` 결과로 한다 |
| 편집기 spec 만 몰아 실행할 때 | 레이아웃 편집기 spec 만 골라 돌리면 워커 전부가 동시에 편집기 페이지를 연다 — 전체 스위트에서는 가벼운 spec 이 섞여 그 집중이 생기지 않는다. 실측: 편집기 7파일 27건을 7워커로 돌리면 `g7le-preview-frame` 대기가 전부 30초 타임아웃(27/27 실패), 같은 코드로 2워커는 26/27 통과. 편집기만 선택 실행할 때는 `--workers=2` 이하로 둔다 |
+| 봇 렌더 spec 의 IP 예산 | 검색봇 화면의 미스 렌더는 **IP 당 분당 상한**(`G7_SEO_RENDER_MISSES_PER_MINUTE`, 기본 60)을 쓴다. 자동 검사는 전부 같은 IP 에서 나가므로 봇 경로 spec 을 많이 몰아 돌리면 예산을 넘긴 요청이 SEO HTML 대신 SPA(`X-SEO-Cache: BYPASS`)를 받아 본문 단언이 실패한다. 오류가 아니라 200 이라 원인이 드러나지 않으므로, 봇 spec 이 간헐 실패하면 응답 헤더가 `BYPASS` 인지 먼저 본다 (실측: `seo-bot-rendering` 12건 + `page-og-image` 2건은 기본값에서 여유가 있다) |
### 브라우저 범위
@@ -168,6 +169,7 @@ Firefox/WebKit 프로젝트를 상시 스위트에 넣지 않은 이유:
|---|---|---|---|
| 코어 권한/역할/유저/Sanctum 토큰 | 코어 | `app/Console/Commands/PlaywrightIssueToken.php` | `php artisan playwright:issue-token --permissions=core.xxx` (권한 경계 검증은 `--no-admin-role` 추가 — §5.1) |
| 편집기 저장 spec 대상 시드 화면 | 코어 | `app/Console/Commands/PlaywrightSeedLayout.php` | `php artisan playwright:seed-layout [--remove]` (globalSetup/globalTeardown 자동 호출) |
+| 로그인 2단계 인증 계정·인증번호 | 코어 | `app/Console/Commands/PlaywrightSeedTwoFactor.php` | `php artisan playwright:seed-two-factor --ensure-user=… \| --plant=
+
diff --git a/modules/_bundled/sirsoft-board/composer.json b/modules/_bundled/sirsoft-board/composer.json
index f2c4f06a..e143c37f 100644
--- a/modules/_bundled/sirsoft-board/composer.json
+++ b/modules/_bundled/sirsoft-board/composer.json
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-board",
"description": "Board module for Gnuboard7",
"type": "library",
- "version": "1.1.1",
+ "version": "1.1.2",
"license": "MIT",
"autoload": {
"psr-4": {
diff --git a/modules/_bundled/sirsoft-board/composer.lock b/modules/_bundled/sirsoft-board/composer.lock
index 817a71f8..201bd324 100644
--- a/modules/_bundled/sirsoft-board/composer.lock
+++ b/modules/_bundled/sirsoft-board/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "f3ccc886d4904110ff4788fd5bc9e15d",
+ "content-hash": "dd055a1a5b6c59b9e30d2eaeb74b3416",
"packages": [],
"packages-dev": [],
"aliases": [],
@@ -16,5 +16,5 @@
"php": "^8.2"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/modules/_bundled/sirsoft-board/module.json b/modules/_bundled/sirsoft-board/module.json
index 2385af7c..54449f3a 100644
--- a/modules/_bundled/sirsoft-board/module.json
+++ b/modules/_bundled/sirsoft-board/module.json
@@ -5,7 +5,7 @@
"ko": "게시판",
"en": "Board"
},
- "version": "1.1.1",
+ "version": "1.1.2",
"license": "MIT",
"description": {
"ko": "게시판 관리를 위한 모듈",
diff --git a/modules/_bundled/sirsoft-board/package-lock.json b/modules/_bundled/sirsoft-board/package-lock.json
index fa177ce9..53b07fe9 100644
--- a/modules/_bundled/sirsoft-board/package-lock.json
+++ b/modules/_bundled/sirsoft-board/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-board",
- "version": "1.1.1",
+ "version": "1.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-board",
- "version": "1.1.1",
+ "version": "1.1.2",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
diff --git a/modules/_bundled/sirsoft-board/package.json b/modules/_bundled/sirsoft-board/package.json
index 3ac608c6..da338bba 100644
--- a/modules/_bundled/sirsoft-board/package.json
+++ b/modules/_bundled/sirsoft-board/package.json
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-board",
- "version": "1.1.1",
+ "version": "1.1.2",
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
diff --git a/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php b/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php
index 9e335886..5a816d90 100644
--- a/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php
+++ b/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php
@@ -792,6 +792,10 @@ class AttachmentService
/**
* 첨부파일 URL 조회
*
+ * 게시판 첨부는 비밀글·삭제글 게이트가 걸린 서빙 경로를 통해서만 내보낸다.
+ * 직접 URL(CDN)로 바꾸면 그 게이트가 통째로 우회되므로, 게이트가 살아 있는
+ * 다운로드 서빙 URL 을 돌려준다.
+ *
* @param string $slug 게시판 식별자
* @param int $id 첨부파일 ID
* @return string|null 파일 URL 또는 없을 경우 null
@@ -800,11 +804,7 @@ class AttachmentService
{
$attachment = $this->repository->findById($slug, $id);
- if (! $attachment) {
- return null;
- }
-
- return $this->storage->url('attachments', $attachment->path);
+ return $attachment?->download_url;
}
/**
diff --git a/modules/_bundled/sirsoft-board/tests/Unit/AttachmentServiceTest.php b/modules/_bundled/sirsoft-board/tests/Unit/AttachmentServiceTest.php
index 89d4fa12..3cb320b6 100644
--- a/modules/_bundled/sirsoft-board/tests/Unit/AttachmentServiceTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Unit/AttachmentServiceTest.php
@@ -823,29 +823,54 @@ class AttachmentServiceTest extends ModuleTestCase
HookManager::clearAction('sirsoft-board.attachment.after_download');
}
+ /**
+ * 업로드 응답의 url 칸이 게이트가 살아 있는 서빙 URL 이어야 합니다.
+ *
+ * 이전에는 비공개 디스크에서 항상 null 이 나가 응답의 url 칸이 늘 비어 있었다.
+ * 게시판 첨부는 비밀글·삭제글 게이트가 걸려 있으므로 직접 URL 로 바꾸지 않고,
+ * 그 게이트를 통과하는 다운로드 서빙 URL 로 채운다.
+ *
+ * @effects board_upload_response_url_uses_gated_route, download_url_falls_back_to_api_path_when_direct_unavailable
+ */
#[Test]
- public function test_get_url_returns_url(): void
+ public function test_get_url_returns_gated_serving_url(): void
{
// Arrange
$attachment = new Attachment([
- 'id' => 1,
'path' => 'notice/2025/01/21/test.jpg',
]);
+ $attachment->hash = 'abc123def456';
+ $attachment->setRelation('board', new Board(['slug' => 'notice']));
$this->repository->shouldReceive('findById')
->once()
->with('notice', 1)
->andReturn($attachment);
- $this->storage->shouldReceive('url')
- ->once()
- ->with('attachments', 'notice/2025/01/21/test.jpg')
- ->andReturn('https://example.com/storage/modules/sirsoft-board/attachments/notice/2025/01/21/test.jpg');
+ // 직접 URL 은 시도조차 하지 않는다 (게이트 우회 차단)
+ $this->storage->shouldNotReceive('url');
// Act
$result = $this->service->getUrl('notice', 1);
// Assert
- $this->assertEquals('https://example.com/storage/modules/sirsoft-board/attachments/notice/2025/01/21/test.jpg', $result);
+ $this->assertSame(
+ '/api/modules/sirsoft-board/boards/notice/attachment/abc123def456',
+ $result
+ );
+ }
+
+ /**
+ * 첨부가 없으면 null 이어야 합니다 (기존 계약 유지).
+ */
+ #[Test]
+ public function test_get_url_returns_null_when_attachment_missing(): void
+ {
+ $this->repository->shouldReceive('findById')
+ ->once()
+ ->with('notice', 99)
+ ->andReturnNull();
+
+ $this->assertNull($this->service->getUrl('notice', 99));
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
index 050eb456..aadbbb21 100644
--- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
@@ -10,11 +10,16 @@
- 상품 상세에서 옵션을 고른 뒤 「바로 구매」·「장바구니 담기」가 동작하지 않던 문제가 코어 7.0.10 과 함께 해소됩니다. 화면에는 고른 옵션이 목록에 담긴 것으로 보이는데 실제 요청에는 아무 옵션도 실리지 않아, 「바로 구매」는 오류로 끝나고 「장바구니 담기」는 「옵션을 선택해주세요」 안내만 반복됐습니다. 원인은 코어 화면 엔진에 있었으며 이 모듈의 코드는 바뀌지 않았습니다.
- `sudo` 로 코어를 업데이트할 때 업그레이드 단계가 저장하는 설정 파일이 root 소유로 남을 수 있던 문제를 수정했습니다. 저장 뒤 폴더 소유자와 같게 맞춥니다.
+- 장바구니에서 수량을 바꾸면 배송 불가 안내와 주문 차단이 풀리던 문제를 수정했습니다. 선택한 배송 국가로 보낼 수 없는 상품이 담겨 있으면 주문이 막혀야 하는데, 수량을 한 번만 조정해도 그 잠금이 사라져 그대로 주문할 수 있었습니다. 수량 변경 응답이 장바구니 조회 응답보다 적은 정보를 담고 있었던 것이며, 이제 두 응답이 같은 내용을 돌려줍니다.
+- 비회원 주문의 결제 취소 기록이 주문번호만으로 실행되던 문제를 수정했습니다. 종전에는 로그인하지 않은 누구든 주문번호만 알면 다른 사람의 비회원 주문을 결제 취소 상태로 바꿀 수 있었습니다. 이제 비회원 주문 조회에 쓰는 것과 같은 본인 확인 절차를 거친 경우에만 처리되며, 그 밖의 요청은 주문을 찾을 수 없다는 응답으로 차단됩니다. 회원 주문은 종전과 같이 본인만 가능합니다. 비회원이 결제창에서 결제를 취소한 경우에는 주문 직후 발급된 본인 확인 정보로 종전처럼 취소 이력이 남습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2041)
+- 상세설명을 편집기(HTML)로 작성한 상품을 등록하거나 수정할 때 저장이 실패하던 문제를 수정했습니다. 상품 설명의 보안 정화에 쓰는 구성요소가 모듈 설치 폴더 안에 자기 캐시 파일을 만들려 했기 때문에, 보안상 모듈 폴더에 쓰기를 막아 둔 서버에서는 저장이 항상 오류로 끝났고 다시 시도해도 같은 결과였습니다. 이제 이 캐시는 `storage` 폴더 아래에 만들어지며, 그 위치마저 쓸 수 없는 경우에는 캐시 없이 정화만 수행해 저장이 실패하지 않습니다(설명은 종전과 똑같이 정화됩니다). (#125 @lyg-kaban 님께서 제보해주셨습니다.)
### Changed
- 입금 기한 만료 주문 자동취소가 카드 등 결제창 결제까지 대상에 포함합니다. 종전에는 무통장입금·가상계좌만 정리되어, 결제창까지 갔으나 결제가 끝나지 않은 주문은 정리하는 주체가 없어 계속 남았습니다. 이제 주문 후 24시간이 지나면 함께 취소되며 미리 차감된 마일리지도 돌아옵니다. 결제가 이미 완료된 주문과 입금 대기 중인 가상계좌 주문은 대상이 아니며, 주문설정의 「만료 주문 자동취소」를 끄면 종전처럼 동작합니다.
- 확장 문서(README · AGENTS.md · docs/README.md)의 제목에 「그누보드7」과 확장 유형을 함께 표기했습니다. 제목만 보고도 그누보드7의 어떤 종류 확장인지 알 수 있습니다.
+- 코어 최소 요구 버전을 7.0.10 으로 상향
+- Composer 잠금 파일을 확장 정보와 다시 맞추고, Composer 를 실행할 수 없는 환경을 위한 vendor 번들을 같은 구성으로 다시 만들었습니다. 포함된 패키지 버전은 바뀌지 않았습니다.
### Added
@@ -23,16 +28,6 @@
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
- 환경설정 > 주문 설정 > 주문 자동취소 에 「결제 미완료 주문 만료 기준(분)」 입력이 추가되었습니다. 결제창까지 갔으나 결제가 끝나지 않은 주문을 몇 분 뒤에 정리할지 정하며, 0 으로 두면 그 부류는 정리하지 않습니다(기본 1440분 = 24시간). 종전에는 값만 있고 화면에서 조작할 수 없었습니다.
-### Fixed
-
-- 장바구니에서 수량을 바꾸면 배송 불가 안내와 주문 차단이 풀리던 문제를 수정했습니다. 선택한 배송 국가로 보낼 수 없는 상품이 담겨 있으면 주문이 막혀야 하는데, 수량을 한 번만 조정해도 그 잠금이 사라져 그대로 주문할 수 있었습니다. 수량 변경 응답이 장바구니 조회 응답보다 적은 정보를 담고 있었던 것이며, 이제 두 응답이 같은 내용을 돌려줍니다.
-- 비회원 주문의 결제 취소 기록이 주문번호만으로 실행되던 문제를 수정했습니다. 종전에는 로그인하지 않은 누구든 주문번호만 알면 다른 사람의 비회원 주문을 결제 취소 상태로 바꿀 수 있었습니다. 이제 비회원 주문 조회에 쓰는 것과 같은 본인 확인 절차를 거친 경우에만 처리되며, 그 밖의 요청은 주문을 찾을 수 없다는 응답으로 차단됩니다. 회원 주문은 종전과 같이 본인만 가능합니다. 비회원이 결제창에서 결제를 취소한 경우에는 주문 직후 발급된 본인 확인 정보로 종전처럼 취소 이력이 남습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2041)
-- 상세설명을 편집기(HTML)로 작성한 상품을 등록하거나 수정할 때 저장이 실패하던 문제를 수정했습니다. 상품 설명의 보안 정화에 쓰는 구성요소가 모듈 설치 폴더 안에 자기 캐시 파일을 만들려 했기 때문에, 보안상 모듈 폴더에 쓰기를 막아 둔 서버에서는 저장이 항상 오류로 끝났고 다시 시도해도 같은 결과였습니다. 이제 이 캐시는 `storage` 폴더 아래에 만들어지며, 그 위치마저 쓸 수 없는 경우에는 캐시 없이 정화만 수행해 저장이 실패하지 않습니다(설명은 종전과 똑같이 정화됩니다). (#125 @lyg-kaban 님께서 제보해주셨습니다.)
-
-### Changed
-
-- 코어 최소 요구 버전을 7.0.10 으로 상향
-
## [1.2.0] - 2026-08-24
### Added
diff --git a/modules/_bundled/sirsoft-ecommerce/composer.lock b/modules/_bundled/sirsoft-ecommerce/composer.lock
index 0e444193..63510c8c 100644
--- a/modules/_bundled/sirsoft-ecommerce/composer.lock
+++ b/modules/_bundled/sirsoft-ecommerce/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "226f06cb690d9b15f95314738c32f711",
+ "content-hash": "b0e08190b712afcc602a24f54098f5a8",
"packages": [
{
"name": "ezyang/htmlpurifier",
@@ -78,5 +78,5 @@
"php": "^8.2"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/data-model.md b/modules/_bundled/sirsoft-ecommerce/docs/data-model.md
index 09a5c71c..e79551d2 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/data-model.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/data-model.md
@@ -71,6 +71,13 @@
관계없이 같은 방식으로 자산 URL 을 내도록 묶습니다. 표에 모델처럼 잡힌 것은 수집기가 클래스
파일 단위로 세기 때문이며, 테이블이 `(규약)` 인 것이 그 표식입니다.
+이 규약은 **권한 검사가 걸린 자산에는 배선하지 않는 것이 원칙**입니다 — 직접 URL 은 서버
+스트리밍 경로의 검사를 우회합니다. 예외가 하나 있습니다: `ProductReviewImage` 는 이 규약을
+씁니다. 리뷰 노출 상태는 리소스 층에서 걸러지므로 숨김 리뷰의 이미지는 응답에 실리지
+않지만, 공개 자산 디스크를 켠 상태에서 **이미 발급된 직접 URL 을 아는 쪽은 노출→숨김 전환
+이후에도 그 파일에 접근할 수 있습니다.** 노출 상태를 접근 차단 수단으로 써야 하는 운용이라면
+그 카테고리에 공개 자산 디스크를 적용하지 않아야 합니다.
+
**`HasUserOverrides` 를 쓰는 셋**(`ClaimReason` · `ShippingCarrier` · `ShippingType`)은 시더가
기본값을 넣지만 운영자가 고칠 수 있는 테이블입니다. 시더를 다시 돌려도 운영자 수정분은
보존되므로, 이 셋의 기본 데이터를 바꿀 때는 시더만 고쳐서는 기설치본에 반영되지 않습니다.
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Models/Concerns/HasDirectAssetUrl.php b/modules/_bundled/sirsoft-ecommerce/src/Models/Concerns/HasDirectAssetUrl.php
index 4e3a06f8..8d7e6bbf 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Models/Concerns/HasDirectAssetUrl.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Models/Concerns/HasDirectAssetUrl.php
@@ -16,6 +16,13 @@ use Modules\Sirsoft\Ecommerce\Support\AssetStorage;
* 이미지 외의 완전 공개 자산 모델도 재사용할 수 있습니다. 단, 권한 검사가 걸린
* 첨부파일(비밀글/회원 전용 게시판 등)에는 배선하지 않습니다 — 직접 URL 은
* 서버 스트리밍 경로의 권한 검사를 우회합니다.
+ *
+ * 알려진 예외와 그 한계 — `ProductReviewImage` 는 이 trait 을 씁니다. 리뷰 노출 상태는
+ * 리소스 층에서 걸러지므로 HIDDEN 리뷰의 이미지는 응답에 실리지 않지만, 공개 자산
+ * 디스크를 켠 상태에서 **이미 발급된 직접 URL 을 알고 있는 쪽은 VISIBLE→HIDDEN 전환
+ * 이후에도 그 파일에 접근할 수 있습니다.** 직접 URL 은 저장소의 파일을 직접 가리키므로
+ * 응답에서 감추는 것으로는 회수되지 않습니다. 노출 상태를 접근 차단 수단으로 써야 하는
+ * 운용이라면 그 카테고리에 공개 자산 디스크를 적용하지 않아야 합니다.
*/
trait HasDirectAssetUrl
{
diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json
index 711fcd44..7d9c6db2 100644
--- a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json
+++ b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json
@@ -1,15 +1,15 @@
{
"schema_version": "1.0",
- "generated_at": "2026-08-28T01:27:59+00:00",
+ "generated_at": "2026-09-08T10:13:14+00:00",
"generator": "g7 vendor-bundle:build",
"target": "module:sirsoft-ecommerce",
"composer_json_sha256": "4f224863e21777f5ef474a7fcfb7d5f66132551b4c58220a660bd3acb573dfdb",
- "composer_lock_sha256": "876ca9c2273a33baff878d25050a567018a946412db053930a7f548c4add595d",
- "zip_sha256": "5f954f1597fc2a39bb601af441e3af3768e303b835c9047f0ade71cb56aec66c",
- "zip_size": 435549,
+ "composer_lock_sha256": "66ab3fbbe582562f86af06406f45164a2e3aa6790f5136dce9fd4f73944126a3",
+ "zip_sha256": "713f98578a866fada6782d11f8c80ff13037f1faf0edaaa55f004ddc4e80d1b9",
+ "zip_size": 435546,
"package_count": 1,
"php_requirement": "^8.2",
- "g7_version": "7.0.10",
+ "g7_version": "7.0.11",
"packages": [
{
"name": "ezyang/htmlpurifier",
diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip
index a933076c..3305e4f1 100644
Binary files a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip and b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip differ
diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md
index e2f12ec8..c6622855 100644
--- a/modules/_bundled/sirsoft-page/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-page/CHANGELOG.md
@@ -4,6 +4,16 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.1.2] - 2026-09-09
+
+### Changed
+
+- Composer 잠금 파일을 최신 Composer 형식으로 갱신했습니다.
+
+### Removed
+
+- 사용되지 않던 첨부파일 주소 조회 기능을 제거했습니다. 화면·API 어디에서도 호출되지 않았고 항상 빈 값을 돌려주던 기능이라 실제 동작 변화는 없습니다.
+
## [1.1.1] - 2026-09-06
### Added
diff --git a/modules/_bundled/sirsoft-page/README.md b/modules/_bundled/sirsoft-page/README.md
index 7ccbed3e..8fb3e05c 100644
--- a/modules/_bundled/sirsoft-page/README.md
+++ b/modules/_bundled/sirsoft-page/README.md
@@ -5,7 +5,7 @@
+
diff --git a/modules/_bundled/sirsoft-page/composer.json b/modules/_bundled/sirsoft-page/composer.json
index 133f79e8..df6069c8 100644
--- a/modules/_bundled/sirsoft-page/composer.json
+++ b/modules/_bundled/sirsoft-page/composer.json
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-page",
"description": "Page module for Gnuboard7",
"type": "library",
- "version": "1.1.1",
+ "version": "1.1.2",
"license": "MIT",
"autoload": {
"psr-4": {
diff --git a/modules/_bundled/sirsoft-page/composer.lock b/modules/_bundled/sirsoft-page/composer.lock
index b54bdee8..b72f9432 100644
--- a/modules/_bundled/sirsoft-page/composer.lock
+++ b/modules/_bundled/sirsoft-page/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "b5b9d14252427e57c4bc8c36e891c5de",
+ "content-hash": "024fea001a7af7fa7570756507a49ec2",
"packages": [],
"packages-dev": [],
"aliases": [],
@@ -16,5 +16,5 @@
"php": "^8.2"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/modules/_bundled/sirsoft-page/module.json b/modules/_bundled/sirsoft-page/module.json
index d5c43683..5b828dad 100644
--- a/modules/_bundled/sirsoft-page/module.json
+++ b/modules/_bundled/sirsoft-page/module.json
@@ -5,7 +5,7 @@
"ko": "페이지",
"en": "Page"
},
- "version": "1.1.1",
+ "version": "1.1.2",
"license": "MIT",
"description": {
"ko": "정적 페이지(정보/정책/안내) 관리 모듈",
diff --git a/modules/_bundled/sirsoft-page/package-lock.json b/modules/_bundled/sirsoft-page/package-lock.json
index e20deeb6..115fdf16 100644
--- a/modules/_bundled/sirsoft-page/package-lock.json
+++ b/modules/_bundled/sirsoft-page/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-page",
- "version": "1.1.1",
+ "version": "1.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-page",
- "version": "1.1.1",
+ "version": "1.1.2",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
diff --git a/modules/_bundled/sirsoft-page/package.json b/modules/_bundled/sirsoft-page/package.json
index 3047c5fa..617a3609 100644
--- a/modules/_bundled/sirsoft-page/package.json
+++ b/modules/_bundled/sirsoft-page/package.json
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-page",
- "version": "1.1.1",
+ "version": "1.1.2",
"description": "그누보드7 페이지 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
diff --git a/modules/_bundled/sirsoft-page/src/Services/PageAttachmentService.php b/modules/_bundled/sirsoft-page/src/Services/PageAttachmentService.php
index c9cbd4f8..48edeb03 100644
--- a/modules/_bundled/sirsoft-page/src/Services/PageAttachmentService.php
+++ b/modules/_bundled/sirsoft-page/src/Services/PageAttachmentService.php
@@ -470,17 +470,6 @@ class PageAttachmentService
);
}
- /**
- * 파일 URL을 반환합니다.
- *
- * @param PageAttachment $attachment 첨부파일 모델
- * @return string|null 파일 URL 또는 null
- */
- public function getUrl(PageAttachment $attachment): ?string
- {
- return $this->storage->url('attachments', $attachment->path);
- }
-
/**
* 첨부파일 삭제 권한을 확인합니다.
*
diff --git a/plugins/_bundled/gnuboard7-hello_plugin/CHANGELOG.md b/plugins/_bundled/gnuboard7-hello_plugin/CHANGELOG.md
index fbfba113..a5dd14c7 100644
--- a/plugins/_bundled/gnuboard7-hello_plugin/CHANGELOG.md
+++ b/plugins/_bundled/gnuboard7-hello_plugin/CHANGELOG.md
@@ -15,6 +15,7 @@
### Changed
- 확장 문서(README · AGENTS.md · docs/README.md)의 제목에 「그누보드7」과 확장 유형을 함께 표기했습니다. 제목만 보고도 그누보드7의 어떤 종류 확장인지 알 수 있습니다.
+- Composer 잠금 파일을 확장 정보와 다시 맞췄습니다. 설치·업데이트 과정에서 잠금 파일이 오래되었다는 경고가 나오지 않습니다.
## [0.1.1] - 2026-08-17
diff --git a/plugins/_bundled/gnuboard7-hello_plugin/composer.lock b/plugins/_bundled/gnuboard7-hello_plugin/composer.lock
index d1264f95..92c5a061 100644
--- a/plugins/_bundled/gnuboard7-hello_plugin/composer.lock
+++ b/plugins/_bundled/gnuboard7-hello_plugin/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "cb11c22109cd1b70301cfffe7f82b973",
+ "content-hash": "dc78377c56e6cbc3a436d27fcfe9e258",
"packages": [],
"packages-dev": [],
"aliases": [],
@@ -16,5 +16,5 @@
"php": "^8.2"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md b/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md
index 78e383ea..23a548d7 100644
--- a/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md
@@ -25,6 +25,7 @@
### Changed
- 확장 문서(README · AGENTS.md · docs/README.md)의 제목에 「그누보드7」과 확장 유형을 함께 표기했습니다. 제목만 보고도 그누보드7의 어떤 종류 확장인지 알 수 있습니다.
+- Composer 잠금 파일을 확장 정보와 다시 맞췄습니다. 설치·업데이트 과정에서 잠금 파일이 오래되었다는 경고가 나오지 않습니다.
## [1.0.2] - 2026-08-19
diff --git a/plugins/_bundled/sirsoft-ckeditor5/composer.lock b/plugins/_bundled/sirsoft-ckeditor5/composer.lock
index d849a1f2..39250ca7 100644
--- a/plugins/_bundled/sirsoft-ckeditor5/composer.lock
+++ b/plugins/_bundled/sirsoft-ckeditor5/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "dd62f80e956b1dd28c3f4f4e94c48774",
+ "content-hash": "0f4b4f5edf9a00794889ba89418f8b5d",
"packages": [],
"packages-dev": [],
"aliases": [],
@@ -16,5 +16,5 @@
"php": "^8.2"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/plugins/_bundled/sirsoft-daum_postcode/CHANGELOG.md b/plugins/_bundled/sirsoft-daum_postcode/CHANGELOG.md
index 49e18dd8..ed0def57 100644
--- a/plugins/_bundled/sirsoft-daum_postcode/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-daum_postcode/CHANGELOG.md
@@ -20,6 +20,7 @@
### Changed
- 확장 문서(README · AGENTS.md · docs/README.md)의 제목에 「그누보드7」과 확장 유형을 함께 표기했습니다. 제목만 보고도 그누보드7의 어떤 종류 확장인지 알 수 있습니다.
+- Composer 잠금 파일을 확장 정보와 다시 맞췄습니다. 설치·업데이트 과정에서 잠금 파일이 오래되었다는 경고가 나오지 않습니다.
## [1.0.2] - 2026-08-19
diff --git a/plugins/_bundled/sirsoft-daum_postcode/composer.lock b/plugins/_bundled/sirsoft-daum_postcode/composer.lock
index 916bb328..44a39797 100644
--- a/plugins/_bundled/sirsoft-daum_postcode/composer.lock
+++ b/plugins/_bundled/sirsoft-daum_postcode/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "2cf3389f6c00f3e542e3f7f4e7d556bc",
+ "content-hash": "1a8ad4ef4e86f6dca9d3c8f143ee89b2",
"packages": [],
"packages-dev": [],
"aliases": [],
@@ -16,5 +16,5 @@
"php": "^8.2"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md
index eaaecda0..808a9cf9 100644
--- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md
@@ -32,6 +32,7 @@
### Changed
- 확장 문서(README · AGENTS.md · docs/README.md)의 제목에 「그누보드7」과 확장 유형을 함께 표기했습니다. 제목만 보고도 그누보드7의 어떤 종류 확장인지 알 수 있습니다.
+- Composer 잠금 파일을 확장 정보와 다시 맞췄습니다. 설치·업데이트 과정에서 잠금 파일이 오래되었다는 경고가 나오지 않습니다.
## [1.0.3] - 2026-08-19
diff --git a/plugins/_bundled/sirsoft-gdpr/composer.lock b/plugins/_bundled/sirsoft-gdpr/composer.lock
index 58d078a6..ef3dcde2 100644
--- a/plugins/_bundled/sirsoft-gdpr/composer.lock
+++ b/plugins/_bundled/sirsoft-gdpr/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "ed69cfb7d799ccb19ce6420c9d3bc5b9",
+ "content-hash": "3327277d126e144e258ef68cf5e4352e",
"packages": [],
"packages-dev": [],
"aliases": [],
@@ -17,5 +17,5 @@
"ext-json": "*"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/necessary-allowlist-settings.spec.ts b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/necessary-allowlist-settings.spec.ts
index d7b8e0eb..e7916e76 100644
--- a/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/necessary-allowlist-settings.spec.ts
+++ b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/necessary-allowlist-settings.spec.ts
@@ -18,7 +18,7 @@
* 3. 저장된 목록이 인라인 페이로드에 실려 브라우저에 도달한다 (+ 잠금 집합 동반)
* 4. 형식 위반 항목은 422 로 거부되고 그 카드에 에러가 붙는다
*
- * 종료 시 추가한 항목을 되돌린다 — PO 와 브라우저를 공유하므로 원상 복구는 의무다.
+ * 종료 시 추가한 항목을 되돌린다 — 사람이 쓰는 브라우저를 공유하므로 원상 복구는 의무다.
*/
import { test, expect, authenticatePage } from '../../fixtures/gdpr-auth';
import type { Page } from '@playwright/test';
diff --git a/plugins/_bundled/sirsoft-marketing/CHANGELOG.md b/plugins/_bundled/sirsoft-marketing/CHANGELOG.md
index adf85f08..5332572c 100644
--- a/plugins/_bundled/sirsoft-marketing/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-marketing/CHANGELOG.md
@@ -15,6 +15,7 @@
### Changed
- 확장 문서(README · AGENTS.md · docs/README.md)의 제목에 「그누보드7」과 확장 유형을 함께 표기했습니다. 제목만 보고도 그누보드7의 어떤 종류 확장인지 알 수 있습니다.
+- Composer 잠금 파일을 확장 정보와 다시 맞췄습니다. 설치·업데이트 과정에서 잠금 파일이 오래되었다는 경고가 나오지 않습니다.
## [1.0.3] - 2026-08-22
diff --git a/plugins/_bundled/sirsoft-marketing/composer.lock b/plugins/_bundled/sirsoft-marketing/composer.lock
index b1122f5c..505f9e2c 100644
--- a/plugins/_bundled/sirsoft-marketing/composer.lock
+++ b/plugins/_bundled/sirsoft-marketing/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "b2b87b2f71ae1ead87a7304067243689",
+ "content-hash": "c90b712b291a2f1b249b8525880f280f",
"packages": [],
"packages-dev": [],
"aliases": [],
@@ -16,5 +16,5 @@
"php": "^8.2"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md
index aa54eab1..b3666d6f 100644
--- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md
@@ -15,6 +15,7 @@
### Changed
- 확장 문서(README · AGENTS.md · docs/README.md)의 제목에 「그누보드7」과 확장 유형을 함께 표기했습니다. 제목만 보고도 그누보드7의 어떤 종류 확장인지 알 수 있습니다.
+- Composer 잠금 파일을 새로 추가했습니다. 다른 번들 확장과 같은 방식으로 의존성 상태가 고정됩니다.
## [1.0.0] - 2026-08-24
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/composer.lock b/plugins/_bundled/sirsoft-message_bizppurio/composer.lock
new file mode 100644
index 00000000..9b4f15eb
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/composer.lock
@@ -0,0 +1,21 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "103d4ddd41142225a88cfbadee4aca1f",
+ "packages": [],
+ "packages-dev": [],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": {},
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": {
+ "php": "^8.2",
+ "ext-json": "*"
+ },
+ "platform-dev": {},
+ "plugin-api-version": "2.9.0"
+}
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md
index b90f08c8..feb7be2c 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md
@@ -21,6 +21,7 @@
### Changed
- 확장 문서(README · AGENTS.md · docs/README.md)의 제목에 「그누보드7」과 확장 유형을 함께 표기했습니다. 제목만 보고도 그누보드7의 어떤 종류 확장인지 알 수 있습니다.
+- Composer 잠금 파일을 확장 정보와 다시 맞췄습니다. 설치·업데이트 과정에서 잠금 파일이 오래되었다는 경고가 나오지 않습니다.
### Fixed
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/composer.lock b/plugins/_bundled/sirsoft-pay_kginicis/composer.lock
index 02be1cb8..fc4da071 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/composer.lock
+++ b/plugins/_bundled/sirsoft-pay_kginicis/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "33f95805cb42a29997a78d260d49592d",
+ "content-hash": "90bb21c817fbec07b681b62e4127c40d",
"packages": [],
"packages-dev": [],
"aliases": [],
@@ -16,5 +16,5 @@
"php": "^8.2"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md
index f21aa0ac..b87ee493 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md
@@ -28,6 +28,7 @@
### Changed
- 확장 문서(README · AGENTS.md · docs/README.md)의 제목에 「그누보드7」과 확장 유형을 함께 표기했습니다. 제목만 보고도 그누보드7의 어떤 종류 확장인지 알 수 있습니다.
+- Composer 잠금 파일을 확장 정보와 다시 맞췄습니다. 설치·업데이트 과정에서 잠금 파일이 오래되었다는 경고가 나오지 않습니다.
## [1.0.3] - 2026-08-22
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/composer.lock b/plugins/_bundled/sirsoft-pay_nhnkcp/composer.lock
index 8ba2f579..1b1e6be2 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/composer.lock
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "88988d859f2433db8b07b8700f481c2d",
+ "content-hash": "40ed20fff4e899e55521bf44236ee61a",
"packages": [],
"packages-dev": [],
"aliases": [],
@@ -16,5 +16,5 @@
"php": "^8.2"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md
index 5f629c6d..f545e360 100644
--- a/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md
@@ -25,6 +25,7 @@
### Changed
- 확장 문서(README · AGENTS.md · docs/README.md)의 제목에 「그누보드7」과 확장 유형을 함께 표기했습니다. 제목만 보고도 그누보드7의 어떤 종류 확장인지 알 수 있습니다.
+- Composer 잠금 파일을 확장 정보와 다시 맞췄습니다. 설치·업데이트 과정에서 잠금 파일이 오래되었다는 경고가 나오지 않습니다.
## [1.0.2] - 2026-08-19
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/composer.lock b/plugins/_bundled/sirsoft-pay_nicepayments/composer.lock
index c29a16db..1354b3e2 100644
--- a/plugins/_bundled/sirsoft-pay_nicepayments/composer.lock
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "1fce8e0d8388bbc1df8b2d62fdc98b7e",
+ "content-hash": "da2660ccc648f031a25e299592f5fb55",
"packages": [],
"packages-dev": [],
"aliases": [],
@@ -16,5 +16,5 @@
"php": "^8.2"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md b/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md
index dcb3e8b4..d30aa7ac 100644
--- a/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md
@@ -26,6 +26,7 @@
### Changed
- 확장 문서(README · AGENTS.md · docs/README.md)의 제목에 「그누보드7」과 확장 유형을 함께 표기했습니다. 제목만 보고도 그누보드7의 어떤 종류 확장인지 알 수 있습니다.
+- Composer 잠금 파일을 확장 정보와 다시 맞췄습니다. 설치·업데이트 과정에서 잠금 파일이 오래되었다는 경고가 나오지 않습니다.
## [1.0.2] - 2026-08-19
diff --git a/plugins/_bundled/sirsoft-tosspayments/composer.lock b/plugins/_bundled/sirsoft-tosspayments/composer.lock
index 254377c1..d4948b9d 100644
--- a/plugins/_bundled/sirsoft-tosspayments/composer.lock
+++ b/plugins/_bundled/sirsoft-tosspayments/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "6754a7ed2d766f6ab8147eb4506789cb",
+ "content-hash": "9260e25a1dfa51b52ab9da9cff55bce5",
"packages": [],
"packages-dev": [],
"aliases": [],
@@ -16,5 +16,5 @@
"php": "^8.2"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md
index 11cebb08..b02bc172 100644
--- a/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md
@@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Changed
- 확장 문서(README · AGENTS.md · docs/README.md)의 제목에 「그누보드7」과 확장 유형을 함께 표기했습니다. 제목만 보고도 그누보드7의 어떤 종류 확장인지 알 수 있습니다.
+- Composer 잠금 파일을 확장 정보와 다시 맞췄습니다. 설치·업데이트 과정에서 잠금 파일이 오래되었다는 경고가 나오지 않습니다.
## [1.0.4] - 2026-08-22
diff --git a/plugins/_bundled/sirsoft-verification_kginicis/composer.lock b/plugins/_bundled/sirsoft-verification_kginicis/composer.lock
index 3395a6e7..fe58d34c 100644
--- a/plugins/_bundled/sirsoft-verification_kginicis/composer.lock
+++ b/plugins/_bundled/sirsoft-verification_kginicis/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "692c2c26341fb56857aaac63a2b59910",
+ "content-hash": "40744923a1c241a42ea17e09d952a2c3",
"packages": [],
"packages-dev": [],
"aliases": [],
@@ -16,5 +16,5 @@
"php": "^8.2"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/CHANGELOG.md b/plugins/_bundled/sirsoft-verification_nhnkcp/CHANGELOG.md
index 2ac76f86..6aa5eb47 100644
--- a/plugins/_bundled/sirsoft-verification_nhnkcp/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-verification_nhnkcp/CHANGELOG.md
@@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Changed
- 확장 문서(README · AGENTS.md · docs/README.md)의 제목에 「그누보드7」과 확장 유형을 함께 표기했습니다. 제목만 보고도 그누보드7의 어떤 종류 확장인지 알 수 있습니다.
+- Composer 잠금 파일을 새로 추가했습니다. 다른 번들 확장과 같은 방식으로 의존성 상태가 고정됩니다.
## [1.0.1] - 2026-08-19
diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/composer.lock b/plugins/_bundled/sirsoft-verification_nhnkcp/composer.lock
new file mode 100644
index 00000000..b191e5fd
--- /dev/null
+++ b/plugins/_bundled/sirsoft-verification_nhnkcp/composer.lock
@@ -0,0 +1,21 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "bf7a2e9cc2c7101b900fb987e77a41f5",
+ "packages": [],
+ "packages-dev": [],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": {},
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": {
+ "php": "^8.2",
+ "ext-openssl": "*"
+ },
+ "platform-dev": {},
+ "plugin-api-version": "2.9.0"
+}
diff --git a/public/build/core/dev-dashboard.css b/public/build/core/dev-dashboard.css
index efe1ec67..34129338 100644
--- a/public/build/core/dev-dashboard.css
+++ b/public/build/core/dev-dashboard.css
@@ -1 +1 @@
-@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-content:""}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-900:oklch(39.6% .141 25.723);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-yellow-400:oklch(85.2% .199 91.936);--color-lime-600:oklch(64.8% .2 131.684);--color-lime-700:oklch(53.2% .157 131.589);--color-green-400:oklch(79.2% .209 151.711);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-900:oklch(37.8% .077 168.94);--color-teal-500:oklch(70.4% .14 182.503);--color-teal-600:oklch(60% .118 184.704);--color-teal-700:oklch(51.1% .096 186.391);--color-cyan-300:oklch(86.5% .127 207.078);--color-cyan-400:oklch(78.9% .154 211.53);--color-cyan-500:oklch(71.5% .143 215.221);--color-cyan-600:oklch(60.9% .126 221.723);--color-cyan-700:oklch(52% .105 223.128);--color-cyan-800:oklch(45% .085 224.283);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-sky-800:oklch(44.3% .11 240.79);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-violet-600:oklch(54.1% .281 293.009);--color-violet-700:oklch(49.1% .27 292.581);--color-purple-200:oklch(90.2% .063 306.703);--color-purple-300:oklch(82.7% .119 306.383);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-700:oklch(49.6% .265 301.924);--color-fuchsia-600:oklch(59.1% .293 322.896);--color-fuchsia-700:oklch(51.8% .253 323.949);--color-fuchsia-800:oklch(45.2% .211 324.591);--color-pink-500:oklch(65.6% .241 354.308);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height: 1.2 ;--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--blur-lg:16px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.top-0\.5{top:calc(var(--spacing) * .5)}.left-0\.5{left:calc(var(--spacing) * .5)}.z-10{z-index:10}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-10{margin-bottom:calc(var(--spacing) * 10)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-16{height:calc(var(--spacing) * 16)}.max-h-48{max-height:calc(var(--spacing) * 48)}.max-h-96{max-height:calc(var(--spacing) * 96)}.min-h-\[42px\]{min-height:42px}.min-h-screen{min-height:100vh}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-9{width:calc(var(--spacing) * 9)}.w-11{width:calc(var(--spacing) * 11)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-full{width:100%}.max-w-6xl{max-width:var(--container-6xl)}.min-w-\[120px\]{min-width:120px}.flex-1{flex:1}.shrink-0{flex-shrink:0}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-y-1{row-gap:var(--spacing)}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-emerald-700\/50{border-color:#00795680}@supports (color:color-mix(in lab,red,red)){.border-emerald-700\/50{border-color:color-mix(in oklab,var(--color-emerald-700) 50%,transparent)}}.border-red-700\/50{border-color:#bf000f80}@supports (color:color-mix(in lab,red,red)){.border-red-700\/50{border-color:color-mix(in oklab,var(--color-red-700) 50%,transparent)}}.border-slate-500{border-color:var(--color-slate-500)}.border-slate-600{border-color:var(--color-slate-600)}.border-slate-700{border-color:var(--color-slate-700)}.border-slate-700\/20{border-color:#31415833}@supports (color:color-mix(in lab,red,red)){.border-slate-700\/20{border-color:color-mix(in oklab,var(--color-slate-700) 20%,transparent)}}.border-slate-700\/30{border-color:#3141584d}@supports (color:color-mix(in lab,red,red)){.border-slate-700\/30{border-color:color-mix(in oklab,var(--color-slate-700) 30%,transparent)}}.border-slate-700\/40{border-color:#31415866}@supports (color:color-mix(in lab,red,red)){.border-slate-700\/40{border-color:color-mix(in oklab,var(--color-slate-700) 40%,transparent)}}.border-slate-700\/50{border-color:#31415880}@supports (color:color-mix(in lab,red,red)){.border-slate-700\/50{border-color:color-mix(in oklab,var(--color-slate-700) 50%,transparent)}}.bg-amber-600{background-color:var(--color-amber-600)}.bg-amber-700{background-color:var(--color-amber-700)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-cyan-600{background-color:var(--color-cyan-600)}.bg-cyan-700{background-color:var(--color-cyan-700)}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-emerald-600\/30{background-color:#0097674d}@supports (color:color-mix(in lab,red,red)){.bg-emerald-600\/30{background-color:color-mix(in oklab,var(--color-emerald-600) 30%,transparent)}}.bg-emerald-900\/30{background-color:#004e3b4d}@supports (color:color-mix(in lab,red,red)){.bg-emerald-900\/30{background-color:color-mix(in oklab,var(--color-emerald-900) 30%,transparent)}}.bg-fuchsia-600{background-color:var(--color-fuchsia-600)}.bg-fuchsia-700{background-color:var(--color-fuchsia-700)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-lime-600{background-color:var(--color-lime-600)}.bg-orange-600{background-color:var(--color-orange-600)}.bg-purple-600{background-color:var(--color-purple-600)}.bg-purple-600\/30{background-color:#9810fa4d}@supports (color:color-mix(in lab,red,red)){.bg-purple-600\/30{background-color:color-mix(in oklab,var(--color-purple-600) 30%,transparent)}}.bg-red-600{background-color:var(--color-red-600)}.bg-red-900\/30{background-color:#82181a4d}@supports (color:color-mix(in lab,red,red)){.bg-red-900\/30{background-color:color-mix(in oklab,var(--color-red-900) 30%,transparent)}}.bg-sky-600{background-color:var(--color-sky-600)}.bg-sky-700{background-color:var(--color-sky-700)}.bg-slate-600{background-color:var(--color-slate-600)}.bg-slate-700{background-color:var(--color-slate-700)}.bg-slate-800{background-color:var(--color-slate-800)}.bg-slate-800\/30{background-color:#1d293d4d}@supports (color:color-mix(in lab,red,red)){.bg-slate-800\/30{background-color:color-mix(in oklab,var(--color-slate-800) 30%,transparent)}}.bg-slate-800\/50{background-color:#1d293d80}@supports (color:color-mix(in lab,red,red)){.bg-slate-800\/50{background-color:color-mix(in oklab,var(--color-slate-800) 50%,transparent)}}.bg-slate-800\/80{background-color:#1d293dcc}@supports (color:color-mix(in lab,red,red)){.bg-slate-800\/80{background-color:color-mix(in oklab,var(--color-slate-800) 80%,transparent)}}.bg-slate-900{background-color:var(--color-slate-900)}.bg-slate-900\/30{background-color:#0f172b4d}@supports (color:color-mix(in lab,red,red)){.bg-slate-900\/30{background-color:color-mix(in oklab,var(--color-slate-900) 30%,transparent)}}.bg-slate-900\/40{background-color:#0f172b66}@supports (color:color-mix(in lab,red,red)){.bg-slate-900\/40{background-color:color-mix(in oklab,var(--color-slate-900) 40%,transparent)}}.bg-slate-900\/50{background-color:#0f172b80}@supports (color:color-mix(in lab,red,red)){.bg-slate-900\/50{background-color:color-mix(in oklab,var(--color-slate-900) 50%,transparent)}}.bg-teal-600{background-color:var(--color-teal-600)}.bg-transparent{background-color:#0000}.bg-violet-600{background-color:var(--color-violet-600)}.bg-white{background-color:var(--color-white)}.bg-gradient-to-br{--tw-gradient-position:to bottom right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-blue-500{--tw-gradient-from:var(--color-blue-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-blue-500\/0{--tw-gradient-from:#0000}@supports (color:color-mix(in lab,red,red)){.from-blue-500\/0{--tw-gradient-from:color-mix(in oklab, var(--color-blue-500) 0%, transparent)}}.from-blue-500\/0{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-emerald-500\/0{--tw-gradient-from:#0000}@supports (color:color-mix(in lab,red,red)){.from-emerald-500\/0{--tw-gradient-from:color-mix(in oklab, var(--color-emerald-500) 0%, transparent)}}.from-emerald-500\/0{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-purple-500\/0{--tw-gradient-from:#0000}@supports (color:color-mix(in lab,red,red)){.from-purple-500\/0{--tw-gradient-from:color-mix(in oklab, var(--color-purple-500) 0%, transparent)}}.from-purple-500\/0{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-red-500\/0{--tw-gradient-from:#0000}@supports (color:color-mix(in lab,red,red)){.from-red-500\/0{--tw-gradient-from:color-mix(in oklab, var(--color-red-500) 0%, transparent)}}.from-red-500\/0{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-slate-800\/50{--tw-gradient-from:#1d293d80}@supports (color:color-mix(in lab,red,red)){.from-slate-800\/50{--tw-gradient-from:color-mix(in oklab, var(--color-slate-800) 50%, transparent)}}.from-slate-800\/50{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-cyan-400{--tw-gradient-to:var(--color-cyan-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-cyan-500\/0{--tw-gradient-to:#0000}@supports (color:color-mix(in lab,red,red)){.to-cyan-500\/0{--tw-gradient-to:color-mix(in oklab, var(--color-cyan-500) 0%, transparent)}}.to-cyan-500\/0{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-orange-500\/0{--tw-gradient-to:#0000}@supports (color:color-mix(in lab,red,red)){.to-orange-500\/0{--tw-gradient-to:color-mix(in oklab, var(--color-orange-500) 0%, transparent)}}.to-orange-500\/0{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-pink-500\/0{--tw-gradient-to:#0000}@supports (color:color-mix(in lab,red,red)){.to-pink-500\/0{--tw-gradient-to:color-mix(in oklab, var(--color-pink-500) 0%, transparent)}}.to-pink-500\/0{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-slate-800\/30{--tw-gradient-to:#1d293d4d}@supports (color:color-mix(in lab,red,red)){.to-slate-800\/30{--tw-gradient-to:color-mix(in oklab, var(--color-slate-800) 30%, transparent)}}.to-slate-800\/30{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-teal-500\/0{--tw-gradient-to:#0000}@supports (color:color-mix(in lab,red,red)){.to-teal-500\/0{--tw-gradient-to:color-mix(in oklab, var(--color-teal-500) 0%, transparent)}}.to-teal-500\/0{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-4{padding-block:calc(var(--spacing) * 4)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-400{color:var(--color-amber-400)}.text-blue-400{color:var(--color-blue-400)}.text-cyan-300{color:var(--color-cyan-300)}.text-cyan-400{color:var(--color-cyan-400)}.text-emerald-300{color:var(--color-emerald-300)}.text-emerald-400{color:var(--color-emerald-400)}.text-emerald-400\/60{color:#00d29499}@supports (color:color-mix(in lab,red,red)){.text-emerald-400\/60{color:color-mix(in oklab,var(--color-emerald-400) 60%,transparent)}}.text-gray-500{color:var(--color-gray-500)}.text-gray-900{color:var(--color-gray-900)}.text-green-400{color:var(--color-green-400)}.text-orange-400{color:var(--color-orange-400)}.text-purple-300{color:var(--color-purple-300)}.text-purple-400{color:var(--color-purple-400)}.text-purple-500{color:var(--color-purple-500)}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.text-slate-50{color:var(--color-slate-50)}.text-slate-100{color:var(--color-slate-100)}.text-slate-200{color:var(--color-slate-200)}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-white{color:var(--color-white)}.text-yellow-400{color:var(--color-yellow-400)}.uppercase{text-transform:uppercase}.placeholder-slate-500::placeholder{color:var(--color-slate-500)}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.backdrop-blur-lg{--tw-backdrop-blur:blur(var(--blur-lg));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.outline-none{--tw-outline-style:none;outline-style:none}@media(hover:hover){.group-hover\:from-blue-500\/10:is(:where(.group):hover *){--tw-gradient-from:#3080ff1a}@supports (color:color-mix(in lab,red,red)){.group-hover\:from-blue-500\/10:is(:where(.group):hover *){--tw-gradient-from:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.group-hover\:from-blue-500\/10:is(:where(.group):hover *){--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.group-hover\:from-purple-500\/10:is(:where(.group):hover *){--tw-gradient-from:#ac4bff1a}@supports (color:color-mix(in lab,red,red)){.group-hover\:from-purple-500\/10:is(:where(.group):hover *){--tw-gradient-from:color-mix(in oklab, var(--color-purple-500) 10%, transparent)}}.group-hover\:from-purple-500\/10:is(:where(.group):hover *){--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.group-hover\:from-red-500\/5:is(:where(.group):hover *){--tw-gradient-from:#fb2c360d}@supports (color:color-mix(in lab,red,red)){.group-hover\:from-red-500\/5:is(:where(.group):hover *){--tw-gradient-from:color-mix(in oklab, var(--color-red-500) 5%, transparent)}}.group-hover\:from-red-500\/5:is(:where(.group):hover *){--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.group-hover\:to-cyan-500\/5:is(:where(.group):hover *){--tw-gradient-to:#00b7d70d}@supports (color:color-mix(in lab,red,red)){.group-hover\:to-cyan-500\/5:is(:where(.group):hover *){--tw-gradient-to:color-mix(in oklab, var(--color-cyan-500) 5%, transparent)}}.group-hover\:to-cyan-500\/5:is(:where(.group):hover *){--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.group-hover\:to-orange-500\/5:is(:where(.group):hover *){--tw-gradient-to:#fe6e000d}@supports (color:color-mix(in lab,red,red)){.group-hover\:to-orange-500\/5:is(:where(.group):hover *){--tw-gradient-to:color-mix(in oklab, var(--color-orange-500) 5%, transparent)}}.group-hover\:to-orange-500\/5:is(:where(.group):hover *){--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.group-hover\:to-pink-500\/5:is(:where(.group):hover *){--tw-gradient-to:#f6339a0d}@supports (color:color-mix(in lab,red,red)){.group-hover\:to-pink-500\/5:is(:where(.group):hover *){--tw-gradient-to:color-mix(in oklab, var(--color-pink-500) 5%, transparent)}}.group-hover\:to-pink-500\/5:is(:where(.group):hover *){--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.group-hover\:text-blue-200:is(:where(.group):hover *){color:var(--color-blue-200)}.group-hover\:text-purple-200:is(:where(.group):hover *){color:var(--color-purple-200)}}.peer-checked\:translate-x-4:is(:where(.peer):checked~*){--tw-translate-x:calc(var(--spacing) * 4);translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-checked\:bg-blue-600:is(:where(.peer):checked~*){background-color:var(--color-blue-600)}.peer-checked\:bg-red-600:is(:where(.peer):checked~*){background-color:var(--color-red-600)}.peer-focus\:ring-4:is(:where(.peer):focus~*){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.peer-focus\:ring-blue-300:is(:where(.peer):focus~*){--tw-ring-color:var(--color-blue-300)}.peer-focus\:outline-none:is(:where(.peer):focus~*){--tw-outline-style:none;outline-style:none}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:h-4:after{content:var(--tw-content);height:calc(var(--spacing) * 4)}.after\:h-5:after{content:var(--tw-content);height:calc(var(--spacing) * 5)}.after\:h-6:after{content:var(--tw-content);height:calc(var(--spacing) * 6)}.after\:w-4:after{content:var(--tw-content);width:calc(var(--spacing) * 4)}.after\:w-5:after{content:var(--tw-content);width:calc(var(--spacing) * 5)}.after\:w-6:after{content:var(--tw-content);width:calc(var(--spacing) * 6)}.after\:rounded-full:after{content:var(--tw-content);border-radius:3.40282e38px}.after\:border:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:1px}.after\:border-gray-300:after{content:var(--tw-content);border-color:var(--color-gray-300)}.after\:bg-white:after{content:var(--tw-content);background-color:var(--color-white)}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.peer-checked\:after\:translate-x-full:is(:where(.peer):checked~*):after{content:var(--tw-content);--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-checked\:after\:border-white:is(:where(.peer):checked~*):after{content:var(--tw-content);border-color:var(--color-white)}.focus-within\:border-emerald-500:focus-within{border-color:var(--color-emerald-500)}.focus-within\:border-purple-500:focus-within{border-color:var(--color-purple-500)}@media(hover:hover){.hover\:bg-amber-700:hover{background-color:var(--color-amber-700)}.hover\:bg-amber-800:hover{background-color:var(--color-amber-800)}.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}.hover\:bg-cyan-700:hover{background-color:var(--color-cyan-700)}.hover\:bg-cyan-800:hover{background-color:var(--color-cyan-800)}.hover\:bg-emerald-700:hover{background-color:var(--color-emerald-700)}.hover\:bg-emerald-900\/30:hover{background-color:#004e3b4d}@supports (color:color-mix(in lab,red,red)){.hover\:bg-emerald-900\/30:hover{background-color:color-mix(in oklab,var(--color-emerald-900) 30%,transparent)}}.hover\:bg-fuchsia-700:hover{background-color:var(--color-fuchsia-700)}.hover\:bg-fuchsia-800:hover{background-color:var(--color-fuchsia-800)}.hover\:bg-indigo-700:hover{background-color:var(--color-indigo-700)}.hover\:bg-lime-700:hover{background-color:var(--color-lime-700)}.hover\:bg-orange-700:hover{background-color:var(--color-orange-700)}.hover\:bg-purple-700:hover{background-color:var(--color-purple-700)}.hover\:bg-red-700:hover{background-color:var(--color-red-700)}.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:bg-sky-800:hover{background-color:var(--color-sky-800)}.hover\:bg-slate-700:hover{background-color:var(--color-slate-700)}.hover\:bg-slate-700\/20:hover{background-color:#31415833}@supports (color:color-mix(in lab,red,red)){.hover\:bg-slate-700\/20:hover{background-color:color-mix(in oklab,var(--color-slate-700) 20%,transparent)}}.hover\:bg-slate-700\/50:hover{background-color:#31415880}@supports (color:color-mix(in lab,red,red)){.hover\:bg-slate-700\/50:hover{background-color:color-mix(in oklab,var(--color-slate-700) 50%,transparent)}}.hover\:bg-teal-700:hover{background-color:var(--color-teal-700)}.hover\:bg-violet-700:hover{background-color:var(--color-violet-700)}.hover\:text-red-400:hover{color:var(--color-red-400)}.hover\:text-white:hover{color:var(--color-white)}}.focus\:border-emerald-500:focus{border-color:var(--color-emerald-500)}.focus\:ring-purple-500:focus{--tw-ring-color:var(--color-purple-500)}@media(min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(prefers-color-scheme:dark){.dark\:bg-gray-700{background-color:var(--color-gray-700)}.dark\:text-gray-400{color:var(--color-gray-400)}.dark\:text-white{color:var(--color-white)}.dark\:peer-checked\:bg-blue-500:is(:where(.peer):checked~*){background-color:var(--color-blue-500)}.dark\:peer-focus\:ring-blue-800:is(:where(.peer):focus~*){--tw-ring-color:var(--color-blue-800)}.dark\:after\:border-gray-600:after{content:var(--tw-content);border-color:var(--color-gray-600)}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"
{const s=e.slice(r.start,r.end+1);let a=0,l=0,d=0,c=0;for(const g of s)g.oldLine!==null&&(a===0&&(a=g.oldLine),d++),g.newLine!==null&&(l===0&&(l=g.newLine),c++);const p=a||(d===0?0:1),f=l||(c===0?0:1);return{header:`@@ -${p},${d} +${f},${c} @@`,oldStart:p,oldCount:d,newStart:f,newCount:c,lines:s}})}function Ru(e){return JSON.stringify(Rs(e),null,2)}function Rs(e){if(Array.isArray(e))return e.map(Rs);if(e&&typeof e=="object"){const t=e,n={};for(const o of Object.keys(t).sort())n[o]=Rs(t[o]);return n}return e}const L$={display:"flex",flexDirection:"column",height:"100%",minHeight:0},P$={display:"flex",alignItems:"center",gap:8,padding:"12px 16px",borderBottom:"1px solid #e2e8f0",background:"#f8fafc"},O$={display:"inline-flex",alignItems:"center",gap:4,padding:"4px 10px",borderRadius:6,border:"1px solid #cbd5e1",background:"#ffffff",color:"#0f172a",fontSize:12,fontWeight:600,cursor:"pointer",outline:"none"},B$={marginLeft:"auto",border:"none",background:"transparent",fontSize:16,color:"#64748b",cursor:"pointer",outline:"none"},W$={display:"flex",alignItems:"center",gap:12,padding:"8px 16px",borderBottom:"1px solid #e2e8f0",background:"#ffffff",fontSize:12,fontWeight:600},j$={flex:1,minHeight:0,overflow:"auto",background:"#ffffff",fontFamily:'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',fontSize:12,lineHeight:1.5},ep={padding:"24px 12px",textAlign:"center",color:"#94a3b8",fontSize:13},H$={padding:"2px 12px",background:"#f1f5f9",color:"#64748b",borderTop:"1px solid #e2e8f0",borderBottom:"1px solid #e2e8f0",userSelect:"none"};function F$(e){return e==="add"?{row:{background:"#e6ffed"},gutter:{background:"#cdffd8",color:"#22863a"},sign:"+",signColor:"#22863a"}:e==="remove"?{row:{background:"#ffeef0"},gutter:{background:"#ffdce0",color:"#b31d28"},sign:"-",signColor:"#b31d28"}:{row:{background:"#ffffff"},gutter:{background:"#f8fafc",color:"#94a3b8"},sign:" ",signColor:"#94a3b8"}}const tp={display:"inline-block",width:44,textAlign:"right",padding:"0 6px",color:"#94a3b8",userSelect:"none",borderRight:"1px solid #e2e8f0",whiteSpace:"nowrap"};function K$({oldVersion:e,newVersion:t,oldContent:n,newContent:o,t:r,onBack:s,onClose:a}){const l=u.useMemo(()=>{const c=Ru(n),p=Ru(o);return N$(c,p)},[n,o]),d=l.addedCount<0;return i.jsxs("div",{className:"g7le-version-diff","data-testid":"g7le-version-diff",style:L$,children:[i.jsxs("div",{"data-modal-drag-handle":!0,style:P$,children:[i.jsxs("button",{type:"button",onClick:s,style:O$,"data-testid":"g7le-version-diff-back",children:["← ",r("layout_editor.version_history.diff_back")]}),i.jsx("span",{style:{fontSize:14,fontWeight:700},"data-testid":"g7le-version-diff-title",children:r("layout_editor.version_history.diff_title",{old:e,new:t})}),i.jsx("button",{type:"button","aria-label":r("layout_editor.version_history.close"),onClick:a,style:B$,"data-testid":"g7le-version-diff-close",children:"✕"})]}),!d&&i.jsxs("div",{style:W$,"data-testid":"g7le-version-diff-summary",children:[i.jsx("span",{style:{color:"#16a34a"},"data-testid":"g7le-version-diff-added",children:r("layout_editor.version_history.added",{count:Math.max(0,l.addedCount)})}),i.jsx("span",{style:{color:"#dc2626"},"data-testid":"g7le-version-diff-removed",children:r("layout_editor.version_history.removed",{count:Math.max(0,l.removedCount)})})]}),i.jsx("div",{style:j$,children:d?i.jsx("div",{style:ep,"data-testid":"g7le-version-diff-too-large",children:r("layout_editor.version_history.diff_too_large")}):l.identical?i.jsx("div",{style:ep,"data-testid":"g7le-version-diff-identical",children:r("layout_editor.version_history.diff_identical")}):l.hunks.map((c,p)=>i.jsxs("div",{"data-testid":`g7le-version-diff-hunk-${p}`,children:[i.jsx("div",{style:H$,"data-testid":`g7le-version-diff-hunk-header-${p}`,children:c.header}),c.lines.map((f,b)=>{const g=F$(f.kind);return i.jsxs("div",{style:{display:"flex",alignItems:"stretch",...g.row},"data-kind":f.kind,"data-testid":`g7le-version-diff-line-${p}-${b}`,children:[i.jsx("span",{style:{...tp,...g.gutter},"aria-hidden":"true",children:f.oldLine??""}),i.jsx("span",{style:{...tp,...g.gutter},"aria-hidden":"true",children:f.newLine??""}),i.jsx("span",{style:{display:"inline-block",width:16,textAlign:"center",color:g.signColor,userSelect:"none",fontWeight:700},"aria-hidden":"true",children:g.sign}),i.jsx("span",{style:{whiteSpace:"pre-wrap",wordBreak:"break-word",flex:1,padding:"0 4px"},children:f.content})]},b)})]},p))})]})}const V$={display:"flex",flexDirection:"column",height:"100%",minHeight:0},U$={display:"flex",alignItems:"center",gap:8,padding:"12px 16px",borderBottom:"1px solid #e2e8f0",background:"#f8fafc"},q$={marginLeft:"auto",border:"none",background:"transparent",fontSize:16,color:"#64748b",cursor:"pointer",outline:"none"},G$={flex:1,minHeight:0,overflowY:"auto",padding:12},np={padding:"24px 12px",textAlign:"center",color:"#94a3b8",fontSize:13},Y$={margin:"8px 16px 0",padding:"8px 12px",borderRadius:6,background:"#fef2f2",color:"#b91c1c",fontSize:12,border:"1px solid #fecaca"},X$={display:"flex",flexDirection:"column",gap:8},J$={display:"flex",alignItems:"center",gap:12,padding:"10px 12px",borderRadius:8,border:"1px solid #e2e8f0",background:"#ffffff"},Z$={display:"inline-flex",alignItems:"center",justifyContent:"center",minWidth:44,padding:"2px 8px",borderRadius:999,background:"#eff6ff",color:"#1d4ed8",fontSize:12,fontWeight:700},Q$={display:"inline-flex",alignItems:"center",padding:"1px 8px",borderRadius:999,background:"#0f172a",color:"#ffffff",fontSize:10,fontWeight:700,letterSpacing:.3},R$={display:"flex",alignItems:"center",gap:10,fontSize:11,fontWeight:600},eE={display:"flex",flexDirection:"column",gap:2,minWidth:0,flex:1},op={padding:"6px 12px",borderRadius:6,border:"1px solid #cbd5e1",background:"#ffffff",color:"#0f172a",fontSize:12,fontWeight:600,cursor:"pointer",outline:"none",whiteSpace:"nowrap"};function rp(e){return e.full_content&&typeof e.full_content=="object"?e.full_content:{endpoint:e.endpoint,components:e.components,data_sources:e.data_sources,metadata:e.metadata}}function tE(e){if(!e)return"";const t=Date.parse(e);if(Number.isNaN(t))return e;try{return new Date(t).toLocaleString()}catch{return e}}function nE({templateIdentifier:e,target:t,t:n,onRestored:o,onClose:r}){const{versions:s,isLoading:a,isLoadingMore:l,error:d,restoringId:c,hasMore:p,loadVersions:f,loadMore:b,restore:g,loadVersionDetail:m}=T$(e,t,o),[h,v]=u.useState(null),[y,_]=u.useState(null),[w,k]=u.useState(null);u.useEffect(()=>{f()},[f]);const x=u.useCallback(async C=>{v(null),k(C.version);try{const T=await m(C.version);if(T.kind!=="success"){v(T.kind==="not_found"?n("layout_editor.version_history.restore_not_found"):T.message);return}const E=C.version-1,A=s.some(D=>D.version===E);let N={};if(A){const D=await m(E);if(D.kind==="success")N=rp(D.detail);else if(D.kind==="network_error"){v(D.message);return}}_({oldVersion:A?E:0,newVersion:C.version,oldContent:N,newContent:rp(T.detail)})}finally{k(null)}},[m,s,n]),$=u.useCallback(async C=>{v(null);const T=n("layout_editor.version_history.restore_confirm");if(!(typeof window<"u"?window.confirm(T):!0))return;const A=await g(C);A.kind==="success"?r():A.kind==="not_found"?v(n("layout_editor.version_history.restore_not_found")):v(A.message)},[g,r,n]),S=h??d;return y?i.jsx(K$,{oldVersion:y.oldVersion,newVersion:y.newVersion,oldContent:y.oldContent,newContent:y.newContent,t:n,onBack:()=>_(null),onClose:r}):i.jsxs("div",{className:"g7le-version-history","data-testid":"g7le-version-history",style:V$,children:[i.jsxs("div",{"data-modal-drag-handle":!0,style:U$,children:[i.jsxs("span",{style:{fontSize:14,fontWeight:700},"data-testid":"g7le-version-history-title",children:["🕘 ",n("layout_editor.version_history.title")]}),i.jsx("button",{type:"button","aria-label":n("layout_editor.version_history.close"),onClick:r,style:q$,"data-testid":"g7le-version-history-close",children:"✕"})]}),S&&i.jsx("div",{style:Y$,"data-testid":"g7le-version-history-error",children:S}),i.jsx("div",{style:G$,children:a?i.jsx("div",{style:np,"data-testid":"g7le-version-history-loading",children:n("layout_editor.version_history.loading")}):s.length===0?i.jsx("div",{style:np,"data-testid":"g7le-version-history-empty",children:n("layout_editor.version_history.empty")}):i.jsxs("div",{"data-testid":"g7le-version-history-list",style:X$,children:[s.map((C,T)=>{const E=C.changes_summary,A=E?.added_count??0,N=E?.removed_count??0,D=E?.char_diff??0,B=tE(C.created_at),M=c===C.id,O=T===0;return i.jsxs("div",{style:J$,"data-testid":`g7le-version-row-${C.id}`,children:[i.jsxs("span",{style:Z$,"data-testid":`g7le-version-badge-${C.id}`,children:["v",C.version]}),i.jsxs("div",{style:eE,children:[i.jsxs("span",{style:{display:"flex",alignItems:"center",gap:8},children:[B&&i.jsx("span",{style:{fontSize:12,color:"#475569",fontWeight:600},children:B}),O&&i.jsx("span",{style:Q$,"data-testid":`g7le-version-latest-${C.id}`,children:n("layout_editor.version_history.latest")})]}),i.jsxs("span",{style:R$,"data-testid":`g7le-version-summary-${C.id}`,children:[i.jsx("span",{style:{color:"#16a34a"},"data-testid":`g7le-version-added-${C.id}`,children:n("layout_editor.version_history.added",{count:A})}),i.jsx("span",{style:{color:"#dc2626"},"data-testid":`g7le-version-removed-${C.id}`,children:n("layout_editor.version_history.removed",{count:N})}),i.jsx("span",{style:{color:"#94a3b8",fontWeight:500},"data-testid":`g7le-version-chars-${C.id}`,children:n("layout_editor.version_history.chars",{diff:`${D>=0?"+":""}${D}`})})]}),i.jsx("span",{style:{fontSize:11,color:"#94a3b8"},"data-testid":`g7le-version-author-${C.id}`,children:n("layout_editor.version_history.saved_by",{name:C.created_by_name??n("layout_editor.version_history.unknown_author")})})]}),i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6},children:[i.jsx("button",{type:"button",onClick:()=>{x(C)},disabled:w!==null,style:{...op,background:"#f8fafc",opacity:w!==null?.6:1},"data-testid":`g7le-version-compare-${C.id}`,children:w===C.version?n("layout_editor.version_history.comparing"):n("layout_editor.version_history.compare")}),!O&&i.jsx("button",{type:"button",onClick:()=>{$(C.id)},disabled:M||c!==null,style:{...op,opacity:M||c!==null?.6:1},"data-testid":`g7le-version-restore-${C.id}`,children:n(M?"layout_editor.version_history.restoring":"layout_editor.version_history.restore")})]})]},C.id)}),p&&i.jsx("button",{type:"button","data-testid":"g7le-version-history-load-more",style:oE,disabled:l,onClick:()=>{b()},children:n(l?"layout_editor.version_history.loading":"layout_editor.version_history.load_more")})]})})]})}const oE={width:"100%",marginTop:8,padding:"8px 12px",fontSize:12,color:"#475569",background:"#f8fafc",border:"1px solid #e2e8f0",borderRadius:6,cursor:"pointer"};function Po(e){return`/api/admin/extensions/${encodeURIComponent(e.type)}/${encodeURIComponent(e.identifier)}/custom-assets`}function Oo(e,t){const n=e?.message;return typeof n=="string"&&n!==""?n:`HTTP ${t}`}function rE(e){const[t,n]=u.useState([]),[o,r]=u.useState(null),[s,a]=u.useState(!1),[l,d]=u.useState(!1),[c,p]=u.useState(null),f=u.useCallback(async()=>{a(!0),p(null);try{const v=await fetch(Po(e),{credentials:"same-origin",headers:Ne()}),y=await v.json().catch(()=>null);if(v.status===401||v.status===403){d(!0),n([]);return}if(d(!1),!v.ok){p(Oo(y,v.status)),n([]);return}const _=y?.data??{},w=_.files;n(Array.isArray(w)?w:[]),r({editable_extensions:_.editable_extensions??[],uploadable_extensions:_.uploadable_extensions??[],max_text_bytes:_.max_text_bytes??0,max_upload_bytes:_.max_upload_bytes??0})}catch(v){p(v instanceof Error?v.message:"network error"),n([])}finally{a(!1)}},[e.type,e.identifier]),b=u.useCallback(async v=>{p(null);try{const y=`${Po(e)}/content?path=${encodeURIComponent(v)}`,_=await fetch(y,{credentials:"same-origin",headers:Ne()}),w=await _.json().catch(()=>null);if(!_.ok)return p(Oo(w,_.status)),null;const k=w?.data?.content;return typeof k=="string"?k:""}catch(y){return p(y instanceof Error?y.message:"network error"),null}},[e.type,e.identifier]),g=u.useCallback(async(v,y)=>{p(null);try{const _=await fetch(`${Po(e)}/content`,{method:"PUT",credentials:"same-origin",headers:Ne({"Content-Type":"application/json"}),body:JSON.stringify({path:v,content:y})}),w=await _.json().catch(()=>null);return _.ok?(await f(),!0):(p(Oo(w,_.status)),!1)}catch(_){return p(_ instanceof Error?_.message:"network error"),!1}},[e.type,e.identifier,f]),m=u.useCallback(async(v,y)=>{p(null);try{const _=new FormData;_.append("file",v),y&&_.append("directory",y);const w=await fetch(`${Po(e)}/upload`,{method:"POST",credentials:"same-origin",headers:Ne(),body:_}),k=await w.json().catch(()=>null);return w.ok?(await f(),!0):(p(Oo(k,w.status)),!1)}catch(_){return p(_ instanceof Error?_.message:"network error"),!1}},[e.type,e.identifier,f]),h=u.useCallback(async v=>{p(null);try{const y=`${Po(e)}?path=${encodeURIComponent(v)}`,_=await fetch(y,{method:"DELETE",credentials:"same-origin",headers:Ne()}),w=await _.json().catch(()=>null);return _.ok?(await f(),!0):(p(Oo(w,_.status)),!1)}catch(y){return p(y instanceof Error?y.message:"network error"),!1}},[e.type,e.identifier,f]);return{files:t,meta:o,isLoading:s,forbidden:l,error:c,reload:f,read:b,save:g,upload:m,remove:h}}const iE={display:"flex",flexDirection:"column",height:"100%",minHeight:0},sE={display:"flex",alignItems:"center",gap:8,padding:"12px 16px",borderBottom:"1px solid #e2e8f0",background:"#f8fafc",fontWeight:600,color:"#0f172a"},aE={marginLeft:"auto",border:"none",background:"transparent",fontSize:16,color:"#64748b",cursor:"pointer",outline:"none"},lE={flex:1,minHeight:0,display:"flex"},dE={width:260,flexShrink:0,borderRight:"1px solid #e2e8f0",overflowY:"auto",padding:8},cE={flex:1,minWidth:0,display:"flex",flexDirection:"column",padding:12,gap:8},ip={display:"flex",alignItems:"center",gap:6,width:"100%",padding:"6px 8px",borderRadius:6,borderWidth:1,borderStyle:"solid",borderColor:"transparent",backgroundColor:"transparent",fontSize:12,color:"#334155",cursor:"pointer",textAlign:"left"},uE={...ip,backgroundColor:"#eff6ff",borderColor:"#bfdbfe",color:"#1d4ed8",fontWeight:600},pE={margin:16,padding:16,borderRadius:8,border:"1px solid #fcd34d",background:"#fffbeb",color:"#92400e",fontSize:13,lineHeight:1.7},fE={padding:"8px 12px",borderRadius:6,border:"1px solid #fecaca",background:"#fef2f2",color:"#b91c1c",fontSize:12},gE={flex:1,minHeight:220,width:"100%",boxSizing:"border-box",padding:10,borderRadius:6,borderWidth:1,borderStyle:"solid",borderColor:"#cbd5e1",fontFamily:"ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",fontSize:12,lineHeight:1.6,resize:"vertical"},sp={display:"flex",alignItems:"center",gap:8,flexWrap:"wrap"},io={padding:"6px 12px",borderRadius:6,borderWidth:1,borderStyle:"solid",borderColor:"#cbd5e1",backgroundColor:"#ffffff",color:"#334155",fontSize:12,cursor:"pointer"},hE={...io,backgroundColor:"#2563eb",borderColor:"#2563eb",color:"#ffffff",fontWeight:600},yE={...io,borderColor:"#fca5a5",color:"#b91c1c"},bE={...io,cursor:"text",minWidth:200},mE={...io,cursor:"pointer",minWidth:220};function vE(e,t){if(typeof e=="string"&&e!=="")return e;if(e&&typeof e=="object"){const n=e,o=window?.G7Config?.locale??"ko",r=n[o]??n.ko??n.en??Object.values(n)[0];if(typeof r=="string"&&r!=="")return r}return t}function _E(e){const t=(typeof window<"u"?window.G7Config:null)??{},n=[{type:"template",identifier:e,label:e}];for(const[o,r]of[["module",t.activeModules],["plugin",t.activePlugins]])if(Array.isArray(r))for(const s of r){const a=typeof s?.identifier=="string"?s.identifier:"";a&&n.push({type:o,identifier:a,label:vE(s?.display_name,a)})}return n}function xE(e){return e<1024?`${e} B`:e<1024*1024?`${Math.round(e/1024)} KB`:`${(e/(1024*1024)).toFixed(1)} MB`}function wE(e){const{templateIdentifier:t,t:n,onClose:o}=e,r=u.useMemo(()=>_E(t),[t]),[s,a]=u.useState(`template:${t}`),l=u.useMemo(()=>r.find(S=>`${S.type}:${S.identifier}`===s)??r[0],[r,s]),d=rE({type:l.type,identifier:l.identifier}),{reload:c}=d,[p,f]=u.useState(null),[b,g]=u.useState(""),[m,h]=u.useState(""),[v,y]=u.useState(!1),_=u.useRef(null);u.useEffect(()=>{c()},[c]),u.useEffect(()=>{f(null),g(""),h("")},[s]);const w=u.useCallback(async S=>{if(f(S),!S.editable){g("");return}y(!0);const C=await d.read(S.path);y(!1),g(C??"")},[d]),k=u.useCallback(async()=>{const S=p?.path??m.trim();if(!S)return;y(!0);const C=await d.save(S,b);y(!1),C&&!p&&(h(""),f({path:S,name:S.split("/").pop()??S,extension:(S.split(".").pop()??"").toLowerCase(),size:b.length,modified_at:new Date().toISOString(),editable:!0,loaded:!0}))},[d,p,m,b]),x=u.useCallback(async()=>{if(!p||typeof window<"u"&&!window.confirm(n("layout_editor.custom_assets.delete_confirm")))return;y(!0);const S=await d.remove(p.path);y(!1),S&&(f(null),g(""))},[d,p,n]),$=u.useCallback(async S=>{const C=S.target.files?.[0];C&&(y(!0),await d.upload(C),y(!1),_.current&&(_.current.value=""))},[d]);return i.jsxs("div",{className:"g7le-custom-assets",style:iE,"data-testid":"g7le-custom-assets-modal",children:[i.jsxs("div",{style:sE,children:[i.jsxs("span",{children:["🎨 ",n("layout_editor.custom_assets.title")]}),i.jsx("select",{value:s,onChange:S=>a(S.target.value),style:mE,"aria-label":n("layout_editor.custom_assets.target_label"),"data-testid":"g7le-custom-assets-target",children:r.map(S=>i.jsxs("option",{value:`${S.type}:${S.identifier}`,children:[n(`layout_editor.custom_assets.type_${S.type}`)," · ",S.label]},`${S.type}:${S.identifier}`))}),i.jsx("button",{type:"button",style:aE,onClick:o,"aria-label":n("layout_editor.custom_assets.close"),"data-testid":"g7le-custom-assets-close",children:"✕"})]}),d.forbidden?i.jsx("div",{style:pE,"data-testid":"g7le-custom-assets-forbidden",children:n("layout_editor.custom_assets.forbidden")}):i.jsxs("div",{style:lE,children:[i.jsxs("div",{style:dE,"data-testid":"g7le-custom-assets-list",children:[d.isLoading&&d.files.length===0?i.jsx("div",{style:{padding:12,color:"#94a3b8",fontSize:12},children:n("layout_editor.custom_assets.loading")}):null,!d.isLoading&&d.files.length===0?i.jsx("div",{style:{padding:12,color:"#94a3b8",fontSize:12},children:n("layout_editor.custom_assets.empty")}):null,d.files.map(S=>i.jsxs("button",{type:"button",style:p?.path===S.path?uE:ip,onClick:()=>{w(S)},"data-testid":`g7le-custom-asset-row-${S.path}`,"data-loaded":S.loaded?"true":"false",children:[i.jsx("span",{style:{flex:1,minWidth:0,overflow:"hidden",textOverflow:"ellipsis"},children:S.path}),i.jsx("span",{style:{color:"#94a3b8",fontSize:11},children:xE(S.size)})]},S.path))]}),i.jsxs("div",{style:cE,children:[d.error?i.jsx("div",{style:fE,"data-testid":"g7le-custom-assets-error",children:d.error}):null,i.jsxs("div",{style:sp,children:[i.jsx("input",{type:"text",value:p?p.path:m,readOnly:!!p,onChange:S=>h(S.target.value),placeholder:n("layout_editor.custom_assets.new_path_placeholder"),style:bE,"aria-label":n("layout_editor.custom_assets.path_label"),"data-testid":"g7le-custom-assets-path"}),p?i.jsx("button",{type:"button",style:io,onClick:()=>{f(null),g("")},"data-testid":"g7le-custom-assets-new",children:n("layout_editor.custom_assets.new_file")}):null]}),p&&!p.editable?i.jsx("div",{style:{color:"#64748b",fontSize:12,lineHeight:1.7},children:n("layout_editor.custom_assets.binary_hint",{extension:p.extension})}):i.jsx("textarea",{value:b,onChange:S=>g(S.target.value),style:gE,spellCheck:!1,"aria-label":n("layout_editor.custom_assets.content_label"),"data-testid":"g7le-custom-assets-content"}),i.jsxs("div",{style:sp,children:[i.jsx("button",{type:"button",style:hE,disabled:v||(p?!p.editable:m.trim()===""),onClick:()=>{k()},"data-testid":"g7le-custom-assets-save",children:n("layout_editor.custom_assets.save")}),i.jsx("button",{type:"button",style:io,disabled:v,onClick:()=>_.current?.click(),"data-testid":"g7le-custom-assets-upload",children:n("layout_editor.custom_assets.upload")}),i.jsx("input",{ref:_,type:"file",style:{display:"none"},onChange:S=>{$(S)},"data-testid":"g7le-custom-assets-file-input"}),i.jsx("button",{type:"button",style:yE,disabled:v||!p,onClick:()=>{x()},"data-testid":"g7le-custom-assets-delete",children:n("layout_editor.custom_assets.delete")}),i.jsx("span",{style:{color:"#94a3b8",fontSize:11},children:n("layout_editor.custom_assets.apply_hint")})]})]})]})]})}const ap=[{id:"copy",group:"clipboard",combos:[{key:"c",mod:!0}],labelKey:"layout_editor.shortcuts.action.copy",requiresSelection:!0},{id:"cut",group:"clipboard",combos:[{key:"x",mod:!0}],labelKey:"layout_editor.shortcuts.action.cut",requiresSelection:!0},{id:"paste",group:"clipboard",combos:[{key:"v",mod:!0}],labelKey:"layout_editor.shortcuts.action.paste"},{id:"openProps",group:"element",combos:[{key:"enter"}],labelKey:"layout_editor.shortcuts.action.open_props",requiresSelection:!0},{id:"delete",group:"element",combos:[{key:"delete"},{key:"backspace"}],labelKey:"layout_editor.shortcuts.action.delete",requiresSelection:!0},{id:"selectParent",group:"element",combos:[{key:"arrowup"}],labelKey:"layout_editor.shortcuts.action.select_parent",requiresSelection:!0},{id:"deselect",group:"element",combos:[{key:"escape"}],labelKey:"layout_editor.shortcuts.action.deselect"},{id:"addElement",group:"element",combos:[{key:"/",mod:!0}],labelKey:"layout_editor.shortcuts.action.add_element"},{id:"undo",group:"history",combos:[{key:"z",mod:!0}],labelKey:"layout_editor.shortcuts.action.undo"},{id:"redo",group:"history",combos:[{key:"z",mod:!0,shift:!0},{key:"y",mod:!0}],labelKey:"layout_editor.shortcuts.action.redo"},{id:"editCode",group:"view",combos:[{key:"e",mod:!0}],labelKey:"layout_editor.shortcuts.action.edit_code"},{id:"preview",group:"view",combos:[{key:"p",mod:!0}],labelKey:"layout_editor.shortcuts.action.preview"},{id:"translations",group:"view",combos:[{key:"l",mod:!0}],labelKey:"layout_editor.shortcuts.action.translations"},{id:"help",group:"view",combos:[{key:"?"}],labelKey:"layout_editor.shortcuts.action.help"},{id:"reset",group:"document",combos:[{key:"r",mod:!0,shift:!0}],labelKey:"layout_editor.shortcuts.action.reset"},{id:"save",group:"document",combos:[{key:"s",mod:!0}],labelKey:"layout_editor.shortcuts.action.save"},{id:"exit",group:"document",combos:[{key:"escape"}],labelKey:"layout_editor.shortcuts.action.exit"}];function kE(e){return{key:(e.key||"").toLowerCase(),mod:e.ctrlKey||e.metaKey,shift:e.shiftKey,alt:e.altKey}}function SE(e,t){return e.key===t.key&&!!e.mod==!!t.mod&&!!e.shift==!!t.shift&&!!e.alt==!!t.alt}function CE(e){for(const t of ap)if(t.combos.some(n=>SE(n,e)))return t;return null}function $E(e,t){const n=[];return e.mod&&n.push(t?"⌘":"Ctrl"),e.shift&&n.push(t?"⇧":"Shift"),e.alt&&n.push(t?"⌥":"Alt"),n.push(EE(e.key)),n.join(" + ")}function EE(e){return{arrowup:"↑",arrowdown:"↓",arrowleft:"←",arrowright:"→",enter:"Enter",escape:"Esc",delete:"Delete",backspace:"Backspace"," ":"Space"}[e]??(e.length===1?e.toUpperCase():e)}const IE=["clipboard","element","history","view","document"],AE={clipboard:"layout_editor.shortcuts.group.clipboard",element:"layout_editor.shortcuts.group.element",history:"layout_editor.shortcuts.group.history",view:"layout_editor.shortcuts.group.view",document:"layout_editor.shortcuts.group.document"};function TE(){return typeof navigator>"u"?!1:/mac|iphone|ipad|ipod/i.test(navigator.platform||navigator.userAgent||"")}function DE({t:e,onClose:t,isMac:n}){const o=n??TE(),r=u.useMemo(()=>{const s=new Map;for(const a of ap){const l=s.get(a.group)??[];l.push(a),s.set(a.group,l)}return s},[]);return i.jsxs("div",{className:"g7le-shortcut-help","data-testid":"g7le-shortcut-help",style:zE,children:[i.jsxs("div",{style:NE,children:[i.jsx("span",{style:ME,children:e("layout_editor.shortcuts.title")}),i.jsx("button",{type:"button","data-testid":"g7le-shortcut-help-close",onClick:t,"aria-label":e("layout_editor.shortcuts.close"),style:LE,children:"✕"})]}),i.jsx("div",{style:PE,children:IE.map(s=>{const a=r.get(s);return!a||a.length===0?null:i.jsxs("div",{style:OE,"data-testid":`g7le-shortcut-group-${s}`,children:[i.jsx("div",{style:BE,children:e(AE[s])}),i.jsx("table",{style:WE,children:i.jsx("tbody",{children:a.map(l=>i.jsxs("tr",{"data-testid":`g7le-shortcut-row-${l.id}`,children:[i.jsx("td",{style:jE,children:e(l.labelKey)}),i.jsx("td",{style:HE,children:l.combos.map((d,c)=>i.jsxs(u.Fragment,{children:[c>0&&i.jsx("span",{style:KE,children:e("layout_editor.shortcuts.or")}),i.jsx("kbd",{style:FE,children:$E(d,o)})]},c))})]},l.id))})})]},s)})}),i.jsx("div",{style:VE,children:e("layout_editor.shortcuts.footnote")})]})}const zE={display:"flex",flexDirection:"column",padding:20,gap:16,width:"100%",boxSizing:"border-box"},NE={display:"flex",alignItems:"center",justifyContent:"space-between",paddingBottom:12,borderBottom:"1px solid #e2e8f0"},ME={fontSize:15,fontWeight:700,color:"#0f172a"},LE={width:28,height:28,border:"none",background:"transparent",color:"#64748b",cursor:"pointer",fontSize:14,borderRadius:6},PE={display:"grid",gridTemplateColumns:"1fr 1fr",columnGap:28,rowGap:18,alignItems:"start"},OE={display:"flex",flexDirection:"column",gap:6},BE={fontSize:12,fontWeight:700,color:"#2563eb"},WE={width:"100%",borderCollapse:"collapse"},jE={padding:"4px 0",fontSize:12.5,color:"#334155",textAlign:"left",whiteSpace:"nowrap",verticalAlign:"middle"},HE={padding:"4px 0",paddingLeft:12,textAlign:"right",verticalAlign:"middle"},FE={display:"inline-block",padding:"1px 6px",fontSize:11,fontFamily:"ui-monospace, monospace",color:"#0f172a",background:"#f1f5f9",border:"1px solid #cbd5e1",borderBottomWidth:2,borderRadius:5,whiteSpace:"nowrap"},KE={fontSize:11,color:"#94a3b8",margin:"0 4px"},VE={fontSize:11,color:"#94a3b8",paddingTop:12,borderTop:"1px solid #f1f5f9"};function UE(e){if(!e||!(e instanceof HTMLElement))return!1;const t=e.tagName;return!!(t==="INPUT"||t==="TEXTAREA"||t==="SELECT"||e.isContentEditable)}function qE(){return typeof document>"u"?!1:!!document.querySelector(".g7le-modal-backdrop, .g7le-modal")}function lp(e){const{handlers:t,hasSelection:n,enabled:o=!0}=e;u.useEffect(()=>{if(!o||typeof window>"u")return;const r=s=>{if(UE(s.target))return;const a=kE(s),l=CE(a);if(!l||qE()||l.requiresSelection&&!n)return;if(l.key==="escape"||a.key==="escape"){if(n){const c=t.deselect;c&&(s.preventDefault(),c())}else{const c=t.exit;c&&(s.preventDefault(),c())}return}const d=t[l.id];d&&(s.preventDefault(),d())};return window.addEventListener("keydown",r),()=>window.removeEventListener("keydown",r)},[t,n,o])}function GE(e){const{result:t,onDismiss:n,onLoadLatest:o,onKeepMyChanges:r}=e,{t:s}=tt();if(u.useEffect(()=>{if(!t||t.kind==="concurrent_modification"||t.kind==="validation_failed")return;const a=setTimeout(n,5e3);return()=>clearTimeout(a)},[t,n]),!t)return null;if(t.kind==="success")return i.jsx(Bo,{tone:"success",testId:"g7le-save-banner-success",onDismiss:n,children:s("layout_editor.save.success")});if(t.kind==="validation_failed"){const a=t.errors??{},l=Object.entries(a);return i.jsxs(Bo,{tone:"error",testId:"g7le-save-banner-validation",onDismiss:n,children:[i.jsx("strong",{children:s("layout_editor.save.validation_failed")}),l.length>0&&i.jsx("ul",{style:dp,"data-testid":"g7le-save-banner-validation-errors",children:l.map(([d,c])=>i.jsxs("li",{children:[i.jsx("code",{style:cp,children:d}),": ",c.join(", ")]},d))})]})}return t.kind==="blocked_inactive_extension"?i.jsxs(Bo,{tone:"warning",testId:"g7le-save-banner-blocked",onDismiss:n,children:[i.jsx("strong",{children:s("layout_editor.save.blocked_inactive_extension")}),t.blockedPaths.length>0&&i.jsx("ul",{style:dp,"data-testid":"g7le-save-banner-blocked-paths",children:t.blockedPaths.map(a=>i.jsx("li",{children:i.jsx("code",{style:cp,children:a})},a))})]}):t.kind==="network_error"?i.jsxs(Bo,{tone:"error",testId:"g7le-save-banner-network",onDismiss:n,children:[i.jsx("strong",{children:s("layout_editor.save.network_error_title")}),i.jsx("div",{style:{marginTop:4,fontSize:12},children:t.message})]}):t.kind==="guard_no_document"?i.jsx(Bo,{tone:"neutral",testId:"g7le-save-banner-guard-no-document",onDismiss:n,children:s("layout_editor.save.guard_no_document")}):i.jsx("div",{role:"dialog","aria-modal":"true","aria-label":s("layout_editor.save.concurrent.title"),"data-testid":"g7le-save-banner-concurrent",style:ZE,children:i.jsxs("div",{style:QE,children:[i.jsx("h2",{style:RE,children:s("layout_editor.save.concurrent.title")}),i.jsx("p",{style:e7,children:s("layout_editor.save.concurrent.message")}),i.jsx("div",{style:t7,children:s("layout_editor.save.concurrent.version_info",{current:String(t.currentVersion),yours:String(t.yourVersion)})}),i.jsxs("div",{style:n7,children:[i.jsx("button",{type:"button","data-testid":"g7le-save-banner-concurrent-load-latest",onClick:()=>{o?.(),n()},style:o7,children:s("layout_editor.save.concurrent.load_latest")}),i.jsx("button",{type:"button","data-testid":"g7le-save-banner-concurrent-keep-mine",onClick:()=>{r?.(),n()},style:r7,children:s("layout_editor.save.concurrent.view_my_changes")}),i.jsx("button",{type:"button","data-testid":"g7le-save-banner-concurrent-cancel",onClick:n,style:i7,children:s("layout_editor.save.concurrent.cancel")})]})]})})}function Bo(e){const{tone:t,testId:n,onDismiss:o,children:r}=e,s=YE[t];return i.jsxs("div",{role:"status","data-testid":n,style:{...XE,background:s.bg,borderColor:s.border,color:s.fg},children:[i.jsx("div",{style:{flex:1,minWidth:0},children:r}),i.jsx("button",{type:"button",onClick:o,"aria-label":"close","data-testid":`${n}-close`,style:JE,children:"×"})]})}const YE={success:{bg:"#dcfce7",border:"#86efac",fg:"#14532d"},error:{bg:"#fee2e2",border:"#fca5a5",fg:"#7f1d1d"},warning:{bg:"#fef3c7",border:"#fcd34d",fg:"#78350f"},neutral:{bg:"#f1f5f9",border:"#cbd5e1",fg:"#334155"}},XE={display:"flex",alignItems:"flex-start",gap:12,padding:"12px 16px",border:"1px solid",borderRadius:0,fontSize:13,lineHeight:1.5},JE={background:"transparent",border:"none",fontSize:18,cursor:"pointer",lineHeight:1,color:"inherit",padding:"0 4px",flexShrink:0},dp={margin:"8px 0 0",paddingLeft:20,fontSize:12},cp={background:"rgba(0, 0, 0, 0.06)",padding:"1px 4px",borderRadius:3,fontFamily:"monospace"},ZE={position:"fixed",inset:0,background:"rgba(15, 23, 42, 0.6)",display:"flex",alignItems:"center",justifyContent:"center",zIndex:1e4},QE={background:"#fff",borderRadius:8,padding:24,width:"min(480px, 90vw)",boxShadow:"0 10px 25px rgba(0,0,0,0.15)"},RE={margin:"0 0 8px",fontSize:16,fontWeight:600,color:"#0f172a"},e7={margin:"0 0 12px",color:"#334155",fontSize:13,lineHeight:1.5},t7={marginBottom:16,padding:"8px 12px",background:"#f8fafc",borderRadius:6,fontSize:12,color:"#64748b",fontFamily:"monospace"},n7={display:"flex",gap:8,justifyContent:"flex-end"},o7={padding:"8px 14px",background:"#2563eb",color:"#fff",border:"none",borderRadius:6,fontSize:13,fontWeight:600,cursor:"pointer"},r7={padding:"8px 14px",background:"#fff",color:"#334155",border:"1px solid #cbd5e1",borderRadius:6,fontSize:13,fontWeight:500,cursor:"pointer"},i7={padding:"8px 14px",background:"transparent",color:"#64748b",border:"none",borderRadius:6,fontSize:13,cursor:"pointer"};function up(e){if(!e)return null;const n=new URLSearchParams(e).get("route");return n&&n.length>0?n:null}function ea(e,t,n){const o=`/admin/layout-editor/${encodeURIComponent(e)}`;if(!t||t.length===0)return o;if(t.startsWith("__")){let r=`${o}?edit=${encodeURIComponent(t)}`;return(t.startsWith("__modal__/")||t.startsWith("__iteration__/"))&&n&&(r+=`&host=${encodeURIComponent(n)}`),r}return`${o}?route=${encodeURIComponent(t)}`}function pp(e){if(!e)return null;const n=new URLSearchParams(e).get("host");return n&&n.length>0?n:null}function fp(e){if(!e)return null;const n=new URLSearchParams(e).get("edit");return n&&n.length>0&&n.startsWith("__")?n:null}function s7(e,t){const n=`/admin/templates/${encodeURIComponent(e)}/edit`;if(!t||t.length===0||t.startsWith("__"))return n;const o=t.replace(/^\*+/,"");return o.length===0?n:`${n}?route=${encodeURIComponent(o)}`}const a7="g7le-editor-route",l7=new Set;function ta(e){return e.path.startsWith("__group__/")||e.path.startsWith("__conngroup__/")}function gp(e,t){const{t:n,translationEngine:o,translationContext:r,templateIdentifier:s}=t,a=ta(e);if(!e.label.startsWith("$t:"))return e.label;const l=e.label.slice(3),d=l.indexOf("|"),c=d===-1?l:l.slice(0,d),p={};if(d!==-1)for(const b of l.slice(d+1).split("|")){const g=b.indexOf("=");g!==-1&&(p[b.slice(0,g)]=b.slice(g+1))}if((e.kind==="route"||e.kind==="modal")&&!a&&o&&r&&s!==r.templateId){const b={templateId:s,locale:r.locale},g=Object.keys(p).length>0?"|"+Object.entries(p).map(([h,v])=>`${h}=${v}`).join("|"):void 0,m=o.translate(c,b,g);if(m!==c)return m}const f=n(c,p);if(f===c&&typeof window<"u"){const b=window.G7Core?.t;if(typeof b=="function")try{const g=b(c,p);if(g!==c)return g}catch{}}if(f===c&&e.kind==="route"&&!a){const b=e.path;if(b&&b!=="/"){const g=b.split("/").filter(Boolean);return g[g.length-1]??b}return b==="/"?"/":c}return f}function d7(e){return e.trim().toLocaleLowerCase()}function hp(e,t){if(!t)return e;const n=e.toLocaleLowerCase(),o=[];let r=0,s=n.indexOf(t,r);if(s===-1)return e;let a=0;for(;s!==-1;)s>r&&o.push(e.slice(r,s)),o.push(i.jsx("mark",{className:"g7le-route-tree__highlight",style:{background:"#fde68a",color:"inherit",borderRadius:2,padding:"0 1px"},children:e.slice(s,s+t.length)},`m${a++}`)),r=s+t.length,s=n.indexOf(t,r);return r{const s=e.slice(r.start,r.end+1);let a=0,l=0,d=0,c=0;for(const g of s)g.oldLine!==null&&(a===0&&(a=g.oldLine),d++),g.newLine!==null&&(l===0&&(l=g.newLine),c++);const p=a||(d===0?0:1),f=l||(c===0?0:1);return{header:`@@ -${p},${d} +${f},${c} @@`,oldStart:p,oldCount:d,newStart:f,newCount:c,lines:s}})}function sp(e){return JSON.stringify(ia(e),null,2)}function ia(e){if(Array.isArray(e))return e.map(ia);if(e&&typeof e=="object"){const t=e,n={};for(const o of Object.keys(t).sort())n[o]=ia(t[o]);return n}return e}const eE={display:"flex",flexDirection:"column",height:"100%",minHeight:0},tE={display:"flex",alignItems:"center",gap:8,padding:"12px 16px",borderBottom:"1px solid #e2e8f0",background:"#f8fafc"},nE={display:"inline-flex",alignItems:"center",gap:4,padding:"4px 10px",borderRadius:6,border:"1px solid #cbd5e1",background:"#ffffff",color:"#0f172a",fontSize:12,fontWeight:600,cursor:"pointer",outline:"none"},oE={marginLeft:"auto",border:"none",background:"transparent",fontSize:16,color:"#64748b",cursor:"pointer",outline:"none"},rE={display:"flex",alignItems:"center",gap:12,padding:"8px 16px",borderBottom:"1px solid #e2e8f0",background:"#ffffff",fontSize:12,fontWeight:600},iE={flex:1,minHeight:0,overflow:"auto",background:"#ffffff",fontFamily:'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',fontSize:12,lineHeight:1.5},ap={padding:"24px 12px",textAlign:"center",color:"#94a3b8",fontSize:13},sE={padding:"2px 12px",background:"#f1f5f9",color:"#64748b",borderTop:"1px solid #e2e8f0",borderBottom:"1px solid #e2e8f0",userSelect:"none"};function aE(e){return e==="add"?{row:{background:"#e6ffed"},gutter:{background:"#cdffd8",color:"#22863a"},sign:"+",signColor:"#22863a"}:e==="remove"?{row:{background:"#ffeef0"},gutter:{background:"#ffdce0",color:"#b31d28"},sign:"-",signColor:"#b31d28"}:{row:{background:"#ffffff"},gutter:{background:"#f8fafc",color:"#94a3b8"},sign:" ",signColor:"#94a3b8"}}const lp={display:"inline-block",width:44,textAlign:"right",padding:"0 6px",color:"#94a3b8",userSelect:"none",borderRight:"1px solid #e2e8f0",whiteSpace:"nowrap"};function lE({oldVersion:e,newVersion:t,oldContent:n,newContent:o,t:r,onBack:s,onClose:a}){const l=u.useMemo(()=>{const c=sp(n),p=sp(o);return Q$(c,p)},[n,o]),d=l.addedCount<0;return i.jsxs("div",{className:"g7le-version-diff","data-testid":"g7le-version-diff",style:eE,children:[i.jsxs("div",{"data-modal-drag-handle":!0,style:tE,children:[i.jsxs("button",{type:"button",onClick:s,style:nE,"data-testid":"g7le-version-diff-back",children:["← ",r("layout_editor.version_history.diff_back")]}),i.jsx("span",{style:{fontSize:14,fontWeight:700},"data-testid":"g7le-version-diff-title",children:r("layout_editor.version_history.diff_title",{old:e,new:t})}),i.jsx("button",{type:"button","aria-label":r("layout_editor.version_history.close"),onClick:a,style:oE,"data-testid":"g7le-version-diff-close",children:"✕"})]}),!d&&i.jsxs("div",{style:rE,"data-testid":"g7le-version-diff-summary",children:[i.jsx("span",{style:{color:"#16a34a"},"data-testid":"g7le-version-diff-added",children:r("layout_editor.version_history.added",{count:Math.max(0,l.addedCount)})}),i.jsx("span",{style:{color:"#dc2626"},"data-testid":"g7le-version-diff-removed",children:r("layout_editor.version_history.removed",{count:Math.max(0,l.removedCount)})})]}),i.jsx("div",{style:iE,children:d?i.jsx("div",{style:ap,"data-testid":"g7le-version-diff-too-large",children:r("layout_editor.version_history.diff_too_large")}):l.identical?i.jsx("div",{style:ap,"data-testid":"g7le-version-diff-identical",children:r("layout_editor.version_history.diff_identical")}):l.hunks.map((c,p)=>i.jsxs("div",{"data-testid":`g7le-version-diff-hunk-${p}`,children:[i.jsx("div",{style:sE,"data-testid":`g7le-version-diff-hunk-header-${p}`,children:c.header}),c.lines.map((f,b)=>{const g=aE(f.kind);return i.jsxs("div",{style:{display:"flex",alignItems:"stretch",...g.row},"data-kind":f.kind,"data-testid":`g7le-version-diff-line-${p}-${b}`,children:[i.jsx("span",{style:{...lp,...g.gutter},"aria-hidden":"true",children:f.oldLine??""}),i.jsx("span",{style:{...lp,...g.gutter},"aria-hidden":"true",children:f.newLine??""}),i.jsx("span",{style:{display:"inline-block",width:16,textAlign:"center",color:g.signColor,userSelect:"none",fontWeight:700},"aria-hidden":"true",children:g.sign}),i.jsx("span",{style:{whiteSpace:"pre-wrap",wordBreak:"break-word",flex:1,padding:"0 4px"},children:f.content})]},b)})]},p))})]})}const dE={display:"flex",flexDirection:"column",height:"100%",minHeight:0},cE={display:"flex",alignItems:"center",gap:8,padding:"12px 16px",borderBottom:"1px solid #e2e8f0",background:"#f8fafc"},uE={marginLeft:"auto",border:"none",background:"transparent",fontSize:16,color:"#64748b",cursor:"pointer",outline:"none"},pE={flex:1,minHeight:0,overflowY:"auto",padding:12},dp={padding:"24px 12px",textAlign:"center",color:"#94a3b8",fontSize:13},fE={margin:"8px 16px 0",padding:"8px 12px",borderRadius:6,background:"#fef2f2",color:"#b91c1c",fontSize:12,border:"1px solid #fecaca"},gE={display:"flex",flexDirection:"column",gap:8},hE={display:"flex",alignItems:"center",gap:12,padding:"10px 12px",borderRadius:8,border:"1px solid #e2e8f0",background:"#ffffff"},yE={display:"inline-flex",alignItems:"center",justifyContent:"center",minWidth:44,padding:"2px 8px",borderRadius:999,background:"#eff6ff",color:"#1d4ed8",fontSize:12,fontWeight:700},bE={display:"inline-flex",alignItems:"center",padding:"1px 8px",borderRadius:999,background:"#0f172a",color:"#ffffff",fontSize:10,fontWeight:700,letterSpacing:.3},mE={display:"flex",alignItems:"center",gap:10,fontSize:11,fontWeight:600},vE={display:"flex",flexDirection:"column",gap:2,minWidth:0,flex:1},cp={padding:"6px 12px",borderRadius:6,border:"1px solid #cbd5e1",background:"#ffffff",color:"#0f172a",fontSize:12,fontWeight:600,cursor:"pointer",outline:"none",whiteSpace:"nowrap"};function up(e){return e.full_content&&typeof e.full_content=="object"?e.full_content:{endpoint:e.endpoint,components:e.components,data_sources:e.data_sources,metadata:e.metadata}}function _E(e){if(!e)return"";const t=Date.parse(e);if(Number.isNaN(t))return e;try{return new Date(t).toLocaleString()}catch{return e}}function xE({templateIdentifier:e,target:t,t:n,onRestored:o,onClose:r}){const{versions:s,isLoading:a,isLoadingMore:l,error:d,restoringId:c,hasMore:p,loadVersions:f,loadMore:b,restore:g,loadVersionDetail:m}=X$(e,t,o),[h,v]=u.useState(null),[y,_]=u.useState(null),[w,k]=u.useState(null);u.useEffect(()=>{f()},[f]);const x=u.useCallback(async S=>{v(null),k(S.version);try{const D=await m(S.version);if(D.kind!=="success"){v(D.kind==="not_found"?n("layout_editor.version_history.restore_not_found"):D.message);return}const E=S.version-1,I=s.some(T=>T.version===E);let N={};if(I){const T=await m(E);if(T.kind==="success")N=up(T.detail);else if(T.kind==="network_error"){v(T.message);return}}_({oldVersion:I?E:0,newVersion:S.version,oldContent:N,newContent:up(D.detail)})}finally{k(null)}},[m,s,n]),$=u.useCallback(async S=>{v(null);const D=n("layout_editor.version_history.restore_confirm");if(!(typeof window<"u"?window.confirm(D):!0))return;const I=await g(S);I.kind==="success"?r():I.kind==="not_found"?v(n("layout_editor.version_history.restore_not_found")):v(I.message)},[g,r,n]),C=h??d;return y?i.jsx(lE,{oldVersion:y.oldVersion,newVersion:y.newVersion,oldContent:y.oldContent,newContent:y.newContent,t:n,onBack:()=>_(null),onClose:r}):i.jsxs("div",{className:"g7le-version-history","data-testid":"g7le-version-history",style:dE,children:[i.jsxs("div",{"data-modal-drag-handle":!0,style:cE,children:[i.jsxs("span",{style:{fontSize:14,fontWeight:700},"data-testid":"g7le-version-history-title",children:["🕘 ",n("layout_editor.version_history.title")]}),i.jsx("button",{type:"button","aria-label":n("layout_editor.version_history.close"),onClick:r,style:uE,"data-testid":"g7le-version-history-close",children:"✕"})]}),C&&i.jsx("div",{style:fE,"data-testid":"g7le-version-history-error",children:C}),i.jsx("div",{style:pE,children:a?i.jsx("div",{style:dp,"data-testid":"g7le-version-history-loading",children:n("layout_editor.version_history.loading")}):s.length===0?i.jsx("div",{style:dp,"data-testid":"g7le-version-history-empty",children:n("layout_editor.version_history.empty")}):i.jsxs("div",{"data-testid":"g7le-version-history-list",style:gE,children:[s.map((S,D)=>{const E=S.changes_summary,I=E?.added_count??0,N=E?.removed_count??0,T=E?.char_diff??0,O=_E(S.created_at),M=c===S.id,B=D===0;return i.jsxs("div",{style:hE,"data-testid":`g7le-version-row-${S.id}`,children:[i.jsxs("span",{style:yE,"data-testid":`g7le-version-badge-${S.id}`,children:["v",S.version]}),i.jsxs("div",{style:vE,children:[i.jsxs("span",{style:{display:"flex",alignItems:"center",gap:8},children:[O&&i.jsx("span",{style:{fontSize:12,color:"#475569",fontWeight:600},children:O}),B&&i.jsx("span",{style:bE,"data-testid":`g7le-version-latest-${S.id}`,children:n("layout_editor.version_history.latest")})]}),i.jsxs("span",{style:mE,"data-testid":`g7le-version-summary-${S.id}`,children:[i.jsx("span",{style:{color:"#16a34a"},"data-testid":`g7le-version-added-${S.id}`,children:n("layout_editor.version_history.added",{count:I})}),i.jsx("span",{style:{color:"#dc2626"},"data-testid":`g7le-version-removed-${S.id}`,children:n("layout_editor.version_history.removed",{count:N})}),i.jsx("span",{style:{color:"#94a3b8",fontWeight:500},"data-testid":`g7le-version-chars-${S.id}`,children:n("layout_editor.version_history.chars",{diff:`${T>=0?"+":""}${T}`})})]}),i.jsx("span",{style:{fontSize:11,color:"#94a3b8"},"data-testid":`g7le-version-author-${S.id}`,children:n("layout_editor.version_history.saved_by",{name:S.created_by_name??n("layout_editor.version_history.unknown_author")})})]}),i.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6},children:[i.jsx("button",{type:"button",onClick:()=>void x(S),disabled:w!==null,style:{...cp,background:"#f8fafc",opacity:w!==null?.6:1},"data-testid":`g7le-version-compare-${S.id}`,children:w===S.version?n("layout_editor.version_history.comparing"):n("layout_editor.version_history.compare")}),!B&&i.jsx("button",{type:"button",onClick:()=>void $(S.id),disabled:M||c!==null,style:{...cp,opacity:M||c!==null?.6:1},"data-testid":`g7le-version-restore-${S.id}`,children:n(M?"layout_editor.version_history.restoring":"layout_editor.version_history.restore")})]})]},S.id)}),p&&i.jsx("button",{type:"button","data-testid":"g7le-version-history-load-more",style:wE,disabled:l,onClick:()=>{b()},children:n(l?"layout_editor.version_history.loading":"layout_editor.version_history.load_more")})]})})]})}const wE={width:"100%",marginTop:8,padding:"8px 12px",fontSize:12,color:"#475569",background:"#f8fafc",border:"1px solid #e2e8f0",borderRadius:6,cursor:"pointer"};function Ho(e){return`/api/admin/extensions/${encodeURIComponent(e.type)}/${encodeURIComponent(e.identifier)}/custom-assets`}function Fo(e,t){const n=e?.message;return typeof n=="string"&&n!==""?n:`HTTP ${t}`}function kE(e){const[t,n]=u.useState([]),[o,r]=u.useState(null),[s,a]=u.useState(!1),[l,d]=u.useState(!1),[c,p]=u.useState(null),f=u.useCallback(async()=>{a(!0),p(null);try{const v=await fetch(Ho(e),{credentials:"same-origin",headers:Me()}),y=await v.json().catch(()=>null);if(v.status===401||v.status===403){d(!0),n([]);return}if(d(!1),!v.ok){p(Fo(y,v.status)),n([]);return}const _=y?.data??{},w=_.files;n(Array.isArray(w)?w:[]),r({editable_extensions:_.editable_extensions??[],uploadable_extensions:_.uploadable_extensions??[],max_text_bytes:_.max_text_bytes??0,max_upload_bytes:_.max_upload_bytes??0})}catch(v){p(v instanceof Error?v.message:"network error"),n([])}finally{a(!1)}},[e.type,e.identifier]),b=u.useCallback(async v=>{p(null);try{const y=`${Ho(e)}/content?path=${encodeURIComponent(v)}`,_=await fetch(y,{credentials:"same-origin",headers:Me()}),w=await _.json().catch(()=>null);if(!_.ok)return p(Fo(w,_.status)),null;const k=w?.data?.content;return typeof k=="string"?k:""}catch(y){return p(y instanceof Error?y.message:"network error"),null}},[e.type,e.identifier]),g=u.useCallback(async(v,y)=>{p(null);try{const _=await fetch(`${Ho(e)}/content`,{method:"PUT",credentials:"same-origin",headers:Me({"Content-Type":"application/json"}),body:JSON.stringify({path:v,content:y})}),w=await _.json().catch(()=>null);return _.ok?(await f(),!0):(p(Fo(w,_.status)),!1)}catch(_){return p(_ instanceof Error?_.message:"network error"),!1}},[e.type,e.identifier,f]),m=u.useCallback(async(v,y)=>{p(null);try{const _=new FormData;_.append("file",v),y&&_.append("directory",y);const w=await fetch(`${Ho(e)}/upload`,{method:"POST",credentials:"same-origin",headers:Me(),body:_}),k=await w.json().catch(()=>null);return w.ok?(await f(),!0):(p(Fo(k,w.status)),!1)}catch(_){return p(_ instanceof Error?_.message:"network error"),!1}},[e.type,e.identifier,f]),h=u.useCallback(async v=>{p(null);try{const y=`${Ho(e)}?path=${encodeURIComponent(v)}`,_=await fetch(y,{method:"DELETE",credentials:"same-origin",headers:Me()}),w=await _.json().catch(()=>null);return _.ok?(await f(),!0):(p(Fo(w,_.status)),!1)}catch(y){return p(y instanceof Error?y.message:"network error"),!1}},[e.type,e.identifier,f]);return{files:t,meta:o,isLoading:s,forbidden:l,error:c,reload:f,read:b,save:g,upload:m,remove:h}}const SE={display:"flex",flexDirection:"column",height:"100%",minHeight:0},CE={display:"flex",alignItems:"center",gap:8,padding:"12px 16px",borderBottom:"1px solid #e2e8f0",background:"#f8fafc",fontWeight:600,color:"#0f172a"},$E={marginLeft:"auto",border:"none",background:"transparent",fontSize:16,color:"#64748b",cursor:"pointer",outline:"none"},EE={flex:1,minHeight:0,display:"flex"},IE={width:260,flexShrink:0,borderRight:"1px solid #e2e8f0",overflowY:"auto",padding:8},AE={flex:1,minWidth:0,display:"flex",flexDirection:"column",padding:12,gap:8},pp={display:"flex",alignItems:"center",gap:6,width:"100%",padding:"6px 8px",borderRadius:6,borderWidth:1,borderStyle:"solid",borderColor:"transparent",backgroundColor:"transparent",fontSize:12,color:"#334155",cursor:"pointer",textAlign:"left"},TE={...pp,backgroundColor:"#eff6ff",borderColor:"#bfdbfe",color:"#1d4ed8",fontWeight:600},DE={margin:16,padding:16,borderRadius:8,border:"1px solid #fcd34d",background:"#fffbeb",color:"#92400e",fontSize:13,lineHeight:1.7},zE={padding:"8px 12px",borderRadius:6,border:"1px solid #fecaca",background:"#fef2f2",color:"#b91c1c",fontSize:12},NE={flex:1,minHeight:220,width:"100%",boxSizing:"border-box",padding:10,borderRadius:6,borderWidth:1,borderStyle:"solid",borderColor:"#cbd5e1",fontFamily:"ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",fontSize:12,lineHeight:1.6,resize:"vertical"},fp={display:"flex",alignItems:"center",gap:8,flexWrap:"wrap"},ao={padding:"6px 12px",borderRadius:6,borderWidth:1,borderStyle:"solid",borderColor:"#cbd5e1",backgroundColor:"#ffffff",color:"#334155",fontSize:12,cursor:"pointer"},ME={...ao,backgroundColor:"#2563eb",borderColor:"#2563eb",color:"#ffffff",fontWeight:600},LE={...ao,borderColor:"#fca5a5",color:"#b91c1c"},PE={...ao,cursor:"text",minWidth:200},OE={...ao,cursor:"pointer",minWidth:220};function BE(e,t){if(typeof e=="string"&&e!=="")return e;if(e&&typeof e=="object"){const n=e,o=window?.G7Config?.locale??"ko",r=n[o]??n.ko??n.en??Object.values(n)[0];if(typeof r=="string"&&r!=="")return r}return t}function WE(e){const t=(typeof window<"u"?window.G7Config:null)??{},n=[{type:"template",identifier:e,label:e}];for(const[o,r]of[["module",t.activeModules],["plugin",t.activePlugins]])if(Array.isArray(r))for(const s of r){const a=typeof s?.identifier=="string"?s.identifier:"";a&&n.push({type:o,identifier:a,label:BE(s?.display_name,a)})}return n}function jE(e){return e<1024?`${e} B`:e<1024*1024?`${Math.round(e/1024)} KB`:`${(e/(1024*1024)).toFixed(1)} MB`}function HE(e){const{templateIdentifier:t,t:n,onClose:o}=e,r=u.useMemo(()=>WE(t),[t]),[s,a]=u.useState(`template:${t}`),l=u.useMemo(()=>r.find(C=>`${C.type}:${C.identifier}`===s)??r[0],[r,s]),d=kE({type:l.type,identifier:l.identifier}),{reload:c}=d,[p,f]=u.useState(null),[b,g]=u.useState(""),[m,h]=u.useState(""),[v,y]=u.useState(!1),_=u.useRef(null);u.useEffect(()=>{c()},[c]),u.useEffect(()=>{f(null),g(""),h("")},[s]);const w=u.useCallback(async C=>{if(f(C),!C.editable){g("");return}y(!0);const S=await d.read(C.path);y(!1),g(S??"")},[d]),k=u.useCallback(async()=>{const C=p?.path??m.trim();if(!C)return;y(!0);const S=await d.save(C,b);y(!1),S&&!p&&(h(""),f({path:C,name:C.split("/").pop()??C,extension:(C.split(".").pop()??"").toLowerCase(),size:b.length,modified_at:new Date().toISOString(),editable:!0,loaded:!0}))},[d,p,m,b]),x=u.useCallback(async()=>{if(!p||typeof window<"u"&&!window.confirm(n("layout_editor.custom_assets.delete_confirm")))return;y(!0);const C=await d.remove(p.path);y(!1),C&&(f(null),g(""))},[d,p,n]),$=u.useCallback(async C=>{const S=C.target.files?.[0];S&&(y(!0),await d.upload(S),y(!1),_.current&&(_.current.value=""))},[d]);return i.jsxs("div",{className:"g7le-custom-assets",style:SE,"data-testid":"g7le-custom-assets-modal",children:[i.jsxs("div",{style:CE,children:[i.jsxs("span",{children:["🎨 ",n("layout_editor.custom_assets.title")]}),i.jsx("select",{value:s,onChange:C=>a(C.target.value),style:OE,"aria-label":n("layout_editor.custom_assets.target_label"),"data-testid":"g7le-custom-assets-target",children:r.map(C=>i.jsxs("option",{value:`${C.type}:${C.identifier}`,children:[n(`layout_editor.custom_assets.type_${C.type}`)," · ",C.label]},`${C.type}:${C.identifier}`))}),i.jsx("button",{type:"button",style:$E,onClick:o,"aria-label":n("layout_editor.custom_assets.close"),"data-testid":"g7le-custom-assets-close",children:"✕"})]}),d.forbidden?i.jsx("div",{style:DE,"data-testid":"g7le-custom-assets-forbidden",children:n("layout_editor.custom_assets.forbidden")}):i.jsxs("div",{style:EE,children:[i.jsxs("div",{style:IE,"data-testid":"g7le-custom-assets-list",children:[d.isLoading&&d.files.length===0?i.jsx("div",{style:{padding:12,color:"#94a3b8",fontSize:12},children:n("layout_editor.custom_assets.loading")}):null,!d.isLoading&&d.files.length===0?i.jsx("div",{style:{padding:12,color:"#94a3b8",fontSize:12},children:n("layout_editor.custom_assets.empty")}):null,d.files.map(C=>i.jsxs("button",{type:"button",style:p?.path===C.path?TE:pp,onClick:()=>void w(C),"data-testid":`g7le-custom-asset-row-${C.path}`,"data-loaded":C.loaded?"true":"false",children:[i.jsx("span",{style:{flex:1,minWidth:0,overflow:"hidden",textOverflow:"ellipsis"},children:C.path}),i.jsx("span",{style:{color:"#94a3b8",fontSize:11},children:jE(C.size)})]},C.path))]}),i.jsxs("div",{style:AE,children:[d.error?i.jsx("div",{style:zE,"data-testid":"g7le-custom-assets-error",children:d.error}):null,i.jsxs("div",{style:fp,children:[i.jsx("input",{type:"text",value:p?p.path:m,readOnly:!!p,onChange:C=>h(C.target.value),placeholder:n("layout_editor.custom_assets.new_path_placeholder"),style:PE,"aria-label":n("layout_editor.custom_assets.path_label"),"data-testid":"g7le-custom-assets-path"}),p?i.jsx("button",{type:"button",style:ao,onClick:()=>{f(null),g("")},"data-testid":"g7le-custom-assets-new",children:n("layout_editor.custom_assets.new_file")}):null]}),p&&!p.editable?i.jsx("div",{style:{color:"#64748b",fontSize:12,lineHeight:1.7},children:n("layout_editor.custom_assets.binary_hint",{extension:p.extension})}):i.jsx("textarea",{value:b,onChange:C=>g(C.target.value),style:NE,spellCheck:!1,"aria-label":n("layout_editor.custom_assets.content_label"),"data-testid":"g7le-custom-assets-content"}),i.jsxs("div",{style:fp,children:[i.jsx("button",{type:"button",style:ME,disabled:v||(p?!p.editable:m.trim()===""),onClick:()=>void k(),"data-testid":"g7le-custom-assets-save",children:n("layout_editor.custom_assets.save")}),i.jsx("button",{type:"button",style:ao,disabled:v,onClick:()=>_.current?.click(),"data-testid":"g7le-custom-assets-upload",children:n("layout_editor.custom_assets.upload")}),i.jsx("input",{ref:_,type:"file",style:{display:"none"},onChange:C=>void $(C),"data-testid":"g7le-custom-assets-file-input"}),i.jsx("button",{type:"button",style:LE,disabled:v||!p,onClick:()=>void x(),"data-testid":"g7le-custom-assets-delete",children:n("layout_editor.custom_assets.delete")}),i.jsx("span",{style:{color:"#94a3b8",fontSize:11},children:n("layout_editor.custom_assets.apply_hint")})]})]})]})]})}const gp=[{id:"copy",group:"clipboard",combos:[{key:"c",mod:!0}],labelKey:"layout_editor.shortcuts.action.copy",requiresSelection:!0},{id:"cut",group:"clipboard",combos:[{key:"x",mod:!0}],labelKey:"layout_editor.shortcuts.action.cut",requiresSelection:!0},{id:"paste",group:"clipboard",combos:[{key:"v",mod:!0}],labelKey:"layout_editor.shortcuts.action.paste"},{id:"openProps",group:"element",combos:[{key:"enter"}],labelKey:"layout_editor.shortcuts.action.open_props",requiresSelection:!0},{id:"delete",group:"element",combos:[{key:"delete"},{key:"backspace"}],labelKey:"layout_editor.shortcuts.action.delete",requiresSelection:!0},{id:"selectParent",group:"element",combos:[{key:"arrowup"}],labelKey:"layout_editor.shortcuts.action.select_parent",requiresSelection:!0},{id:"deselect",group:"element",combos:[{key:"escape"}],labelKey:"layout_editor.shortcuts.action.deselect"},{id:"addElement",group:"element",combos:[{key:"/",mod:!0}],labelKey:"layout_editor.shortcuts.action.add_element"},{id:"undo",group:"history",combos:[{key:"z",mod:!0}],labelKey:"layout_editor.shortcuts.action.undo"},{id:"redo",group:"history",combos:[{key:"z",mod:!0,shift:!0},{key:"y",mod:!0}],labelKey:"layout_editor.shortcuts.action.redo"},{id:"editCode",group:"view",combos:[{key:"e",mod:!0}],labelKey:"layout_editor.shortcuts.action.edit_code"},{id:"preview",group:"view",combos:[{key:"p",mod:!0}],labelKey:"layout_editor.shortcuts.action.preview"},{id:"translations",group:"view",combos:[{key:"l",mod:!0}],labelKey:"layout_editor.shortcuts.action.translations"},{id:"help",group:"view",combos:[{key:"?"}],labelKey:"layout_editor.shortcuts.action.help"},{id:"reset",group:"document",combos:[{key:"r",mod:!0,shift:!0}],labelKey:"layout_editor.shortcuts.action.reset"},{id:"save",group:"document",combos:[{key:"s",mod:!0}],labelKey:"layout_editor.shortcuts.action.save"},{id:"exit",group:"document",combos:[{key:"escape"}],labelKey:"layout_editor.shortcuts.action.exit"}];function FE(e){return{key:(e.key||"").toLowerCase(),mod:e.ctrlKey||e.metaKey,shift:e.shiftKey,alt:e.altKey}}function KE(e,t){return e.key===t.key&&!!e.mod==!!t.mod&&!!e.shift==!!t.shift&&!!e.alt==!!t.alt}function VE(e){for(const t of gp)if(t.combos.some(n=>KE(n,e)))return t;return null}function UE(e,t){const n=[];return e.mod&&n.push(t?"⌘":"Ctrl"),e.shift&&n.push(t?"⇧":"Shift"),e.alt&&n.push(t?"⌥":"Alt"),n.push(qE(e.key)),n.join(" + ")}function qE(e){return{arrowup:"↑",arrowdown:"↓",arrowleft:"←",arrowright:"→",enter:"Enter",escape:"Esc",delete:"Delete",backspace:"Backspace"," ":"Space"}[e]??(e.length===1?e.toUpperCase():e)}const GE=["clipboard","element","history","view","document"],YE={clipboard:"layout_editor.shortcuts.group.clipboard",element:"layout_editor.shortcuts.group.element",history:"layout_editor.shortcuts.group.history",view:"layout_editor.shortcuts.group.view",document:"layout_editor.shortcuts.group.document"};function XE(){return typeof navigator>"u"?!1:/mac|iphone|ipad|ipod/i.test(navigator.platform||navigator.userAgent||"")}function JE({t:e,onClose:t,isMac:n}){const o=n??XE(),r=u.useMemo(()=>{const s=new Map;for(const a of gp){const l=s.get(a.group)??[];l.push(a),s.set(a.group,l)}return s},[]);return i.jsxs("div",{className:"g7le-shortcut-help","data-testid":"g7le-shortcut-help",style:ZE,children:[i.jsxs("div",{style:QE,children:[i.jsx("span",{style:RE,children:e("layout_editor.shortcuts.title")}),i.jsx("button",{type:"button","data-testid":"g7le-shortcut-help-close",onClick:t,"aria-label":e("layout_editor.shortcuts.close"),style:e7,children:"✕"})]}),i.jsx("div",{style:t7,children:GE.map(s=>{const a=r.get(s);return!a||a.length===0?null:i.jsxs("div",{style:n7,"data-testid":`g7le-shortcut-group-${s}`,children:[i.jsx("div",{style:o7,children:e(YE[s])}),i.jsx("table",{style:r7,children:i.jsx("tbody",{children:a.map(l=>i.jsxs("tr",{"data-testid":`g7le-shortcut-row-${l.id}`,children:[i.jsx("td",{style:i7,children:e(l.labelKey)}),i.jsx("td",{style:s7,children:l.combos.map((d,c)=>i.jsxs(u.Fragment,{children:[c>0&&i.jsx("span",{style:l7,children:e("layout_editor.shortcuts.or")}),i.jsx("kbd",{style:a7,children:UE(d,o)})]},c))})]},l.id))})})]},s)})}),i.jsx("div",{style:d7,children:e("layout_editor.shortcuts.footnote")})]})}const ZE={display:"flex",flexDirection:"column",padding:20,gap:16,width:"100%",boxSizing:"border-box"},QE={display:"flex",alignItems:"center",justifyContent:"space-between",paddingBottom:12,borderBottom:"1px solid #e2e8f0"},RE={fontSize:15,fontWeight:700,color:"#0f172a"},e7={width:28,height:28,border:"none",background:"transparent",color:"#64748b",cursor:"pointer",fontSize:14,borderRadius:6},t7={display:"grid",gridTemplateColumns:"1fr 1fr",columnGap:28,rowGap:18,alignItems:"start"},n7={display:"flex",flexDirection:"column",gap:6},o7={fontSize:12,fontWeight:700,color:"#2563eb"},r7={width:"100%",borderCollapse:"collapse"},i7={padding:"4px 0",fontSize:12.5,color:"#334155",textAlign:"left",whiteSpace:"nowrap",verticalAlign:"middle"},s7={padding:"4px 0",paddingLeft:12,textAlign:"right",verticalAlign:"middle"},a7={display:"inline-block",padding:"1px 6px",fontSize:11,fontFamily:"ui-monospace, monospace",color:"#0f172a",background:"#f1f5f9",border:"1px solid #cbd5e1",borderBottomWidth:2,borderRadius:5,whiteSpace:"nowrap"},l7={fontSize:11,color:"#94a3b8",margin:"0 4px"},d7={fontSize:11,color:"#94a3b8",paddingTop:12,borderTop:"1px solid #f1f5f9"};function c7(e){if(!e||!(e instanceof HTMLElement))return!1;const t=e.tagName;return!!(t==="INPUT"||t==="TEXTAREA"||t==="SELECT"||e.isContentEditable)}function u7(){return typeof document>"u"?!1:!!document.querySelector(".g7le-modal-backdrop, .g7le-modal")}function hp(e){const{handlers:t,hasSelection:n,enabled:o=!0}=e;u.useEffect(()=>{if(!o||typeof window>"u")return;const r=s=>{if(c7(s.target))return;const a=FE(s),l=VE(a);if(!l||u7()||l.requiresSelection&&!n)return;if(l.key==="escape"||a.key==="escape"){if(n){const c=t.deselect;c&&(s.preventDefault(),c())}else{const c=t.exit;c&&(s.preventDefault(),c())}return}const d=t[l.id];d&&(s.preventDefault(),d())};return window.addEventListener("keydown",r),()=>window.removeEventListener("keydown",r)},[t,n,o])}function p7(e){const{result:t,onDismiss:n,onLoadLatest:o,onKeepMyChanges:r}=e,{t:s}=et();if(u.useEffect(()=>{if(!t||t.kind==="concurrent_modification"||t.kind==="validation_failed"||t.kind==="guard_extension_reassembly")return;const a=setTimeout(n,5e3);return()=>clearTimeout(a)},[t,n]),!t)return null;if(t.kind==="success")return i.jsx(lo,{tone:"success",testId:"g7le-save-banner-success",onDismiss:n,children:s("layout_editor.save.success")});if(t.kind==="validation_failed"){const a=t.errors??{},l=Object.entries(a);return i.jsxs(lo,{tone:"error",testId:"g7le-save-banner-validation",onDismiss:n,children:[i.jsx("strong",{children:s("layout_editor.save.validation_failed")}),l.length>0&&i.jsx("ul",{style:yp,"data-testid":"g7le-save-banner-validation-errors",children:l.map(([d,c])=>i.jsxs("li",{children:[i.jsx("code",{style:bp,children:d}),": ",c.join(", ")]},d))})]})}return t.kind==="blocked_inactive_extension"?i.jsxs(lo,{tone:"warning",testId:"g7le-save-banner-blocked",onDismiss:n,children:[i.jsx("strong",{children:s("layout_editor.save.blocked_inactive_extension")}),t.blockedPaths.length>0&&i.jsx("ul",{style:yp,"data-testid":"g7le-save-banner-blocked-paths",children:t.blockedPaths.map(a=>i.jsx("li",{children:i.jsx("code",{style:bp,children:a})},a))})]}):t.kind==="network_error"?i.jsxs(lo,{tone:"error",testId:"g7le-save-banner-network",onDismiss:n,children:[i.jsx("strong",{children:s("layout_editor.save.network_error_title")}),i.jsx("div",{style:{marginTop:4,fontSize:12},children:t.message})]}):t.kind==="guard_extension_reassembly"?i.jsxs(lo,{tone:"error",testId:"g7le-save-banner-guard-extension-reassembly",onDismiss:n,children:[i.jsx("strong",{children:s("layout_editor.save.guard_extension_reassembly_title")}),i.jsx("div",{style:{marginTop:4,fontSize:12},children:s("layout_editor.save.guard_extension_reassembly",{count:String(t.unassigned)})})]}):t.kind==="guard_no_document"?i.jsx(lo,{tone:"neutral",testId:"g7le-save-banner-guard-no-document",onDismiss:n,children:s("layout_editor.save.guard_no_document")}):i.jsx("div",{role:"dialog","aria-modal":"true","aria-label":s("layout_editor.save.concurrent.title"),"data-testid":"g7le-save-banner-concurrent",style:y7,children:i.jsxs("div",{style:b7,children:[i.jsx("h2",{style:m7,children:s("layout_editor.save.concurrent.title")}),i.jsx("p",{style:v7,children:s("layout_editor.save.concurrent.message")}),i.jsx("div",{style:_7,children:s("layout_editor.save.concurrent.version_info",{current:String(t.currentVersion),yours:String(t.yourVersion)})}),i.jsxs("div",{style:x7,children:[i.jsx("button",{type:"button","data-testid":"g7le-save-banner-concurrent-load-latest",onClick:()=>{o?.(),n()},style:w7,children:s("layout_editor.save.concurrent.load_latest")}),i.jsx("button",{type:"button","data-testid":"g7le-save-banner-concurrent-keep-mine",onClick:()=>{r?.(),n()},style:k7,children:s("layout_editor.save.concurrent.view_my_changes")}),i.jsx("button",{type:"button","data-testid":"g7le-save-banner-concurrent-cancel",onClick:n,style:S7,children:s("layout_editor.save.concurrent.cancel")})]})]})})}function lo(e){const{tone:t,testId:n,onDismiss:o,children:r}=e,s=f7[t];return i.jsxs("div",{role:"status","data-testid":n,style:{...g7,background:s.bg,borderColor:s.border,color:s.fg},children:[i.jsx("div",{style:{flex:1,minWidth:0},children:r}),i.jsx("button",{type:"button",onClick:o,"aria-label":"close","data-testid":`${n}-close`,style:h7,children:"×"})]})}const f7={success:{bg:"#dcfce7",border:"#86efac",fg:"#14532d"},error:{bg:"#fee2e2",border:"#fca5a5",fg:"#7f1d1d"},warning:{bg:"#fef3c7",border:"#fcd34d",fg:"#78350f"},neutral:{bg:"#f1f5f9",border:"#cbd5e1",fg:"#334155"}},g7={display:"flex",alignItems:"flex-start",gap:12,padding:"12px 16px",border:"1px solid",borderRadius:0,fontSize:13,lineHeight:1.5},h7={background:"transparent",border:"none",fontSize:18,cursor:"pointer",lineHeight:1,color:"inherit",padding:"0 4px",flexShrink:0},yp={margin:"8px 0 0",paddingLeft:20,fontSize:12},bp={background:"rgba(0, 0, 0, 0.06)",padding:"1px 4px",borderRadius:3,fontFamily:"monospace"},y7={position:"fixed",inset:0,background:"rgba(15, 23, 42, 0.6)",display:"flex",alignItems:"center",justifyContent:"center",zIndex:1e4},b7={background:"#fff",borderRadius:8,padding:24,width:"min(480px, 90vw)",boxShadow:"0 10px 25px rgba(0,0,0,0.15)"},m7={margin:"0 0 8px",fontSize:16,fontWeight:600,color:"#0f172a"},v7={margin:"0 0 12px",color:"#334155",fontSize:13,lineHeight:1.5},_7={marginBottom:16,padding:"8px 12px",background:"#f8fafc",borderRadius:6,fontSize:12,color:"#64748b",fontFamily:"monospace"},x7={display:"flex",gap:8,justifyContent:"flex-end"},w7={padding:"8px 14px",background:"#2563eb",color:"#fff",border:"none",borderRadius:6,fontSize:13,fontWeight:600,cursor:"pointer"},k7={padding:"8px 14px",background:"#fff",color:"#334155",border:"1px solid #cbd5e1",borderRadius:6,fontSize:13,fontWeight:500,cursor:"pointer"},S7={padding:"8px 14px",background:"transparent",color:"#64748b",border:"none",borderRadius:6,fontSize:13,cursor:"pointer"};function mp(e){if(!e)return null;const n=new URLSearchParams(e).get("route");return n&&n.length>0?n:null}function sa(e,t,n){const o=`/admin/layout-editor/${encodeURIComponent(e)}`;if(!t||t.length===0)return o;if(t.startsWith("__")){let r=`${o}?edit=${encodeURIComponent(t)}`;return(t.startsWith("__modal__/")||t.startsWith("__iteration__/"))&&n&&(r+=`&host=${encodeURIComponent(n)}`),r}return`${o}?route=${encodeURIComponent(t)}`}function vp(e){if(!e)return null;const n=new URLSearchParams(e).get("host");return n&&n.length>0?n:null}function _p(e){if(!e)return null;const n=new URLSearchParams(e).get("edit");return n&&n.length>0&&n.startsWith("__")?n:null}function C7(e,t){const n=`/admin/templates/${encodeURIComponent(e)}/edit`;if(!t||t.length===0||t.startsWith("__"))return n;const o=t.replace(/^\*+/,"");return o.length===0?n:`${n}?route=${encodeURIComponent(o)}`}const $7="g7le-editor-route",E7=new Set;function aa(e){return e.path.startsWith("__group__/")||e.path.startsWith("__conngroup__/")}function xp(e,t){const{t:n,translationEngine:o,translationContext:r,templateIdentifier:s}=t,a=aa(e);if(!e.label.startsWith("$t:"))return e.label;const l=e.label.slice(3),d=l.indexOf("|"),c=d===-1?l:l.slice(0,d),p={};if(d!==-1)for(const b of l.slice(d+1).split("|")){const g=b.indexOf("=");g!==-1&&(p[b.slice(0,g)]=b.slice(g+1))}if((e.kind==="route"||e.kind==="modal")&&!a&&o&&r&&s!==r.templateId){const b={templateId:s,locale:r.locale},g=Object.keys(p).length>0?"|"+Object.entries(p).map(([h,v])=>`${h}=${v}`).join("|"):void 0,m=o.translate(c,b,g);if(m!==c)return m}const f=n(c,p);if(f===c&&typeof window<"u"){const b=window.G7Core?.t;if(typeof b=="function")try{const g=b(c,p);if(g!==c)return g}catch{}}if(f===c&&e.kind==="route"&&!a){const b=e.path;if(b&&b!=="/"){const g=b.split("/").filter(Boolean);return g[g.length-1]??b}return b==="/"?"/":c}return f}function I7(e){return e.trim().toLocaleLowerCase()}function wp(e,t){if(!t)return e;const n=e.toLocaleLowerCase(),o=[];let r=0,s=n.indexOf(t,r);if(s===-1)return e;let a=0;for(;s!==-1;)s>r&&o.push(e.slice(r,s)),o.push(i.jsx("mark",{className:"g7le-route-tree__highlight",style:{background:"#fde68a",color:"inherit",borderRadius:2,padding:"0 1px"},children:e.slice(s,s+t.length)},`m${a++}`)),r=s+t.length,s=n.indexOf(t,r);return r=0?(a.push(s.containerIndex),l=Bn(e)[s.containerIndex]??e):l=e;let d=-1,c=-1,p=null;const f=Bn(l);for(let m=0;mee?(Q.sortIndex=De,e(h,Q),n(f)===null&&Q===n(h)&&(x?(O(z),z=-1):x=!0,Et(B,De-ee))):(Q.sortIndex=me,e(f,Q),_||S||(_=!0,q||(q=!0,Ie()))),Q},s.unstable_shouldYield=_e,s.unstable_wrapCallback=function(Q){var ue=v;return function(){var De=v;v=ue;try{return Q.apply(this,arguments)}finally{v=De}}}})(Rd)),Rd}var jg;function s0(){return jg||(jg=1,kd.exports=i0()),kd.exports}var Dd={exports:{}},An={};var Hg;function o0(){if(Hg)return An;Hg=1;var s=xd();function e(f){var h="https://react.dev/errors/"+f;if(1
ee?(Q.sortIndex=De,e(h,Q),n(f)===null&&Q===n(h)&&(x?(O(z),z=-1):x=!0,Et(B,De-ee))):(Q.sortIndex=me,e(f,Q),_||S||(_=!0,q||(q=!0,je()))),Q},s.unstable_shouldYield=_e,s.unstable_wrapCallback=function(Q){var ue=v;return function(){var De=v;v=ue;try{return Q.apply(this,arguments)}finally{v=De}}}})(Td)),Td}var Ig;function o0(){return Ig||(Ig=1,xd.exports=s0()),xd.exports}var kd={exports:{}},An={};/**
+ * @license React
+ * react-dom.production.js
+ *
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */var jg;function l0(){if(jg)return An;jg=1;var s=_d();function e(f){var h="https://react.dev/errors/"+f;if(1