Merge branch 'develop'

This commit is contained in:
HeuJung
2026-09-09 14:37:34 +09:00
314 changed files with 19678 additions and 1361 deletions
+11 -1
View File
@@ -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 절과 같다.
+1 -1
View File
@@ -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 를 인식하려면
# 신뢰할 프록시를 지정합니다. 미설정 시 아무 프록시도 신뢰하지 않습니다(기존 동작).
+129 -4
View File
@@ -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)
+51
View File
@@ -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
+23 -7
View File
@@ -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 설치 방식 선택:**
- **자동 (권장)**: 환경을 자동 감지하여 번들 모드로 폴백합니다.
+4 -1
View File
@@ -6,7 +6,7 @@
A modern, extensible CMS platform built with Laravel + React
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.10-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/version-7.0.11-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
@@ -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
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/keidichoi-gif" title="keidichoi-gif"><img src="https://github.com/keidichoi-gif.png" width="48" alt="keidichoi-gif"></a>
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
+7 -3
View File
@@ -6,7 +6,7 @@
The next generation of Gnuboard — Korea's most widely used open-source CMS
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.10-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/version-7.0.11-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
@@ -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 —
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/keidichoi-gif" title="keidichoi-gif"><img src="https://github.com/keidichoi-gif.png" width="48" alt="keidichoi-gif"></a>
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
@@ -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;
@@ -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(
@@ -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 캐시를 재생성한다.
@@ -0,0 +1,242 @@
<?php
namespace App\Console\Commands;
use App\Enums\ExtensionOwnerType;
use App\Enums\UserStatus;
use App\Models\IdentityVerificationLog;
use App\Models\Role;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Hash;
/**
* Playwright E2E 용 2단계 인증 픽스처 커맨드.
*
* 두 가지 일을 한다.
* - `--ensure-user` : 알려진 비밀번호를 가진 Active 사용자를 만들거나 갱신한다.
* (`--admin` 이면 admin 역할을 함께 부여)
* - `--plant` : 지정한 challenge 에 알려진 인증 코드의 해시를 심는다.
*
* 인증번호는 해시로만 저장되므로 브라우저 테스트가 되읽을 수 없다. 메일을 실제로 열어
* 보는 대신 알려진 값을 심어 "코드가 맞을 때" 를 재현한다 — 검증 대상은 코드 생성이
* 아니라 로그인 흐름(코드 확인 전 토큰 미발급 / 확인 후 발급)이다.
* 심는 방식은 PHPUnit `TwoFactorAuthTest::issuedCode()` 와 동일하다.
*
* 보안 가드 (3중, `playwright:issue-token` 과 동형):
* ① CLI 한정 — `php_sapi_name() === 'cli'`. production 웹 요청에서 도달 불가
* ② 명시 옵트인 — `G7_PLAYWRIGHT_BYPASS=1` 환경변수 필수
* ③ APP_DEBUG 강제 — production + debug=false 환경에서도 픽스처 조작이 가능하도록
*
* 호출 예시 (PowerShell):
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-two-factor --ensure-user=user --password='Passw0rd!2fa'
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-two-factor --plant=<challenge_id> --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}건");
}
}
}
@@ -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 에 외부 패키지 의존성이 있는지 확인합니다.
*
@@ -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;
/**
* 특정 확장의 모든 버전 조회 (최신순)
@@ -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;
/**
* 특정 레이아웃의 모든 버전 조회 (최신순)
@@ -0,0 +1,25 @@
<?php
namespace App\Exceptions\Auth;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* 2단계 인증 코드 발송 실패 예외 — 비밀번호 확인은 통과했으나 인증번호를 보낼 수 없어
* 로그인을 완료할 수단이 없을 때 발생합니다.
*
* HTTP 503 Service Unavailable 로 매핑됩니다. 자격 증명은 올바르므로 401 로 뭉뚱그리면
* 사용자는 비밀번호를 의심하며 같은 실패를 반복하게 되고, 운영자는 메일 설정이 깨진 사실을
* 알 방법이 없습니다.
*
* 메시지 자리에는 번역문이 아니라 다국어 **키**를 보관합니다 (선례: AccountLockedException).
*
* @since 7.0.11
*/
class TwoFactorDeliveryFailedException extends HttpException
{
public function __construct(?string $message = null)
{
parent::__construct(503, $message ?? 'auth.two_factor_delivery_failed');
}
}
+11 -14
View File
@@ -10,6 +10,7 @@ use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\Cache\ModuleCacheDriver;
use App\Extension\Storage\ModuleStorageDriver;
use App\Extension\Traits\ReportsLifecycleFailure;
use App\Support\PublicAssetDisk;
use Illuminate\Database\Seeder;
use ReflectionClass;
@@ -1418,15 +1419,7 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
*/
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);
}
/**
@@ -1464,26 +1457,30 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
/**
* 카테고리별 스토리지 기본 경로 반환
*
* 카테고리가 다른 디스크로 배선돼 있으면(getStorageDiskFor 오버라이드) 그 디스크
* 기준 경로를 돌려줍니다. 기본 디스크를 보면 배선한 카테고리의 경로가 어긋납니다.
*
* @param string $category 카테고리 (settings, attachments, images, cache, 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);
}
}
+11 -14
View File
@@ -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);
}
}
+18 -11
View File
@@ -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');
}
/**
+109 -22
View File
@@ -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');
}
@@ -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');
}
}
/**
@@ -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) {
@@ -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` 메타를 부여한 응답을 반환
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Concerns;
use App\Exceptions\Auth\AccountLockedException;
use Illuminate\Http\JsonResponse;
/**
* 세션을 발급하는 엔드포인트가 공유하는 실패 응답을 구성하는 트레이트.
*
* 사용자 로그인과 관리자 로그인은 서로 다른 베이스 컨트롤러를 상속하지만, 계정 잠금과
* 인증번호 발송 실패는 **같은 사건**이므로 같은 형태로 응답해야 합니다. 두 컨트롤러가
* 각자 사본을 들고 있으면 한쪽 페이로드에 필드가 추가될 때 다른 쪽이 조용히 뒤처져,
* 같은 실패인데 화면이 다르게 안내하게 됩니다.
*
* @since 7.0.11
*/
trait BuildsAuthFailureResponses
{
/**
* 계정 잠금 응답(423)을 구성합니다.
*
* @param AccountLockedException $e 잠금 예외
* @return JsonResponse 423 응답
*/
protected function lockedResponse(AccountLockedException $e): 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]
);
}
/**
* 인증번호 발송 실패 응답(503)을 구성합니다.
*
* 자격 증명은 올바르므로 401 로 답하지 않는다 — 사용자는 비밀번호를 의심하며 같은
* 실패를 반복하고, 운영자는 메일 설정이 깨진 사실을 알 방법이 없다.
*
* @return JsonResponse 503 응답
*/
protected function deliveryFailedResponse(): JsonResponse
{
return $this->error('auth.two_factor_delivery_failed', 503);
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Http\Requests\Auth;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
/**
* 2단계 인증 코드 재발송 요청
*
* 비밀번호 확인 단계가 돌려준 challenge 로만 재발송할 수 있습니다. 이 요청은 아직 로그인 전
* 상태에서 호출되므로 인증 미들웨어를 거치지 않으며, 주체 식별은 challenge 에 기록된
* 사용자로만 이루어집니다.
*/
class TwoFactorResendRequest extends FormRequest
{
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인합니다.
*
* @return bool 항상 true (주체 식별은 challenge 가 담당)
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, mixed> 검증 규칙
*/
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<string, string> 검증 메시지
*/
public function messages(): array
{
return [
'challenge_id.required' => __('validation.auth.two_factor.challenge_required'),
'challenge_id.uuid' => __('validation.auth.two_factor.challenge_invalid'),
];
}
}
+13 -1
View File
@@ -9,6 +9,7 @@ use App\Contracts\Notifications\ChannelReadinessCheckerInterface;
use App\Extension\HookManager;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Helpers\ResponseHelper;
use App\Http\View\Composers\TemplateComposer;
use App\Http\View\Composers\UserTemplateComposer;
use App\Notifications\NotificationChannelManager;
@@ -126,7 +127,18 @@ class AppServiceProvider extends ServiceProvider
$maxPerMinute = 60;
}
return Limit::perMinute($maxPerMinute)->by($request->ip());
// 기본 응답은 영문 "Too Many Attempts." 이다 — 로그인 화면은 이 문구를
// 그대로 노출하므로 다국어 키로 갈아끼운다.
return Limit::perMinute($maxPerMinute)
->by($request->ip())
->response(function (Request $request, array $headers) {
return ResponseHelper::error(
'auth.too_many_attempts',
429,
null,
['seconds' => (int) ($headers['Retry-After'] ?? 60)]
)->withHeaders($headers);
});
});
}
+19 -27
View File
@@ -97,6 +97,7 @@ use App\Services\LayoutExtensionService;
use App\Services\TemplateLayoutAttachmentService;
use App\Services\TemplateService;
use App\Services\UniqueIdService;
use App\Support\CoreUpdateContext;
use App\Support\ExtensionSettingsMirror;
use App\Support\PrivilegedDatabaseAccounts;
use Illuminate\Support\Facades\Config;
@@ -562,7 +563,7 @@ class CoreServiceProvider extends ServiceProvider
'type' => $type,
'identifier' => $identifier,
'required_version' => $requiredVersion,
'core_version' => config('app.version'),
'core_version' => CoreVersionChecker::getCoreVersion(),
]);
}
}
@@ -626,6 +627,21 @@ class CoreServiceProvider extends ServiceProvider
$cache->put($cacheKey, $recovered, CoreVersionChecker::getCacheTtl());
}
/**
* 현재 프로세스가 코어 업데이트 중인지 판정합니다.
*
* 판정은 `App\Support\CoreUpdateContext::isInProgress()` 가 단독으로 소유한다 — 같은
* 플래그가 확장 자동 비활성화 스킵 · 코어 버전의 env 우선 판독 · `bootstrap/app.php` 의
* 패키지 매니페스트 자가 치유를 함께 게이트하므로, 판정이 갈라지면 한 경로만 조용히
* 다르게 동작한다. 이 메서드는 기존 호출처(각 Manager)를 위한 위임으로 남는다.
*
* @return bool 업데이트 트리 안이면 true (version-based 자동 비활성화 스킵)
*/
public static function isCoreUpdateInProgress(): bool
{
return CoreUpdateContext::isInProgress();
}
/**
* 호환되지 않는 템플릿을 자동 비활성화합니다.
*
@@ -634,30 +650,6 @@ class CoreServiceProvider extends ServiceProvider
*
* @param TemplateManager $templateManager 템플릿 매니저
*/
/**
* 현재 프로세스가 코어 업데이트 중인지 판정합니다.
*
* 판정 조건 (OR):
* 1. 환경변수 `G7_UPDATE_IN_PROGRESS=1` — 부모 CoreUpdateCommand 가 시작 시 설정,
* spawn 자식에도 `$env` 로 전파
* 2. artisan command 이름이 `core:update` / `core:execute-upgrade-steps` — 1 이 전파되지
* 않은 극단 상황 대비 보조 판정
*
* 둘 중 하나라도 true 면 업데이트 컨텍스트로 간주하여 version-based 자동 비활성화 스킵.
*/
public static function isCoreUpdateInProgress(): bool
{
$envFlag = $_ENV['G7_UPDATE_IN_PROGRESS'] ?? $_SERVER['G7_UPDATE_IN_PROGRESS'] ?? getenv('G7_UPDATE_IN_PROGRESS');
if ($envFlag === '1' || $envFlag === 1 || $envFlag === true) {
return true;
}
$argv = $_SERVER['argv'] ?? [];
$command = $argv[1] ?? '';
return in_array($command, ['core:update', 'core:execute-upgrade-steps'], true);
}
protected function validateAndDeactivateIncompatibleTemplates(TemplateManager $templateManager): void
{
// 업데이트 중 자동 비활성화 스킵 (validateAndDeactivateIncompatibleExtensions 와 동일 사유)
@@ -702,7 +694,7 @@ class CoreServiceProvider extends ServiceProvider
'type' => 'templates',
'identifier' => $identifier,
'required_version' => $requiredVersion,
'core_version' => config('app.version'),
'core_version' => CoreVersionChecker::getCoreVersion(),
]);
}
}
@@ -730,7 +722,7 @@ class CoreServiceProvider extends ServiceProvider
$alerts = $cache->get('ext.compatibility_alerts', []);
$alerts[$type] = [
'deactivated' => $deactivated,
'core_version' => config('app.version'),
'core_version' => CoreVersionChecker::getCoreVersion(),
'timestamp' => now()->toIso8601String(),
];
$cache->put('ext.compatibility_alerts', $alerts, 86400); // 24시간
@@ -135,10 +135,17 @@ trait CalculatesJsonContentDiff
/**
* 라인 배열 두 개의 LCS diff — 추가/삭제 라인 "수"를 반환한다.
*
* 공통 prefix/suffix 를 먼저 트리밍해 LCS DP 입력을 변경 영역으로 축소한다(큰
* content 의 작은 변경도 빠르게 처리). 라인 원문은 누적하지 않고 카운트만 세므로
* 메모리도 절약된다(저장 대상은 카운트뿐). 프론트 computeLineDiff 와 동일 전략이라
* added/removed 카운트가 일치한다.
* 공통 prefix/suffix 를 먼저 트리밍해 LCS 입력을 변경 영역으로 축소한다(큰 content 의
* 작은 변경도 빠르게 처리). 카운트는 LCS **길이**만으로 결정된다 — 추가 = 새 줄 수 − LCS,
* 삭제 = 옛 줄 수 − LCS — 이므로 표 전체를 만들어 되짚을 필요가 없고, 두 행만 쓰는
* 길이 계산으로 메모리를 변경 영역 줄 수에 비례하게 묶는다. 프론트 computeLineDiff 는
* 실제 diff 줄을 그려야 해서 전체 표를 쓰지만 같은 LCS 규칙이므로 added/removed 카운트가
* 일치한다.
*
* 트리밍은 양끝이 동시에 바뀌면 무력하다 — 편집기는 저장 시 `comment` 키를 떼어내므로
* 큰 공통 레이아웃의 첫 편집기 저장은 변경 영역이 파일 전체(2,000줄 이상)가 된다.
* 종전의 (줄 수)² PHP 배열은 그 경우 약 150MB 를 써서 PHP 기본 memory_limit(128M)
* 서버에서 저장이 500 으로 끝났다(개발 머신은 512M 이라 드러나지 않았다).
*
* @param array<string> $a 이전 라인
* @param array<string> $b 새 라인
@@ -172,43 +179,50 @@ trait CalculatesJsonContentDiff
$nb = count($midB);
// 안전 가드 — 변경 영역이 과대하면 LCS 를 생략하고 라인 집합 차집합으로 근사한다.
// 프론트 lineDiff.ts 의 DIFF_MAX_LINES(4000) 와 동일 임계. 인접 버전 비교는 변경
// 영역이 작아 이 경로를 타지 않으며, 비정상적으로 큰 변경에서만 O(n·m) DP 를 회피한다.
// 프론트 lineDiff.ts 의 DIFF_MAX_LINES(4000) 와 동일 임계. 시간 O(n·m) 의 상한이며,
// 아래 길이 계산은 메모리가 두 행뿐이라 이 임계 안에서는 memory_limit 과 무관하다.
if ($na > self::DIFF_MAX_LINES || $nb > self::DIFF_MAX_LINES) {
return $this->approximateLineCounts($midA, $midB);
}
// LCS DP
$dp = array_fill(0, $na + 1, array_fill(0, $nb + 1, 0));
$lcs = $this->lcsLength($midA, $midB);
return [$nb - $lcs, $na - $lcs];
}
/**
* 두 라인 배열의 LCS(최장 공통 부분수열) 길이 — 두 행만 쓰는 DP.
*
* 전체 표(종전 `$dp[$i][$j]`)와 같은 점화식(`a[i] === b[j] ? next[j+1] + 1 : max(next[j],
* cur[j+1])`)을 행 단위로 굴려 마지막 행의 첫 칸만 남긴다. 되짚기(backtrack)가 필요한
* 프론트 diff 뷰와 달리 여기서는 길이만 쓰므로 카운트가 종전과 정확히 같다.
*
* @param array<int, string> $a 이전 라인 (0 부터 연속 인덱스)
* @param array<int, string> $b 새 라인 (0 부터 연속 인덱스)
* @return int LCS 길이
*/
private function lcsLength(array $a, array $b): int
{
$na = count($a);
$nb = count($b);
if ($na === 0 || $nb === 0) {
return 0;
}
$next = array_fill(0, $nb + 1, 0);
for ($i = $na - 1; $i >= 0; $i--) {
$cur = array_fill(0, $nb + 1, 0);
$line = $a[$i];
for ($j = $nb - 1; $j >= 0; $j--) {
$dp[$i][$j] = $midA[$i] === $midB[$j]
? $dp[$i + 1][$j + 1] + 1
: max($dp[$i + 1][$j], $dp[$i][$j + 1]);
$cur[$j] = $line === $b[$j]
? $next[$j + 1] + 1
: max($next[$j], $cur[$j + 1]);
}
$next = $cur;
}
// backtrack — 삭제/추가 라인 수만 카운트
$added = 0;
$removed = 0;
$i = 0;
$j = 0;
while ($i < $na && $j < $nb) {
if ($midA[$i] === $midB[$j]) {
$i++;
$j++;
} elseif ($dp[$i + 1][$j] >= $dp[$i][$j + 1]) {
$removed++;
$i++;
} else {
$added++;
$j++;
}
}
$removed += $na - $i;
$added += $nb - $j;
return [$added, $removed];
return $next[0];
}
/**
@@ -8,6 +8,7 @@ use App\Models\TemplateLayoutExtensionVersion;
use App\Repositories\Concerns\CalculatesJsonContentDiff;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class LayoutExtensionVersionRepository implements LayoutExtensionVersionRepositoryInterface
@@ -28,9 +29,10 @@ class LayoutExtensionVersionRepository implements LayoutExtensionVersionReposito
* @param int $extensionId 레이아웃 확장 ID
* @param array $content 저장할 content 스냅샷
* @param array|null $previousContent 직전 버전 content (변경 요약 기준). null 이면 변경 요약 0
* @param int|null $createdBy 저장자 ID (null 이면 현재 인증 사용자)
* @return TemplateLayoutExtensionVersion 생성된 버전
*/
public function saveVersion(int $extensionId, array $content, ?array $previousContent = null): TemplateLayoutExtensionVersion
public function saveVersion(int $extensionId, array $content, ?array $previousContent = null, ?int $createdBy = null): TemplateLayoutExtensionVersion
{
$nextVersion = $this->getNextVersion($extensionId);
@@ -44,6 +46,8 @@ class LayoutExtensionVersionRepository implements LayoutExtensionVersionReposito
'version' => $nextVersion,
'content' => $content,
'changes_summary' => $changesSummary,
// 저장자 — 버전 목록의 「저장자」 표시 근거. 종전엔 기록하지 않아 항상 「알 수 없음」이었다.
'created_by' => $createdBy ?? Auth::id(),
]);
}
@@ -165,9 +169,10 @@ class LayoutExtensionVersionRepository implements LayoutExtensionVersionReposito
$extension = LayoutExtension::findOrFail($extensionId);
$currentContent = $extension->content;
// 3. 확장을 복원할 content로 업데이트
// 3. 확장을 복원할 content로 업데이트 — lock_version 도 올린다(레이아웃 본체와 동형).
$extension->update([
'content' => $versionToRestore->content,
'lock_version' => ((int) ($extension->lock_version ?? 0)) + 1,
]);
// 4. 복원 결과를 새 버전으로 저장 — content 는 복원된 내용, changes_summary 는 복원
+8 -2
View File
@@ -8,6 +8,7 @@ use App\Models\TemplateLayoutVersion;
use App\Repositories\Concerns\CalculatesJsonContentDiff;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class LayoutVersionRepository implements LayoutVersionRepositoryInterface
@@ -28,9 +29,10 @@ class LayoutVersionRepository implements LayoutVersionRepositoryInterface
* @param int $layoutId 레이아웃 ID
* @param array $content 저장할 content 스냅샷 (이 버전이 담는 내용)
* @param array|null $previousContent 직전 버전 content (변경 요약 기준). null 이면 변경 요약 0
* @param int|null $createdBy 저장자 ID (null 이면 현재 인증 사용자)
* @return TemplateLayoutVersion 생성된 버전
*/
public function saveVersion(int $layoutId, array $content, ?array $previousContent = null): TemplateLayoutVersion
public function saveVersion(int $layoutId, array $content, ?array $previousContent = null, ?int $createdBy = null): TemplateLayoutVersion
{
$nextVersion = $this->getNextVersion($layoutId);
@@ -44,6 +46,8 @@ class LayoutVersionRepository implements LayoutVersionRepositoryInterface
'version' => $nextVersion,
'content' => $content,
'changes_summary' => $changesSummary,
// 저장자 — 버전 목록의 「저장자」 표시 근거. 종전엔 기록하지 않아 항상 「알 수 없음」이었다.
'created_by' => $createdBy ?? Auth::id(),
]);
}
@@ -109,9 +113,11 @@ class LayoutVersionRepository implements LayoutVersionRepositoryInterface
$layout = TemplateLayout::findOrFail($layoutId);
$currentContent = $layout->content;
// 3. 레이아웃을 복원할 content로 업데이트
// 3. 레이아웃을 복원할 content로 업데이트 — lock_version 도 올린다. 복원 직전 화면을 열어 둔
// 다른 편집기가 옛 lock 으로 저장하면 409 가 나야 복원 결과가 조용히 덮이지 않는다.
$layout->update([
'content' => $versionToRestore->content,
'lock_version' => ((int) ($layout->lock_version ?? 0)) + 1,
]);
// 4. 복원 결과를 새 버전으로 저장 — content 는 복원된 내용(versionToRestore),
+30
View File
@@ -2,6 +2,7 @@
namespace App\Rules;
use App\Support\SiteAssetHosts;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
@@ -20,9 +21,22 @@ use Illuminate\Contracts\Validation\ValidationRule;
* 차단합니다. 반면 state/computed 는 데이터 값이며, 실제 위험은 그 값이 바인딩되는
* sink(컴포넌트 prop = img src 등)에서 발생하고 그 sink 는 이미 여기서 검사됩니다 —
* 예시/안내용 URL 을 담는 정당한 용례를 깨지 않기 위해 데이터 계층은 재차단하지 않습니다.
*
* 외부의 기준: 사이트 자기 host(`app.url`)와 운영자가 선언한 공개 자산 디스크의 host
* (`SiteAssetHosts`)는 외부가 아닙니다. 레이아웃 첨부 API 가 스스로 발급하는 주소(프록시
* 서빙 URL·직접 URL)가 그 host 를 쓰므로, 여기서 차단하면 image 위젯으로 올린 파일이
* 업로드는 되고 저장은 422 가 됩니다. 판정은 정규화 뒤 host 등가 비교이며 protocol-relative
* 와 http/https 밖의 스킴은 host 가 같아도 종전대로 차단합니다.
*/
class NoExternalUrls implements ValidationRule
{
/**
* 사이트 자산 host 목록 (검증 1회당 1회 해석 후 캐시)
*
* @var array<int, string>|null
*/
private ?array $siteAssetHosts = null;
/**
* 차단할 위험 URI 스킴 목록
*/
@@ -203,6 +217,12 @@ class NoExternalUrls implements ValidationRule
*/
private function checkForDangerousUrl(string $value, string $path, Closure $fail): void
{
// 사이트 자기 host·선언된 공개 자산 디스크 host 의 http(s) 절대 URL 은 외부가 아니다.
// 위험 스킴·protocol-relative 는 이 판정의 대상이 아니므로 아래 검사가 그대로 적용된다.
if (SiteAssetHosts::isSiteAssetUrl($value, $this->siteAssetHosts())) {
return;
}
$lowerValue = strtolower(trim($value));
foreach (self::DANGEROUS_SCHEMES as $scheme) {
@@ -225,6 +245,16 @@ class NoExternalUrls implements ValidationRule
}
}
/**
* 사이트 자산 host 목록을 반환합니다 (검증 1회당 1회 해석 후 캐시).
*
* @return array<int, string> 소문자 host 목록
*/
private function siteAssetHosts(): array
{
return $this->siteAssetHosts ??= SiteAssetHosts::hosts();
}
/**
* 스킴별 에러 메시지 출력
*/
+187
View File
@@ -0,0 +1,187 @@
<?php
namespace App\Seo;
use Illuminate\Support\Facades\RateLimiter;
/**
* SEO 봇 캐시의 상한 판정 단일 출처
*
* 봇 판정은 User-Agent 문자열뿐이라 누구나 위장할 수 있다. 그런데 캐시 키가 경로 + 전체
* 쿼리였으므로 물음표 뒤 값만 바꾸면 매 요청이 미스가 되고, 미스마다 레이아웃 병합 ·
* 표현식 평가 · 자기 API 루프백 HTTP 호출이 일어나고 그 결과가 무제한으로 저장됐다.
* 요청 하나가 워커 여러 개를 묶고 캐시 저장소를 계속 키우는 통로였다.
*
* 상한은 셋으로 나뉜다:
*
* - **키 형태** — 정규화할 수 없을 만큼 큰 쿼리는 애초에 색인 대상이 아니다(캐시하지 않고 SPA).
* - **렌더 예산** — IP 당 분당 미스 렌더 수. 초과분은 SPA 로 돌려보낸다(429 가 아니다 — 봇에게
* 오류를 주면 색인에서 그 URL 이 사라진다).
* - **저장 규모** — 경로당 변종 수와 전체 항목 수.
*
* 값은 `config/core.php` 의 `seo_cache_limits` 가 SSoT 이고 env 로 덮을 수 있다.
*/
final class SeoCacheBounds
{
/**
* 캐시 키에서 제외하는 시스템 파라미터.
*
* `locale` 은 캐시 키의 별도 축이고, `_escaped_fragment_` 는 봇 렌더 요청 표식일 뿐
* 내용에 영향이 없다 — 키에 남기면 같은 페이지가 두 벌 저장된다.
*/
private const SYSTEM_QUERY_PARAMS = ['locale', '_escaped_fragment_'];
/**
* 상한값을 반환합니다.
*
* @param string $key `seo_cache_limits` 하위 키
* @param int $fallback 설정이 없을 때의 기본값
* @return int 상한값
*/
private static function limit(string $key, int $fallback): int
{
return (int) config('core.seo_cache_limits.'.$key, $fallback);
}
/**
* 캐시 키에 쓸 쿼리 파라미터를 정규화합니다.
*
* 시스템 파라미터를 제거하고 키 순서로 정렬한다. 개수·길이 상한을 넘으면 **null** 을
* 돌려주며, 그것은 "이 URL 은 캐시하지도 렌더하지도 않는다" 를 뜻한다.
*
* @param array<string, mixed> $query 요청 쿼리 파라미터
* @return array<string, mixed>|null 정규화된 파라미터 (상한 초과 시 null)
*/
public static function normalizeQuery(array $query): ?array
{
foreach (self::SYSTEM_QUERY_PARAMS as $param) {
unset($query[$param]);
}
if (count($query) > self::limit('max_query_params', 10)) {
return null;
}
ksort($query);
if (strlen(http_build_query($query)) > self::limit('max_query_length', 512)) {
return null;
}
return $query;
}
/**
* 이 IP 가 미스 렌더를 더 수행할 수 있는지 판정합니다.
*
* @param string $ip 요청 IP
* @return bool 렌더 허용 여부
*/
public static function renderAllowed(string $ip): bool
{
return ! RateLimiter::tooManyAttempts(
'seo-render:'.$ip,
self::limit('render_misses_per_minute', 60)
);
}
/**
* 미스 렌더 1건을 예산에서 차감합니다.
*
* @param string $ip 요청 IP
*/
public static function recordRender(string $ip): void
{
RateLimiter::hit('seo-render:'.$ip, 60);
}
/**
* 차감한 미스 렌더 1건을 예산에 되돌립니다.
*
* 렌더러가 "그릴 게 없음"(null)으로 돌아온 요청 — 미라우트 404, SEO 비활성 화면 — 은
* 캐시에 남지 않아 올 때마다 다시 예산을 쓴다. 봇은 예전에 있던 죽은 주소를 오래 다시
* 긁으므로, 그 요청까지 세면 정상 페이지의 예산이 죽은 주소에 소진된다. 렌더 도중 예외는
* 비용을 이미 치른 것이라 되돌리지 않는다.
*
* @param string $ip 요청 IP
*/
public static function refundRender(string $ip): void
{
RateLimiter::decrement('seo-render:'.$ip, 60);
}
/**
* 이 IP 의 요청을 통계로 기록할 수 있는지 판정합니다.
*
* 통계 테이블이 새로운 증식 축이 되지 않도록 기록 자체에도 상한을 둔다.
*
* @param string $ip 요청 IP
* @return bool 기록 허용 여부
*/
public static function statsAllowed(string $ip): bool
{
return ! RateLimiter::tooManyAttempts(
'seo-stats:'.$ip,
self::limit('stats_records_per_minute', 300)
);
}
/**
* 통계 기록 1건을 예산에서 차감합니다.
*
* @param string $ip 요청 IP
*/
public static function recordStat(string $ip): void
{
RateLimiter::hit('seo-stats:'.$ip, 60);
}
/**
* 새 URL 을 캐시에 저장할 수 있는지 판정합니다.
*
* 이미 인덱스에 있는 키의 **갱신**은 이 판정을 거치지 않는다(호출측 책임) — 저장 규모가
* 늘지 않기 때문이다.
*
* 경로당 변종은 **언어별로** 센다. 인덱스 항목은 url|locale 별이라 경로만 보고 합산하면
* 언어 수만큼 실효 상한이 줄어, 다국어 사이트의 목록 뒤쪽 페이지가 언어마다 캐시에서 빠진다.
*
* @param array<string, array<string, mixed>> $index 현재 캐시 인덱스
* @param string $url 저장하려는 URL (경로 + 정규화 쿼리)
* @param string $locale 저장하려는 로케일
* @return bool 저장 허용 여부
*/
public static function canStore(array $index, string $url, string $locale): bool
{
if (count($index) >= self::limit('max_entries', 20000)) {
return false;
}
$path = self::pathOf($url);
$variants = 0;
foreach ($index as $entry) {
if (($entry['locale'] ?? null) !== $locale) {
continue;
}
if (self::pathOf((string) ($entry['url'] ?? '')) === $path) {
$variants++;
}
}
return $variants < self::limit('max_variants_per_path', 50);
}
/**
* URL 에서 경로 부분만 잘라냅니다.
*
* @param string $url 캐시 URL (`/path?a=1` 형태)
* @return string 경로
*/
private static function pathOf(string $url): string
{
$path = parse_url($url, PHP_URL_PATH);
return is_string($path) ? $path : $url;
}
}
+125 -43
View File
@@ -18,20 +18,57 @@ class SeoCacheManager implements SeoCacheManagerInterface
*/
private const INDEX_KEY = 'seo.cached_urls';
/**
* 저장 상한에서 인덱스를 정리한 시각을 남기는 표식 키
*/
private const PRUNE_MARK_KEY = 'seo.index_pruned_at';
/**
* 저장 상한에서 인덱스 정리를 다시 시도하기까지의 최소 간격 (초)
*/
private const PRUNE_INTERVAL_SECONDS = 60;
public function __construct(private readonly CacheInterface $cache) {}
/**
* {@inheritdoc}
*/
public function get(string $url, string $locale): ?string
{
return $this->getEntry($url, $locale)['html'] ?? null;
}
/**
* 캐시 항목(HTML + 레이아웃명)을 조회합니다.
*
* 페이지는 레이아웃명과 함께 저장된다 — 캐시 적중 경로는 렌더러를 거치지 않아 요청
* 속성에 레이아웃명이 없고, 통계를 화면별로 귀속하려면 항목이 그것을 알아야 한다.
* 이전 버전이 문자열로만 저장한 항목은 레이아웃명 없이 그대로 읽힌다 — 배포 직후
* 살아 있는 캐시를 버리지 않는다.
*
* @param string $url URL
* @param string $locale 로케일
* @return array{html: string, layout: string|null}|null 캐시 항목 (없으면 null)
*/
public function getEntry(string $url, string $locale): ?array
{
if (! $this->isEnabled()) {
return null;
}
$key = $this->buildKey($url, $locale);
$value = $this->cache->get($this->buildKey($url, $locale));
return $this->cache->get($key);
if (is_string($value)) {
return ['html' => $value, 'layout' => null];
}
if (is_array($value) && is_string($value['html'] ?? null)) {
$layout = $value['layout'] ?? null;
return ['html' => $value['html'], 'layout' => is_string($layout) ? $layout : null];
}
return null;
}
/**
@@ -39,17 +76,7 @@ class SeoCacheManager implements SeoCacheManagerInterface
*/
public function put(string $url, string $locale, string $html): void
{
if (! $this->isEnabled()) {
return;
}
$key = $this->buildKey($url, $locale);
$ttl = $this->getCacheTtl();
$this->cache->put($key, $html, $ttl);
// URL 인덱스 업데이트
$this->addToIndex($url, $locale, $key);
$this->storePage($url, $locale, $html, null);
}
/**
@@ -148,26 +175,6 @@ class SeoCacheManager implements SeoCacheManagerInterface
return SeoCacheSettings::pageCacheTtl();
}
/**
* URL 인덱스에 항목을 추가합니다.
*
* @param string $url URL
* @param string $locale 로케일
* @param string $key 캐시 키
*/
private function addToIndex(string $url, string $locale, string $key): void
{
$index = $this->getIndex();
$index[$key] = [
'url' => $url,
'locale' => $locale,
'key' => $key,
'cached_at' => now()->toIso8601String(),
];
$this->cache->put(self::INDEX_KEY, $index, 86400 * 30); // 30일
}
/**
* 캐시 인덱스를 조회합니다.
*/
@@ -177,9 +184,34 @@ class SeoCacheManager implements SeoCacheManagerInterface
}
/**
* 유효한 캐시만 남겨 인덱스를 재구성합니다.
* 저장 상한에서 인덱스 정리를 시도해도 되는지 판정하고, 시도한다면 표식을 남깁니다.
*
* 정리는 인덱스 전체를 훑는다(항목마다 캐시 조회). 살아 있는 항목만으로 상한에 닿은
* 경로는 저장 시도마다 그 스캔을 되풀이하게 되고, 그 빈도는 봇 미스 렌더 예산만큼이다
* — 정리해도 자리가 나지 않는 상태에서 비용만 곱해진다. 그래서 간격으로 묶는다.
*
* @return bool 정리를 수행해도 되면 true
*/
private function rebuildIndex(): void
private function shouldAttemptPrune(): bool
{
if ($this->cache->has(self::PRUNE_MARK_KEY)) {
return false;
}
$this->cache->put(self::PRUNE_MARK_KEY, true, self::PRUNE_INTERVAL_SECONDS);
return true;
}
/**
* 유효한 캐시만 남겨 인덱스를 재구성하고 그 결과를 반환합니다.
*
* 페이지는 TTL 로 사라지지만 인덱스 항목은 남는다 — 이 메서드가 그 차이를 메우는
* 유일한 지점이므로, 인덱스를 근거로 판정하는 쪽(저장 상한)은 판정 전에 여기를 거친다.
*
* @return array<string, array<string, mixed>> 정리된 인덱스
*/
private function rebuildIndex(): array
{
$index = $this->getIndex();
$validIndex = [];
@@ -191,6 +223,8 @@ class SeoCacheManager implements SeoCacheManagerInterface
}
$this->cache->put(self::INDEX_KEY, $validIndex, 86400 * 30);
return $validIndex;
}
/**
@@ -210,32 +244,80 @@ class SeoCacheManager implements SeoCacheManagerInterface
/**
* 캐시 저장 시 레이아웃 정보를 함께 저장합니다.
*
* 인덱스는 단일 캐시 항목에 전체 변종 배열을 담고 저장마다 통째로 다시 쓴다. 항목 수에
* 상한이 없으면 쿼리만 바꾼 반복 요청이 그 배열을 무한히 키운다 — 그래서 **새 URL** 은
* 경로당 변종 수와 전체 항목 수 상한 안에서만 저장한다. 이미 인덱스에 있는 키의 갱신은
* 저장 규모를 늘리지 않으므로 상한과 무관하게 쓴다.
*
* @param string $url URL
* @param string $locale 로케일
* @param string $html HTML
* @param string $layoutName 레이아웃명
*/
public function putWithLayout(string $url, string $locale, string $html, string $layoutName): void
{
$this->storePage($url, $locale, $html, $layoutName);
}
/**
* 페이지와 인덱스 항목을 저장합니다 (`put`/`putWithLayout` 공통 경로).
*
* 두 공개 메서드는 같은 자원(페이지 캐시 + 인덱스)을 쓰므로 저장 규모 상한도 같아야
* 한다 — 한쪽에만 두면 다른 쪽이 우회로가 되고, 인터페이스는 확장에 열려 있어
* "지금 호출부가 없다" 는 방어가 되지 않는다.
*
* @param string $url URL (경로 + 정규화 쿼리)
* @param string $locale 로케일
* @param string $html 저장할 HTML
* @param string|null $layoutName 레이아웃명 (없으면 인덱스에 기록하지 않음)
*/
private function storePage(string $url, string $locale, string $html, ?string $layoutName): void
{
if (! $this->isEnabled()) {
return;
}
$key = $this->buildKey($url, $locale);
$ttl = $this->getCacheTtl();
$this->cache->put($key, $html, $ttl);
// 레이아웃 정보 포함하여 인덱스 업데이트
$index = $this->getIndex();
$index[$key] = [
if (! isset($index[$key])) {
// 상한이 세는 인덱스에는 **페이지가 이미 만료된** 항목이 섞인다 — 인덱스는
// 페이지보다 훨씬 오래 살고(30일 vs 기본 2시간) 저장마다 수명이 갱신되며
// 스스로 줄지 않는다. 여기서 한 번 정리하지 않으면 상한이 "지금 저장된 양"이
// 아니라 "과거에 저장한 적이 있는 양"을 재게 되어, 한 번 닿은 경로는 실제
// 캐시가 비어도 영영 저장이 막힌다(상한이 아니라 일방향 래치가 된다).
if (! SeoCacheBounds::canStore($index, $url, $locale) && $this->shouldAttemptPrune()) {
$index = $this->rebuildIndex();
}
if (! SeoCacheBounds::canStore($index, $url, $locale)) {
Log::debug('[SEO] 캐시 저장 상한에 도달해 저장하지 않습니다', [
'url' => $url,
'locale' => $locale,
'entries' => count($index),
]);
return;
}
}
// 레이아웃명을 페이지와 함께 둔다 — 적중 경로가 통계를 화면별로 귀속할 유일한 출처다.
$this->cache->put($key, ['html' => $html, 'layout' => $layoutName], $this->getCacheTtl());
$entry = [
'url' => $url,
'locale' => $locale,
'key' => $key,
'layout' => $layoutName,
'cached_at' => now()->toIso8601String(),
];
if ($layoutName !== null) {
$entry['layout'] = $layoutName;
}
$entry['cached_at'] = now()->toIso8601String();
$index[$key] = $entry;
$this->cache->put(self::INDEX_KEY, $index, 86400 * 30);
}
}
+116 -19
View File
@@ -15,10 +15,15 @@ class SeoMiddleware
private readonly BotDetector $botDetector,
private readonly SeoCacheManagerInterface $cacheManager,
private readonly SeoRendererInterface $renderer,
private readonly SeoCacheStatsService $statsService,
) {}
/**
* 검색 봇 요청 시 SEO HTML을 반환합니다.
*
* @param Request $request HTTP 요청
* @param Closure $next 다음 미들웨어
* @return Response SEO HTML(HIT/MISS) 또는 SPA 폴백(BYPASS 포함)
*/
public function handle(Request $request, Closure $next): Response
{
@@ -56,18 +61,44 @@ class SeoMiddleware
$request->attributes->set('seo_default_locale', $defaultLocale);
app()->setLocale($locale);
// 캐시 키용 URL 생성 (경로 + 쿼리 파라미터, locale 제외)
$cacheUrl = $this->buildCacheUrl($request);
// 캐시 키용 쿼리 정규화 — 정규화할 수 없을 만큼 큰 쿼리는 색인 대상이 아니다.
// 그런 URL 까지 렌더·저장하면 물음표 뒤 값만 바꾼 반복 요청이 무한한 미스가 된다.
$normalizedQuery = SeoCacheBounds::normalizeQuery($request->query());
// 캐시 확인
$cachedHtml = $this->cacheManager->get($cacheUrl, $locale);
if ($cachedHtml !== null) {
return response($cachedHtml, 200, [
if ($normalizedQuery === null) {
return $this->bypass($request, $next);
}
$ip = (string) $request->ip();
// 캐시 키용 URL 생성 (경로 + 정규화된 쿼리)
$cacheUrl = $this->buildCacheUrl($request, $normalizedQuery);
// 캐시 확인 — 적중은 비용이 없으므로 렌더 예산과 무관하게 서빙한다
$entry = $this->readEntry($cacheUrl, $locale);
if ($entry !== null) {
$this->recordStat($ip, fn () => $this->statsService->recordHit(
$cacheUrl,
$locale,
$entry['layout'] ?: null
));
return response($entry['html'], 200, [
'Content-Type' => 'text/html; charset=utf-8',
'X-SEO-Cache' => 'HIT',
]);
}
// 미스 렌더는 IP 당 분당 예산 안에서만 — 초과분은 오류가 아니라 SPA 를 받는다.
// 봇에게 429 를 주면 그 URL 이 색인에서 빠지므로 차단이 곧 손해가 된다.
if (! SeoCacheBounds::renderAllowed($ip)) {
return $this->bypass($request, $next);
}
SeoCacheBounds::recordRender($ip);
$startedAt = microtime(true);
// 렌더링
try {
$html = $this->renderer->render($request);
@@ -103,6 +134,10 @@ class SeoMiddleware
// 렌더링 실패 시 SPA fallback
if ($html === null) {
// "그릴 게 없음" 은 비용을 치르지 않았다 — 되돌리지 않으면 캐시에 남지 않는 죽은
// 주소 재크롤이 올 때마다 정상 페이지의 예산을 태운다.
SeoCacheBounds::refundRender($ip);
return $next($request);
}
@@ -110,6 +145,14 @@ class SeoMiddleware
$layoutName = $request->attributes->get('seo_layout_name', '');
$this->cacheManager->putWithLayout($cacheUrl, $locale, $html, $layoutName);
$this->recordStat($ip, fn () => $this->statsService->recordMiss(
$cacheUrl,
$locale,
$layoutName ?: null,
null,
(int) round((microtime(true) - $startedAt) * 1000)
));
return response($html, 200, [
'Content-Type' => 'text/html; charset=utf-8',
'X-SEO-Cache' => 'MISS',
@@ -117,30 +160,84 @@ class SeoMiddleware
}
/**
* 캐시 키용 URL을 생성합니다.
* 캐시 항목(HTML + 레이아웃명)을 읽습니다.
*
* 경로 + 쿼리 파라미터를 포함하되, locale 파라미터는 제외합니다.
* 쿼리 파라미터를 키 순서로 정렬하여 동일 파라미터 조합이 같은 캐시 키를 생성하도록 합니다.
* 적중 경로는 렌더러를 거치지 않아 요청 속성에 레이아웃명이 없다 — 통계를 화면별로
* 귀속하려면 캐시 항목이 그것을 알아야 한다. 인터페이스(`get`)는 HTML 만 돌려주므로
* 코어 매니저일 때만 항목 전체를 읽고, 다른 구현이 바인딩된 경우에는 레이아웃명 없이
* HTML 만 쓴다(그 통계는 레이아웃 미상으로 귀속된다).
*
* @param string $cacheUrl 캐시 키용 URL
* @param string $locale 로케일
* @return array{html: string, layout: string|null}|null 캐시 항목 (없으면 null)
*/
private function readEntry(string $cacheUrl, string $locale): ?array
{
if ($this->cacheManager instanceof SeoCacheManager) {
return $this->cacheManager->getEntry($cacheUrl, $locale);
}
$html = $this->cacheManager->get($cacheUrl, $locale);
return $html === null ? null : ['html' => $html, 'layout' => null];
}
/**
* 캐시·렌더를 건너뛰고 SPA 응답을 돌려줍니다.
*
* 상한 초과는 오류가 아니라 "이 요청은 봇 렌더 대상이 아니다" 라는 판정이다. 헤더는
* 운영 진단의 유일한 통로다 — 응답 본문만으로는 일반 SPA 폴백과 구분되지 않는다.
*
* @param Request $request HTTP 요청
* @param Closure $next 다음 미들웨어
* @return Response SPA 응답
*/
private function bypass(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-SEO-Cache', 'BYPASS');
return $response;
}
/**
* 통계 기록을 IP 당 상한 안에서만 수행합니다.
*
* 기록 자체에 상한이 없으면 통계 테이블이 새로운 증식 축이 된다.
*
* @param string $ip 요청 IP
* @param callable $record 기록 동작
*/
private function recordStat(string $ip, callable $record): void
{
if (! SeoCacheBounds::statsAllowed($ip)) {
return;
}
SeoCacheBounds::recordStat($ip);
$record();
}
/**
* 캐시 키용 URL을 생성합니다.
*
* 경로 + 정규화된 쿼리 파라미터로 구성한다. 정규화는 시스템 파라미터(`locale`,
* `_escaped_fragment_`)를 제거하고 키 순서로 정렬하므로, 같은 조합은 순서와 무관하게
* 같은 키가 되고 봇 렌더 표식만 다른 두 URL 이 두 벌로 저장되지 않는다.
*
* @param Request $request HTTP 요청
* @param array<string, mixed> $normalizedQuery 정규화된 쿼리 파라미터
* @return string 캐시 키용 URL
*/
private function buildCacheUrl(Request $request): string
private function buildCacheUrl(Request $request, array $normalizedQuery): string
{
$path = $request->getPathInfo();
// locale을 제외한 쿼리 파라미터 추출
$query = $request->query();
unset($query['locale']);
if (empty($query)) {
if ($normalizedQuery === []) {
return $path;
}
// 키 순서 정렬 (동일 파라미터 조합 → 동일 캐시 키 보장)
ksort($query);
return $path.'?'.http_build_query($query);
return $path.'?'.http_build_query($normalizedQuery);
}
/**
+83 -3
View File
@@ -2,6 +2,7 @@
namespace App\Services;
use App\Contracts\Repositories\IdentityVerificationLogRepositoryInterface;
use App\Contracts\Repositories\PasswordResetTokenRepositoryInterface;
use App\Contracts\Repositories\RoleRepositoryInterface;
use App\Contracts\Repositories\UserConsentRepositoryInterface;
@@ -11,6 +12,7 @@ use App\Enums\IdentityVerificationPurpose;
use App\Enums\IdentityVerificationStatus;
use App\Enums\UserStatus;
use App\Exceptions\Auth\AccountLockedException;
use App\Exceptions\Auth\TwoFactorDeliveryFailedException;
use App\Extension\HookManager;
use App\Models\User;
use Carbon\Carbon;
@@ -30,6 +32,7 @@ class AuthService
private UserConsentRepositoryInterface $userConsentRepository,
private PasswordResetTokenRepositoryInterface $passwordResetTokenRepository,
private IdentityPolicyService $policyService,
private IdentityVerificationLogRepositoryInterface $identityLogRepository,
) {}
/**
@@ -228,9 +231,9 @@ class AuthService
'provider_id' => $challenge->providerId,
]);
throw ValidationException::withMessages([
'email' => [__('auth.two_factor_delivery_failed')],
]);
// 자격 증명은 올바르다 — 401 로 뭉개면 사용자는 비밀번호를 의심하며 같은 실패를
// 반복하고, 운영자는 메일 설정이 깨진 사실을 알 방법이 없다.
throw new TwoFactorDeliveryFailedException;
}
HookManager::doAction('core.auth.two_factor_requested', $user, [
@@ -298,6 +301,83 @@ class AuthService
return $this->issueLoginSession($user, (string) $user->email);
}
/**
* 2단계 인증 코드를 재발송합니다.
*
* 아직 사용되지 않은 challenge 만 재발송 대상입니다. 기존 challenge 를 취소하고 새로
* 발행하므로, 재발송 이후에는 앞서 받은 인증번호가 통하지 않습니다 — 재발송을 남겨 두면
* 유효한 코드가 여러 개 동시에 살아 있어 대입 시도의 표적이 넓어집니다.
*
* 해석된 사용자는 응답 페이로드에 실리지 않고 out 파라미터로만 올린다 — 관리자 경로가
* 그 사용자로 등급을 판정해야 하지만, 사용자 경로가 그대로 내보내면 모델이 응답에 새는다.
*
* @param string $challengeId 비밀번호 확인 단계가 돌려준 challenge UUID
* @param User|null $resolvedUser (out) challenge 가 식별한 사용자
* @return array{two_factor_required: bool, challenge_id: string, provider_id: string, expires_at: mixed} 새 challenge 정보
*
* @throws ValidationException challenge 가 재발송 대상이 아닐 때
* @throws AccountLockedException 계정이 잠겨 있을 때
* @throws TwoFactorDeliveryFailedException 인증번호를 보내지 못했을 때
*/
public function resendTwoFactorChallenge(string $challengeId, ?User &$resolvedUser = null): array
{
$log = $this->identityLogRepository->findById($challengeId);
// 존재하지 않음 / 다른 용도 / 이미 검증·취소·실패·만료 — 전부 같은 응답으로 답한다.
// 사유를 구분해 주면 challenge id 를 넣어 보며 상태를 캐낼 수 있다.
$resendable = [
IdentityVerificationStatus::Requested->value,
IdentityVerificationStatus::Sent->value,
];
if ($log === null
|| $log->purpose !== IdentityVerificationPurpose::Login->value
|| ! in_array($log->status->value, $resendable, true)
|| ($log->expires_at !== null && $log->expires_at->isPast())
) {
throw ValidationException::withMessages([
'challenge_id' => [__('auth.two_factor_invalid_challenge')],
]);
}
$user = $log->user_id === null ? null : $this->userRepository->findById((int) $log->user_id);
if (! $user || $user->status !== UserStatus::Active->value) {
throw ValidationException::withMessages([
'challenge_id' => [__('auth.two_factor_invalid_challenge')],
]);
}
// challenge 발급 이후에 잠겼을 수 있다 — 재발송도 잠긴 계정에는 코드를 보내지 않는다.
$this->assertNotLocked($user);
$resolvedUser = $user;
app(IdentityVerificationService::class)->cancel($log->id);
return $this->startTwoFactorChallenge($user, (string) $user->email);
}
/**
* 이미 발급된 세션(토큰 + web 세션)을 되돌립니다.
*
* 2단계 인증 완료는 코드 확인 시점에 토큰을 발급하므로, 그 뒤에 권한 검사로 거절하는
* 경로는 발급분을 반드시 회수해야 합니다. `logout()` 은 `currentAccessToken()` 에
* 의존해 이 시점(요청 자체는 미인증)에는 아무 일도 하지 않습니다.
*
* @param User $user 대상 사용자
* @param string $plainTextToken 방금 발급한 평문 토큰 ({id}|{token} 형식)
*/
public function revokeIssuedSession(User $user, string $plainTextToken): void
{
PersonalAccessToken::findToken($plainTextToken)?->delete();
// completeTwoFactor() 의 Auth::login() 이 연 세션도 함께 닫는다.
if (request()->hasSession() && request()->session()->isStarted() && Auth::guard('web')->check()) {
Auth::guard('web')->logout();
}
}
/**
* 토큰을 발급하고 로그인 완료 훅을 실행합니다.
*
+44 -9
View File
@@ -23,6 +23,7 @@ use App\Extension\Vendor\VendorInstallContext;
use App\Extension\Vendor\VendorInstallResult;
use App\Extension\Vendor\VendorMode;
use App\Extension\Vendor\VendorResolver;
use App\Support\PackageManifestCacheHelper;
use Database\Seeders\IdentityMessageDefinitionSeeder;
use Database\Seeders\IdentityPolicySeeder;
use Database\Seeders\NotificationDefinitionSeeder;
@@ -1835,6 +1836,20 @@ class CoreUpdateService
}
File::put($envPath, $content);
// 프로세스 환경도 함께 갱신한다.
//
// 부모가 config 캐시 **없이** 부팅했다면 Dotenv 가 `.env` 를 읽어 `$_ENV['APP_VERSION']` 에
// 이전 버전을 채워 두었고, Laravel 의 env 저장소는 불변(immutable)이라 뒤이어 부팅하는
// 프로세스가 `.env` 를 다시 읽어도 그 값을 덮지 않는다. 그래서 Step 11 의 `config:cache` 가
// 굽는 `bootstrap/cache/config.php` 에 **이전 버전**이 박제되고, 이후 모든 웹 요청이 옛 버전으로
// 판정한다 — 새 코어를 요구하는 확장이 `incompatible_core` 로 꺼지는 경로다
// (2026-09-07 실측: `config:clear` 후 7.0.10 → 7.0.11 업데이트가 config 캐시에 7.0.10 을 구웠다).
//
// 캐시 부팅에서는 Dotenv 가 아예 돌지 않아 이 값이 비어 있을 수 있으므로 세 채널 모두 세운다.
$_ENV['APP_VERSION'] = $version;
$_SERVER['APP_VERSION'] = $version;
putenv('APP_VERSION='.$version);
}
/**
@@ -2074,7 +2089,8 @@ class CoreUpdateService
* 모든 캐시를 초기화하고 패키지 목록을 재생성합니다.
*
* vendor 교체 후 bootstrap/cache의 컴파일 캐시가 stale 상태일 수 있으므로
* services.php/packages.php 삭제 후 package:discover로 재생성합니다.
* services.php/packages.php 삭제 후 package:discover로 재생성합니다
* (`PackageManifestCacheHelper::rebuild()` — spawn 직전 선정리와 같은 삭제 로직을 공유).
* 이는 composer install의 post-autoload-dump 후속 작업(clearCompiled + package:discover)을 재현합니다.
*/
public function clearAllCaches(): void
@@ -2085,14 +2101,10 @@ class CoreUpdateService
Artisan::call('route:clear');
Artisan::call('view:clear');
// 2. 컴파일 캐시 삭제 (composer postAutoloadDump → clearCompiled 재현)
// services.php, packages.php가 교체 전 vendor를 참조할 수 있음
$app = app();
@unlink($app->getCachedServicesPath());
@unlink($app->getCachedPackagesPath());
// 3. 현재 vendor 기반으로 packages.php 재생성
Artisan::call('package:discover');
// 2~3. 컴파일 캐시 삭제 후 현재 vendor 기반으로 재생성
// (composer postAutoloadDump → clearCompiled + package:discover 재현).
// services.php, packages.php 가 교체 전 vendor 를 참조할 수 있다.
PackageManifestCacheHelper::rebuild();
// 4. 확장 오토로드 재생성 (코어 업데이트로 _bundled 변경 가능)
Artisan::call('extension:update-autoload');
@@ -2108,6 +2120,29 @@ class CoreUpdateService
}
}
/**
* 상주 중인 큐 워커에 정상 종료 후 재시작 신호를 보냅니다.
*
* 큐 워커는 부팅이 한 번뿐이라 코어 코드·config·확장 목록을 기동 시점 상태로 물고 있다.
* 코어 업데이트가 파일을 전부 교체해도 워커는 옛 코드로 잡을 계속 처리하며, 그 사실이
* 오류로 드러나지 않는다 — 운영자가 워커를 손수 재시작할 때까지 조용히 어긋난 채 돈다.
*
* 캐시가 새 코드 기준으로 정리된 뒤(`clearAllCaches()` 직후) 호출해야 워커가 새 캐시로
* 재기동한다. 신호 전송 실패는 업데이트 결과를 되돌리지 않는다 (경고 로깅 후 계속).
*
* @return void
*/
public function signalQueueRestart(): void
{
try {
Artisan::call('queue:restart');
} catch (\Throwable $e) {
Log::channel('upgrade')->warning('queue:restart 실행 실패 (업데이트는 계속 진행)', [
'error' => $e->getMessage(),
]);
}
}
/**
* 코어 업그레이드 컨텍스트의 번들 확장 업데이트 감지.
*
+283 -99
View File
@@ -9,6 +9,8 @@ use App\Extension\Traits\ClearsTemplateCaches;
use App\Http\View\Composers\TemplateComposer;
use App\Support\AssetCssUrlRewriter;
use App\Support\AssetUrl;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
@@ -49,6 +51,21 @@ class ExtensionBundleService
*/
private const TEMP_BUNDLE_STALE_SECONDS = 600;
/**
* 캐시 미스 빌드를 (type, kind, version) 단위로 수렴시키는 잠금 키 접두사.
*/
private const BUILD_LOCK_PREFIX = 'ext-bundles.build.';
/**
* 빌드 잠금의 보유 상한 (초). 좀비 잠금 방지용이며 실제 빌드는 밀리초 단위다.
*/
private const BUILD_LOCK_TTL_SECONDS = 30;
/**
* 다른 프로세스의 빌드를 기다리는 상한 (초). 초과하면 잠금 없이 각자 빌드한다.
*/
private const BUILD_LOCK_WAIT_SECONDS = 5;
/**
* 서비스 주입
*
@@ -137,39 +154,7 @@ class ExtensionBundleService
*/
public function buildJsBundle(string $type): string
{
$ordered = $this->getOrderedGlobalAssetPaths($type);
$isProduction = app()->environment('production');
$segments = [];
foreach ($ordered as $identifier => $paths) {
if (empty($paths['jsAbsPath'])) {
continue;
}
try {
$content = @file_get_contents($paths['jsAbsPath']);
if ($content === false) {
Log::warning('확장 JS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [
'type' => $type,
'identifier' => $identifier,
'path' => $paths['jsAbsPath'],
]);
continue;
}
$segments[] = $this->processJsSourceMap($content, $type, $identifier, $isProduction);
} catch (\Throwable $e) {
Log::warning('확장 JS 번들 병합 중 오류, 해당 확장 skip', [
'type' => $type,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
}
}
return implode("\n;\n", $segments);
return $this->mergeBundle($type, 'js')['content'];
}
/**
@@ -186,12 +171,265 @@ class ExtensionBundleService
* @return string 병합된 CSS (활성 global 에셋이 없으면 빈 문자열)
*/
public function buildCssBundle(string $type): string
{
return $this->mergeBundle($type, 'css')['content'];
}
/**
* 캐시된 번들 파일의 절대 경로를 반환합니다(없으면 build → 원자적 write).
*
* 파일명에 version 을 포함(`{type}.{version}.{js|css}`)하므로 활성 조합이
* 바뀌어 version 이 bump 되면 새 파일명으로 자연 무효화된다. 프로덕션에서만
* 디스크 캐시하며, 비프로덕션(dev/watch)에서는 캐시하지 않고 매 요청 build 해
* rebuild 를 즉시 반영한다.
*
* 프로덕션에서 **캐시 존재 확인이 빌드보다 먼저** 온다. 캐시 키는 인자만으로
* 계산되므로 빌드가 필요 없는데, 빌드를 앞세우면 캐시가 있어도 요청마다 활성 확장을
* 열거하고 산출물을 전부 읽는다. 응답은 정상 200 이라 그 반복은 타이밍 말고는 드러나지
* 않고, 원본이 사라지는 순간에는 멀쩡한 캐시를 두고 빈 경로가 반환되어 503 이 된다.
*
* @param string $type 'module' | 'plugin'
* @param string $kind 'js' | 'css'
* @param int $version 확장 캐시 버전(ClearsTemplateCaches::getExtensionCacheVersion)
* @return string 캐시(또는 방금 build 한) 파일의 절대 경로. 캐시할 수 없으면 빈 문자열.
*/
public function getBundleFilePath(string $type, string $kind, int $version): string
{
$relativeName = $this->bundleFileName($type, $kind, $version);
// 디스크 캐시는 **최적화**다 — 쓰기 실패가 공개 엔드포인트의 500 이 되면 안 된다.
// `ext-bundles` 디스크는 `throw => true` 라 권한 문제(uid 독점 0700 등)에서
// `UnableToWriteFile` 이 그대로 올라오고, 그러면 모든 확장의 프론트엔드 JS/CSS 가
// 통째로 나가지 못한다. 병합 결과는 이미 메모리에 있으므로 그것을 그대로 응답하면
// 화면은 정상이다 (커밋 63a30ab29 의 AbstractCacheDriver fail-soft 와 같은 원칙).
try {
$storage = $this->bundleStorage();
// 비프로덕션은 캐시하지 않고 임시 파일로 매번 build → rebuild 즉시 반영
if (! app()->environment('production')) {
$content = $this->buildBundleContent($type, $kind);
// 병합할 에셋이 하나도 없으면 파일을 만들지 않는다(호출측이 빈 문자열로 판단).
if ($content === '') {
return '';
}
return $this->writeAtomically($storage, $relativeName, $content, cache: false);
}
// 프로덕션: 동일 version 캐시가 있으면 빌드 없이 그대로 사용
if ($storage->exists('', $relativeName)) {
return $storage->getBasePath('').'/'.$relativeName;
}
return $this->buildAndCacheOnce($storage, $type, $kind, $version, $relativeName);
} catch (\Throwable $e) {
Log::warning('확장 번들 디스크 캐시 실패 — 메모리 병합 결과로 서빙합니다', [
'type' => $type,
'kind' => $kind,
'version' => $version,
'error' => $e->getMessage(),
]);
return '';
}
}
/**
* 캐시 미스에서 한 번만 병합해 캐시 파일을 만들고 그 절대 경로를 반환합니다.
*
* 같은 (type, kind, version) 의 동시 요청은 잠금으로 하나에 수렴시킨다 — 버전 교체
* 직후에는 캐시가 없는 상태로 요청이 몰리고, 각자 병합하면 그 비용이 워커 수만큼 곱해진다.
* 잠금은 **최적화**이므로 대기 초과·저장소 장애는 실패로 바꾸지 않고 각자 빌드로 폴백한다
* (정적 게시 잠금 `ext-static.publish.{v}` 와 같은 저장소·같은 규율이며 키가 달라 자기
* 교착이 없다).
*
* 병합 결과가 비어 있어도 선언한 산출물이 **전부 존재하면**(또는 선언이 0이면) 0바이트
* 캐시 파일을 만든다. 그래야 정적 게시 대상이 되어 방문자가 웹서버에서 직접 받는다 —
* 만들지 않으면 그 구성의 모든 페이지 로드가 PHP 를 거친다. 캐시하지 않는 것은 둘이다 —
* 선언한 산출물이 **소실**된 경우(호출측의 503 판정을 그대로 유지한다)와 병합 단계에서
* 확장을 **건너뛴** 경우(그 상태가 굳지 않도록 매 요청 재시도에 맡긴다).
*
* @param CoreStorageDriver $storage 번들 디스크 스토리지
* @param string $type 'module' | 'plugin'
* @param string $kind 'js' | 'css'
* @param int $version 확장 캐시 버전
* @param string $relativeName 캐시 파일명
* @return string 캐시 파일의 절대 경로 (캐시하지 않았으면 빈 문자열)
*/
private function buildAndCacheOnce(
CoreStorageDriver $storage,
string $type,
string $kind,
int $version,
string $relativeName
): string {
$lock = null;
$acquired = false;
try {
$lock = Cache::lock(self::BUILD_LOCK_PREFIX."{$type}.{$kind}.{$version}", self::BUILD_LOCK_TTL_SECONDS);
$acquired = (bool) $lock->block(self::BUILD_LOCK_WAIT_SECONDS);
} catch (LockTimeoutException $e) {
// 대기 초과는 정상적인 경합이다 — 흔적만 남기고 각자 빌드한다.
Log::debug('확장 번들 빌드 잠금 대기 초과 — 잠금 없이 병합합니다', [
'type' => $type,
'kind' => $kind,
'version' => $version,
]);
} catch (\Throwable $e) {
// 저장소가 잠금을 제공하지 못한다(드라이버 미지원, 캐시 디렉토리 권한 등).
// 번들 서빙 자체를 막지는 않으므로 사유만 남기고 계속한다.
Log::warning('확장 번들 빌드 잠금 획득 불가 — 잠금 없이 병합합니다', [
'type' => $type,
'kind' => $kind,
'version' => $version,
'store' => config('cache.default'),
'error' => $e->getMessage(),
]);
}
try {
// 기다리는 동안 다른 프로세스가 완성했을 수 있다 — 병합 전에 다시 본다.
if ($storage->exists('', $relativeName)) {
return $storage->getBasePath('').'/'.$relativeName;
}
['content' => $content, 'skipped' => $skipped] = $this->mergeBundle($type, $kind);
// 건너뛴 확장이 있으면 캐시하지 않는다 — 파일은 존재·판독 가능한데 읽기·치환이
// 실패한 상태가 캐시로 굳으면 버전 bump 전까지 그 확장 자산이 사라진 채 고정된다.
// 캐시 없이 돌아가면 호출측이 매 요청 다시 병합하므로 원인이 사라지는 순간 회복한다.
// 출하 기본 로그 수준이 error 라 warning 은 기록되지 않는다 — 이 통지가 유일한 흔적이다.
if ($skipped !== []) {
Log::error('확장 번들 캐시 보류 — 병합 단계에서 건너뛴 확장이 있어 캐시하지 않습니다', [
'type' => $type,
'kind' => $kind,
'version' => $version,
'skipped' => $skipped,
]);
return '';
}
// 비었는데 선언한 산출물이 소실이면 캐시하지 않는다 — 배포 중 dist 가 잠깐 빈
// 장애가 0바이트 캐시로 굳어 정상(빈 200)으로 위장되면 안 된다.
if ($content === '' && $this->findMissingDeclaredAssets($type, $kind) !== []) {
return '';
}
return $this->writeAtomically($storage, $relativeName, $content, cache: true);
} finally {
if ($acquired && $lock !== null) {
try {
$lock->release();
} catch (\Throwable $e) {
// 해제 실패는 TTL 이 정리한다 — 서빙에는 영향이 없다.
}
}
}
}
/**
* 번들을 서빙할 때 쓸 병합 결과를 반환합니다 (디스크 캐시 실패 시 메모리 폴백용).
*
* @param string $type 'module' | 'plugin'
* @param string $kind 'js' | 'css'
* @return string 병합 결과 (없으면 빈 문자열)
*/
public function buildBundleContent(string $type, string $kind): string
{
return $this->mergeBundle($type, $kind)['content'];
}
/**
* 병합 결과와 함께 **건너뛴 확장**을 돌려줍니다 (캐시 판정용).
*
* 캐시할지는 결과 문자열만으로 판정할 수 없다 — 파일은 존재·판독 가능한데 읽기나 치환이
* 실패해 건너뛴 확장은 결과에서 조용히 빠질 뿐이다. 그 상태가 캐시로 굳으면 버전 bump
* 전까지 그 확장 자산이 사라진 채 고정되므로, 캐시 경로는 건너뜀 여부를 함께 받는다.
*
* @param string $type 'module' | 'plugin'
* @param string $kind 'js' | 'css'
* @return array{content: string, skipped: list<string>} 병합 결과와 건너뛴 확장 식별자
*/
private function mergeBundle(string $type, string $kind): array
{
$merged = $kind === 'css' ? $this->mergeCss($type) : $this->mergeJs($type);
return [
'content' => implode($kind === 'css' ? "\n" : "\n;\n", $merged['segments']),
'skipped' => $merged['skipped'],
];
}
/**
* JS 세그먼트를 priority 순으로 모읍니다.
*
* 확장별 fine-grained try/catch — 읽기 실패·처리 예외는 그 확장만 건너뛰고(`skipped`
* 에 기록) 나머지 병합을 지속한다. 한 확장의 실패가 번들 전체를 붕괴시키지 않는다.
*
* @param string $type 'module' | 'plugin'
* @return array{segments: list<string>, skipped: list<string>} 세그먼트와 건너뛴 확장 식별자
*/
private function mergeJs(string $type): array
{
$ordered = $this->getOrderedGlobalAssetPaths($type);
$isProduction = app()->environment('production');
$segments = [];
$skipped = [];
foreach ($ordered as $identifier => $paths) {
if (empty($paths['jsAbsPath'])) {
continue;
}
try {
$content = $this->readAssetSource($paths['jsAbsPath']);
if ($content === false) {
Log::warning('확장 JS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [
'type' => $type,
'identifier' => $identifier,
'path' => $paths['jsAbsPath'],
]);
$skipped[] = (string) $identifier;
continue;
}
$segments[] = $this->processJsSourceMap($content, $type, $identifier, $isProduction);
} catch (\Throwable $e) {
Log::warning('확장 JS 번들 병합 중 오류, 해당 확장 skip', [
'type' => $type,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
$skipped[] = (string) $identifier;
}
}
return ['segments' => $segments, 'skipped' => $skipped];
}
/**
* CSS 세그먼트를 priority 순으로 모읍니다.
*
* CSS 안의 상대 `url(...)`·`@import` 참조는 그 확장의 절대 자산 URL 로 치환한다 —
* 병합본의 주소는 어느 확장의 dist 디렉토리도 아니라 상대 해석이 반드시 어긋나기 때문이다.
* 치환은 개별 자산 서빙(ServesRewritableCssAssets)과 같은 규칙(AssetCssUrlRewriter)을 쓴다.
*
* @param string $type 'module' | 'plugin'
* @return array{segments: list<string>, skipped: list<string>} 세그먼트와 건너뛴 확장 식별자
*/
private function mergeCss(string $type): array
{
$ordered = $this->getOrderedGlobalAssetPaths($type);
$isProduction = app()->environment('production');
$typeSegment = $type === 'plugin' ? 'plugins' : 'modules';
$version = $this->getCurrentVersion();
$segments = [];
$skipped = [];
foreach ($ordered as $identifier => $paths) {
if (empty($paths['cssAbsPath'])) {
@@ -199,7 +437,7 @@ class ExtensionBundleService
}
try {
$content = @file_get_contents($paths['cssAbsPath']);
$content = $this->readAssetSource($paths['cssAbsPath']);
if ($content === false) {
Log::warning('확장 CSS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [
@@ -207,6 +445,7 @@ class ExtensionBundleService
'identifier' => $identifier,
'path' => $paths['cssAbsPath'],
]);
$skipped[] = (string) $identifier;
continue;
}
@@ -237,81 +476,26 @@ class ExtensionBundleService
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
$skipped[] = (string) $identifier;
}
}
return implode("\n", $segments);
return ['segments' => $segments, 'skipped' => $skipped];
}
/**
* 캐시된 번들 파일의 절대 경로를 반환합니다(없으면 build → 원자적 write).
* 확장 자산 원본을 읽습니다.
*
* 파일명에 version 을 포함(`{type}.{version}.{js|css}`)하므로 활성 조합이
* 바뀌어 version 이 bump 되면 새 파일명으로 자연 무효화된다. 프로덕션에서만
* 디스크 캐시하며, 비프로덕션(dev/watch)에서는 캐시하지 않고 매 요청 build 해
* rebuild 를 즉시 반영한다.
* 실패는 `false` 로 돌아오고 호출측이 그 확장을 건너뛴다. 별도 메서드인 이유는
* "존재·판독 가능한데 읽기가 실패하는" 상태를 테스트가 재현할 수 있어야 하기 때문이다 —
* 그 상태가 캐시로 굳는 것이 이 서비스가 막아야 할 결함이다.
*
* @param string $type 'module' | 'plugin'
* @param string $kind 'js' | 'css'
* @param int $version 확장 캐시 버전(ClearsTemplateCaches::getExtensionCacheVersion)
* @return string 캐시(또는 방금 build 한) 파일의 절대 경로. 병합 결과가 빈 문자열이면 빈 문자열.
* @param string $path 절대 경로
* @return string|false 파일 내용 (실패 시 false)
*/
public function getBundleFilePath(string $type, string $kind, int $version): string
protected function readAssetSource(string $path): string|false
{
$content = $kind === 'css'
? $this->buildCssBundle($type)
: $this->buildJsBundle($type);
// 병합할 에셋이 하나도 없으면 파일을 만들지 않는다(호출측이 빈 문자열로 판단).
if ($content === '') {
return '';
}
$relativeName = $this->bundleFileName($type, $kind, $version);
// 디스크 캐시는 **최적화**다 — 쓰기 실패가 공개 엔드포인트의 500 이 되면 안 된다.
// `ext-bundles` 디스크는 `throw => true` 라 권한 문제(uid 독점 0700 등)에서
// `UnableToWriteFile` 이 그대로 올라오고, 그러면 모든 확장의 프론트엔드 JS/CSS 가
// 통째로 나가지 못한다. 병합 결과는 이미 메모리에 있으므로 그것을 그대로 응답하면
// 화면은 정상이다 (커밋 63a30ab29 의 AbstractCacheDriver fail-soft 와 같은 원칙).
try {
$storage = $this->bundleStorage();
// 비프로덕션은 캐시하지 않고 임시 파일로 매번 build → rebuild 즉시 반영
if (! app()->environment('production')) {
return $this->writeAtomically($storage, $relativeName, $content, cache: false);
}
// 프로덕션: 동일 version 캐시가 있으면 그대로 사용
if ($storage->exists('', $relativeName)) {
return $storage->getBasePath('').'/'.$relativeName;
}
return $this->writeAtomically($storage, $relativeName, $content, cache: true);
} catch (\Throwable $e) {
Log::warning('확장 번들 디스크 캐시 실패 — 메모리 병합 결과로 서빙합니다', [
'type' => $type,
'kind' => $kind,
'version' => $version,
'error' => $e->getMessage(),
]);
return '';
}
}
/**
* 번들을 서빙할 때 쓸 병합 결과를 반환합니다 (디스크 캐시 실패 시 메모리 폴백용).
*
* @param string $type 'module' | 'plugin'
* @param string $kind 'js' | 'css'
* @return string 병합 결과 (없으면 빈 문자열)
*/
public function buildBundleContent(string $type, string $kind): string
{
return $kind === 'css'
? $this->buildCssBundle($type)
: $this->buildJsBundle($type);
return @file_get_contents($path);
}
/**
+19 -4
View File
@@ -292,13 +292,19 @@ class LayoutExtensionService
* @param array $components 주입된 컴포넌트 배열
* @param int $extensionId 확장 PK
* @param LayoutExtension|null $extension 출처 라벨 부여용 확장 모델
* @param int|null $injectionIndex overlay `injections[]` 순번 — 편집기가 저장 시 노드를 원래 injection 으로 되돌리는 열쇠. extension_point 주입은 null
* @return array 메타가 부여된 컴포넌트 배열
*
* @since engine-v1.50.0
*/
private function markExtensionSource(array $components, int $extensionId, ?LayoutExtension $extension = null): array
private function markExtensionSource(array $components, int $extensionId, ?LayoutExtension $extension = null, ?int $injectionIndex = null): array
{
return $this->applySourceMetaRecursively($components, $this->buildExtensionSourceMeta($extension, $extensionId));
$meta = $this->buildExtensionSourceMeta($extension, $extensionId);
if ($injectionIndex !== null) {
$meta['injectionIndex'] = $injectionIndex;
}
return $this->applySourceMetaRecursively($components, $meta);
}
/**
@@ -747,7 +753,11 @@ class LayoutExtensionService
'injection_count' => count($injections),
]);
foreach ($injections as $injection) {
// injection 순번을 함께 순회한다 — 편집기 확장 편집 모드가 호스트 병합 트리에서 이 확장의
// 노드를 추출해 `injections[].components` 로 되돌릴 때 어느 injection 인지 알 수 있어야
// 한다. 순번이 메타에 없으면 재조립이 모든 노드를 버려 저장본의 injections 가 통째로
// 비워진다(무변경 저장으로도 발생, 예외·경고 없음).
foreach ($injections as $injectionIndex => $injection) {
$targetId = $injection['target_id'] ?? null;
$position = $injection['position'] ?? 'append_child';
@@ -785,7 +795,12 @@ class LayoutExtensionService
// 편집 모드 출처 메타 부여 — 주입 노드와 그 자식 모두에 extension 메타
// @since engine-v1.50.0
if ($withSourceMeta) {
$components = $this->markExtensionSource($components, $overlay->id, $overlay);
$components = $this->markExtensionSource(
$components,
$overlay->id,
$overlay,
is_int($injectionIndex) ? $injectionIndex : null
);
}
$injected = $this->injectAtTarget(
@@ -8,6 +8,7 @@ use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Extension\HookManager;
use App\Models\TemplateLayoutAttachment;
use App\Support\ImageResizer;
use App\Support\PublicAssetDisk;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
@@ -64,7 +65,10 @@ class TemplateLayoutAttachmentService
$storedFilename = Str::uuid().'.'.$file->getClientOriginalExtension();
$relativePath = "{$templateIdentifier}/".date('Y/m/d')."/{$storedFilename}";
$disk = config('attachment.disk', 'attachments');
// 운영자가 공개 자산 디스크를 선언했으면 그쪽에 저장한다 (방문자 요청이 CDN 을 탄다).
// 미선언이면 기존 첨부 디스크 그대로. 행이 자기 disk 를 기록하므로 나중에 설정이
// 바뀌어도 각 행은 자기 저장 위치를 기억한다.
$disk = PublicAssetDisk::resolve() ?? config('attachment.disk', 'attachments');
// 환경설정 > 업로드의 최대 가로/세로·품질 적용 (코어 설정이 모든 업로드 경로에 동일 적용).
// 임시 파일을 제자리에서 줄이므로 아래의 저장·크기 기록이 모두 축소본을 본다.
@@ -138,8 +142,7 @@ class TemplateLayoutAttachmentService
public function delete(TemplateLayoutAttachment $attachment): bool
{
// 1. 스토리지 파일 실삭제 (명시적 — CASCADE 미의존)
$this->storage
->withDisk($attachment->disk)
$this->storageForRow($attachment->disk)
->delete(self::STORAGE_CATEGORY, $attachment->path);
// 2. DB 행 삭제
@@ -149,16 +152,54 @@ class TemplateLayoutAttachmentService
/**
* 첨부 파일의 공개 접근 URL을 생성합니다.
*
* 첨부 파일은 비공개 `attachments` 디스크에 저장되어 직접 공개 URL 이 없다
* (`StorageInterface::url()` 은 public 디스크 전용). 발행된 배경 이미지는 일반
* 사이트 방문자에게도 로드되어야 하므로, 인증 불필요한 공개 서빙 라우트
* (`PublicTemplateController::serveFile`)의 URL 을 돌려준다. 라우트는 첨부 id 로
* 키되며 서빙 시 첨부가 해당 템플릿 소속인지 검증한다.
* 운영자가 공개 자산 디스크(`core.storage.public_asset_disk`)를 선언했고 행 disk 가
* 그 값과 일치할 때만 직접 URL(CDN)을 돌려준다. 그 외에는 인증 불필요한 공개 서빙
* 라우트(`PublicTemplateController::serveFile`)의 URL 을 돌려주며, 라우트는 첨부 id 로
* 키되고 서빙 시 첨부가 해당 템플릿 소속인지 검증한다.
*
* 디스크의 `url` 설정 유무로 판정하지 않는다 — 그 설정은 "URL 문자열을 만들 수
* 있는가" 일 뿐 "익명 읽기가 되는가" 가 아니어서, 비공개 버킷에 공개 URL 이
* 설정된 조합에서는 발급된 직접 URL 이 403 이 된다.
*
* @param TemplateLayoutAttachment $attachment 첨부 파일
* @return string 공개 서빙 URL
* @return string 직접 URL 또는 공개 서빙 URL
*/
public function resolveUrl(TemplateLayoutAttachment $attachment): string
{
return $this->resolveDirectUrl($attachment) ?? $this->proxyUrl($attachment);
}
/**
* 행 disk 가 공개 자산 디스크일 때만 직접 URL 해석을 시도합니다.
*
* @param TemplateLayoutAttachment $attachment 첨부 파일
* @return string|null 직접 URL (대상이 아니거나 훅이 차단하면 null)
*/
private function resolveDirectUrl(TemplateLayoutAttachment $attachment): ?string
{
if (! PublicAssetDisk::isCurrent($attachment->disk)) {
return null;
}
// 게이트를 통과한 disk 는 PublicAssetDisk::resolve() 가 존재를 이미 확인했다.
return $this->storage
->withDisk($attachment->disk)
->url(self::STORAGE_CATEGORY, $attachment->path);
}
/**
* 공개 서빙 라우트(프록시) URL 을 생성합니다.
*
* 사이트 상대 경로(`/api/templates/...`)로 발급한다. 절대 URL 로 발급하면 ① 저장
* 게이트(`NoExternalUrls`)가 서버 자신이 발급한 주소를 외부로 차단해 image 위젯의
* 업로드 → 저장이 422 로 끝나고(배경은 style 로 들어가 스캔되지 않아 드러나지 않았다),
* ② 저장된 레이아웃이 발급 시점의 도메인·스킴에 묶여 주소가 바뀌면 그 이미지가 전부
* 깨진다. 직접 URL(CDN)은 저장소가 정하는 절대 주소이므로 이 규칙의 대상이 아니다.
*
* @param TemplateLayoutAttachment $attachment 첨부 파일
* @return string 공개 서빙 URL (사이트 상대 경로)
*/
private function proxyUrl(TemplateLayoutAttachment $attachment): string
{
$template = $attachment->template;
$identifier = $template?->identifier ?? (string) $attachment->template_id;
@@ -166,7 +207,27 @@ class TemplateLayoutAttachmentService
return route('api.public.templates.layout-attachment-file', [
'identifier' => $identifier,
'attachment' => $attachment->id,
]);
], false);
}
/**
* 행에 기록된 disk 기준 스토리지를 반환합니다 (고아 disk 방어).
*
* 공개 자산 디스크는 플러그인이 등록한 디스크일 수 있고, 그 플러그인이 비활성화되면
* 해당 disk 가 config 에서 사라진다. 미등록 disk 로 withDisk 를 만들면 이후
* response/delete 가 InvalidArgumentException 을 던져 **무인증 공개 서빙 라우트가
* 500** 이 되므로, 주입 스토리지로 폴백해 404 로 끝나게 한다.
*
* @param string|null $disk 행의 disk 컬럼 값
* @return StorageInterface 행 disk 의 스토리지 (고아면 주입 스토리지)
*/
private function storageForRow(?string $disk): StorageInterface
{
if ($disk === null || $disk === '' || config("filesystems.disks.{$disk}") === null) {
return $this->storage;
}
return $this->storage->withDisk($disk);
}
/**
@@ -190,7 +251,7 @@ class TemplateLayoutAttachmentService
// 행 disk 기준 인라인 스트림 (존재 검사는 response() 내부에서 수행).
// 로컬 절대 경로 조립(getBasePath)은 S3 등 원격 디스크 행에서 성립하지 않는다 (#99).
$response = $this->storage->withDisk($attachment->disk)->response(
$response = $this->storageForRow($attachment->disk)->response(
self::STORAGE_CATEGORY,
$attachment->path,
$attachment->original_name ?? basename($attachment->path),
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Support;
/**
* vendor 디렉토리가 개발용(require-dev 포함) 설치인지 판정하는 유틸리티.
*
* `composer install`(옵션 없음)은 require-dev 와 그 전이 의존성까지 설치하고, 그 목록이
* `bootstrap/cache/packages.php` 에 provider 로 등재된다. 운영 사이트가 그 상태로 남으면
* 이후 코어 업데이트가 vendor 를 `--no-dev` 로 교체할 때 매니페스트에만 남은 provider 를
* 찾다 부팅이 깨진다. 그래서 인스톨러와 코어 업데이트가 이 판정으로 운영자에게 알린다.
*
* 프레임워크에 의존하지 않는다 — 인스톨러는 Laravel 오토로드 없이 도는 순수 PHP 라
* 이 클래스를 `require_once` 로 직접 읽는다.
*/
final class ComposerInstallInfo
{
/**
* vendor 디렉토리 기준 Composer 설치 정보 파일의 상대 경로
*/
public const INSTALLED_JSON = 'composer/installed.json';
/**
* vendor 디렉토리의 Composer 설치 구성을 조사합니다.
*
* 판정: `installed.json` 의 최상위 `dev` 가 true 이거나 `dev-package-names` 가 비어 있지
* 않으면 개발용 설치. 두 신호를 OR 로 보는 이유는 Composer 버전에 따라 한쪽만 채워질 수
* 있기 때문이다.
*
* @param string $vendorPath vendor 디렉토리 절대 경로
* @return array{dev: bool|null, packages: array<int, string>} dev 가 null 이면 판정 불가
* (파일 부재 · JSON 오류 · Composer 1 형식)
*/
public static function inspect(string $vendorPath): array
{
$unknown = ['dev' => null, 'packages' => []];
$path = rtrim($vendorPath, '/\\').'/'.self::INSTALLED_JSON;
if (! is_file($path)) {
return $unknown;
}
$raw = @file_get_contents($path);
if ($raw === false || $raw === '') {
return $unknown;
}
$decoded = json_decode($raw, true);
// Composer 1 은 최상위가 패키지 배열이라 dev 정보 자체가 없다 — 판정 불가로 둔다.
if (! is_array($decoded) || ! array_key_exists('packages', $decoded)) {
return $unknown;
}
$names = [];
if (isset($decoded['dev-package-names']) && is_array($decoded['dev-package-names'])) {
foreach ($decoded['dev-package-names'] as $name) {
if (is_string($name) && trim($name) !== '') {
$names[] = $name;
}
}
}
$devFlag = $decoded['dev'] ?? null;
return [
'dev' => $devFlag === true || $names !== [],
'packages' => $names,
];
}
/**
* vendor 가 개발용 설치인지 반환합니다.
*
* @param string $vendorPath vendor 디렉토리 절대 경로
* @return bool|null 판정 불가 시 null
*/
public static function isDevInstall(string $vendorPath): ?bool
{
return self::inspect($vendorPath)['dev'];
}
/**
* vendor 에 설치된 개발용(require-dev) 패키지 이름 목록을 반환합니다.
*
* @param string $vendorPath vendor 디렉토리 절대 경로
* @return array<int, string> 개발용 패키지가 없거나 판정 불가면 빈 배열
*/
public static function devPackageNames(string $vendorPath): array
{
return self::inspect($vendorPath)['packages'];
}
}
+30 -8
View File
@@ -4,6 +4,7 @@ namespace App\Support;
use Illuminate\Container\Container;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Facade;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
@@ -62,26 +63,47 @@ class ConfigCacheHelper
}
/**
* 콜백 실행 뒤 전역 컨테이너 인스턴스를 원래 앱으로 되돌립니다.
* 콜백 실행 뒤 전역 컨테이너 인스턴스와 파사드 애플리케이션을 원래 앱으로 되돌립니다.
*
* `config:cache` 는 신선한 설정을 얻기 위해 **새 Application 을 부팅**하는데, `Application` 생성자가
* `Container::setInstance()` 를 호출하므로 그 순간부터 `app()` 헬퍼가 실행 중인 앱이 아니라 그
* 일회용 앱을 가리킨다(파사드는 별도 참조라 그대로다). 같은 프로세스에서 그 뒤에 등록되는
* `app()->terminating()` 콜백은 종료되지 않는 앱에 걸려 **영원히 실행되지 않는다** — 설정 저장
* 뒤의 확장 캐시 버전 bump 가 예약한 정적 재게시가 그렇게 조용히 사라졌다(#651 F5 실측: 버전은
* 올랐는데 게시는 다음 렌더의 자가 치유까지 미뤄짐). 예외도 로그도 없고, 자가 치유가 한 렌더
* 뒤에 덮어 주므로 "한 박자 늦게 반영" 으로만 나타난다.
* `config:cache` 는 신선한 설정을 얻기 위해 **새 Application 을 부팅**하는데, 그 부팅이 전역 상태를
* 두 군데 바꾼다.
*
* 1. `Application` 생성자의 `Container::setInstance()` — 그 순간부터 `app()` 헬퍼가 실행 중인 앱이
* 아니라 일회용 앱을 가리킨다. 같은 프로세스에서 그 뒤에 등록되는 `app()->terminating()` 콜백은
* 종료되지 않는 앱에 걸려 **영원히 실행되지 않는다** — 설정 저장 뒤의 확장 캐시 버전 bump 가
* 예약한 정적 재게시가 그렇게 조용히 사라졌다(#651 F5 실측: 버전은 올랐는데 게시는 다음 렌더의
* 자가 치유까지 미뤄짐).
* 2. `registerBaseBindings()` 의 `Facade::clearResolvedInstances()` + `Facade::setFacadeApplication()`
* — 그 순간부터 **모든 파사드**(`Artisan`·`Log`·`DB`·`Cache` …)가 일회용 앱에서 새 인스턴스를
* 해석한다. 컨테이너만 되돌리면 이 축이 남는다.
*
* 2번이 코어 업데이트에서 드러난 형태: Step 11 의 `ConfigCacheHelper::rebuild()` 뒤에 오는
* `RouteCacheHelper::rebuild()` 의 `Artisan::call('route:clear')` 가 일회용 앱에서 **새 콘솔 Kernel** 을
* 해석하고, 그 Kernel 은 콘솔 Application 을 처음부터 구성하며 등록된 커맨드를 전부 resolve 한다.
* 부팅 시점 vendor 가 개발용이었다면 그 목록에 `command.tinker` 같은 require-dev 커맨드가 들어
* 있는데, Step 8 이 vendor 를 `--no-dev` 로 이미 교체했으므로 그 클래스가 없어
* `Target class [command.tinker] does not exist.` 로 업데이트 전체가 실패·롤백한다
* (2026-09-07 7.0.9 → 7.0.11 실측). 프로세스에 이미 등록된 provider 목록은 vendor 교체로 갱신되지
* 않으므로, 방어는 "교체 뒤에 콘솔 Application 을 다시 구성하지 않는 것" 이다.
*
* @param callable $callback 전역 인스턴스를 바꿔 놓을 수 있는 작업
*/
public static function withPreservedContainer(callable $callback): void
{
$app = Container::getInstance();
$facadeApp = Facade::getFacadeApplication();
try {
$callback();
} finally {
Container::setInstance($app);
// 일회용 앱이 남긴 파사드 해석 결과를 버리고 원래 앱으로 되돌린다.
// 순서 주의: clear 를 먼저 해야 일회용 앱에서 해석된 인스턴스가 캐시에 남지 않는다.
Facade::clearResolvedInstances();
if ($facadeApp !== null) {
Facade::setFacadeApplication($facadeApp);
}
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace App\Support;
/**
* 현재 PHP 프로세스가 코어 업데이트 프로세스 트리 안에 있는지 판정하는 단일 SSoT.
*
* 코어 업데이트는 부모 `core:update` 가 `core:execute-upgrade-steps` 를 `proc_open` 으로
* spawn 하고, 그 자식이 다시 `config:cache` 등으로 일회용 Application 을 부팅하는 다층 구조다.
* 이 트리 안에서만 켜져야 하는 예외 동작이 여럿이라(확장 자동 비활성화 스킵, 코어 버전의
* env 우선 판독, `bootstrap/app.php` 의 패키지 매니페스트 자가 치유) 판정이 흩어지면
* 한 곳만 어긋나도 그 경로가 조용히 다르게 동작한다 — 판정을 여기 한 곳에 둔다.
*
* 판정 채널이 둘인 이유: `G7_UPDATE_IN_PROGRESS` env 는 부모가 세우고 spawn 자식에게
* `$env` 로 전파되지만, `variables_order` 에 `E` 가 없는 호스팅에서는 `$_ENV` 가 비어 있을 수
* 있어 argv 보조 판정을 함께 둔다.
*
* argv 채널은 명령줄 SAPI 에서만 읽는다. CGI/FPM 은 `register_argc_argv=On` 이면 `$_SERVER['argv']`
* 를 쿼리스트링을 `+` 로 쪼갠 값으로 채우므로(`GET /?x+core:update` → `argv[1] === 'core:update'`),
* 그 SAPI 에서 argv 를 믿으면 비인증 웹 요청이 업데이트 트리로 판정되어 `bootstrap/app.php` 의
* 자가 치유가 요청마다 매니페스트를 지운다. env 플래그 채널은 웹 요청으로 주입할 수 없어 SAPI 와
* 무관하게 인정한다 — 웹 요청 안에서 시작하는 업데이트 흐름은 그 플래그를 프로세스 안에서 세운다.
*
* 주의: `bootstrap/app.php` 의 자가 치유 블록은 부팅 전이라 이 클래스를 참조할 수 없어
* 같은 판정을 순수 PHP 로 복제한다. 조건을 바꾸면 그쪽도 함께 고친다.
*/
final class CoreUpdateContext
{
/**
* 코어 업데이트를 수행하는 artisan 커맨드 이름 (argv 보조 판정용)
*/
private const UPDATE_COMMANDS = ['core:update', 'core:execute-upgrade-steps'];
/**
* argv 보조 판정을 신뢰하는 SAPI — 명령줄 프로세스만. 웹 SAPI 의 argv 는 쿼리스트링에서 채워질 수 있다.
*/
private const CONSOLE_SAPIS = ['cli', 'phpdbg'];
/**
* 현재 프로세스가 코어 업데이트 트리(부모 core:update 또는 그 spawn 자식) 안에 있는지 판정합니다.
*
* 판정 조건 (OR):
* 1. 환경변수 `G7_UPDATE_IN_PROGRESS=1` — 부모가 시작 시 설정하고 spawn 자식에 전파
* 2. 명령줄 SAPI 에서 artisan 커맨드 이름이 `core:update` / `core:execute-upgrade-steps` —
* 1 이 전파되지 않은 극단 상황 대비 보조 판정. 웹 SAPI 에서는 읽지 않는다
*
* @param string|null $sapi 판정에 쓸 SAPI 이름. 기본은 `PHP_SAPI` 이며, 테스트가 웹 SAPI 를 주입할 때만 지정
* @return bool 업데이트 트리 안이면 true
*/
public static function isInProgress(?string $sapi = null): bool
{
if (self::hasEnvFlag()) {
return true;
}
if (! in_array($sapi ?? PHP_SAPI, self::CONSOLE_SAPIS, true)) {
return false;
}
$argv = $_SERVER['argv'] ?? [];
return in_array($argv[1] ?? '', self::UPDATE_COMMANDS, true);
}
/**
* `G7_UPDATE_IN_PROGRESS=1` 환경변수 플래그만 확인합니다 (argv 보조 판정 없음).
*
* spawn 자식 여부를 가려야 하는 지점(자식은 사전·사후 단계를 부모에 위임)에서 쓴다.
* argv 판정을 섞으면 `core:execute-upgrade-steps` 단독 실행이 자식으로 오판된다.
*
* @return bool env 플래그가 켜져 있으면 true
*/
public static function hasEnvFlag(): bool
{
$flag = $_ENV['G7_UPDATE_IN_PROGRESS'] ?? $_SERVER['G7_UPDATE_IN_PROGRESS'] ?? getenv('G7_UPDATE_IN_PROGRESS');
return $flag === '1' || $flag === 1 || $flag === true;
}
}
@@ -2303,11 +2303,11 @@ class ExtensionDocScaffolder
$sections,
);
// 확장명은 히어로 이미지가 아니라 평범한 H1 이다 (PO 결정 2026-08-31). 확장 20개는
// 확장명은 히어로 이미지가 아니라 평범한 H1 이다 (설계 결정 2026-08-31). 확장 20개는
// 대등하게 병렬로 존재하는 구성요소이고, 각자가 코어와 같은 히어로 브랜딩을 받으면
// "이 확장이 곧 독립 프로젝트" 라는 착시를 준다. `@generated:badges` 블록의
// flat-square 정보 배지는 manifest 에서 오는 것이라 그대로 둔다.
// 제목은 확장명만이 아니라 「그누보드7 {확장명} {유형}」 이다 (PO 결정 2026-09-04) —
// 제목은 확장명만이 아니라 「그누보드7 {확장명} {유형}」 이다 (설계 결정 2026-09-04) —
// 이 문서만 연 사람이 그누보드7의 어떤 종류 확장인지 제목에서 알 수 있어야 한다.
$lines = [];
$lines[] = '# '.ExtensionInventory::docTitle($name, $record['type']);
@@ -281,7 +281,7 @@ class ExtensionInventory
* 진입 문서(README · AGENTS.md · docs/README.md)의 제목을 만듭니다.
*
* 확장명만 제목으로 두면(`# 게시판`) 제3자가 그 문서만 열었을 때 이것이 그누보드7의
* 확장인지, 모듈인지 템플릿인지 알 수 없다(PO 결정 2026-09-04). 그래서 제목은
* 확장인지, 모듈인지 템플릿인지 알 수 없다(설계 결정 2026-09-04). 그래서 제목은
* 「그누보드7 {확장명} {유형}」 으로 조립한다 — 「그누보드7 게시판 모듈」.
*
* 확장명이 이미 유형으로 끝나면(「Hello 모듈」·「Hello User Template」) 유형을 겹쳐
@@ -0,0 +1,73 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\Artisan;
/**
* 패키지 매니페스트 캐시(bootstrap/cache/packages.php · services.php) 정리·재생성 헬퍼.
*
* Laravel 의 `PackageManifest::getManifest()` 는 `packages.php` 가 존재하면 stale 여부를
* 검사하지 않고 그대로 읽고, `ProviderRepository::load()` 가 거기 등재된 eager provider 를
* `new` 한다. 그래서 vendor 를 교체한 뒤 그 파일을 남겨 두면 다음에 부팅하는 프로세스가
* 새 vendor 에 없는 클래스를 찾다 **부팅 단계에서** 죽는다 — 앱 로그가 아직 열리기 전이라
* 예외도 남지 않고, 부모 프로세스에는 자식의 비정상 종료로만 보인다.
*
* `ConfigCacheHelper`/`RouteCacheHelper` 와 같은 자리의 헬퍼로, vendor 를 바꾼 뒤 새 PHP
* 프로세스를 띄우는 지점이 이 헬퍼를 경유한다. 삭제 로직을 호출부마다 `@unlink` 로 복제하면
* 한 곳만 빠져도 그 경로가 조용히 옛 매니페스트로 부팅한다.
*/
class PackageManifestCacheHelper
{
/**
* 패키지 매니페스트 캐시 두 파일을 삭제합니다 (재생성 없음).
*
* 경로는 `Application` 의 게터로 읽어 `APP_PACKAGES_CACHE`/`APP_SERVICES_CACHE` 환경변수
* 재지정을 존중한다(테스트 격리가 이 경로 재지정에 의존한다).
*
* 삭제 실패(권한 · 소유권 불일치 · Windows 파일 핸들 점유)는 예외로 올리지 않는다 — 부팅 직전에
* 불리므로 실패가 흐름을 막으면 안 되고, 코어 업데이트의 마지막 단계(`clearAllCaches()`)가 다시
* 시도한다. 대신 지우지 못한 파일의 경로를 돌려준다. spawn 직전 호출부는 그 목록을 업그레이드
* 로그에 남긴다 — 그 상태면 자식의 자가 치유도 같은 권한으로 같은 이유로 실패해 증상은 이전
* 설치본 provider 의 「Class not found」 그대로인데, 이 기록이 권한이 원인이라는 유일한 흔적이다.
*
* @return array<int, string> 삭제하지 못하고 남은 파일의 절대 경로. 전부 지웠거나 원래 없었으면 빈 배열
*/
public static function clear(): array
{
$app = app();
$remaining = [];
foreach ([$app->getCachedServicesPath(), $app->getCachedPackagesPath()] as $path) {
if (! is_file($path)) {
continue;
}
if (! @unlink($path)) {
clearstatcache(true, $path);
if (is_file($path)) {
$remaining[] = $path;
}
}
}
clearstatcache();
return $remaining;
}
/**
* 패키지 매니페스트 캐시를 비우고 현재 vendor 기준으로 다시 만듭니다.
*
* `package:discover` 는 새 Application 을 부팅하지 않으므로(현재 앱의 `PackageManifest` 를
* 그대로 쓴다) `ConfigCacheHelper::withPreservedContainer()` 로 감쌀 필요가 없다.
*
* @return void
*/
public static function rebuild(): void
{
self::clear();
Artisan::call('package:discover');
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Support;
/**
* 공개 자산 디스크 해석기
*
* 운영자가 "이 저장소는 익명 읽기가 가능한 공개 저장소다" 라고 명시 선언한 디스크를
* 해석하는 단일 지점입니다. `filesystems.disks.{disk}.url` 설정 유무는 "URL 문자열을
* 만들 수 있는가" 일 뿐 "익명 읽기가 되는가" 가 아니므로 공개 판정의 근거가 될 수
* 없습니다 — 비공개 버킷에 공개 URL 이 설정된 조합에서는 그 URL 이 403 을 돌려줍니다.
*
* 해석 결과는 memoize 하지 않습니다. `core.storage.public_asset_disk` 는 런타임에
* 바뀔 수 있고(관리자 환경설정 저장, 테스트의 Config::set), 정적 캐시를 두면 그 변경이
* 반영되지 않습니다.
*/
class PublicAssetDisk
{
/**
* 공개 자산 디스크 설정값을 해석합니다.
*
* 우선순위: 확장 개별 설정(override) > 코어 전역 설정(core.storage.public_asset_disk).
* 미설정('')/'none'/config 에 존재하지 않는 디스크(고아 플러그인 디스크)는 null 로
* 해석되어 호출측이 기존 디스크(스트리밍)로 폴백합니다.
*
* @param string|null $override 확장 개별 설정값 (''/null 이면 코어 전역 설정 사용)
* @return string|null 사용할 디스크 이름 (스트리밍 유지면 null)
*/
public static function resolve(?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;
}
/**
* 주어진 disk 가 "지금" 유효한 공개 자산 디스크인지 판정합니다.
*
* 직접 URL 발급 여부의 단일 판정점입니다. 반드시 resolve() 를 경유하므로
* ''/'none'/고아 디스크가 등가 비교만으로 통과하지 않습니다.
*
* @param string|null $disk 행에 기록된 disk 값
* @return bool 공개 자산 디스크와 일치하면 true
*/
public static function isCurrent(?string $disk): bool
{
return $disk !== null && $disk !== '' && $disk === self::resolve();
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace App\Support;
use App\Extension\Storage\CoreStorageDriver;
use Illuminate\Support\Facades\Log;
/**
* 사이트 자산 host 해석기
*
* "이 사이트가 스스로 자산을 내보내는 host" 를 한 지점에서 해석합니다. 저장측 규칙
* (`App\Rules\NoExternalUrls`)이 절대 URL 을 외부로 차단할 때, 서버가 스스로 발급하는
* 주소 — 레이아웃 첨부 API 의 프록시 서빙 URL·운영자가 선언한 공개 자산 디스크의 직접
* URL — 까지 외부로 오판하면 업로드는 되는데 저장이 422 로 끝납니다. 그 오판을 막는
* 근거가 되는 host 목록입니다.
*
* 근거는 둘뿐입니다:
* 1. `config('app.url')` 의 host — 운영자가 설정한 사이트 주소
* 2. `PublicAssetDisk::resolve()` 가 돌려주는 디스크의 기준 URL host — 운영자가
* "익명 읽기가 되는 저장소" 라고 명시 선언한 곳
*
* 요청의 `Host` 헤더는 근거로 쓰지 않습니다 — 신뢰 프록시 밖에서는 위조 가능하고, 위조된
* host 가 허용되면 레이아웃에 임의 외부 주소를 실을 수 있습니다. 디스크의 `url` 설정
* 유무도 근거가 아닙니다(`PublicAssetDisk` 와 같은 이유 — "URL 을 만들 수 있다" 는
* "공개다" 가 아닙니다).
*
* 판정은 접두 문자열 비교가 아니라 브라우저와 같은 정규화(`TrustedScriptHosts::
* normalizeForOriginCheck`) 뒤의 host 등가 비교입니다. 접두 비교는
* `https://shop.example.test.evil.com` · `https://shop.example.test@evil.com` 을 통과시킵니다.
*/
class SiteAssetHosts
{
/**
* 사이트 자산 host 목록을 해석합니다.
*
* memoize 하지 않습니다 — `app.url`·`core.storage.public_asset_disk` 는 런타임에
* 바뀔 수 있습니다(관리자 환경설정 저장, 테스트의 Config::set). 한 검증 안에서의
* 반복 호출은 호출측이 캐시합니다.
*
* @return array<int, string> 소문자 host 목록 (중복 제거, 해석 불가면 빈 배열)
*/
public static function hosts(): array
{
$hosts = [];
$appHost = TrustedScriptHosts::hostOf((string) config('app.url', ''));
if ($appHost !== null) {
$hosts[] = $appHost;
}
$disk = PublicAssetDisk::resolve();
if ($disk !== null) {
$diskHost = self::publicAssetDiskHost($disk);
if ($diskHost !== null && ! in_array($diskHost, $hosts, true)) {
$hosts[] = $diskHost;
}
}
return $hosts;
}
/**
* http/https 절대 URL 이고 그 host 가 사이트 자산 host 이면 true 를 돌려줍니다.
*
* 경로만 있는 값(`/api/...`)은 host 가 없어 이 판정의 대상이 아니고(호출측이 경로
* 규칙으로 다룹니다), protocol-relative(`//host/...`)와 http/https 밖의 스킴은 host 가
* 같아도 false 입니다 — 그 축은 종전 판정을 그대로 둡니다.
*
* @param string $url 검사 대상 URL
* @param array<int, string>|null $hosts host 목록 (미지정 시 self::hosts())
* @return bool 사이트 자산 host 의 http(s) 절대 URL 이면 true
*/
public static function isSiteAssetUrl(string $url, ?array $hosts = null): bool
{
$normalized = TrustedScriptHosts::normalizeForOriginCheck(trim($url));
if (preg_match('#^https?://#i', $normalized) !== 1) {
return false;
}
$host = TrustedScriptHosts::hostOf($normalized);
if ($host === null) {
return false;
}
$hosts ??= self::hosts();
return in_array($host, $hosts, true);
}
/**
* 공개 자산 디스크의 기준 URL host 를 해석합니다.
*
* 첨부 API 가 직접 URL 을 만드는 것과 같은 경로(`CoreStorageDriver::url()` — 디스크 url
* 설정과 `core.storage.filter_url` 훅을 모두 거친다)로 빈 경로의 URL 을 만들어 host 만
* 취합니다. 설정값을 직접 읽으면 훅이 바꾼 host 를 놓칩니다.
*
* @param string $disk PublicAssetDisk::resolve() 가 돌려준 디스크
* @return string|null 소문자 host (직접 URL 을 만들 수 없는 디스크면 null)
*/
private static function publicAssetDiskHost(string $disk): ?string
{
try {
// StorageInterface 는 컨텍스트 바인딩만 있어 컨테이너로 못 받는다 (ExtensionBundleService 와 같은 선례).
$base = (new CoreStorageDriver($disk))->url('', '');
} catch (\Throwable $e) {
Log::warning('SiteAssetHosts: 공개 자산 디스크 기준 URL 해석 실패 - '.$e->getMessage(), ['disk' => $disk]);
return null;
}
return is_string($base) ? TrustedScriptHosts::hostOf($base) : null;
}
}
+35
View File
@@ -214,4 +214,39 @@ $app = Application::configure(basePath: dirname(__DIR__))
});
})->create();
/*
| Core Update: Stale Package Manifest Self-Heal — 코어 업데이트 트리 안에서 부팅하는 프로세스
| (core:update 부모, G7_UPDATE_IN_PROGRESS=1 을 물려받은 spawn 자식, 그 안의 config:cache/route:cache
| 일회용 앱)는 bootstrap/cache/packages.php · services.php 를 비우고 부팅한다. Laravel 은 두 파일이
| 없을 때만 vendor/composer/installed.json 에서 다시 만들므로, vendor 교체 뒤 남은 이전 설치본의
| 목록(dev composer 의 require-dev 전이 provider 등)으로 부팅하다 "Class ... not found" 로 죽는 일을
| 막는다. 7.0.11 미만 부모는 spawn 직전에 비우지 않으므로 그 부모 아래에서 도는 신버전 자식의
| 유일한 방어다 (7.0.9/7.0.10 → 7.0.11). 7.0.11+ 부모는 이미 비우므로 여기서는 no-op.
| 규칙: App\ 클래스를 참조하지 않는다 (부팅 전이라 오토로드를 신뢰할 수 없고, 자가 치유가 자기
| 실패로 부팅을 막아서는 안 된다). 판정은 App\Support\CoreUpdateContext::isInProgress() 와 동일 —
| 조건을 바꾸면 양쪽을 함께 고친다.
| argv 채널은 명령줄 SAPI 에서만 읽는다. CGI/FPM 은 register_argc_argv=On 이면 $_SERVER['argv'] 를
| 쿼리스트링을 '+' 로 쪼갠 값으로 채우므로(`GET /?x+core:update` → argv[1] === 'core:update'), 이 게이트가
| 없으면 비인증 웹 요청이 요청마다 매니페스트를 지우고 다시 만들게 한다. env 플래그 채널은 웹 요청으로
| 주입할 수 없어 그대로 두며, 웹 요청 안에서 시작하는 업데이트 흐름은 그 플래그를 프로세스 안에서 세운다.
*/
$g7UpdateFlag = $_ENV['G7_UPDATE_IN_PROGRESS'] ?? $_SERVER['G7_UPDATE_IN_PROGRESS'] ?? getenv('G7_UPDATE_IN_PROGRESS');
$g7UpdateArgv = in_array(PHP_SAPI, ['cli', 'phpdbg'], true) ? ($_SERVER['argv'][1] ?? '') : '';
if ($g7UpdateFlag === '1' || $g7UpdateFlag === 1 || $g7UpdateFlag === true
|| in_array($g7UpdateArgv, ['core:update', 'core:execute-upgrade-steps'], true)) {
try {
foreach ([$app->getCachedPackagesPath(), $app->getCachedServicesPath()] as $g7ManifestPath) {
if (is_file($g7ManifestPath)) {
@unlink($g7ManifestPath); // Windows 핸들 점유 시 실패 가능 — 무시(종전 동작)
}
}
clearstatcache();
} catch (Throwable) {
// 자가 치유 실패는 부팅을 막지 않는다.
}
}
unset($g7UpdateFlag, $g7UpdateArgv, $g7ManifestPath);
return $app;
Generated
+412 -415
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -231,7 +231,7 @@ return [
|
*/
'version' => env('APP_VERSION', '7.0.10'),
'version' => env('APP_VERSION', '7.0.11'),
/*
|--------------------------------------------------------------------------
+36
View File
@@ -120,6 +120,42 @@ return [
'enabled' => env('G7_STATIC_CACHE', true),
],
/*
|--------------------------------------------------------------------------
| SEO 봇 캐시 상한
|--------------------------------------------------------------------------
| 봇 판정은 User-Agent 문자열뿐이라 위장이 가능하고, 캐시 키에 쿼리가 들어가므로
| 물음표 뒤 값만 바꾸면 매 요청이 미스가 됩니다. 미스 1건은 레이아웃 병합·표현식
| 평가·자기 API 루프백 호출을 유발하고 그 결과가 캐시에 쌓입니다.
|
| 아래 값이 그 증식을 막는 상한입니다. 렌더 예산을 넘긴 요청은 오류가 아니라
| 일반 SPA 응답을 받습니다(봇에게 오류를 주면 색인에서 URL 이 빠집니다).
|
| IP 단위 상한(render_misses_per_minute, stats_records_per_minute)은 요청 IP 를
| 기준으로 셉니다. 리버스 프록시·CDN 뒤에 두면서 TRUSTED_PROXIES 를 지정하지 않으면
| 모든 요청이 프록시 IP 하나로 보여 사이트 전체가 한 예산을 나눠 쓰게 되고, 정상
| 검색엔진 봇도 예산 초과 시점부터 SPA 를 받습니다. docs/backend/reverse-proxy.md 참조.
*/
'seo_cache_limits' => [
// 캐시 키에 허용하는 쿼리 파라미터 수 (초과 → 캐시·렌더 안 함)
'max_query_params' => (int) env('G7_SEO_CACHE_MAX_QUERY_PARAMS', 10),
// 정규화된 쿼리 문자열 길이 상한 (바이트)
'max_query_length' => (int) env('G7_SEO_CACHE_MAX_QUERY_LENGTH', 512),
// 같은 경로·언어에 대해 저장하는 쿼리 변종 수 상한 (언어별로 따로 센다)
'max_variants_per_path' => (int) env('G7_SEO_CACHE_MAX_VARIANTS_PER_PATH', 50),
// 캐시 인덱스 전체 항목 수 상한
'max_entries' => (int) env('G7_SEO_CACHE_MAX_ENTRIES', 20000),
// IP 당 분당 미스 렌더 수 (초과 → SPA + X-SEO-Cache: BYPASS)
'render_misses_per_minute' => (int) env('G7_SEO_RENDER_MISSES_PER_MINUTE', 60),
// IP 당 분당 통계 기록 수 (통계 테이블이 새 증식 축이 되지 않도록)
'stats_records_per_minute' => (int) env('G7_SEO_STATS_RECORDS_PER_MINUTE', 300),
],
/*
|--------------------------------------------------------------------------
| 아웃바운드 프록시 연결 테스트
@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* SEO 캐시 통계의 url 컬럼을 캐시 키 URL 길이에 맞춥니다.
*
* 캐시 키 URL 은 경로 + 정규화 쿼리(기본 최대 512바이트)라 255자를 넘을 수 있는데,
* 컬럼이 255자면 그 기록이 엄격 모드에서 실패하고 통계 서비스는 그 예외를 삼킨다 —
* 긴 주소의 봇 요청은 통계에서 빠지고 요청마다 실패하는 INSERT 만 남는다.
* 768 은 utf8mb4 인덱스 키 상한(3072바이트)에 맞춘 값이다 — `idx_seo_cache_stats_url`
* 이 이 컬럼에 걸려 있다.
*
* @return void
*/
public function up(): void
{
Schema::table('seo_cache_stats', function (Blueprint $table) {
$table->string('url', 768)->comment('요청 URL (경로 + 정규화 쿼리)')->change();
});
}
/**
* url 컬럼을 원래 길이(255)로 되돌립니다.
*
* 되돌리기 전에 255자를 넘는 행을 지운다 — 엄격 모드에서는 잘리는 값이 하나라도 있으면
* ALTER 자체가 실패한다. 통계 행은 파생 데이터라 손실이 복구 대상이 아니다.
*
* @return void
*/
public function down(): void
{
DB::table('seo_cache_stats')->whereRaw('CHAR_LENGTH(url) > 255')->delete();
Schema::table('seo_cache_stats', function (Blueprint $table) {
$table->string('url', 255)->comment('요청 URL')->change();
});
}
};
+11 -1
View File
@@ -225,7 +225,17 @@ G7은 레이아웃 JSON 보안을 위해 **10개의 Custom Validation Rule**을
**추가 차단**: `//`로 시작하는 프로토콜 상대 URL
**검증 범위**: `components[]` → `props`, `actions` 내 모든 문자열 값을 재귀적으로 스캔
**외부의 기준**: 사이트 자기 host(`app.url`)와 운영자가 「공개 자산 스토리지」로 선언한 디스크의 host 는
외부가 아닙니다. 레이아웃 첨부 API 가 스스로 발급하는 주소(공개 서빙 URL·직접 URL)가 그 host 를
쓰므로, 여기서 차단하면 image 위젯으로 올린 파일이 업로드는 되고 저장은 422 가 됩니다. 판정은
접두 문자열 비교가 아니라 브라우저와 같은 정규화 뒤의 host 등가 비교입니다(`App\Support\SiteAssetHosts`).
`host.evil.com`·`host@evil.com`·`evil.com\@host` 같은 흉내 host 는 통과하지 않으며, 요청의 `Host` 헤더는
위조 가능하므로 근거로 쓰지 않습니다. 프로토콜 상대 URL 과 http/https 밖의 스킴은 host 가 같아도
차단됩니다.
**검증 범위**: `components[]` → `props`, `actions`, `lifecycle`, `onComponentEvent`, `slots`, `component_layout`,
`responsive` 와 최상위 `init_actions`/`modals`/`named_actions`/`errorHandling` 내 모든 문자열 값을 재귀적으로 스캔.
`style` 은 스캔 대상이 아닙니다 — 배경 이미지(`backgroundImage`)가 절대 URL 이어도 통과하는 이유입니다.
### 5. ValidParentLayout
+2 -2
View File
@@ -219,14 +219,14 @@ CSS 가 아닌 자산은 바이트 그대로 서빙됩니다. 정적 게시본(`
## 코어 API 레퍼런스
<!-- @generated:start:api-readme-index -->
- **문서 수**: 36 · **엔드포인트 수**: 325
- **문서 수**: 36 · **엔드포인트 수**: 328
| 문서 | 도메인 | 엔드포인트 |
| --- | --- | --- |
| [activity-logs.md](activity-logs.md) | `activity-logs` | 3 |
| [attachment.md](attachment.md) | `attachment` | 1 |
| [attachments.md](attachments.md) | `attachments` | 4 |
| [auth.md](auth.md) | `auth` | 15 |
| [auth.md](auth.md) | `auth` | 18 |
| [avatar.md](avatar.md) | `avatar` | 2 |
| [broadcasting.md](broadcasting.md) | `broadcasting` | 1 |
| [changelog.md](changelog.md) | `changelog` | 1 |
+244 -2
View File
@@ -339,6 +339,8 @@ HTTP/1.1 200
| 403 | Forbidden | 자격 증명은 맞지만 관리자 역할이 아닌 경우 (`auth.admin_required`) |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 423 | Locked | 로그인 실패 누적으로 계정이 잠긴 경우 (`auth.account_locked` — `error.locked_until`, `error.retry_after_seconds` 포함) |
| 429 | Too Many Requests | `throttle:auth-login` 초과 (`auth.too_many_attempts`) |
| 503 | Service Unavailable | 2단계 인증이 켜져 있고 인증번호를 보내지 못한 경우 (`auth.two_factor_delivery_failed`) |
<!-- @generated:end -->
@@ -346,6 +348,169 @@ HTTP/1.1 200
관리자 로그인. `email`/`password` 검증 후 `AuthService::login()` 이 인증하고, 인증 사용자가 `isAdmin()` 이 아니면 `403 auth.admin_required` 로 거부한다. 성공 시 `data.token`(Sanctum Bearer) 과 `data.user`(UserResource) 를 반환한다. 계정 잠금 시 `AccountLockedException`, 자격 불일치 시 `422` 검증 오류를 반환한다. 이후 모든 관리자 API 호출은 이 토큰을 `Authorization: Bearer` 헤더로 실어야 한다.
**403 거부는 이미 발급된 세션을 회수한다.** `AuthService::login()` 은 관리자 판정보다 먼저 토큰과 web 세션을 발급하므로, 거부하면서 그대로 두면 관리자가 아닌 사용자가 응답만 `403` 을 받을 뿐 유효한 세션을 손에 쥔다. 거부 경로는 `AuthService::revokeIssuedSession()` 으로 그 발급분을 되돌린다.
**2단계 인증이 켜져 있는 경우**: 사용자 로그인과 동일하게 `200` + `message: auth.two_factor_required` 와 `two_factor_required` / `challenge_id` / `provider_id` / `expires_at` 을 반환한다(필드 정의는 `POST /api/auth/login` 의 같은 절 참조). 이 단계에서는 아직 사용자도 토큰도 없으므로 **관리자 판정을 하지 않는다** — 판정은 `POST /api/auth/admin/login/two-factor` 가 코드 확인에 성공한 뒤에 수행한다.
### POST /api/auth/admin/login/two-factor
<!-- @generated:start:api.auth.admin.login.two-factor -->
- **라우트명**: `api.auth.admin.login.two-factor`
- **컨트롤러**: `App\Http\Controllers\Api\Admin\AuthController@verifyTwoFactor`
- **인증/권한**: 공개 (인증 불필요 — 주체는 challenge 가 식별한다)
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| challenge_id | body | string | 예 | uuid | 관리자 로그인 응답이 돌려준 challenge 식별자 |
| code | body | string | 예 | min 4, max 16 | 사용자가 받은 인증 코드 |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.two_factor_validation_rules`).
**요청 예시**
```http
POST /api/auth/admin/login/two-factor HTTP/1.1
Host: api.example.com
Accept: application/json
Content-Type: application/json
{
"challenge_id": "9f1c2f2e-0b3a-4f0a-9a1e-5c1b7f9d2c40",
"code": "135790"
}
```
**응답 필드** (`data` 내부)
_단건 응답: `POST /api/auth/admin/login` 의 성공 페이로드와 동일하다._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| user | object | `{"uuid":"a234c2b1-…","is_admin":true, …}` | 로그인한 관리자 정보 (`UserResource`) |
| token | string | `75\|WgPUplvLGTv8YIj4507uIR6dEOHTXyNUed…` | 발급된 Sanctum 접근 토큰 평문 |
| token_type | string | `Bearer` | 토큰 타입 (항상 `Bearer`) |
**응답 예시**
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "관리자 로그인이 성공했습니다.",
"data": {
"user": {
"uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d",
"name": "API 문서 샘플 사용자",
"email": "apidoc-sample-user@example.com",
"status": "active",
"is_admin": true,
"is_owner": true
},
"token": "{MASKED}",
"token_type": "Bearer"
}
}
```
> `user` 객체는 지면 절약을 위해 축약했습니다. 실제로는 `UserResource` 필드 전수가 내려옵니다.
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthorized | 코드가 틀렸거나(`auth.two_factor_failed`), challenge 의 `purpose` 가 `login` 이 아니거나, 확인된 사용자가 없거나 `active` 상태가 아닌 경우 |
| 403 | Forbidden | 코드 확인은 통과했으나 관리자 역할이 아닌 경우 (`auth.admin_required`) |
| 422 | Unprocessable Entity | `challenge_id`/`code` 형식 위반 |
| 423 | Locked | 로그인 실패 누적으로 계정이 잠긴 경우. 응답 형태는 `POST /api/auth/login` 의 423 과 동일 |
| 429 | Too Many Requests | `throttle:auth-login` 초과 (`auth.too_many_attempts`) |
<!-- @generated:end -->
**설명**
관리자 로그인의 인증번호 확인 단계. 사용자 경로(`POST /api/auth/login/two-factor`)와 같은 규칙으로 코드를 확인하고, **확인에 성공한 뒤에** 관리자 여부를 판정한다.
`AuthService::completeTwoFactor()` 는 코드 확인에 성공한 시점에 토큰을 발급하므로, 관리자 판정으로 `403` 을 돌려줄 때는 반드시 그 발급분을 회수한다(`revokeIssuedSession()`). 회수하지 않으면 관리자가 아닌 사용자가 응답만 `403` 을 받을 뿐 유효한 세션을 손에 쥔다.
### POST /api/auth/admin/login/two-factor/resend
<!-- @generated:start:api.auth.admin.login.two-factor.resend -->
- **라우트명**: `api.auth.admin.login.two-factor.resend`
- **컨트롤러**: `App\Http\Controllers\Api\Admin\AuthController@resendTwoFactor`
- **인증/권한**: 공개 (인증 불필요 — 주체는 challenge 가 식별한다)
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| challenge_id | body | string | 예 | uuid | 관리자 로그인 응답이 돌려준 challenge 식별자 |
**요청 예시**
```http
POST /api/auth/admin/login/two-factor/resend HTTP/1.1
Host: api.example.com
Accept: application/json
Content-Type: application/json
{
"challenge_id": "9f1c2f2e-0b3a-4f0a-9a1e-5c1b7f9d2c40"
}
```
**응답 필드** (`data` 내부)
_`POST /api/auth/login/two-factor/resend` 와 동일한 형태다._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| two_factor_required | boolean | `true` | 항상 `true` |
| challenge_id | string(uuid) | `9f1c2f2e-0b3a-…` | **새** challenge 식별자 (이전 값은 취소됨) |
| provider_id | string | `g7:core.mail` | 코드를 발송한 본인인증 프로바이더 |
| expires_at | string(ISO8601)\|null | `2026-09-07T14:03:00+09:00` | 새 challenge 만료 시각 |
**응답 예시**
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "인증번호를 보냈습니다. 받은 번호를 입력해 로그인을 완료해주세요.",
"data": {
"two_factor_required": true,
"challenge_id": "9f1c2f2e-0b3a-4f0a-9a1e-5c1b7f9d2c40",
"provider_id": "g7:core.mail",
"expires_at": "2026-09-07T14:03:00+09:00"
}
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 403 | Forbidden | challenge 가 식별한 사용자가 관리자 역할이 아닌 경우 (`auth.admin_required`) |
| 422 | Unprocessable Entity | 재발송 대상이 아닌 challenge (`auth.two_factor_invalid_challenge`) — 사유는 구분하지 않는다 |
| 423 | Locked | challenge 를 받은 뒤 계정이 잠긴 경우 |
| 429 | Too Many Requests | `throttle:auth-login` 초과 (`auth.too_many_attempts`) |
| 503 | Service Unavailable | 새 인증번호를 보내지 못한 경우 (`auth.two_factor_delivery_failed`) |
<!-- @generated:end -->
**설명**
관리자 로그인의 인증번호 재발송. 규칙은 `POST /api/auth/login/two-factor/resend` 와 동일하다 — 기존 challenge 를 취소하고 새로 발행하므로 앞서 받은 인증번호는 통하지 않는다.
이 단계는 토큰을 발급하지 않으므로 회수할 것이 없다. 다만 완료할 수 없는 상대에게 새 인증번호를 계속 보내지는 않으므로, challenge 가 식별한 사용자가 관리자가 아니면 `403 auth.admin_required` 로 거부한다. 발급 자체를 막는 것이 아니라 **재발송만** 막는 것이며, 비밀번호 확인 단계(`POST /api/auth/admin/login`)는 종전대로 관리자 판정 없이 challenge 를 돌려준다.
### POST /api/auth/forgot-password
<!-- @generated:start:api.auth.forgot-password -->
@@ -488,6 +653,8 @@ HTTP/1.1 200
| 401 | Unauthenticated | 이메일/비밀번호가 일치하지 않거나 계정 상태가 활성이 아닌 경우 (`auth.login_failed`) |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 423 | Locked | 로그인 실패 누적으로 계정이 잠긴 경우 (`auth.account_locked` — `error.locked_until`, `error.retry_after_seconds` 포함) |
| 429 | Too Many Requests | `throttle:auth-login` 초과 (`auth.too_many_attempts` — `Retry-After` 헤더 동반) |
| 503 | Service Unavailable | 2단계 인증이 켜져 있고 인증번호를 보내지 못한 경우 (`auth.two_factor_delivery_failed`) |
<!-- @generated:end -->
@@ -504,7 +671,9 @@ HTTP/1.1 200
| `provider_id` | string | 코드를 발송한 본인인증 프로바이더 |
| `expires_at` | string(ISO8601)\|null | challenge 만료 시각 |
이 응답에는 `data.token` 과 `data.user` 가 없다. 토큰 존재 여부로 로그인 완료를 판정하는 클라이언트는 그대로 동작한다.
이 응답에는 `data.token` 과 `data.user` 가 없다. **클라이언트는 `two_factor_required` 를 먼저 판정해야 한다** — 응답 형태가 하나라고 가정하고 `data.user.*` 를 읽으면 그 자리에서 예외가 나고, `data.token` 을 그대로 저장하면 `"undefined"` 문자열이 남아 이후 모든 요청이 `401` 로 튕긴다(공개 #133). 코어 클라이언트(`AuthManager.login()`)는 `LoginResult` 판별 유니온으로 두 형태를 구분해 돌려준다.
**인증번호를 보내지 못한 경우**: 자격 증명은 올바르지만 코드를 전달할 수단이 없으므로 로그인을 완료할 수 없다. 이때는 `401`(자격 증명 오류)이 아니라 `503 auth.two_factor_delivery_failed` 를 반환한다 — `401` 로 뭉뚱그리면 사용자는 비밀번호를 의심하며 같은 실패를 반복하고, 운영자는 메일 설정이 깨진 사실을 알 방법이 없다. 발송에 실패해도 2단계 인증을 건너뛰고 로그인시키지는 않는다.
### POST /api/auth/login/two-factor
@@ -590,7 +759,7 @@ HTTP/1.1 200
| 401 | Unauthorized | 코드가 틀렸거나(`auth.two_factor_failed`), challenge 의 `purpose` 가 `login` 이 아니거나, 확인된 사용자가 없거나 `active` 상태가 아닌 경우. **세 사유를 같은 응답으로 뭉뚱그린다** — 구분해 내보내면 challenge 유효성 탐색에 쓰인다 |
| 422 | Unprocessable Entity | `challenge_id`/`code` 형식 위반 |
| 423 | Locked | 로그인 실패 누적으로 계정이 잠긴 경우. 응답 형태는 `POST /api/auth/login` 의 423 과 동일하다 (`auth.account_locked` / 무기한이면 `auth.account_locked_permanently` — `errors.locked_until`, `errors.retry_after_seconds`, `errors.permanent`) |
| 429 | Too Many Requests | `throttle:auth-login` 초과 (로그인과 같은 제한을 공유하므로 코드 대입 시도도 함께 억제된다) |
| 429 | Too Many Requests | `throttle:auth-login` 초과 (로그인과 같은 제한을 공유하므로 코드 대입 시도도 함께 억제된다 — `auth.too_many_attempts`) |
<!-- @generated:end -->
@@ -603,6 +772,79 @@ challenge 의 `purpose` 가 `login` 인지 먼저 대조한다 — 대조하지
**계정 잠금은 이 단계에서 다시 검사한다.** 세션을 여는 것은 비밀번호 단계가 아니라 이 엔드포인트이므로, challenge 를 받은 뒤 잠긴 계정은 여기서 `423` 으로 차단된다. 잠기기 전에 발급받은 challenge 를 잠긴 뒤에 완료하는 것만으로 잠금을 우회할 수 없다. 차단은 로그인 완료 훅(`core.auth.after_login`)보다 앞서므로 실패 횟수·잠금 해제 시각도 초기화되지 않는다.
### POST /api/auth/login/two-factor/resend
<!-- @generated:start:api.auth.login.two-factor.resend -->
- **라우트명**: `api.auth.login.two-factor.resend`
- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@resendTwoFactor`
- **인증/권한**: 공개 (인증 불필요)
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| challenge_id | body | string | 예 | uuid | 로그인 응답이 돌려준 challenge 식별자 |
**요청 예시**
```http
POST /api/auth/login/two-factor/resend HTTP/1.1
Host: api.example.com
Accept: application/json
Content-Type: application/json
{
"challenge_id": "9f1c2f2e-0b3a-4f0a-9a1e-5c1b7f9d2c40"
}
```
**응답 필드** (`data` 내부)
_단건 응답: `data` 객체의 필드 (`AuthService::resendTwoFactorChallenge()` 가 새로 발행한 challenge — `POST /api/auth/login` 의 2단계 인증 응답과 같은 형태)._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| two_factor_required | boolean | `true` | 항상 `true` — 여전히 추가 확인 단계임을 나타낸다 |
| challenge_id | string(uuid) | `9f1c2f2e-0b3a-…` | **새** challenge 식별자. 이전 값은 취소되었으므로 반드시 교체해야 한다 |
| provider_id | string | `g7:core.mail` | 코드를 발송한 본인인증 프로바이더 |
| expires_at | string(ISO8601)\|null | `2026-09-07T14:03:00+09:00` | 새 challenge 만료 시각 |
**응답 예시**
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "인증번호를 보냈습니다. 받은 번호를 입력해 로그인을 완료해주세요.",
"data": {
"two_factor_required": true,
"challenge_id": "9f1c2f2e-0b3a-4f0a-9a1e-5c1b7f9d2c40",
"provider_id": "g7:core.mail",
"expires_at": "2026-09-07T14:03:00+09:00"
}
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 422 | Unprocessable Entity | `challenge_id` 형식 위반, 또는 재발송 대상이 아닌 challenge (`auth.two_factor_invalid_challenge`) — 존재하지 않음 / `purpose` 가 `login` 이 아님 / 이미 검증·취소·실패 / 만료 / 대상 사용자가 없거나 `active` 가 아님. **사유를 구분하지 않는다** — 구분해 내보내면 challenge 유효성 탐색에 쓰인다 |
| 423 | Locked | challenge 를 받은 뒤 계정이 잠긴 경우. 응답 형태는 `POST /api/auth/login` 의 423 과 동일하다 |
| 429 | Too Many Requests | `throttle:auth-login` 초과 (`auth.too_many_attempts`) |
| 503 | Service Unavailable | 새 인증번호를 보내지 못한 경우 (`auth.two_factor_delivery_failed`) |
<!-- @generated:end -->
**설명**
인증번호를 받지 못했을 때 새 코드를 발행한다. 서버는 **기존 challenge 를 취소하고 새로 발행**하므로, 앞서 받은 인증번호는 더 이상 통하지 않는다 — 유효한 코드를 여러 개 동시에 살려 두면 대입 시도의 표적이 넓어진다. 클라이언트는 응답의 `challenge_id` 로 반드시 교체하고 입력란을 비워야 한다.
계정 잠금은 여기서도 다시 검사한다. challenge 를 받은 뒤 잠긴 계정에는 새 코드를 보내지 않는다. 로그인과 같은 요청 제한(`throttle:auth-login`)이 걸린다.
### POST /api/auth/logout
<!-- @generated:start:api.auth.logout -->
- **라우트명**: `api.auth.logout`
+1 -1
View File
@@ -100,7 +100,7 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| update_available | boolean | `false` | 새 버전 존재 여부 (최신 릴리스 버전이 현재 버전보다 높으면 `true`) |
| current_version | string | `7.0.3` | 현재 설치된 코어 버전 (`config('app.version')`) |
| current_version | string | `7.0.3` | 현재 설치된 코어 버전 (프로세스 환경값이 아니라 설정의 버전 — `config('app.version')`) |
| latest_version | string | `7.0.3` | GitHub 릴리스에서 조회한 최신 코어 버전 (조회 값이 없으면 현재 버전과 동일) |
| github_url | string | `https://github.com/gnuboard/g7` | 업데이트 조회 대상 GitHub 저장소 URL (`config('app.update.github_url')`) |
+4 -4
View File
@@ -2518,13 +2518,13 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | Bearer 토큰을 보냈으나 만료/무효인 경우 (비회원은 헤더 생략 가능) |
| 403 | Forbidden | 요구 권한(`core.identity.cancel`)이 없는 경우, 또는 scope=self 가드가 본인 challenge 가 아니라고 판정한 경우 |
| 403 | Forbidden | 요구 권한(`core.identity.cancel`)이 없는 경우, scope=self 가드가 본인 challenge 가 아니라고 판정한 경우, 또는 challenge 의 `purpose` 가 `login` 인 경우 (`identity.errors.purpose_not_allowed` — `errors.failure_code = PURPOSE_NOT_ALLOWED`) |
| 404 | Not Found | path 의 challenge 를 찾을 수 없거나 취소 처리에 실패한 경우 (`유효하지 않은 인증 요청입니다.`) |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
<!-- @generated:end -->
**설명** 진행 중인 challenge 를 취소합니다. `auth:sanctum` + `core.identity.cancel` 권한이 필요합니다. 라우트 모델 바인딩 + PermissionMiddleware 의 scope=self 가드로 로그인 사용자는 본인 challenge 만 취소할 수 있으며, 비로그인 게스트는 guest 역할 권한으로 진입합니다(모달 취소 시 audit trail 정합용). `IdentityVerificationService::cancel` 이 처리하고 대상 challenge 가 없으면 404 를 반환합니다. 사용자가 인증 모달을 닫을 때 서버 상태를 cancelled 로 남겨 이력 정합성을 맞추는 데 사용합니다.
**설명** 진행 중인 challenge 를 취소합니다. **로그인 2단계 인증 challenge(`purpose = login`)는 이 경로로 취소할 수 없습니다** — 취소되면 그 challenge 로는 더 이상 로그인을 마칠 수 없게 되어, 사용자가 자기 로그인을 스스로 막는 상태가 됩니다(자기 DoS). 로그인 흐름은 `POST /api/auth/login/two-factor` · `.../resend` 만 사용합니다. `auth:sanctum` + `core.identity.cancel` 권한이 필요합니다. 라우트 모델 바인딩 + PermissionMiddleware 의 scope=self 가드로 로그인 사용자는 본인 challenge 만 취소할 수 있으며, 비로그인 게스트는 guest 역할 권한으로 진입합니다(모달 취소 시 audit trail 정합용). `IdentityVerificationService::cancel` 이 처리하고 대상 challenge 가 없으면 404 를 반환합니다. 사용자가 인증 모달을 닫을 때 서버 상태를 cancelled 로 남겨 이력 정합성을 맞추는 데 사용합니다.
### POST /api/identity/challenges/{challenge}/verify
@@ -2593,13 +2593,13 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | Bearer 토큰을 보냈으나 만료/무효인 경우 (비회원은 헤더 생략 가능) |
| 403 | Forbidden | 요구 권한(`core.identity.verify`)이 없는 경우, 또는 scope=self 가드가 본인 challenge 가 아니라고 판정한 경우 |
| 403 | Forbidden | 요구 권한(`core.identity.verify`)이 없는 경우, scope=self 가드가 본인 challenge 가 아니라고 판정한 경우, 또는 challenge 의 `purpose` 가 `login` 인 경우 (`identity.errors.purpose_not_allowed` — `errors.failure_code = PURPOSE_NOT_ALLOWED`) |
| 404 | Not Found | path 파라미터에 해당하는 challenge 가 없는 경우 |
| 422 | Unprocessable Entity | 검증 실패 — 응답 `error` 에 `failure_code`(예: `INVALID_CODE`/`EXPIRED`/`MAX_ATTEMPTS`) 와 서버 기준 `attempts`/`max_attempts` 를 함께 반환. 메시지는 `identity.errors.*` (`인증 코드가 올바르지 않습니다.` / `인증 시간이 만료되었습니다. 다시 시도해주세요.` / `시도 횟수를 초과했습니다. 다시 요청해주세요.` 등) |
<!-- @generated:end -->
**설명** challenge 를 검증(인증 완료) 합니다. `auth:sanctum` + `core.identity.verify` 권한이 필요합니다. 라우트 모델 바인딩 + PermissionMiddleware 의 scope=self 가드로 로그인 사용자는 본인 challenge 만 검증하며, 비로그인 게스트는 guest 역할 권한으로 진입합니다(Mode B 가입 흐름). `code`(text_code 흐름) 또는 `token`(link/redirect 흐름) 을 전달하고 `IdentityVerificationService::verify` 가 처리합니다. 실패 시 422 로 `failure_code` 와 서버 기준 `attempts`/`max_attempts` 를 함께 내려 클라이언트의 "남은 시도 횟수" UI 를 서버와 동기화하며, 성공 시 후속 민감 작업에 제출할 `verification_token` 을 반환합니다. 확장은 `core.identity.verify_validation_rules` 필터 훅으로 파라미터를 추가할 수 있습니다.
**설명** challenge 를 검증(인증 완료) 합니다. **로그인 2단계 인증 challenge(`purpose = login`)는 이 경로로 검증할 수 없습니다** — 여기서 검증되면 바로 뒤의 `POST /api/auth/login/two-factor` 가 「이미 처리된 요청」으로 거절해 그 challenge 로는 영영 로그인할 수 없게 됩니다. 게이트는 상태를 전혀 바꾸지 않으므로, 거부된 뒤에도 로그인 경로로 정상 완료할 수 있습니다. `auth:sanctum` + `core.identity.verify` 권한이 필요합니다. 라우트 모델 바인딩 + PermissionMiddleware 의 scope=self 가드로 로그인 사용자는 본인 challenge 만 검증하며, 비로그인 게스트는 guest 역할 권한으로 진입합니다(Mode B 가입 흐름). `code`(text_code 흐름) 또는 `token`(link/redirect 흐름) 을 전달하고 `IdentityVerificationService::verify` 가 처리합니다. 실패 시 422 로 `failure_code` 와 서버 기준 `attempts`/`max_attempts` 를 함께 내려 클라이언트의 "남은 시도 횟수" UI 를 서버와 동기화하며, 성공 시 후속 민감 작업에 제출할 `verification_token` 을 반환합니다. 확장은 `core.identity.verify_validation_rules` 필터 훅으로 파라미터를 추가할 수 있습니다.
### GET /api/identity/policies/resolve
+2 -2
View File
@@ -345,7 +345,7 @@ _단건 응답: `data` 는 병합된 레이아웃 JSON 객체입니다 (상속
| pageConfig / schema | object | `{}` | 플러그인 설정 레이아웃 전용 (설정 UI 안내/스키마) |
| lock_version | integer | `3` | 낙관적 잠금 버전. `with_source_meta=1` 일 때만 부착 (편집기 저장 시 `expected_lock_version` 으로 되돌려 보냄) |
| __editor | object | `{"original": { ... }}` | 자식 레이아웃의 저장 원본 content. `with_source_meta=1` 일 때만 부착 (편집기 전용) |
| __source (각 노드 내부) | object | `{"kind":"base","layout":"_user_base"}` | 각 컴포넌트/데이터소스 노드의 출처 메타 (`base` / `extension` / `partial` / `route`). `with_source_meta=1` 일 때만 부착 |
| __source (각 노드 내부) | object | `{"kind":"base","layout":"_user_base"}` | 각 컴포넌트/데이터소스 노드의 출처 메타 (`base` / `extension` / `partial` / `route`). `with_source_meta=1` 일 때만 부착. `extension` 출처가 overlay 주입이면 `injectionIndex`(그 확장 content 의 `injections[]` 순번)를 함께 실어 편집기가 저장 시 원래 injection 으로 되돌린다 |
응답 헤더: `ETag`(본문 md5), `Cache-Control: public, max-age=3600`, `Vary: Accept-Encoding, Accept-Language`. 클라이언트 `If-None-Match` 가 일치하면 본문 없이 `304 Not Modified` 를 반환합니다.
@@ -394,6 +394,6 @@ _단건 응답: `data` 는 병합된 레이아웃 JSON 객체입니다 (상속
<!-- @generated:end -->
**설명** 활성 템플릿의 병합된 레이아웃 JSON을 프론트엔드에 서빙합니다. 템플릿이 존재하고 활성 상태여야 하며, 상속 병합·확장 적용을 마친 결과를 ETag·Cache-Control 헤더와 함께 반환하고 미변경 시 304로 응답합니다. 레이아웃의 `permissions`에 따라 접근을 제한하고(비회원 401, 권한 부족 403), 컴포넌트 단위 권한 필터링을 사용자별로 적용합니다. 쿼리 `v`(정수 캐시 버전)로 캐시를 구분하며, `with_source_meta=1`은 `core.templates.layouts.edit` 권한이 있어야 노드별 출처 메타(편집기 전용)를 포함해 반환합니다.
**설명** 활성 템플릿의 병합된 레이아웃 JSON을 프론트엔드에 서빙합니다. 템플릿이 존재하고 활성 상태여야 하며, 상속 병합·확장 적용을 마친 결과를 ETag·Cache-Control 헤더와 함께 반환하고 미변경 시 304로 응답합니다. 레이아웃의 `permissions`에 따라 접근을 제한하고(비회원 401, 권한 부족 403), 컴포넌트 단위 권한 필터링을 사용자별로 적용합니다. 쿼리 `v` 는 브라우저 HTTP 캐시 우회용 좌표이며(레이아웃 편집기는 `{cache_version}.{nonce}` 형식으로 보냄), 서버 캐시 키는 요청의 `v` 와 무관하게 서버 현재 확장 캐시 버전으로만 만들어져 저장·복원 직후의 재요청이 항상 최신 내용을 받습니다. `with_source_meta=1`은 `core.templates.layouts.edit` 권한이 있어야 노드별 출처 메타(편집기 전용)를 포함해 반환합니다.
+3 -3
View File
@@ -94667,7 +94667,7 @@ _목록 응답: `data` 는 첨부 항목 배열입니다 (페이지네이션 없
| original_name | string | `hero-bg.png` | 업로드 당시 원본 파일명 |
| mime_type | string | `image/png` | 파일 MIME 타입 |
| size | integer | `204800` | 파일 크기 (바이트) |
| url | string | `/storage/template-layout-attachments/sirsoft-basic/hero-bg.png` | 첨부 파일 접근 URL |
| url | string | `/api/templates/sirsoft-basic/layout-attachments/1/file` | 첨부 파일 접근 URL. 기본은 공개 서빙 라우트(프록시)의 **사이트 상대 경로**(`/api/...`)이고, 관리자 환경설정의 공개 자산 스토리지를 켠 뒤 그 디스크에 올라간 첨부만 직접 URL(CDN 절대 주소)로 내려온다. 어느 형태든 레이아웃 저장의 외부 URL 차단 규칙을 통과한다(사이트 자기 host 와 선언된 공개 자산 디스크 host 는 외부가 아니다) |
| created_at | string | `2026-07-14T10:00:00+09:00` | 업로드 일시 (ISO 8601) |
**응답 예시**
@@ -94748,7 +94748,7 @@ _단건 응답: `data` 객체의 필드._
| original_name | string | `hero-bg.png` | 업로드된 원본 파일명 |
| mime_type | string | `image/png` | 파일 MIME 타입 |
| size | integer | `204800` | 파일 크기 (바이트) |
| url | string | `/storage/template-layout-attachments/sirsoft-basic/hero-bg.png` | 업로드된 파일의 접근 URL (편집기 ImagePickerControl 이 사용) |
| url | string | `/api/templates/sirsoft-basic/layout-attachments/1/file` | 업로드된 파일의 접근 URL (편집기 ImagePickerControl 이 값으로 그대로 쓴다). 기본은 공개 서빙 라우트(프록시)의 **사이트 상대 경로**(`/api/...`)이고, 공개 자산 스토리지를 켜 그 디스크에 저장된 경우에만 직접 URL(CDN 절대 주소)이다. 어느 형태든 레이아웃 저장의 외부 URL 차단 규칙을 통과한다 — 절대 프록시 URL 이던 시절 로고처럼 props 에 들어가는 값은 저장 시 422 였다 |
**응답 예시**
@@ -94762,7 +94762,7 @@ _단건 응답: `data` 객체의 필드._
"original_name": "hero-bg.png",
"mime_type": "image/png",
"size": 204800,
"url": "/storage/template-layout-attachments/sirsoft-basic/hero-bg.png"
"url": "/api/templates/sirsoft-basic/layout-attachments/1/file"
}
}
```
+33 -4
View File
@@ -9,7 +9,8 @@
2. 마이그레이션 = 스키마 변경만, 데이터 백필/변환 = 업그레이드 스텝 (역할 분리 필수)
3. 업데이트 실행 흐름: 11단계 (감지 → 다운로드 → 백업 → 적용 → 마이그레이션 → 동기화 → 업그레이드 → 마무리)
4. 롤백: CoreBackupHelper로 백업 생성, 실패 시 자동 복원
5. 부트스트랩 호환성 검증: .env의 APP_VERSION < 확장 g7_version 시 자동 비활성화 (1시간 캐시)
5. 부트스트랩 호환성 검증: 코어 버전 < 확장 g7_version 시 자동 비활성화 (1시간 캐시)
6. vendor 를 교체한 뒤 새 프로세스를 띄우기 전에는 패키지 매니페스트를 비운다 (3계층 — 부모 선정리 / 자식 자가 치유 / 버전 판독 범위)
```
---
@@ -72,6 +73,7 @@
```text
1. CoreVersionChecker::getCoreVersion() → config('app.version') 읽기
(코어 업데이트 트리 안에서만 env APP_VERSION 우선 — CoreUpdateContext::isInProgress())
2. CoreUpdateService::checkForUpdates() → GitHub API로 최신 릴리스 조회
3. version_compare(current, latest) → 업데이트 가용 여부 판단
4. 원격 CHANGELOG 캐시 → storage/app/temp/core_remote_changelog.md
@@ -168,6 +170,9 @@ v접두사 자동 감지 (resolveGithubArchiveUrl):
- composer.json + composer.lock 의 MD5 비교 (_pending vs base_path)
- 동일 → "composer 의존성 변경 없음 — 스킵" (Step 6 + Step 8 모두 스킵)
- 변경됨 → composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
- 운영 vendor 의 개발용(require-dev) 패키지 감지 → 로그 기록
· 재설치 분기: "--no-dev vendor 로 교체합니다" (정보)
· 스킵 분기: "그대로 남습니다 … composer install --no-dev 실행 권장" (경고)
3. Bundled 모드 (신규, 공유 호스팅 대응):
- _pending/vendor-bundle.zip 무결성 검증 (SHA256)
@@ -265,10 +270,26 @@ v접두사 자동 감지 (resolveGithubArchiveUrl):
> **단독 실행 안전성 (beta.6 이후)**: `core:execute-upgrade-steps` 는 HANDOFF 안내 또는 수동 복구 목적으로 운영자가 직접 호출되는 경로가 있다. 단독 실행 시 자식은 기본값으로 부모 Step 9 (`runMigrations` + `reloadCoreConfigAndResync`), Step 11 (`updateVersionInEnv` + `clearAllCaches`), Step 12 (번들 확장 일괄 업데이트) 를 자체적으로 수행해 단일 명령으로 업그레이드를 완결한다. 부모 `CoreUpdateCommand::spawnUpgradeStepsProcess()` 는 자식 명령 라인에 `--skip-migrations`, `--skip-resync`, `--skip-version-env`, `--skip-cache-clear`, `--skip-bundled-updates` 5개를 무조건 추가해 중복 회피한다 — 부모가 자식 종료 후 동일 단계를 직접 수행하기 때문이다.
> **spawn 자식은 이전 버전의 config 캐시로 부팅한다**: 부모는 Step 10(spawn) 전에 config 캐시를 비우지 않는다 — `clearAllCaches()` 는 Step 11 이다. 그래서 이전 버전 설치본에 `bootstrap/cache/config.php` 가 있으면(설치 마법사·설정 저장·확장 업데이트가 만든다) 자식은 그 캐시로 부팅하고, 자식의 `config('app.version')` 은 부모가 env 로 넘긴 `APP_VERSION={toVersion}` 이 아니라 캐시에 박힌 fromVersion 이다. 업데이트 흐름 안에서 "지금 프로세스의 코어 버전" 을 판정하는 코드는 `config('app.version')` 을 직접 읽지 않고 `CoreVersionChecker::getCoreVersion()`(env 우선, config 폴백)을 쓴다. `runUpgradeSteps()` 의 stale 메모리 가드가 config 만 읽던 시절에는 정상 spawn 자식을 stale 부모로 오판해 스텝이 0건인 릴리즈에서도 핸드오프로 중단됐다(7.0.9→7.0.10). 부모 in-process fallback 에서는 env 가 `.env` 의 fromVersion 이므로 가드는 그대로 발동한다.
> **spawn 자식은 이전 버전의 config 캐시로 부팅한다**: 7.0.9 이하 부모는 Step 10(spawn) 전에 config 캐시를 비우지 않았다 — `clearAllCaches()` 는 Step 11 이다. 그래서 이전 버전 설치본에 `bootstrap/cache/config.php` 가 있으면(설치 마법사·설정 저장·확장 업데이트가 만든다) 자식은 그 캐시로 부팅하고, 자식의 `config('app.version')` 은 부모가 env 로 넘긴 `APP_VERSION={toVersion}` 이 아니라 캐시에 박힌 fromVersion 이다. 업데이트 흐름 안에서 "지금 프로세스의 코어 버전" 을 판정하는 코드는 `config('app.version')` 을 직접 읽지 않고 `CoreVersionChecker::getCoreVersion()`(env 우선, config 폴백)을 쓴다. `runUpgradeSteps()` 의 stale 메모리 가드가 config 만 읽던 시절에는 정상 spawn 자식을 stale 부모로 오판해 스텝이 0건인 릴리즈에서도 핸드오프로 중단됐다(7.0.9→7.0.10). 부모 in-process fallback 에서는 env 가 `.env` 의 fromVersion 이므로 가드는 그대로 발동한다.
>
> 같은 이유로 자식이 `config('app.update.*')` 로 읽는 목록(쓰기 권한 디렉토리 등)도 캐시에 박힌 옛 목록이다 — 신버전이 항목을 추가해도 자식에게 보이지 않는다. 방어는 두 겹이다: ① 부모(7.0.10+)는 `spawnUpgradeStepsProcess()` 가 `proc_open` 직전에 `ConfigCacheHelper::clear()` 로 캐시를 비워 자식이 디스크 config + `.env` + spawn env 로 부팅하게 한다(캐시는 Step 11 이 다시 만든다). ② 자식(7.0.10+)은 이전 버전 부모가 캐시를 남겨 둔 경우를 위해, 캐시 파일이 있으면 `CoreUpdateService::freshDiskUpdateConfig()` 로 디스크의 `config/app.php` 를 직접 읽는다 — 캐시 부팅에서는 `.env` 도 로드되지 않으므로 그 안에서 `.env` 를 먼저 불변 로드한다(프로세스 env 의 `APP_VERSION` 은 덮어쓰지 않는다).
#### spawn 전 캐시 정리 계약 (3계층)
config 캐시와 같은 문제가 **패키지 매니페스트**(`bootstrap/cache/packages.php` · `services.php`)에도 있고, 이쪽은 결과가 더 무겁다. Laravel 의 `PackageManifest` 는 `packages.php` 가 있으면 stale 여부를 검사하지 않고 그대로 읽고, `ProviderRepository` 가 거기 등재된 eager provider 를 `new` 한다. Step 6/8 이 vendor 를 `--no-dev` 로 교체해도 두 파일은 Step 11 까지 이전 설치본의 것이 남으므로, 이전 설치본이 `composer install`(옵션 없음)로 깔린 개발용 설치였다면 자식은 새 vendor 에 없는 provider 를 찾다 **부팅 단계에서** 죽는다. 예외는 앱 로그가 열리기 전이라 남지 않고, 부모에게는 자식의 비정상 종료로만 보인다 (7.0.9 → 7.0.10 실사례).
| 계층 | 위치 | 막는 실패 | 잠그는 테스트 |
|------|------|-----------|--------------|
| ① 부모 선정리 | `CoreUpdateCommand::spawnUpgradeStepsProcess()` 가 `proc_open` 직전 `PackageManifestCacheHelper::clear()`. 지우지 못한 파일이 있으면 그 경로를 업그레이드 로그에 경고로 남긴다 — 권한·소유권 불일치면 자식의 계층 ② 도 같은 이유로 실패해 증상은 제보와 같은 「Class not found」 인데, 이 경고가 원인을 가리키는 유일한 흔적이다 | 7.0.11+ 부모가 띄우는 자식의 부팅 실패 | `CoreUpdateCommandStalePackageManifestTest` |
| ② 자식 자가 치유 | `bootstrap/app.php` 가 `G7_UPDATE_IN_PROGRESS=1`(또는 명령줄 SAPI 에서의 업데이트 argv)이면 두 파일을 스스로 삭제 | **이미 배포된** 7.0.9·7.0.10 부모 아래에서 도는 신버전 자식 — 그 부모 코드는 고칠 수 없다 | 같은 테스트 (플래그 유·무 대조군 포함) |
| ③ 버전 판독 범위 | `CoreVersionChecker::getCoreVersion()` 의 env 우선은 `CoreUpdateContext::isInProgress()` 트리 안에서만 | 업데이트 **전에** 뜬 `php artisan serve`·큐 워커가 옛 `APP_VERSION` 을 물고 확장을 `incompatible_core` 로 끄는 것 | `CoreVersionCheckerEnvPriorityTest` · `CoreUpdateContextTest` |
계층 ②는 `config:cache`/`route:cache` 가 만드는 in-process 일회용 앱에도 발동한다 — 그 부팅도 `bootstrap/app.php` 를 다시 require 하고 플래그를 상속하기 때문이다. 웹 요청·`queue:work`·운영자 셸은 플래그가 없어 no-op 이다.
argv 채널은 명령줄 SAPI(`cli`·`phpdbg`)에서만 읽는다. CGI/FPM 은 `register_argc_argv=On` 이면 `$_SERVER['argv']` 를 쿼리스트링을 `+` 로 쪼갠 값으로 채우므로(`GET /?x+core:update` → `argv[1] === 'core:update'`), 그 게이트가 없으면 비인증 웹 요청이 요청마다 매니페스트를 지우고 다시 만들게 된다. env 플래그 채널은 웹 요청으로 주입할 수 없어 그대로 두며, 웹 요청 안에서 시작되는 업데이트 흐름은 그 플래그를 프로세스 안에서 세워 판정된다.
계층 ③의 판정은 `App\Support\CoreUpdateContext` 가 단독으로 소유하고 `CoreServiceProvider::isCoreUpdateInProgress()` 가 그리로 위임한다. 자동 비활성화 로그의 `core_version` 도 같은 게터를 쓴다 — 로그가 `config('app.version')` 을 적고 판정은 env 로 하면 운영자가 보는 근거와 실제 판정이 어긋난다.
#### 재실행 안내의 권한 분기 (핸드오프 catch)
spawn 자식이 실패(`proc_open` 미지원 · 비정상 종료 · silent skip)하고 `spawn_failure_mode=abort`(기본값) 이면, 파일·버전은 이미 `toVersion` 으로 반영되지만 업그레이드 스텝이 미실행 상태로 남아 운영자에게 `core:execute-upgrade-steps` 재실행을 안내한다. 이때 **sudo(root) 로 `core:update` 를 실행한 경우**, 안내받은 명령을 root 로 그대로 재실행하면 스텝이 만드는 파일·캐시가 root 소유로 생성되어 이후 웹서버(php-fpm www-data 등) 요청이 그 경로에 쓰기 실패한다.
@@ -288,15 +309,20 @@ spawn 자식이 실패(`proc_open` 미지원 · 비정상 종료 · silent skip)
```text
1. .env의 APP_VERSION 갱신
2. 캐시 클리어: config, cache, route, view
2. 캐시 클리어: config, cache, route, view (spawn 직전에도 config + 패키지 매니페스트 선정리)
3. bootstrap/cache 파일 삭제 (services.php, packages.php)
4. php artisan package:discover 재실행
5. php artisan extension:update-autoload (코어 업데이트로 _bundled 변경 가능)
6. _pending 격리 디렉토리(core_{ts}) 루트째 삭제
7. 성공 시 백업 삭제 (이후 `hotfix:rollback-stale-files` 는 대상이 없다)
8. 유지보수 모드 해제
9. 큐 워커 재시작 신호 (queue:restart)
```
> 3~4 는 `PackageManifestCacheHelper::rebuild()` 한 호출이다 — spawn 직전 선정리(계층 ①)와 같은 삭제 로직을 공유한다.
>
> 9 는 상주 큐 워커가 부팅 시점의 코어 코드·config 를 계속 쓰는 것을 막는다. 워커는 옛 코드로도 잡을 정상 처리하므로 오류가 나지 않고, 운영자가 손수 재시작할 때까지 조용히 어긋난 채 돈다. 핸드오프 cleanup 과 `core:execute-upgrade-steps` 단독 실행의 사후 단계도 같은 신호를 보낸다. 롤백 catch 는 제외다 — 백업으로 되돌린 옛 코드가 다시 도는 자리라 재기동시킬 이유가 없다.
### Step 12: _bundled 확장 일괄 업데이트 프롬프트 (인터랙티브)
> Trait: `App\Console\Commands\Core\Concerns\BundledExtensionUpdatePrompt`
@@ -586,6 +612,8 @@ public function withCurrentStep(string $stepVersion): self // 불변 복제
```php
CoreVersionChecker::getCoreVersion() // → config('app.version')
// (코어 업데이트 트리 안에서만 env APP_VERSION 우선
// — CoreUpdateContext::isInProgress())
```
### 버전 갱신 (Step 11)
@@ -710,7 +738,8 @@ config('app.version') = env('APP_VERSION', 'config/app.php 기본값')
|--------|---------|------|
| `runUpgradeSteps()` | `(string $from, string $to, ?Closure $onStep): void` | upgrades/ 자동 발견 + 실행 |
| `updateVersionInEnv()` | `(string $version): void` | .env의 APP_VERSION 갱신 |
| `clearAllCaches()` | `(): void` | config/cache/route/view 클리어 + package:discover |
| `clearAllCaches()` | `(): void` | config/cache/route/view 클리어 + 패키지 매니페스트 재생성(`PackageManifestCacheHelper::rebuild()`) |
| `signalQueueRestart()` | `(): void` | 상주 큐 워커에 재시작 신호 (실패는 경고만 — 업데이트를 되돌리지 않는다) |
### 유지보수 모드
+59 -3
View File
@@ -105,6 +105,10 @@ Request → web.php catch-all → SeoMiddleware (봇 감지)
- 봇 감지: `BotDetector` 4-레이어 체인 (아래 "봇 감지 구조" 섹션 참조)
- 렌더링 실패 시: SPA fallback (기존 응답 통과)
- 캐시 키: 경로 + **정규화된** 쿼리(`locale`·`_escaped_fragment_` 제외, 키 순서 정렬, 개수·길이 상한)
- 미스 렌더는 IP 당 분당 상한 안에서만 — 초과분은 SPA + `X-SEO-Cache: BYPASS`(오류가 아니다). 렌더러가 "그릴 게 없음"(null)으로 돌아온 요청은 차감을 되돌린다
- 저장 상한: 경로·언어당 쿼리 변종 수 · 캐시 인덱스 전체 항목 수
- 캐시 HIT/MISS 를 통계에 기록 (IP 당 분당 상한). HIT 의 레이아웃명은 캐시 항목에 함께 저장된 값으로 귀속한다
## 봇 감지 구조
@@ -655,7 +659,7 @@ php artisan seo:warmup # SEO 캐시 워밍업
php artisan seo:warmup --layout=shop/show # 특정 레이아웃만
php artisan seo:clear # 전체 SEO 캐시 삭제
php artisan seo:clear --layout=home # 특정 레이아웃만
php artisan seo:stats # 캐시 통계 출력
php artisan seo:stats # 캐시 통계 출력 (미들웨어가 HIT/MISS 를 기록 — IP 당 분당 상한)
php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치, mode=auto)
php artisan seo:generate-sitemap --sync # Sitemap 동기 생성
php artisan seo:generate-sitemap --rebuild # 전체 재생성 (mode=full 상당)
@@ -755,6 +759,57 @@ sitemap/_tmp/ 생성 중 임시 디렉토리 (커밋 시 정리)
| twitter_default_card | string | "summary_large_image" | twitter:card 기본 (summary/summary_large_image/app/player) |
| twitter_default_site | string | "" | twitter:site 핸들 (예: @gnuboard). 비면 출력 생략 |
## 캐시 상한 (config/core.php `seo_cache_limits`)
봇 판정은 User-Agent 문자열뿐이라 위장이 가능하고, 캐시 키에 쿼리가 들어가므로 물음표 뒤 값만 바꾼 반복 요청이 매번 미스가 됩니다. 미스 1건은 레이아웃 병합 · 표현식 평가 · 자기 API 루프백 호출을 유발하고 그 결과가 캐시에 쌓이므로, 요청 하나가 워커 여러 개를 묶고 저장소를 계속 키울 수 있습니다.
아래 상한이 그 증식을 막습니다. 관리자 화면에는 노출하지 않고 서버 설정으로만 조정합니다.
| 키 | env | 기본값 | 설명 |
|----|-----|-------|------|
| max_query_params | `G7_SEO_CACHE_MAX_QUERY_PARAMS` | 10 | 캐시 키에 허용하는 쿼리 파라미터 수. 초과 시 캐시·렌더 안 함 |
| max_query_length | `G7_SEO_CACHE_MAX_QUERY_LENGTH` | 512 | 정규화된 쿼리 문자열 길이 상한(바이트) |
| max_variants_per_path | `G7_SEO_CACHE_MAX_VARIANTS_PER_PATH` | 50 | 같은 경로·언어에 저장하는 쿼리 변종 수 (언어별로 따로 센다) |
| max_entries | `G7_SEO_CACHE_MAX_ENTRIES` | 20000 | 캐시 인덱스 전체 항목 수 |
| render_misses_per_minute | `G7_SEO_RENDER_MISSES_PER_MINUTE` | 60 | IP 당 분당 미스 렌더 수. 렌더러가 그릴 게 없다고 돌아온 요청(미라우트 404·SEO 비활성 화면)은 세지 않는다 |
| stats_records_per_minute | `G7_SEO_STATS_RECORDS_PER_MINUTE` | 300 | IP 당 분당 통계 기록 수 |
상한을 넘긴 요청은 **차단되지 않고** 일반 SPA 응답을 받습니다. 봇에게 오류를 돌려주면 그 URL 이 색인에서 빠지므로 차단이 곧 손해입니다. 판정 결과는 응답 헤더 `X-SEO-Cache`(`HIT`/`MISS`/`BYPASS`)로 드러나며, 그것이 운영 진단의 통로입니다.
이미 캐시에 있는 키의 **갱신**은 저장 규모를 늘리지 않으므로 상한과 무관하게 수행됩니다.
### IP 단위 상한은 프록시 신뢰 설정에 의존합니다
`render_misses_per_minute` 와 `stats_records_per_minute` 는 요청 IP 를 기준으로 셉니다. TLS 가 앞단에서 종단되는 구성(리버스 프록시, CDN, 로드밸런서)에서 신뢰할 프록시를 지정하지 않으면 모든 요청의 IP 가 프록시 IP 하나로 보이므로, 그 분당 예산을 사이트 전체 봇 트래픽이 함께 쓰게 됩니다. 예산을 넘긴 시점부터 정상 검색엔진 봇도 SEO HTML 대신 SPA 를 받아 색인 품질이 떨어지고, 응답은 200 이라 서버 로그에 흔적이 남지 않습니다.
프록시 뒤에 두는 설치본은 `TRUSTED_PROXIES` 를 반드시 지정합니다 — 설정 방법과 진단은 [리버스 프록시 환경](reverse-proxy.md)에 있습니다. 진단이 어려우면 응답 헤더 `X-SEO-Cache` 가 `BYPASS` 로 몰리는지를 먼저 봅니다.
### 경로당 변종 상한은 언어별로 셉니다
캐시 인덱스 항목은 URL 과 로케일의 조합마다 하나입니다. 경로만 보고 변종을 합산하면 언어 수만큼 실효 상한이 줄어, 언어가 셋인 사이트는 한 경로에 언어당 16개만 저장되고 목록 17페이지부터는 봇이 올 때마다 새로 그리되 저장하지 않게 됩니다. 그래서 상한은 경로와 언어의 조합 단위로 판정합니다.
### 그릴 게 없는 요청은 렌더 예산을 쓰지 않습니다
렌더 예산은 새로 그리기 직전에 1을 차감합니다. 그런데 미라우트 주소(404)나 SEO 를 끈 화면은 렌더러가 "그릴 게 없음"으로 바로 돌아오고 캐시에도 남지 않아, 올 때마다 다시 차감됩니다. 봇은 예전에 있던 죽은 주소를 오래 다시 긁으므로 그 요청까지 세면 정상 페이지의 예산이 죽은 주소에 소진됩니다. 렌더러가 null 을 돌려주면 그 차감을 되돌리고, 렌더 도중 예외는 비용을 이미 치른 것이라 되돌리지 않습니다.
### 캐시 항목은 레이아웃명을 함께 담습니다
페이지 캐시 값은 HTML 과 레이아웃명의 쌍입니다. 캐시 적중(HIT) 경로는 렌더러를 거치지 않아 요청 속성에 레이아웃명이 없으므로, 통계를 화면별로 귀속하려면 캐시 항목이 그것을 알아야 합니다. `SeoCacheManagerInterface::get()` 은 종전대로 HTML 만 돌려주고, 코어 구현 `SeoCacheManager::getEntry()` 가 쌍 전체를 돌려줍니다. 미들웨어는 코어 구현일 때만 쌍을 읽고, 다른 구현이 바인딩된 경우에는 레이아웃명 없이 기록합니다. 이전 버전이 문자열로만 저장한 항목은 레이아웃명 없이 그대로 읽힙니다 — 배포 직후 살아 있는 캐시를 버리지 않습니다.
통계 테이블(`seo_cache_stats`)의 `url` 컬럼은 캐시 키 URL 상한(경로 + 정규화 쿼리 최대 512바이트)을 담도록 768자입니다. 컬럼이 짧으면 긴 주소의 기록이 엄격 모드에서 실패하고, 통계 서비스는 그 예외를 삼키므로 흔적이 남지 않습니다.
### 저장 상한이 세는 것은 살아 있는 항목입니다
캐시 인덱스 항목은 페이지보다 오래 삽니다(기본 30일 vs 2시간). 그래서 저장 상한에 닿으면 **먼저 만료된 항목을 인덱스에서 걷어내고 다시 판정**합니다. 그러지 않으면 상한이 "지금 저장된 양"이 아니라 "과거에 저장한 적이 있는 양"을 재게 되어, 한 번 상한에 닿은 경로는 실제 캐시가 비어 있어도 다시는 저장되지 않습니다.
이 정리는 인덱스 전체를 훑으므로 **최소 60초 간격**으로만 수행합니다. 살아 있는 항목만으로 상한에 닿은 경우에는 정리해도 자리가 나지 않는데, 그 상태에서 저장 시도마다 훑으면 비용만 반복되기 때문입니다. 따라서 항목이 만료된 뒤 저장이 다시 열리기까지 최대 그 간격만큼 늦어질 수 있습니다.
### 상한은 봇 요청만이 아니라 모든 저장 경로에 걸립니다
저장 상한은 `SeoCacheManagerInterface` 의 저장 메서드(`put`·`putWithLayout`)에 공통으로 걸립니다. 봇 요청의 미스 렌더뿐 아니라, 게시글·상품을 저장할 때 도는 단건 재생성(`SeoCacheRegenerator`)도 같은 판정을 거칩니다 — 한쪽만 상한 밖이면 그쪽이 증식 우회로가 되기 때문입니다.
상한에 닿아 저장하지 않은 경우 재생성 호출은 **예외를 던지지 않고** 흔적을 `Log::debug` 로만 남깁니다. 콘텐츠 페이지는 서로 다른 경로라 경로당 변종 상한(`max_variants_per_path`)과는 무관하고, 전체 항목 상한(`max_entries`)은 만료 항목을 걷어낸 뒤 판정하므로 실제로는 페이지 TTL 안에 살아 있는 항목만 셉니다. 그래도 대규모 사이트에서 이 상한에 닿는다면 저장이 조용히 넘어가므로, `seo:stats` 의 미스 비율이 지속적으로 오르는지를 신호로 삼고 `G7_SEO_CACHE_MAX_ENTRIES` 를 올립니다.
## SEO Config 동적 확장 시스템
SEO 엔진은 컴포넌트 지식을 갖지 않습니다. 모든 컴포넌트→HTML 매핑, 렌더 모드, 셀프 클로징 태그, 외부 스타일시트는 `seo-config.json`으로 제공됩니다.
@@ -1653,9 +1708,10 @@ SeoCacheManager는 URL + locale 기반 캐시 키(`md5($cacheUrl.'|'.$locale)`)
SeoMiddleware의 `buildCacheUrl()`이 캐시 키용 URL을 구성합니다:
- **경로 + 쿼리 파라미터 포함**: `/shop/products?page=2&sort=price` → 페이지별 독립 캐시
- **`locale` 파라미터 제외**: locale은 캐시 키의 두 번째 차원(`$locale`)으로 별도 관리
- **경로 + 정규화된 쿼리 파라미터**: `/shop/products?page=2&sort=price` → 페이지별 독립 캐시
- **시스템 파라미터 제외**: `locale` 은 캐시 키의 두 번째 차원(`$locale`)으로 별도 관리하고, 봇 렌더 표식인 `_escaped_fragment_` 는 내용에 영향이 없어 키에서 뺍니다(남기면 같은 페이지가 두 벌 저장됩니다)
- **쿼리 파라미터 정렬**: `ksort()` — 동일 파라미터 조합 = 동일 캐시 키 보장
- **상한 초과 시 캐시 불가**: 파라미터 수·길이가 상한을 넘으면 색인 대상이 아니라고 보고 캐시도 렌더도 하지 않습니다(위 "캐시 상한" 절)
## SEO 변수 시스템
+3
View File
@@ -197,6 +197,9 @@ class TemplateService
```
인스톨러 단계 1: composer install
├─ .env 없음 → ModuleRouteServiceProvider 스킵 ✅
├─ vendor 재사용 분기 (vendor/autoload.php + composer.lock 존재)
│ ├─ 개발용 패키지 감지 → 경고 카드·설치 로그 (설치는 계속)
│ └─ 이전 환경의 컴파일 캐시 정리 (packages/services/config)
└─ package:discover 정상 완료
인스톨러 단계 2: .env 생성
+9
View File
@@ -1679,6 +1679,15 @@ $data['depth'] = ($parent->depth ?? 0) + 1;
---
## 입력 크기에 비례하는 메모리
브라우저에서 잘 돌던 알고리즘을 PHP 로 옮길 때 시간 상한만 함께 오고 메모리 상한은 오지 않는다. PHP 배열은 원소당 수십 바이트라 (줄 수)² 크기 표는 2,350줄에서 약 150MB 이고, 운영 서버의 PHP 기본 `memory_limit` 은 128M 이다. 개발 머신에서는 통과하고 서버에서만 500 이 되며, 남는 것은 `Allowed memory size … exhausted` 한 줄뿐이다.
- 카운트·길이만 쓰는 LCS/DP 는 두 행 DP 로 길이만 구한다. 추가 = 새 줄 − LCS, 삭제 = 옛 줄 − LCS 이므로 전체 표를 되짚어 얻는 숫자와 같다. 실제 줄을 그려야 하는 쪽(브라우저 diff 뷰)만 전체 표를 쓴다.
- 브라우저 구현의 임계값을 옮겨 왔다면 그 임계에서의 PHP 메모리를 실측한다. 시간만 막는 임계가 있다.
- "인접 버전 비교는 변경 영역이 작다" 같은 가정으로 최악 경로를 비워 두지 않는다. 앞뒤 공통 부분 트리밍은 양끝이 동시에 바뀌면 무력하고, 편집기가 `comment` 키를 떼어내는 첫 저장이 정확히 그 형태다.
- 메모리 회귀 테스트는 `memory_get_peak_usage()` 증가량의 상한을 단언한다. 잠금 대상은 코어 `CalculatesJsonContentDiff`(레이아웃·레이아웃 확장 버전 변경량)이며 `tests/Unit/Repositories/Concerns/CalculatesJsonContentDiffTest.php` 가 상한과 참조 구현 동치를 함께 고정한다.
## 관련 문서
- [컨트롤러 계층 구조](controllers.md) - Controller에서 Service 사용
+2 -2
View File
@@ -33,7 +33,7 @@ public/build/ext/{cache_version}/
│ ├── routes.json ← 병합 결과 + {"success":true,...} 봉투
│ └── assets/{dist 이하 경로} ← dist/** 사본 (*.map 제외, 허용 확장자만)
└── bundles/
├── modules.js / modules.css ← 확장 병합 번들 사본
├── modules.js / modules.css ← 확장 병합 번들 사본 (빈 번들도 0바이트로 게시)
└── plugins.js / plugins.css
```
@@ -176,7 +176,7 @@ public/build/ext/{cache_version}/
같은 엔드포인트가 대시보드의 「초기 화면 파일 생성 실패」 알림에도 [다시 만들기] 버튼으로 붙는다 — 운영자가 결함을 처음 만나는 곳에서 복구가 끝나도록.
715파일·40MB 복사와 번들 재병합이 **웹 요청 안에서** 돈다. 서버는 게시 락 TTL(300초)만큼 실행 시간을 확보하고 클라이언트가 끊어도 게시를 끝내지만, FPM `request_terminate_timeout`·nginx `fastcgi_read_timeout` 이 그보다 짧은 서버에서는 응답이 먼저 끊길 수 있다. 그 경우에도 게시는 계속되므로 카드를 다시 열어 결과를 확인한다.
715파일·40MB 복사와 번들 재병합이 **웹 요청 안에서** 돈다(번들은 캐시가 있으면 재병합하지 않는다). 서버는 게시 락 TTL(300초)만큼 실행 시간을 확보하고 클라이언트가 끊어도 게시를 끝내지만, FPM `request_terminate_timeout`·nginx `fastcgi_read_timeout` 이 그보다 짧은 서버에서는 응답이 먼저 끊길 수 있다. 그 경우에도 게시는 계속되므로 카드를 다시 열어 결과를 확인한다.
이 통로가 있으므로 kill-switch 를 관리자 UI 로 두지 않는 방침(§5)은 그대로다 — 복구는 화면에서, 끄기는 서버에서.
+13 -1
View File
@@ -6,7 +6,7 @@
```text
1. 확장/코어 버전 업 시 CHANGELOG.md에 변경사항 기록 필수 (미기록 시 버전 업 불가)
2. 형식: Keep a Changelog 표준 (## [버전] - 날짜 / ### 카테고리 / - 항목)
2. 형식: Keep a Changelog 표준 (## [버전] - 날짜 / ### 카테고리 / - 항목) — 한 버전 안에 같은 카테고리 헤딩은 한 번만
3. 허용 카테고리: Added, Changed, Deprecated, Removed, Fixed, Security
4. 확장 위치: 각 확장의 루트 디렉토리 (modules/_bundled/vendor-module/CHANGELOG.md)
5. 코어 위치: 프로젝트 루트 /CHANGELOG.md (코어 버전 변경 시 필수)
@@ -75,6 +75,18 @@ composer test-smoke-ci
- 변경사항 설명
```
### 한 버전 섹션 안의 카테고리는 한 번만
브랜치마다 버전 섹션 머리에 자기 `### Fixed` 블록을 새로 얹으면, 병합이 양쪽을 그대로 이어 붙여 한 버전 안에 같은 카테고리 헤딩이 두 번 남는다. 오류도 경고도 없고, 렌더된 문서에서 같은 제목이 두 번 보이는 것이 유일한 증상이다.
- 새 항목은 **이미 있는 카테고리 블록 끝에** 추가한다 (같은 카테고리 헤딩을 새로 만들지 않는다)
- 병합 뒤 두 번째 블록이 보이면 그 항목을 첫 블록에 합치고 **헤딩만** 지운다 (항목 삭제 금지)
- 같은 버전 헤더(`## [1.2.1]`)도 한 번만 — 뒤 섹션의 내용을 앞 섹션에 합친다
- 카테고리 헤딩은 §2 의 6종만 쓴다. 기능 단위 분류는 `####` 서브 헤딩으로 둔다
- 이미 공개 배포된 과거 버전 섹션은 소급 수정하지 않는다
번들 확장은 정적 검사가 working 버전 섹션(+`[Unreleased]`)에서 이 조건을 차단한다. 제3자 확장에는 강제하지 않는다.
---
## 2. 카테고리 정의
+19 -1
View File
@@ -134,15 +134,31 @@ php artisan {module|plugin|template}:update {id} --force
### controls — 재사용 스타일 컨트롤
위젯 종류(`widget`)·친화 라벨(`label`)·적용 방식(`apply`)·대상(`target`)·선택지(`options`)를 한 곳에 선언하고 `componentCapabilities.styleControls`(스타일 탭) 또는 `propControls`(속성 탭)가 키로 참조한다. `apply` 타입은 `classToken`(클래스 토큰 교체) / `styleProp`(인라인 스타일 속성) / `cssVar` / `propValue`(임의 `node.props[propKey]` 기입 — 속성 탭의 기본 적용 방식). 색·크기 등 라이브러리 대응 컨트롤은 프리셋 토큰 + 자유값(tokenTemplate)을 함께 선언한다. 상/우/하/좌 여백처럼 측마다 다른 값이 공존하는 속성은 다값을 1급으로 다룬다(일괄/개별 2모드, 단일 토큰 교체 금지).
위젯 종류(`widget`)·친화 라벨(`label`)·적용 방식(`apply`)·대상(`target`)·선택지(`options`)를 한 곳에 선언하고 `componentCapabilities.styleControls`(스타일 탭) 또는 `propControls`(속성 탭)가 키로 참조한다. `apply` 타입은 `classToken`(클래스 토큰 교체) / `styleProp`(인라인 스타일 속성) / `cssVar` / `propValue`(임의 `node.props[propKey]` 기입 — 속성 탭의 기본 적용 방식) / `nodeKey`(노드 **최상위** 구조키 `node[nodeKey]` 기입 — `dataKey` 처럼 런타임 엔진이 props 가 아니라 노드 자신에서 읽는 키. `props`·`children`·`type` 등 예약 구조키는 지정해도 무시되고, 디바이스(breakpoint) 축은 무시하며 항상 최상위에 쓴다 — 런타임 responsive 병합은 `props`/`children`/`text`/`if`/`iteration` 5키만 보므로 분기에 쓰면 영원히 읽히지 않는다). 색·크기 등 라이브러리 대응 컨트롤은 프리셋 토큰 + 자유값(tokenTemplate)을 함께 선언한다. 상/우/하/좌 여백처럼 측마다 다른 값이 공존하는 속성은 다값을 1급으로 다룬다(일괄/개별 2모드, 단일 토큰 교체 금지).
**`propControls` 전용 위젯 종류**:
- `icon-picker` — 아이콘 prop(예 `iconName`/`triggerIcon`)을 카탈로그 그리드에서 검색·선택한다. **카탈로그는 템플릿 소유**(라이브러리 종속) — Font Awesome 등 아이콘셋을 쓰는 템플릿이 `G7Core.layoutEditor.registerWidget('icon-picker', ...)` 로 자기 위젯을 등록한다(코어는 위젯명만 디스패치, 아이콘 라이브러리 토큰 0). 프리뷰 폴백은 `preview.html`(코어 비해석) → `preview.className` → raw value 순. 카탈로그 미공급 시 자유 텍스트 입력으로 디그레이드.
- `options-list` — `Select`/`RadioGroup` 등의 정적 `options` 배열(value/label 행)을 추가/삭제/이동 편집한다. 값이 `{{바인딩}}` 이면 "바인딩됨(코드 편집)" 디그레이드(덮어쓰기 차단). 데이터소스 바인딩(dataProps `options`)과 직교 공존(위 "선택지 직교 완화").
- `array-items` / `array-group` / `array-cell-tree` / `number-list` — 정적 배열 prop 편집(탭/차트 데이터/카드 컬럼 등). `nodeEditor` 슬롯 또는 propControl 로 결선한다(아래 "데이터 정의 빌트인" 참조).
- `number` — 숫자를 기대하는 prop(예 `maxVisibleBoards`)을 편집한다. 위젯이 `number` 타입을 직접 내보내므로 `"5"` 같은 문자열이 저장되지 않는다. 빈 입력은 prop 삭제, `0` 은 유효값이라 삭제되지 않으며, 숫자로 해석할 수 없는 입력은 방출하지 않아 저장본을 보존한다(조용한 변조 금지). `min`/`max`/`step` 은 선언만 하고 **클램프하지 않는다** — 사용자가 넣은 값은 그대로 보존한다. 값이 `{{바인딩}}` 문자열일 때의 보호는 위젯이 아니라 아래 「데이터 연결 값 보호」 공용 게이트가 맡는다 — 숫자 입력칸이 그 값을 빈칸으로 표시해 다음 blur 에 소실시키는 것을 그 게이트가 차단한다.
- `i18n-text` vs `text` — **표시 텍스트**(사용자가 화면에서 읽는 라벨/메시지/플레이스홀더)는 `i18n-text` 를 쓴다. `i18n-text` 는 평문 입력 시 `createCustomKey` 로 다국어 키를 자동 생성해 `$t:custom.*` 로 치환하고(미리보기에 raw 키 미노출), 🌐 펼침으로 ko/en/ja 동시 편집을 제공하며, `{{바인딩}}` 값이면 읽기전용 디그레이드한다(공통 위젯 `I18nTextField`). **비-표시값**(URL/id/수치/색/통화코드/separator 등)만 `text` 를 쓴다. 표시 텍스트에 `text` 를 쓰면 raw `$t:` 노출·다국어 누락 회귀가 발생한다(정적 검사가 배열 라벨 필드의 `text` 사용을 차단).
**데이터 연결 값 보호 — 모든 위젯 공통**:
prop 자리에 `{{...}}` 표현식이나 설정 참조가 저장돼 있으면, 그 값을 편집하는 컨트롤은 **원문 배지**로 디그레이드된다. 위젯은 그 문자열을 해석하지 못해 빈 컨트롤로 보이고, 그 상태에서 조작하면 표현식이 그대로 덮여 **환경설정과의 연결이 끊기기** 때문이다. 이 판정은 개별 위젯이 아니라 컨트롤 렌더러 한 곳에서 이뤄지므로 **새 위젯을 등록해도 자동 적용**된다 — 위젯 안에 같은 판정을 다시 넣지 않는다(넣으면 해제 경로까지 막혀 「직접 지정으로 바꾸기」가 무반응이 된다).
보호 상태에서는 그 컨트롤의 파괴적 조작이 함께 잠긴다. 값을 바꾸려면 「직접 지정으로 바꾸기」를 눌러 명시적으로 열고, 「되돌리기」로 원래 연결값을 복구할 수 있다(해제 직후에는 취소, 값을 넣은 뒤에는 복구로 동작).
예외는 표현식 자체를 다루도록 설계된 위젯뿐이다 — `text`·`i18n-text` 는 표현식을 데이터 칩으로 **분해해 보여 주는 것**이 그 위젯의 기능이고, `image`·`core-id` 는 같은 보호를 위젯 안에서 더 세밀하게(갤러리·업로드·관리 모달 진입까지) 제공한다. 새 위젯을 이 예외에 넣으려면 **원문 표시·해제·복구 셋을 모두** 갖춰야 한다.
**`image` 위젯의 값 형태 — apply 경로에 따라 축약된다**:
`image` 위젯은 배경 이미지용으로 설계되어 `{url, size, repeat, position}` **객체**를 내보낸다. 그런데 값 슬롯이 하나뿐인 apply 경로(`propValue` / `cssVar` / 단일 `styleProp`)는 그 객체를 담을 수 없다 — 엔진이 `url` 만 남겨 스칼라로 축약한다(컴포넌트 prop 은 맨 문자열, CSS 값 문맥은 `url(...)` 래핑). `url` 이 없거나 비어 있으면 그 자리를 삭제해 컴포넌트의 폴백이 살아난다.
축약을 전제로 위젯 표면도 달라진다 — 단일 값 슬롯 컨트롤에서는 표시모드(채움/맞춤/타일) 버튼이 **렌더되지 않는다**(`size`/`repeat`/`position` 을 저장할 자리가 없어 눌러도 저장되지 않는 죽은 컨트롤이다). 4속성을 모두 저장하려면 `styleProp` 의 `props` 배열에 `backgroundImage`·`backgroundSize`·`backgroundRepeat`·`backgroundPosition` 을 함께 선언한다.
`image` 를 `classToken` 이나 `cssVar` 에 연결하거나 `apply` 를 생략하면 값이 문자열로 축약될 경로가 없어 `[object Object]` 가 저장된다 — 정적 검사가 그 선언을 차단한다.
### componentCapabilities — 컴포넌트별 편집 역량
컴포넌트별로 `propControls`(속성 탭 컨트롤 화이트리스트 — `controls.json` 의 `apply.type==="propValue"` 컨트롤 참조, 비-스타일 prop 편집) / `dataProps`(데이터 연결 선언 — 아래) / `styleControls`(스타일 탭 컨트롤 화이트리스트) / `advanced`(고급 속성) / `events`(액션 편집 이벤트 — 미선언 시 동작 탭 숨김) / `flexEditor`(`container`/`item`/`auto` — 정렬 박스 편집 역할) / `visibilityCondition`(표시조건 탭 허용 여부) / `nodeEditor`·`canvasOverlay`(구조 에디터 일반 슬롯 `{kind,params}` — 종류별 고정 키 금지, kind 핸들러 레지스트리 디스패치)을 선언한다.
@@ -172,6 +188,8 @@ php artisan {module|plugin|template}:update {id} --force
**코어 제공 속성(요소 ID) + opt-out**: 코어는 모든 draggable 컴포넌트의 [속성] 탭 최상단에 "요소 ID" 컨트롤을 일괄 제공한다(값 = 표준 `node.props.id`, 강제 DOM 주입 없음 — 컴포넌트 passthrough 책임). 기존 `elemId`(→id) 같은 템플릿 propControl 은 코어로 이전(중복 선언 금지 — 코어 우선). 인라인 루트가 없는 컴포넌트(서드파티 모달/Portal)는 capability 에 `"coreProps": false` 로 opt-out 한다. `"coreProps": ["id"]` 처럼 부분집합 선언도 가능(미선언 = 코어 기본 전체). 코어 id 컨트롤은 `{{바인딩}}` 값이면 "바인딩됨(코드 편집)" 디그레이드, HTML 안전 문자만 허용(한글·공백 자동 제거). 컴포넌트 측 id passthrough 규약은 `docs/frontend/components-types.md` "요소 id 패스스루" 참조.
**전용 UI 를 가진 코어 속성은 `coreProps` 로 다시 선언하지 않는다**: `isolatedState`·`isolatedScopeId` 는 편집기가 별도 전용 컨트롤로 제공하며 그것이 값의 단일 출처다. 이 두 키를 `coreProps` 배열에 적어도 [속성] 탭의 일반 컨트롤 목록에는 나타나지 않는다 — 같은 값을 두 경로가 쓰면 값 형태가 갈리기 때문이다(전용 UI 는 켜짐 상태를 객체로 쓰는데 일반 토글 위젯은 `true` 를 낸다). 선언 자체는 오류가 아니고 조용히 무시되므로, 격리 설정을 노출하려면 그 전용 컨트롤을 쓴다.
### nesting — 중첩 규칙
`draggable`(드래그/팔레트 배치 가능한 컴포넌트 이름) + `containers`(컨테이너별 `accepts` 자식 허용 목록). 병합 시 `draggable` 은 union, `containers` 는 key 병합. `accepts: []` 는 명시적 자식 거부, 엔트리 없는 컴포넌트는 어떤 자식도 받지 않는다(폴백 없음).
+14
View File
@@ -238,6 +238,20 @@ $process = proc_open($cmd, $descriptors, $pipes, $cwd, $env);
`isCoreUpdateInProgress()` 는 env 외에 `$argv[1]` 이 `core:update` / `core:execute-upgrade-steps` 인 경우도 true 판정 — env 전파 실패 극단 상황 방어용. 하지만 `php -r '...'` 로 기동되는 spawn (inline 스크립트) 은 argv 판정이 되지 않으므로 env 전파가 유일한 수단입니다.
argv 판정은 명령줄 SAPI(`cli`·`phpdbg`)에서만 유효합니다. 웹 요청의 `$_SERVER['argv']` 는 `register_argc_argv` 설정에 따라 쿼리스트링에서 채워지므로 신뢰하지 않으며, 웹 요청 안에서 업데이트 흐름을 시작하는 코드는 env 플래그를 프로세스 안에서 세워 판정을 받습니다.
#### 판정의 단일 출처와 이 플래그가 게이트하는 것
`CoreServiceProvider::isCoreUpdateInProgress()` 는 `App\Support\CoreUpdateContext::isInProgress()` 위임입니다. 같은 플래그가 서로 다른 계층에서 셋을 게이트하므로 판정이 갈라지면 그중 한 경로만 조용히 다르게 동작합니다.
| 게이트 대상 | 위치 | 플래그가 없으면 |
|-------------|------|-----------------|
| 확장·템플릿 자동 비활성화 스킵 | `CoreServiceProvider::validateAndDeactivate*` | 일시적 버전 불일치로 활성 확장이 꺼진다 |
| 코어 버전의 env `APP_VERSION` 우선 판독 | `CoreVersionChecker::getCoreVersion()` | 캐시된 config 의 fromVersion 으로 판정한다 (반대로, 트리 밖에서 env 를 우선하면 상주 프로세스가 옛 버전을 물고 확장을 끈다) |
| 패키지 매니페스트 자가 치유 | `bootstrap/app.php` | 이전 설치본의 `packages.php` 로 부팅하다 "Class ... not found" 로 죽는다 |
`bootstrap/app.php` 의 자가 치유 블록은 부팅 전이라 `App\` 클래스를 참조할 수 없어 같은 판정을 순수 PHP 로 복제합니다 — 조건을 바꾸면 양쪽을 함께 고쳐야 합니다.
### 동적 엔티티 보존 (Permission / Role / Menu)
모듈·플러그인이 런타임에 동적으로 생성한 Permission/Role/Menu 는 정적 정의(`getPermissions()`, `getRoles()`, `getAdminMenus()`)에 포함되지 않으므로, 업데이트 시 `cleanupStaleModuleEntries()` / `cleanupStalePluginEntries()` 가 stale 로 오판하지 않도록 모듈 측에서 아래 hook 을 override 해 현재 식별자 전체를 반환한다.
+15 -2
View File
@@ -520,6 +520,7 @@ GET /api/plugins/bundle.css?v={version}
| CSS url() | 상대 `url()`·`@import` 참조는 그 확장의 절대 자산 URL 로 **치환**해 병합. 병합본의 주소는 어느 확장의 dist 디렉토리도 아니라 상대 해석이 반드시 어긋난다 | (계약 테스트) |
| 디스크 캐시 fail-soft | 캐시 쓰기 실패는 **500 이 아니다** — 메모리 병합 결과를 그대로 200 으로 서빙 | (계약 테스트) |
| 빈 번들 판정 | 선언한 산출물이 소실·판독 불가면 **503**, 존재하되 비었으면 빈 200 (선언 0 도 빈 200) | (계약 테스트) |
| 캐시 우선 | 프로덕션은 `(type, kind, version)` 캐시 파일 존재를 **빌드보다 먼저** 확인한다. 캐시 미스는 같은 키의 잠금으로 1회 빌드에 수렴(잠금 실패는 각자 빌드). 병합 단계에서 건너뛴 확장이 있는 결과는 캐시하지 않는다 | (계약 테스트) |
### 병합 CSS 의 상대 참조
@@ -538,7 +539,7 @@ GET /api/plugins/bundle.css?v={version}
| 상태 | 판정 | 응답 |
|---|---|---|
| 에셋을 선언한 활성 확장이 0개 | 정상 | 빈 200 |
| 선언은 있고 그 산출물이 **전부 존재**하되 비어 있음 | 정상 (스타일이 비어 있는 확장) | 빈 200 |
| 선언은 있고 그 산출물이 **전부 존재**하되 비어 있음 | 정상 (스타일이 비어 있는 확장) | 0바이트 캐시 파일 + 정적 게시, 빈 200 |
| 선언한 산출물이 **소실·판독 불가** | 장애 (배포 중 `dist` 가 잠깐 빔, 경로 어긋남) | **503** + `Log::error`(소실 경로 목록) |
장애를 정상으로 흘리면 프론트는 404 도 오류도 받지 못한 채 한참 뒤 "Unknown action handler" 로 죽는다 — 그 시점에는 원인이 번들이라는 사실이 화면에도 로그에도 남아 있지 않다. 반대로 정상을 장애로 잡으면 스타일 소스가 자리표시 주석뿐인 확장만 설치된 기본 구성이 통째로 503 이 되어 **사용자 화면마다 실패 안내가 뜬다.** 판정은 **kind 별**이다(js 만 선언한 확장이 있는 상태에서 css 번들이 비는 것은 정상).
@@ -550,7 +551,9 @@ GET /api/plugins/bundle.css?v={version}
0바이트 산출물은 정당한 상태다. 스타일 규칙이 아직 없는 확장이 CSS 를 선언하는 것은 어긋남이 아니며, 그 상태를 배포 장애로 등치하면 정상 사이트가 서비스 불능으로 보고된다.
빈 번들은 정적 게시 대상이 아니다(게시할 사본이 없다). 그 결과 `AssetUrl::extensionBundle()` 이 정적 URL 대신 API URL 을 방출하므로 브라우저에는 정적 404 폴백이 생기지 않고, 그 API 가 위 표대로 빈 200 을 낸다.
선언 산출물이 전부 존재하는 빈 번들은 **0바이트 파일로 캐시·정적 게시**되어 브라우저가 웹서버에서 직접 받는다. 게시하지 않으면 `AssetUrl::extensionBundle()` 이 API URL 을 방출해 그 구성의 **모든 페이지 로드**가 PHP 를 거치고, 그 요청마다 컨트롤러가 활성 확장 열거를 세 번 반복한다(경로 조회 → 재빌드 → 소실 판정). 오류도 로그도 남지 않아 드러나지 않는 경로다.
캐시하지 않는 것은 셋뿐이다 — 선언 산출물이 **소실**된 경우(503 판정을 그대로 유지해야 한다), 병합 단계에서 확장을 **건너뛴** 경우(파일은 있는데 읽기·치환이 실패한 상태가 캐시로 굳으면 버전 bump 전까지 그 확장 자산이 사라진 채 고정되므로, 종전처럼 매 요청 재시도에 맡긴다), 그리고 디스크 쓰기 자체가 실패한 경우다.
두 판정은 모듈·플러그인 컨트롤러가 **공유하는 단일 지점**(`ServesExtensionBundles::bundleResponse()`)에 둔다. 각자 구현하면 한쪽만 고쳐진 채 다른 쪽이 옛 동작으로 남는다.
@@ -569,10 +572,20 @@ php artisan template:cache-clear # 전체 번들 파일 정리 포함
프로덕션은 version-in-path 디스크 캐시, 비프로덕션(dev/watch)은 캐시 없이 매 요청 concat(rebuild 즉시 반영). `_bundled` 수정 후에는 `{type}:update {id} --force` 로 활성 반영 후 version bump 로 번들이 재생성된다.
캐시 적중 시에는 원본 에셋을 읽지 않으므로 원본 교체는 반드시 version bump 로 반영한다 — `{type}:update {id} --force` 가 그 bump 를 수행한다. `dist` 를 손으로 덮어쓰고 bump 를 건너뛰면 같은 version 의 캐시가 계속 서빙된다.
> 개별 에셋 서빙 라우트(`/api/{type}/assets/...`, `*.map` 포함)는 소스맵·static 참조를 위해 존치한다.
> 다만 `*.map` 의 **실제 서빙은 `local` 환경에서만** 허용된다 — 소스맵에는 원본 코드 전문이
> 담기므로 운영에서는 확장자 화이트리스트가 차단한다. 상세: [template-security.md](template-security.md) "소스맵 (`map`) — 로컬 개발 환경 전용".
### 요청 제한을 두지 않는 이유
공개 번들 라우트(`/api/{modules,plugins}/bundle.{js,css}`)에는 애플리케이션 수준의 요청 제한을 붙이지 않는다.
- 캐시 우선 순서를 갖춘 뒤로 이 엔드포인트의 응답 비용은 **프레임워크 부팅**이 지배한다. 부팅을 마친 뒤에야 판정할 수 있는 앱 throttle 은 그 비용을 이미 치른 다음이라 방어 효과가 제한적이다.
- 정적 자산 성격의 공개 API 는 요청 제한을 두지 않는 것이 이 저장소의 기존 관행이다(공개 컨트롤러 계층 전반).
- 반복 요청을 실제로 줄이는 것은 앞단 캐시다. 응답이 `Cache-Control: immutable, max-age=31536000, public` 을 내보내므로, 리버스 프록시나 CDN 이 그 헤더를 존중하도록 `/api/*/bundle*` 을 캐시 대상에 넣으면 앱까지 도달하는 요청 자체가 사라진다. 파일명에 캐시 버전이 들어 있어 stale 위험도 없다.
### 전송 압축 (gzip)
번들 JS/CSS 는 `fileResponse()`(= `response()->file()` → `BinaryFileResponse`)로 서빙되며, `GzipEncodeResponse` 미들웨어가 gzip 압축을 적용한다. `BinaryFileResponse` 는 `getContent()` 가 `false` 를 반환하므로, 미들웨어는 파일 경로(`getFile()->getPathname()`)에서 본문을 읽어 압축한 뒤 헤더(Content-Type/ETag/Cache-Control)를 승계한 일반 `Response` 로 치환한다.
+33 -2
View File
@@ -1581,8 +1581,9 @@ class AttachmentServiceTest extends TestCase
## 공개 자산 전용 디스크 분리 (직접 URL/CDN 서빙)
완전 공개 자산(상품/카테고리/리뷰/에디터 이미지 등)을 S3+CDN 등 원격 디스크에서
직접 URL 로 서빙하는 옵트인 기능입니다. 미설정 시 기존 PHP 스트리밍이 100% 보존됩니다.
완전 공개 자산(상품/카테고리/리뷰/에디터 이미지, 레이아웃 배경 이미지 등)을 S3+CDN 등
원격 디스크에서 직접 URL 로 서빙하는 옵트인 기능입니다. 미설정 시 기존 PHP 스트리밍이
100% 보존됩니다.
### 설정 사슬
@@ -1608,6 +1609,20 @@ class AttachmentServiceTest extends TestCase
- `none` 은 스트리밍 유지(확장 개별 설정에서는 전역이 CDN 이어도 강제 스트리밍)입니다.
- 플러그인 비활성화로 디스크가 config 에서 사라지면(고아 디스크) 자동으로 스트리밍
폴백합니다 — 저장값은 보존되므로 재활성화 시 되살아납니다.
- 코어의 **레이아웃 첨부**(레이아웃 편집기에서 올리는 배경 이미지)도 이 배선 대상입니다.
이 카테고리의 서빙 라우트는 무인증 공개이고 검사가 템플릿 소속 확인 하나뿐이라
"완전 공개 자산" 에 해당합니다. 설정을 켠 뒤 업로드한 파일이 공개 자산 디스크에
저장되고, 그 행의 disk 가 지금 설정된 공개 자산 디스크와 **일치할 때만** 직접 URL 이
발급됩니다. 디스크에 공개 URL(`url`) 설정이 있다는 사실만으로는 직접 URL 을 발급하지
않습니다 — 그 설정은 "URL 문자열을 만들 수 있는가" 일 뿐 "익명 읽기가 되는가" 가
아니어서, 비공개 버킷에 공개 URL 을 적어 둔 구성에서는 발급된 주소가 403 이 됩니다.
- 발급된 주소는 **레이아웃 저장의 외부 URL 차단 규칙을 통과해야** 합니다. 편집기 image 위젯은
그 주소를 props 에 그대로 넣고, 헤더 「로고 이미지」처럼 값 슬롯이 하나뿐인 컨트롤은
`style` 이 아니라 `props` 에 쓰므로 규칙의 스캔 대상입니다. 그래서 공개 서빙(프록시) URL 은
사이트 상대 경로(`/api/templates/.../file`)로 발급하고, 직접 URL 의 host(선언된 공개 자산
디스크)와 사이트 자기 host 는 규칙이 외부로 보지 않습니다(`App\Support\SiteAssetHosts`).
프록시 URL 을 절대 형태로 발급하면 그 저장이 422 로 거부될 뿐 아니라, 저장된 레이아웃이
발급 시점의 도메인·스킴에 묶여 주소가 바뀌면 그 이미지가 전부 깨집니다.
### 혼재 운용
@@ -1632,6 +1647,15 @@ class AttachmentServiceTest extends TestCase
| 객체가 익명 읽기 가능 (버킷 정책 `s3:GetObject` 공개 또는 CDN 공개 배포) | 화면의 그 이미지들이 전부 깨짐 (S3 는 `403 AccessDenied` 를 XML 로 응답) |
| 공개 URL base(`S3 URL`)가 그 객체를 가리킴 | `url()` 이 null → 스트리밍 폴백(기능은 정상, CDN 이점만 없음) |
### 켜기 전에 알아야 할 것
| 항목 | 내용 |
| --- | --- |
| 이미 올라간 파일에도 소급 적용될 수 있다 | 기존 첨부 디스크와 공개 자산 디스크가 **같은 디스크**(예: 둘 다 S3)라면, 설정을 켜는 순간 켜기 이전에 올라간 첨부까지 직접 URL 로 바뀝니다. 행에 기록된 disk 값이 같아 신·구를 구분할 수단이 없기 때문입니다. 두 디스크를 다르게 두면(예: 첨부는 로컬, 공개 자산은 S3) 켠 뒤 올린 파일만 전환됩니다 |
| 저장된 레이아웃에 주소가 그대로 남는다 | 설정을 켠 동안 배경 이미지를 지정해 저장하면 그 직접 URL 문자열이 레이아웃에 고정됩니다. 이후 설정을 끄거나 버킷을 비공개로 바꿔도 그 문자열은 자동으로 되돌아가지 않습니다(서버가 개입할 지점이 없습니다). 되돌리려면 편집기에서 이미지를 다시 선택해 저장해야 합니다 |
| 첨부를 지워도 직접 URL 은 살아 있을 수 있다 | 직접 URL 은 첨부 기록과 무관하게 저장소의 파일을 가리킵니다. 스트리밍 방식에서는 기록을 지우면 그 주소가 곧바로 404 가 되었지만, 직접 URL 은 파일이 남아 있는 한 계속 열립니다. 접근을 확실히 끊으려면 저장소에서 파일 자체를 지워야 합니다 |
| 확장 훅에 새로운 조합이 흘러간다 | `core.storage.filter_url` 훅을 `scope` 로 분기하는 확장은 이번부터 `scope='core'`, `identifier=null`, `category='template-layout-attachments'` 조합을 받습니다. 지금까지 코어에서 이 훅으로 URL 을 만드는 호출부가 없었으므로 이 조합은 처음 등장합니다 |
관리자 화면의 썸네일은 교차 출처 공개 URL 을 `<img>` 로 직접 사용하므로 CORS 설정은
필요하지 않습니다. 다만 그 URL 을 자바스크립트로 읽는 커스텀 확장을 만든다면 그때는
버킷/CDN 에 CORS 규칙이 필요합니다.
@@ -1651,6 +1675,13 @@ class AttachmentServiceTest extends TestCase
직접 URL 은 서버 스트리밍 경로의 권한 검사를 우회하므로, 완전 공개 자산 카테고리에만
공개 자산 디스크를 적용합니다.
**알려진 한계** — 상품 리뷰 이미지는 공개 자산으로 배선되어 있습니다. 리뷰를 노출에서
숨김으로 바꾸면 목록·상세 응답에서 이미지가 사라지지만, 공개 자산 디스크를 켠 상태에서
이미 발급된 직접 URL 을 알고 있는 사람은 그 주소로 파일에 계속 접근할 수 있습니다.
직접 URL 은 저장소의 파일을 직접 가리키므로 응답에서 감추는 것만으로는 회수되지 않습니다.
숨김을 접근 차단으로 쓰려면 그 카테고리에 공개 자산 디스크를 적용하지 않거나, 저장소에서
파일 자체를 지워야 합니다.
---
## 관련 문서
+1 -1
View File
@@ -37,7 +37,7 @@ Custom Rule: ComponentExists
| JSON 구조 유효성 | 올바른 JSON 형식인지 검증 |
| 최대 중첩 깊이 | 10단계 제한 |
| 엔드포인트 화이트리스트 | `/api/(admin\|auth\|public)/` 패턴만 허용 |
| 외부 URL 금지 | 외부 도메인 URL 차단 |
| 외부 URL 금지 | 외부 도메인 URL 차단 (사이트 자기 host 와 「공개 자산 스토리지」로 선언한 디스크의 host 는 외부가 아니다) |
| 컴포넌트 존재 여부 | components.json 기준으로 검증 |
---
+106
View File
@@ -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
로그아웃하고 토큰을 삭제합니다.
+3
View File
@@ -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-액션-속성) - 실행 전 확인 대화상자
+54 -7
View File
@@ -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 설정
+11
View File
@@ -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 레퍼런스
+1 -1
View File
@@ -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 고유성 |
+5
View File
@@ -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. 데이터베이스
+46
View File
@@ -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=<challenge_id>` (§4.2) |
| 모듈 권한 (`sirsoft-ecommerce.*`) | 모듈 | 코어 커맨드의 `--permissions=` 임의 식별자 | 동일 (Permission::firstOrCreate 자동 생성) |
| 모듈 도메인 데이터 (상품/주문) | 모듈 | `modules/_bundled/{id}/src/Console/Commands/PlaywrightSeed{id}.php` | `php artisan playwright:seed-{id}` |
| 플러그인 도메인 데이터 (결제 키) | 플러그인 | `plugins/_bundled/{id}/src/Console/Commands/PlaywrightSeed{id}.php` | 동일 |
@@ -175,6 +177,50 @@ Firefox/WebKit 프로젝트를 상시 스위트에 넣지 않은 이유:
**핵심 원칙**: 코어는 모듈 도메인을 모른다. 모듈 도메인 시드를 코어에 두면 의존 역전.
### 4.2 로그인 2단계 인증 픽스처
2단계 인증은 **사이트 설정과 메일 발송**에 의존하므로 브라우저만으로는 재현할 수 없다.
`tests/Playwright/fixtures/two-factor.ts` 가 세 가지를 가역적으로 준비한다.
| 준비 항목 | 방법 | 원복 |
|---|---|---|
| 보안 설정 `security.two_factor_auth` | 관리자 토큰으로 `/api/admin/settings` GET → POST (탭 전체를 되돌려 보낸다) | 읽어 둔 탭 전체 값을 그대로 되쓴다 |
| 메일 발송 | 받아서 버리는 로컬 SMTP 싱크를 띄우고 `mail.json` 의 `host`/`port`/`encryption` 을 줄 단위 치환 | 백업 파일에서 바이트 그대로 복원 |
| 인증번호 | `php artisan playwright:seed-two-factor --plant=<challenge_id> --code=135790` | 없음 (challenge 는 일회성) |
| 테스트 계정 | `--ensure-user=` 로 생성 | `--purge-users` 로 종료 시 즉시 제거 |
**메일을 `log` 메일러로 돌리지 않는 이유**: `log` 는 이 제품의 설정 스키마에 없다. 저장 검증이
등록된 메일 드라이버(`smtp`·`mailgun`·`ses`)만 허용하고, 설정 파일을 직접 고쳐도 드라이버 해석
단계에서 `smtp` 로 되돌아간다(실측 확인). 그래서 `tests/Playwright/fixtures/smtp-sink.ts` 가
Node 기본 `net` 만으로 최소 SMTP 서버를 127.0.0.1 에 잠깐 띄우고 그쪽으로 돌린다 —
인증도 TLS 도 요구하지 않으며 받은 메일은 어디에도 남기지 않는다.
**메일 설정만 파일을 직접 다루는 이유**: 원래 값(빈 SMTP 호스트)은 저장 검증(`smtp` 이면 host
필수)을 통과하지 못해 **API 로는 되돌릴 수 없다**. 보안 설정은 API 로, 메일 설정은 파일
백업·복원으로 왕복한다.
`playwright:seed-two-factor` 계약:
| 옵션 | 하는 일 |
|---|---|
| `--ensure-user=<접미사>` | `playwright_2fa_<접미사>@example.test` 계정을 알려진 비밀번호로 생성/갱신하고 이메일을 출력. 잠금·실패 카운트도 초기화한다 |
| `--admin` | 위 계정에 admin 역할 부여 (없으면 기존 역할을 떼어 관리자 거부 경로를 재현할 수 있게 한다) |
| `--password=` | `--ensure-user` 가 설정할 비밀번호 (기본 `Passw0rd!2fa`) |
| `--plant=<uuid>` | 그 challenge 에 알려진 인증번호의 해시를 심고 코드를 출력 |
| `--code=` | `--plant` 가 심을 인증번호 (기본 `135790`) |
| `--gc-hours=` | 이 시간보다 오래된 테스트 계정 정리 (기본 6, `0` 이면 정리 안 함) |
| `--purge-users` | 나이와 무관하게 테스트 계정을 전부 제거 — 실측 종료 직후 호출한다. 이 계정들은 알려진 비밀번호를 갖고 관리자용은 관리자 역할까지 가지므로, 나이 기준 정리를 기다리는 사이가 그대로 열린 문이 된다 |
가드는 `playwright:issue-token` 과 동형이다 — CLI 한정 + `G7_PLAYWRIGHT_BYPASS=1` + `APP_DEBUG` 강제.
인증번호는 해시로만 저장되어 되읽을 수 없다. 메일함을 실제로 여는 대신 알려진 값을 심는 이유이며,
방식은 PHPUnit `TwoFactorAuthTest::issuedCode()` 와 같다 — 검증 대상은 코드 생성이 아니라
로그인 흐름(코드 확인 전 토큰 미발급 / 확인 후 발급)이다.
**원복 실패를 삼키지 않는다.** 되돌리지 못한 채 끝나면 사이트가 2단계 인증이 켜진 상태로 남아
이후 **모든 로그인이 막힌다**. 그래서 이 spec 은 `test.describe.configure({ mode: 'serial' })`
로 한 워커에서만 실행하고, `afterAll` 의 원복 실패는 그대로 던진다.
### 4.1 저장(PUT)하는 spec 은 제품 화면을 대상으로 두지 않는다
레이아웃 편집기 spec 이 저장까지 수행하면 그 편집 결과는 **그대로 영속된다**. 대상이 제품 화면
@@ -4,6 +4,16 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.10] - 2026-09-09
### Added
- 로그인 시도가 많아 잠시 차단될 때의 안내와, 본인인증 화면에서 로그인용 인증 요청을 처리할 수 없다는 안내의 일본어 번역을 추가했습니다.
- 레이아웃 편집기 [화면 동작] 탭의 「인증번호 확인」·「인증번호 다시 받기」 항목 이름의 일본어 번역을 추가했습니다.
- 레이아웃 편집기에서 환경설정과 연결된 항목에 표시되는 안내와 「직접 지정으로 바꾸기」·「데이터 연결로 되돌리기」 버튼의 일본어 번역을 추가했습니다.
- 레이아웃 편집기 숫자 입력 항목의 입력 자리 표시 문구의 일본어 번역을 추가했습니다.
- 레이아웃 편집기 「확장 편집」 저장이 끼워 넣은 요소를 되돌릴 수 없어 막혔을 때 표시되는 안내의 일본어 번역을 추가했습니다.
## [1.0.9] - 2026-09-06
### Added
@@ -36,6 +36,7 @@ return [
'account_pending_verification' => '本人認証が完了していないアカウントです。メール認証を完了してください。',
'account_locked' => 'ログイン試行回数の超過によりアカウントがロックされました。:minutes分後に再度お試しください。',
'account_locked_permanently' => 'ログイン試行回数の超過によりアカウントがロックされました。管理者にお問い合わせください。',
'too_many_attempts' => 'リクエストが多すぎます。:seconds秒後にもう一度お試しください。',
'account_unlocked' => 'アカウントのロックを解除しました。',
'reset_token_invalid' => '無効なパスワードリセットトークンです。',
'reset_token_expired' => 'パスワードリセットトークンが有効期限切れです。再度リクエストしてください。',
@@ -30,6 +30,7 @@ return [
'admin_policy_has_no_default' => '管理者が直接作成したポリシーには宣言デフォルト値がありません。',
'reset_field_failed' => '宣言デフォルト値の復元に失敗しました。フィールドが有効であることを確認してください。',
'cannot_delete_system_policy' => 'システムが宣言したポリシーは削除できません。管理者が直接作成したポリシーのみ削除できます。',
'purpose_not_allowed' => 'この認証リクエストはこの画面では処理できません。',
],
'messages' => [
'challenge_requested' => '本人認証コードを送信しました。',
@@ -299,6 +299,8 @@
"blocked_inactive_extension": "無効化された拡張コンポーネントが新たに追加されているため保存できません",
"network_error_title": "保存中にネットワークエラーが発生しました",
"guard_no_document": "保存するレイアウトドキュメントが読み込まれていません",
"guard_extension_reassembly_title": "拡張の内容を元の位置に戻せないため保存しませんでした",
"guard_extension_reassembly": "この拡張が差し込んだ要素 {count} 件が、どの位置から来たのか確認できません。このまま保存するとその要素が消えるため、保存を止めました。ページを再読み込みしてからもう一度お試しください。",
"concurrent": {
"title": "別のユーザーが先に保存しました",
"message": "このレイアウトは別の管理者が先に保存しました。最新版を読み込みますか?",
@@ -322,6 +324,9 @@
"mode_tile": "タイル",
"upload_failed": "画像アップロードに失敗しました"
},
"number": {
"placeholder": "数値を入力"
},
"dimension": {
"placeholder": "例: 320px, 50%, 24rem, auto"
},
@@ -335,6 +340,11 @@
"right": "右",
"bottom": "下",
"left": "左"
},
"bound_value": {
"notice": "データ連携の値です(自動で入ります)",
"replace": "直接指定に変更",
"restore": "データ連携に戻す"
}
},
"recipe": {
@@ -1280,6 +1290,14 @@
"label": "ログイン処理",
"param_body": "ログイン情報"
},
"login_two_factor": {
"label": "認証番号の確認",
"param_body": "認証情報"
},
"login_two_factor_resend": {
"label": "認証番号の再送信",
"param_body": "認証リクエスト情報"
},
"logout": {
"label": "ログアウト処理",
"param_target": "移動先"
@@ -12,7 +12,7 @@
"en": "G7 core Japanese language pack (bundled)",
"ja": "G7 コア 日本語 言語パック(バンドル)"
},
"version": "1.0.9",
"version": "1.0.10",
"license": "MIT",
"scope": "core",
"target_identifier": null,
@@ -4,6 +4,13 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.9] - 2026-09-09
### Added
- 관리자 로그인 화면의 2단계 인증(인증번호 입력·다시 받기·처음부터·유효 시각)과 계정 잠금 해제 시각 안내의 일본어 번역을 추가했습니다.
- 환경설정 > 드라이버 「공개 자산 스토리지」 설명에 추가된 문장(레이아웃 배경 이미지 포함, 켠 뒤 올린 파일만 직접 주소로 발급)의 일본어 번역을 갱신했습니다.
## [1.0.8] - 2026-09-06
### Added
@@ -1697,7 +1697,7 @@
},
"public_asset": {
"title": "公開アセットストレージ",
"desc": "完全公開アセット(商品・カテゴリ・レビュー・エディタ画像など)を直接URLで配信するディスクを設定します。使用しない場合は既存のストリーミング方式で動作します。",
"desc": "完全公開アセット(商品・カテゴリ・レビュー・エディタ画像、レイアウト背景画像など)を直接URLで配信するディスクを設定します。使用しない場合は既存のストリーミング方式で動作します。有効化後にアップロードしたファイルのみ直接URLが発行され、そのアドレスは保存されたレイアウトにそのまま残ります。",
"disk": "ディスク",
"s3_help": "S3を選択する場合は、上のファイルストレージカードのS3 URL(CDNドメイン)設定が必要です。未設定の場合はストリーミングで動作します。"
},
@@ -8,6 +8,19 @@
"processing": "処理中...",
"remember": "ログイン状態を保持する",
"forgot": "パスワードをお忘れですか?",
"two_factor": {
"sent": "認証番号を送信しました。受け取った番号を入力してログインを完了してください。",
"resent": "認証番号を再送信しました。新しく受け取った番号を入力してください。",
"code_label": "認証番号",
"code_placeholder": "受け取った認証番号を入力してください",
"valid_until": "有効期限 {{until}} まで",
"verify": "認証番号を確認",
"verifying": "確認中...",
"resend": "認証番号を再送信",
"restart": "最初から"
},
"locked_until": "解除予定: {{until}}",
"locked_permanent": "管理者にお問い合わせのうえロックを解除してください。",
"error": {
"email_required": "メールアドレスを入力してください。",
"email_invalid": "正しいメールアドレスを入力してください。",
@@ -12,7 +12,7 @@
"en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)",
"ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)"
},
"version": "1.0.8",
"version": "1.0.9",
"license": "MIT",
"scope": "template",
"target_identifier": "sirsoft-admin_basic",
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.1.3] - 2026-09-09
### Added
- 로그인 화면의 2단계 인증(인증번호 입력·다시 받기·처음부터·유효 시각)과 계정 잠금 해제 시각 안내의 일본어 번역을 추가했습니다.
## [1.1.2] - 2026-08-24
### Fixed
@@ -141,6 +141,19 @@
"minTypes": "{{count}}種類以上の組み合わせ (大文字/小文字、数字、特殊文字)"
}
},
"two_factor": {
"sent": "認証番号を送信しました。受け取った番号を入力してログインを完了してください。",
"resent": "認証番号を再送信しました。新しく受け取った番号を入力してください。",
"code_label": "認証番号",
"code_placeholder": "受け取った認証番号を入力してください",
"valid_until": "有効期限 {{until}} まで",
"verify": "認証番号を確認",
"verifying": "確認中...",
"resend": "認証番号を再送信",
"restart": "最初から"
},
"locked_until": "解除予定: {{until}}",
"locked_permanent": "管理者にお問い合わせのうえロックを解除してください。",
"session_expired_toast": "セッションが期限切れになりました。もう一度ログインしてください。",
"guest_checkout": {
"divider": "または",
@@ -12,7 +12,7 @@
"en": "G7 template (sirsoft-basic) Japanese language pack (bundled)",
"ja": "G7 テンプレート (sirsoft-basic) 日本語 言語パック(バンドル)"
},
"version": "1.1.2",
"version": "1.1.3",
"license": "MIT",
"scope": "template",
"target_identifier": "sirsoft-basic",
+1
View File
@@ -38,6 +38,7 @@ return [
'account_pending_verification' => 'Identity verification is not complete. Please complete the email verification.',
'account_locked' => 'Too many failed login attempts. Your account is locked for :minutes minute(s).',
'account_locked_permanently' => 'Too many failed login attempts. Your account has been locked. Please contact an administrator.',
'too_many_attempts' => 'Too many requests. Please try again in :seconds second(s).',
'account_unlocked' => 'The account lock has been released.',
// Password reset
+1
View File
@@ -31,6 +31,7 @@ return [
'admin_policy_has_no_default' => 'Admin-created policies do not have a declared default.',
'reset_field_failed' => 'Failed to reset the field to its declared default. Check if the field is valid.',
'cannot_delete_system_policy' => 'System-declared policies cannot be deleted. Only administrator-created policies can be deleted.',
'purpose_not_allowed' => 'This verification request cannot be handled on this screen.',
],
'messages' => [
+1
View File
@@ -38,6 +38,7 @@ return [
'account_pending_verification' => '본인인증이 완료되지 않은 계정입니다. 이메일 인증을 완료해주세요.',
'account_locked' => '로그인 시도 횟수 초과로 계정이 잠겼습니다. :minutes분 후 다시 시도해주세요.',
'account_locked_permanently' => '로그인 시도 횟수 초과로 계정이 잠겼습니다. 관리자에게 문의해주세요.',
'too_many_attempts' => '요청이 너무 잦습니다. :seconds초 후에 다시 시도해주세요.',
'account_unlocked' => '계정 잠금이 해제되었습니다.',
// 비밀번호 재설정
+1
View File
@@ -31,6 +31,7 @@ return [
'admin_policy_has_no_default' => '관리자가 직접 생성한 정책에는 선언 기본값이 없습니다.',
'reset_field_failed' => '선언 기본값 복원에 실패했습니다. 필드가 유효한지 확인하세요.',
'cannot_delete_system_policy' => '시스템이 선언한 정책은 삭제할 수 없습니다. 관리자가 직접 생성한 정책만 삭제할 수 있습니다.',
'purpose_not_allowed' => '이 인증 요청은 이 화면에서 처리할 수 없습니다.',
],
'messages' => [
+18
View File
@@ -320,6 +320,8 @@
"blocked_inactive_extension": "Cannot save — newly added components belong to a deactivated extension",
"network_error_title": "Network error while saving",
"guard_no_document": "No layout document loaded — nothing to save",
"guard_extension_reassembly_title": "Not saved — extension content could not be mapped back",
"guard_extension_reassembly": "{count} element(s) injected by this extension could not be traced to their original slot. Saving would drop them, so the save was blocked. Reload the page and try again.",
"concurrent": {
"title": "Another user saved first",
"message": "This layout was saved by another administrator. Load the latest version?",
@@ -343,6 +345,9 @@
"mode_tile": "Tile",
"upload_failed": "Image upload failed"
},
"number": {
"placeholder": "Enter a number"
},
"dimension": {
"placeholder": "e.g. 320px, 50%, 24rem, auto"
},
@@ -356,6 +361,11 @@
"right": "Right",
"bottom": "Bottom",
"left": "Left"
},
"bound_value": {
"notice": "Bound to data (filled automatically)",
"replace": "Replace with a fixed value",
"restore": "Restore the data binding"
}
},
"recipe": {
@@ -1301,6 +1311,14 @@
"label": "Handle login",
"param_body": "Login info"
},
"login_two_factor": {
"label": "Verify code",
"param_body": "Verification info"
},
"login_two_factor_resend": {
"label": "Resend code",
"param_body": "Challenge info"
},
"logout": {
"label": "Handle logout",
"param_target": "Destination"
+18
View File
@@ -320,6 +320,8 @@
"blocked_inactive_extension": "비활성화된 확장 컴포넌트가 새로 추가되어 저장할 수 없습니다",
"network_error_title": "저장 중 네트워크 오류가 발생했습니다",
"guard_no_document": "저장할 레이아웃 문서가 로드되지 않았습니다",
"guard_extension_reassembly_title": "확장 내용을 되돌릴 수 없어 저장하지 않았습니다",
"guard_extension_reassembly": "이 확장이 끼워 넣은 요소 {count}개가 어느 자리에서 왔는지 확인되지 않습니다. 그대로 저장하면 그 요소가 사라지므로 저장을 막았습니다. 페이지를 새로고침한 뒤 다시 시도해 주세요.",
"concurrent": {
"title": "다른 사용자가 먼저 저장했습니다",
"message": "이 레이아웃은 다른 관리자가 먼저 저장했습니다. 최신 버전을 불러오시겠습니까?",
@@ -343,6 +345,9 @@
"mode_tile": "타일",
"upload_failed": "이미지 업로드에 실패했습니다"
},
"number": {
"placeholder": "숫자 입력"
},
"dimension": {
"placeholder": "예: 320px, 50%, 24rem, auto"
},
@@ -356,6 +361,11 @@
"right": "오른쪽",
"bottom": "아래",
"left": "왼쪽"
},
"bound_value": {
"notice": "데이터 연결 값입니다(자동으로 채워집니다)",
"replace": "직접 지정으로 바꾸기",
"restore": "데이터 연결로 되돌리기"
}
},
"recipe": {
@@ -1301,6 +1311,14 @@
"label": "로그인 처리",
"param_body": "로그인 정보"
},
"login_two_factor": {
"label": "인증번호 확인",
"param_body": "인증 정보"
},
"login_two_factor_resend": {
"label": "인증번호 다시 받기",
"param_body": "인증 요청 정보"
},
"logout": {
"label": "로그아웃 처리",
"param_target": "이동할 곳"
@@ -19,6 +19,7 @@
### Changed
- 확장 문서(README · AGENTS.md · docs/README.md)의 제목에 「그누보드7」과 확장 유형을 함께 표기했습니다. 제목만 보고도 그누보드7의 어떤 종류 확장인지 알 수 있습니다.
- Composer 잠금 파일을 확장 정보와 다시 맞췄습니다. 설치·업데이트 과정에서 잠금 파일이 오래되었다는 경고가 나오지 않습니다.
## [0.1.1] - 2026-08-10
+2 -2
View File
@@ -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": "4eddbf75b302677f7e516f8a10d236dc",
"content-hash": "71bfa007f76af57a7cc3d98cc663b171",
"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"
}
@@ -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 형식으로 갱신했습니다.
### Fixed
- 첨부파일을 올린 뒤 돌려주는 응답의 파일 주소 칸이 늘 비어 있던 문제를 수정했습니다. 이 칸을 읽어 연동하던 외부 도구는 주소를 얻지 못했습니다. 이제 게시판의 첨부 다운로드 주소가 채워지며, 이 주소는 비밀글·삭제글 확인을 그대로 거치므로 볼 수 없는 첨부가 열리지는 않습니다.
## [1.1.1] - 2026-09-06
### Added
+1 -1
View File
@@ -5,7 +5,7 @@
<!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
<p align="center">
<img src="https://img.shields.io/badge/version-1.1.1-0066FF?style=flat-square" alt="version 1.1.1">
<img src="https://img.shields.io/badge/version-1.1.2-0066FF?style=flat-square" alt="version 1.1.2">
<img src="https://img.shields.io/badge/type-%EB%AA%A8%EB%93%88-555555?style=flat-square" alt="type 모듈">
<img src="https://img.shields.io/badge/%EA%B7%B8%EB%88%84%EB%B3%B4%EB%93%9C7-%3E%3D7.0.10-1F883D?style=flat-square" alt="그누보드7 &gt;=7.0.10">
<img src="https://img.shields.io/badge/license-MIT-8250DF?style=flat-square" alt="license MIT">
+1 -1
View File
@@ -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": {
+2 -2
View File
@@ -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"
}

Some files were not shown because too many files have changed in this diff Show More