Merge pull request from gnuboard:HeuJung/issue651
fix(core,admin_basic): 정적 게시 재게시 누락 3건 수정 + 관리자 수동 복구 UI + sudo core:update 소유권 정합
This commit is contained in:
@@ -138,3 +138,16 @@ G7_UPDATE_PENDING_PATH=
|
||||
# - abort : 즉시 abort + 운영자에게 수동 명령 안내 (안전 우선, stale 메모리 fatal 차단)
|
||||
# - fallback : in-process fallback 진행 (proc_open 미지원 공유 호스팅 호환, stale 메모리 위험 잔존)
|
||||
# G7_UPDATE_SPAWN_FAILURE_MODE=abort
|
||||
|
||||
# 부트스트랩 리소스 정적 게시 (public/build/ext) — 끄면 전면 API 폴백(종전 동작).
|
||||
# 상태·수동 복구: php artisan ext-static:status / 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」
|
||||
# G7_STATIC_CACHE=true
|
||||
|
||||
# 코어 업데이트 경로 목록 (선택). 재정의 시 **전체 목록을 다시 적는다** — 부분값은 기본 항목을
|
||||
# 통째로 대체한다 (예: excludes 에서 build/ext 가 빠지면 --prune 이 정적 게시본을 지운다).
|
||||
# 기본값은 config/app.php 의 update 절과 같다.
|
||||
# G7_UPDATE_EXCLUDES=node_modules,.git,bootstrap/cache,build/ext
|
||||
# G7_UPDATE_TARGETS=app,bootstrap,config,database,docs,lang,lang-packs/_bundled,resources,routes,public,tests,upgrades,scripts,artisan,composer.json,composer.json.default,composer.lock,package.json,package-lock.json,vite.config.js,vite.config.core.js,vite.config.editor.js,vite.config.devtools.js,vite.config.devdashboard.js,vitest.config.ts,playwright.config.ts,tsconfig.json,phpunit.xml,pint.json,.editorconfig,.gitattributes,.gitignore,README.md,README.ko.md,CHANGELOG.md,modules/_bundled,plugins/_bundled,templates/_bundled
|
||||
# G7_UPDATE_PROTECTED_PATHS= (기본 목록은 config/app.php 의 update.protected_paths — 재정의할 때 그 목록을 그대로 옮겨 적고 항목을 덧붙인다)
|
||||
# G7_UPDATE_RESTORE_OWNERSHIP=storage/logs,storage/framework,storage/app/core_pending,storage/app/extension_backups,storage/app/core_backups,bootstrap/cache,vendor,modules,modules/_pending,plugins,plugins/_pending,templates,templates/_pending,lang-packs,lang-packs/_pending,public/build/ext
|
||||
# G7_UPDATE_RESTORE_OWNERSHIP_GROUP_WRITABLE=storage/logs,storage/framework,storage/app/core_pending,storage/app/extension_backups,storage/app/core_backups,bootstrap/cache,vendor,modules,modules/_pending,plugins,plugins/_pending,templates,templates/_pending,lang-packs,lang-packs/_pending,public/build/ext
|
||||
|
||||
@@ -628,6 +628,24 @@ TLS 가 앞단에서 종단되고 앱에는 HTTP 로 전달되는 구성(AWS ALB
|
||||
|
||||
> 상세: [api-resources.md](docs/backend/api-resources.md), [service-repository.md](docs/backend/service-repository.md)
|
||||
|
||||
### 확장 캐시 버전은 트레이트 게터로만 읽고, 만료시키지 않는다
|
||||
|
||||
확장 캐시 버전(`ext.cache_version`)은 모든 자산 URL 의 `?v=`·정적 게시본(`public/build/ext/{v}/`)·병합 번들 파일명의 좌표다. 그 키를 올리는 단일 지점(`incrementExtensionCacheVersion()`)이 재게시까지 예약하므로, 게시본에 구워지는 입력이 바뀌는 경로는 전부 그 지점을 타야 하고, 키 자체는 만료로 재생성되어서는 안 된다 — 재생성은 정적 파일 전체 재생성이자 전 방문자의 자산 URL 변경이다.
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
|------|------------|
|
||||
| `$cache->get('ext.cache_version', 0)` / `app(CacheInterface::class)->get(...)` / `->forget('ext.cache_version')` 원시 접근 | `ExtensionStaticCacheService::getExtensionCacheVersion()` 또는 트레이트를 조합한 클래스 안의 `self::getExtensionCacheVersion()` — 부재 시 재생성하므로 `cache:clear` 직후 0 이 새지 않고, 컨테이너 바인딩의 확장 네임스페이스 누수도 없다 |
|
||||
| 트레이트 정적 메서드를 트레이트 이름으로 직접 호출 (`ClearsTemplateCaches::getExtensionCacheVersion()`) | 트레이트를 조합한 코어 클래스 경유 — PHP 8.1+ E_DEPRECATED 이고, 트레이트 상수는 `self::` 로 읽을 수 없다 |
|
||||
| 게시본에 구워지는 설정값(`general.asset_url_mode`)을 바꾸는 경로에서 bump 누락 | 저장·단건 저장·복원·CLI 어느 경로든 `incrementExtensionCacheVersion()` — 번들 CSS 안의 `url()` 형태가 본문에 구워져 있다 |
|
||||
| 게시 입력이 바뀌는 수명주기 경로를 조건부 bump 에 위임 (템플릿 update 가 레이아웃 변경 건수 > 0 일 때만) | 모듈·플러그인과 동형으로 무조건 bump — 조건은 게시 입력 중 레이아웃 축만 본다 |
|
||||
| custom 변경 감지 서명과 게시 복사 집합을 서로 다른 코드가 정의 | 같은 열거자(`CustomAssets::publishableFiles()`) — 감지가 최상위 css/js 만 보면 하위 글꼴·이미지 교체가 영영 미게시다 |
|
||||
| 버전 키·서명 키를 기본 TTL 로 `put()` | `PERSISTENT_TTL_SECONDS`(10년) 명시 — `forever()` 는 `CacheInterface` 밖(공개 표면 변경), `put(…, 0)` 은 forget |
|
||||
| 서명 스코프를 렌더 템플릿만으로 나눔 | `{템플릿}@{호스트명}` — 다중 서버 공유 캐시에서 서버 간 mtime 차이로 요청마다 재게시가 왕복한다 |
|
||||
|
||||
이 결함군은 예외도 로그도 남기지 않는다 — 게시본이 정상 200 으로 옛 내용을 내보내는 것, 또는 매일 전체 재생성이 일어나는 것이 유일한 증상이다. 재게시 누락의 안전망은 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」의 [지금 다시 만들기](`POST /api/admin/settings/static-cache/republish`)이며, 상태 판정은 `ExtensionStaticCacheService::statusReport()` 한 곳이 CLI·API·화면에 공급한다.
|
||||
|
||||
> 상세: [static-asset-publishing.md](docs/backend/static-asset-publishing.md) "버전은 만료되지 않는다" · "5-1. 관리자 화면에서의 수동 복구"
|
||||
|
||||
### 저장값 + 확장 카탈로그 병합 설정의 공개 응답
|
||||
|
||||
설정 항목이 "운영자 저장값 + 확장이 훅으로 등록한 카탈로그" 의 병합으로 만들어지면, 저장값은 남아 있는데 카탈로그에서 항목이 사라지는 상태가 생긴다 — 그 확장을 삭제·비활성화했거나, 확장이 자기 기능 토글을 껐을 때다. 병합부는 이를 고아 항목으로 표시하지만 저장값의 `is_active` 는 참 그대로 남는다.
|
||||
|
||||
+9
-6
@@ -11,13 +11,13 @@
|
||||
- 화면 구동에 필요한 아이콘·글꼴·편집기 등을 외부 CDN 이 아니라 사이트 자신의 서버에서 불러옵니다. 폐쇄망이나 외부 접속이 제한된 환경에서도 관리자·사용자 화면이 정상 동작합니다. 외부 연결이 필요한 기능은 주소 검색 하나만 남았습니다. (#123 @bigmsg 님께서 건의해주셨습니다.)
|
||||
- 운영자가 자기 CSS·JS 를 덧붙일 자리를 각 확장이 제공합니다. 확장 디렉토리의 `custom/` 에 파일을 놓으면 빌드 없이 바로 적용되고, 확장을 업데이트해도 그 파일은 지워지지 않습니다. 적용 순서는 항상 확장 스타일보다 뒤라서 재정의가 그대로 반영됩니다. (sir.kr 커뮤니티에서 문의해주신 내용입니다.)
|
||||
- 화면에 필요한 파일을 끝내 불러오지 못하면 안내와 [다시 시도] 를 표시합니다. 아이콘 글꼴이나 본문 글꼴처럼 페이지가 직접 불러오는 파일도 대상이라, 아이콘이 통째로 사라져 버튼을 못 누르게 되는 상황에서도 원인이 화면에 남습니다. 종전에는 아무 표시 없이 기능만 사라져 원인을 알 수 없었습니다. (#123 @bigmsg 님께서 건의해주셨습니다.)
|
||||
- 설치 마법사와 개발 대시보드도 외부 CDN 없이 동작합니다.
|
||||
- 설치 마법사와 개발 대시보드도 외부 CDN 없이 동작합니다. 설치 마지막 단계의 선택 작업(초기 화면 파일 게시·설정 캐시 생성 등)이 실패하면 어떤 작업이 실패했는지 그 이름으로 안내합니다.
|
||||
- 확장을 삭제할 때 `custom/` 에 넣어 둔 운영자 파일을 먼저 사본으로 보관하고, 그 보관 경로를 삭제 결과에 함께 알립니다. 관리자 화면과 콘솔 양쪽에서 확인할 수 있습니다.
|
||||
- 운영자가 덧붙인 CSS·JS·글꼴·이미지를 레이아웃 편집기 화면에서 직접 넣고 고칠 수 있습니다. 서버 접속 없이 파일을 만들고 편집하고 지울 수 있으며, 저장하면 다음 화면부터 바로 반영됩니다. 편집 중인 템플릿뿐 아니라 설치된 모듈·플러그인도 대상으로 고를 수 있습니다. 이 기능은 레이아웃 편집과 별도의 「커스텀 자산 관리」 권한으로 열립니다 — 여기서 올린 스크립트는 사이트 전 화면에서 실행되기 때문입니다. (#123 @bigmsg 님께서 건의해주셨습니다.)
|
||||
- 운영자가 덧붙인 CSS·JS·글꼴·이미지를 레이아웃 편집기 화면에서 직접 넣고 고칠 수 있습니다. 서버 접속 없이 파일을 만들고 편집하고 지울 수 있으며, 저장하면 다음 화면부터 바로 반영됩니다. 편집 중인 템플릿뿐 아니라 설치된 모듈·플러그인도 대상으로 고를 수 있습니다. 이 기능은 레이아웃 편집과 별도의 「커스텀 자산 관리」 권한으로 열립니다 — 여기서 올린 스크립트는 사이트 전 화면에서 실행되기 때문입니다. 저장할 폴더를 만들지 못하면 사유·소유자·권한·실행 계정과 조치 예시를 함께 안내합니다. (#123 @bigmsg 님께서 건의해주셨습니다.)
|
||||
- 주소 끝에 `?custom=off` 를 붙여 열면 운영자가 추가한 CSS·JS 없이 화면이 표시됩니다. 추가한 스타일이 화면을 망가뜨려 고치러 들어갈 수조차 없게 된 상황에서 쓰는 탈출구이며, 레이아웃 편집기 툴바에도 같은 동작의 버튼이 있습니다. 이 설정은 저장되지 않고 그 화면에만 적용됩니다.
|
||||
- 템플릿·모듈·플러그인의 `custom/` 파일이 다른 확장 자산과 같은 방식으로 정적 파일로 게시됩니다. CSS 안에서 글꼴·이미지를 상대 경로(`url('./font.woff2')`)로 참조할 수 있게 되었고, 파일을 고치면 자동으로 다시 게시됩니다.
|
||||
- 초기 화면에 필요한 다국어·컴포넌트 정의·라우트 정보·확장 번들·템플릿 에셋을 정적 파일로 미리 만들어 웹서버가 직접 전달합니다. 확장 설치/활성화나 레이아웃 편집 시 자동으로 다시 생성되며, 파일이 없으면 기존 방식으로 동작합니다. 초기 화면 표시가 빨라집니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 관리자 대시보드에 초기 화면 파일 생성 실패 알림이 추가되었습니다. 원인(폴더 권한·디스크 공간·캐시)에 따라 다른 안내가 표시되며, 서버에서 `php artisan ext-static:status` 로 더 자세한 상태를 확인할 수 있습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 템플릿·모듈·플러그인의 `custom/` 파일이 다른 확장 자산과 같은 방식으로 정적 파일로 게시됩니다. CSS 안에서 글꼴·이미지를 상대 경로(`url('./font.woff2')`)로 참조할 수 있게 되었고, 파일을 고치면 자동으로 다시 게시됩니다. 하위 폴더의 글꼴·이미지만 바꿔도 감지되어 다시 게시되며, 여러 서버가 캐시를 공유하는 구성에서도 서버마다 따로 판정해 불필요한 재게시가 반복되지 않습니다.
|
||||
- 초기 화면에 필요한 다국어·컴포넌트 정의·라우트 정보·확장 번들·템플릿 에셋을 정적 파일로 미리 만들어 웹서버가 직접 전달합니다. 확장 설치/활성화나 레이아웃 편집 시 자동으로 다시 생성되며, 파일이 없으면 기존 방식으로 동작합니다. 초기 화면 표시가 빨라집니다. 관리자 > 환경설정 > 일반 의 「초기 화면 정적 파일」에서 게시 상태(버전·파일 수·마지막 게시 시각·최근 실패)를 확인하고 「지금 다시 만들기」로 즉시 다시 만들 수 있습니다. 게시 파일의 버전 번호는 변경이 있을 때만 바뀝니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 관리자 대시보드에 초기 화면 파일 생성 실패 알림이 추가되었습니다. 원인(폴더 권한·디스크 공간·캐시)에 따라 다른 안내가 표시되며, 서버에서 `php artisan ext-static:status` 로 더 자세한 상태를 확인할 수 있습니다. 알림의 「다시 만들기」 버튼으로 그 자리에서 바로 다시 만들 수 있습니다. 상태·게시 명령을 root 로 실행하면 캐시 폴더가 root 소유가 될 수 있음을 경고하고 웹 서버 계정으로 실행하는 명령 형태를 안내합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- AWS ALB·CloudFront·Cloudflare·nginx 처럼 HTTPS 를 앞단에서 처리하고 사이트에는 HTTP 로 전달하는 구성을 지원합니다. `.env` 에 `TRUSTED_PROXIES` 를 지정하면 사이트가 접속 주소를 올바르게 인식해 화면이 정상 표시되고, 게시글 작성 IP·로그인 시도 제한·결제 통보 수신도 실제 방문자 기준으로 동작합니다. 지정하지 않으면 종전과 동일하게 동작합니다. (#124 @lyg-kaban 님께서 건의해주셨습니다.)
|
||||
- 프록시 뒤에서 구동 중인데 신뢰 프록시가 지정되지 않았으면 관리자 대시보드가 그 사실을 알립니다. 환경설정 > 고급 에서 사이트가 인식한 접속 방식과 방문자 IP 를 확인할 수 있고, 서버에서 `php artisan trusted-proxy:status` 로도 확인할 수 있으며, 설치 마법사도 설치 단계에서 함께 안내합니다. HTTPS 를 쓰지 않는 사이트도 대상입니다 — 이 경우 화면은 정상이지만 방문자 IP 기록과 결제 통보 수신이 어긋나 있어도 드러나지 않기 때문입니다. (#124 @lyg-kaban 님께서 건의해주셨습니다.)
|
||||
- 사이트 설정 문제로 화면 구성 파일이 브라우저에 차단된 경우, 네트워크 오류와 구분되는 안내를 표시합니다. 새로고침해도 낫지 않는 상황이므로 [새로고침] 버튼을 두지 않으며, 원인과 조치 방법은 운영자가 확인할 수 있도록 브라우저 콘솔에 남깁니다. (#124 @lyg-kaban 님께서 건의해주셨습니다.)
|
||||
@@ -56,7 +56,7 @@
|
||||
- 설치 마법사에서 PHP·Composer 경로가 거부될 때 안내 문구가 실제 허용 범위와 달라, 안내대로 고쳐도 계속 거부되던 문제를 수정했습니다. 이제 파일 이름 조건과 사용할 수 없는 경로 형태(네트워크 경로·scheme:// 등)를 문구에 함께 안내합니다.
|
||||
- 같은 스크립트를 거의 동시에 두 번 불러오면, 두 번째 요청이 첫 번째 로드가 끝나기 전에 완료된 것으로 처리되어 그 뒤 동작이 아무 반응 없이 끝나던 문제를 수정했습니다. 이제 두 요청 모두 실제 로드가 끝난 뒤에 이어집니다.
|
||||
- 디버그 모드에서도 디버그 도구의 조회 주소(상태·액션 이력·캐시·변경 감지)가 사용자 화면에 가려져 응답하지 못하던 문제를 수정했습니다. 사이트가 미리 예약해 둔 주소는 사용자 화면 처리에서 제외됩니다. (#128 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 「자산 주소에 확장자 사용 안 함」 설정을 켠 사이트에서 글꼴과 국기 아이콘이 표시되지 않던 문제를 수정했습니다. 스타일시트가 그 안에서 상대 경로로 가리키던 글꼴·이미지 파일을 브라우저가 엉뚱한 주소로 찾아 불러오지 못했고, 화면에는 기본 서체와 빈 아이콘만 보였습니다. 이제 스타일시트를 내보낼 때 그 경로를 올바른 주소로 바꿔 전달합니다.
|
||||
- 「자산 주소에 확장자 사용 안 함」 설정을 켠 사이트에서 글꼴과 국기 아이콘이 표시되지 않던 문제를 수정했습니다. 스타일시트가 그 안에서 상대 경로로 가리키던 글꼴·이미지 파일을 브라우저가 엉뚱한 주소로 찾아 불러오지 못했고, 화면에는 기본 서체와 빈 아이콘만 보였습니다. 이제 스타일시트를 내보낼 때 그 경로를 올바른 주소로 바꿔 전달합니다. 설정을 바꾸면(화면 저장·단건 저장·설정 복원·명령줄 어느 경로든) 스타일시트와 초기 화면 파일이 곧바로 새 주소 방식으로 다시 만들어집니다.
|
||||
- 스타일 안에서 글꼴·이미지를 상대 경로로 가리키는 확장이 있으면, 그 확장의 스타일이 화면에 하나도 적용되지 않던 문제를 수정했습니다. 여러 확장의 스타일을 하나로 합쳐 전달하는 과정에서 그런 확장을 통째로 빼고 있었고, 빠졌다는 사실은 어디에도 표시되지 않았습니다. 이제 빼는 대신 그 경로를 올바른 주소로 바꿔 함께 전달합니다.
|
||||
- 확장을 활성화할 때 스크립트가 이미 있으면 그 확장의 스타일(CSS)까지 함께 건너뛰던 문제를 수정했습니다. 스타일만 제공하는 확장은 활성화해도 스타일이 적용되지 않았습니다.
|
||||
- 실제 화면 동작에 쓰이는 라이브러리(axios·laravel-echo·pusher-js)가 개발용으로 분류돼 있어 보안 점검에서 빠지던 문제를 수정했습니다. 이제 점검 대상에 포함되며, 함께 확인된 axios 취약점도 1.20.0 으로 올려 해소했습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
@@ -65,6 +65,9 @@
|
||||
- 상품 상세에서 옵션을 고른 뒤 「바로 구매」·「장바구니 담기」가 동작하지 않던 문제를 수정했습니다. 화면에는 고른 옵션이 목록에 담긴 것으로 보이는데 실제 요청에는 아무 옵션도 실리지 않아, 「바로 구매」는 오류로 끝나고 「장바구니 담기」는 「옵션을 선택해주세요」 안내만 반복됐습니다. 옵션 조합을 두 번 담으면 먼저 담은 조합이 사라지는 것도 같은 원인입니다. 상품 추가옵션(각인·포장 등) 선택도 함께 정상화됐습니다.
|
||||
- 서버측 라이브러리 guzzle·commonmark 의 알려진 취약점 12건을 해소했습니다. 결제·본인인증·알림 발송처럼 외부와 통신하는 경로가 이 라이브러리를 사용합니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 초기 화면 파일을 명령줄과 웹이 번갈아 만들 때, 나중에 생기는 하위 폴더가 한쪽 계정 전용으로 남아 다른 쪽이 쓰지 못하던 문제를 수정했습니다. 게시 폴더가 그룹 권한을 하위 폴더에 물려주도록 정리합니다(Linux·macOS).
|
||||
- 템플릿을 업데이트해도 레이아웃이 바뀌지 않은 릴리스(다국어·라우트·컴포넌트 정의·에셋만 바뀐 경우)에서는 다국어·라우트·에셋 캐시가 갱신되지 않아 이전 내용이 계속 표시되던 문제를 수정했습니다. 모듈·플러그인 업데이트와 같이 항상 갱신됩니다.
|
||||
- 자산 캐시 번호가 하루마다 자동으로 바뀌어 매일 모든 방문자가 자산을 다시 내려받고 서버가 초기 화면 파일 전체를 다시 만들던 문제를 수정했습니다. 이제 확장 설치·업데이트, 설정 변경, 운영자 파일 변경처럼 실제 변경이 있을 때만 바뀝니다.
|
||||
- `sudo` 로 코어를 업데이트한 뒤 모듈·플러그인 설정 저장이나 관리자 화면의 확장 업데이트가 폴더 권한 오류로 실패할 수 있던 문제를 수정했습니다. 업데이트 과정이 만드는 설정 폴더·임시 폴더·로그 파일의 소유권을 웹 서버 계정으로 맞춥니다. 설치 안내(INSTALL.md)에 명령줄·cron 을 웹 서버 계정으로 실행하는 규칙과 cron 예시를 추가했습니다.
|
||||
- 확장 설치가 의존성·버전 검사에서 실패해도 복사된 파일이 남아, 목록에도 보이지 않는 디렉토리가 쌓이던 문제를 수정했습니다. 실패한 설치는 이번에 만든 파일을 되돌립니다(이미 설치돼 있던 확장을 다시 설치하다 실패한 경우에는 기존 파일을 건드리지 않습니다).
|
||||
- 사이트 첫 접속 시 확장 캐시 버전이 어긋나 있으면 라우트·다국어 데이터를 두 번 내려받던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 레이아웃 props 의 `$switch` 조건 분기 값이 검색엔진(봇) 화면에서는 해석되지 않아 해당 속성이 표시되지 않던 문제를 수정했습니다. 이제 일반 화면과 봇 화면이 동일하게 분기 값을 렌더링합니다.
|
||||
|
||||
+9
-1
@@ -418,6 +418,12 @@ sudo chown -R www-data:www-data storage bootstrap/cache modules plugins template
|
||||
|
||||
전체 권한 모델(그룹 공유 방식 A / 소유자 통일 방식 B / ACL 방식 C 와 umask 운영)은 [docs/requirements.md](docs/requirements.md) "파일 권한 및 umask 운영 방식" 절을 참고하세요.
|
||||
|
||||
### 명령줄·cron 실행 계정
|
||||
|
||||
`php artisan` 명령과 cron 은 **웹 서버 실행 계정으로** 실행합니다 (예: `sudo -u www-data php artisan …`, 또는 `crontab -u www-data -e` 로 등록). 대부분의 명령이 캐시·병합 번들·임시 파일을 만드는데, root 나 다른 계정으로 실행하면 그 산출물이 그 계정 소유로 남아 이후 웹 요청이 같은 자리에 쓰려는 순간 `Permission denied` 로 500 을 낼 수 있습니다. 이 문제는 특정 명령의 결함이 아니라 캐시를 건드리는 모든 명령에 공통이므로, 계정을 맞추는 것이 유일한 해법입니다.
|
||||
|
||||
예외는 코어 업데이트(`php artisan core:update`)입니다. 이 명령은 `.env` 등 웹 계정이 쓸 수 없는 파일까지 고치므로 `sudo` 를 권장하며, 실행이 끝날 때 자기가 만든 파일의 소유권을 되돌리는 절차가 내장되어 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 업그레이드
|
||||
@@ -665,7 +671,9 @@ G7_ENV_PRIORITY=true
|
||||
cron에 아래 항목을 등록합니다.
|
||||
|
||||
```bash
|
||||
* * * * * cd /path/to/g7 && php artisan schedule:run >> /dev/null 2>&1
|
||||
* * * * * cd /path/to/g7 && sudo -u www-data php artisan schedule:run >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
웹 서버 계정(`www-data` · `nginx` · `apache` 등)으로 실행해야 합니다 — root 의 crontab 에 그대로 등록하면 매분 캐시 파일이 root 소유로 만들어져 웹 요청이 실패할 수 있습니다. `crontab -u www-data -e` 로 그 계정의 crontab 에 등록하면 `sudo -u` 없이 같은 줄을 씁니다. 「명령줄·cron 실행 계정」 절을 참고하세요.
|
||||
|
||||
> 상세 내용은 [docs/requirements.md](docs/requirements.md)를 참조하세요.
|
||||
|
||||
@@ -1371,44 +1371,17 @@ class CoreUpdateCommand extends Command
|
||||
* - `root_web_symmetric` : root 실행 + 웹서버 계정이 root 로 추정됨 (root 서비스 구성).
|
||||
* - `root_web_unknown` : root 실행 + 웹서버 계정 추정 실패 (스냅샷/추정 불가).
|
||||
*
|
||||
* 웹서버 계정은 `FilePermissionHelper::inferWebServerOwnership()` 이 storage/bootstrap
|
||||
* 쓰기 영역 소유자로 추정한다.
|
||||
* 판정은 `FilePermissionHelper::describeWebServerAccount()` 가 소유한다 — 정적 게시 상태·게시
|
||||
* 명령(`ext-static:*`)의 root 경고와 같은 4분기를 공유하기 위해 옮겼다(#651 C1). 이 메서드는
|
||||
* 그 결과를 종전 반환 형태(`[모드, 계정명]`)로 바꿔 주는 위임만 한다.
|
||||
*
|
||||
* @return array{0: string, 1: string|null} [모드, 웹서버 계정명 또는 null]
|
||||
*/
|
||||
private function classifyResumeExecutionContext(): array
|
||||
{
|
||||
if (! function_exists('posix_geteuid') || ! function_exists('posix_getpwuid')) {
|
||||
return ['non_root', null];
|
||||
}
|
||||
$account = FilePermissionHelper::describeWebServerAccount();
|
||||
|
||||
// root(sudo) 실행이 아니면 일반 SSH 사용자 = 파일 소유자 → 권한 분기 안내 불필요.
|
||||
// (공유 호스팅에서 웹서버·PHP·실행 유저가 같은 경우도 여기서 non_root 로 처리됨.)
|
||||
if (posix_geteuid() !== 0) {
|
||||
return ['non_root', null];
|
||||
}
|
||||
|
||||
[$owner] = FilePermissionHelper::inferWebServerOwnership();
|
||||
|
||||
// 추정 실패 (스냅샷 불가) — 계정명 미상 경고 경로.
|
||||
if ($owner === false) {
|
||||
return ['root_web_unknown', null];
|
||||
}
|
||||
|
||||
// 웹서버 계정이 root 로 추정됨 — root 로 서비스하는 구성이라 재실행도 root 로 무해.
|
||||
if ($owner === 0) {
|
||||
return ['root_web_symmetric', null];
|
||||
}
|
||||
|
||||
$entry = posix_getpwuid($owner);
|
||||
$name = $entry['name'] ?? null;
|
||||
|
||||
// uid 는 나왔지만 이름 해석 실패 — 미상 경로로 처리 (uid 노출은 오히려 혼란).
|
||||
if ($name === null) {
|
||||
return ['root_web_unknown', null];
|
||||
}
|
||||
|
||||
return ['root_web_known', $name];
|
||||
return [$account['mode'], $account['name']];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,6 +38,14 @@ class PublishExtensionStaticCacheCommand extends Command
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// root 로 실행 중이면 경고만 하고 **계속 진행**한다 — 명시적 명령은 운영자 책임이고, 중단 가드는
|
||||
// 두지 않는다(#651 D3). 게시가 만드는 캐시 락 샤드·병합 번들이 root 소유로 남으면 이후 웹 요청의
|
||||
// 캐시 쓰기가 Permission denied 로 죽으므로(전면 500 실사례) 웹 계정 실행 형태를 함께 적는다.
|
||||
if (StatusExtensionStaticCacheCommand::isRootProcess()) {
|
||||
$this->warn('root 로 실행 중입니다 — 이 계정으로 게시하면 캐시 폴더·병합 번들이 root 소유가 되어 이후 웹 요청이 실패할 수 있습니다.');
|
||||
$this->warn('웹 계정으로 실행하세요: '.StatusExtensionStaticCacheCommand::publishCommandHint());
|
||||
}
|
||||
|
||||
$published = $service->publishCurrent(force: (bool) $this->option('force'));
|
||||
|
||||
if (! $published) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
@@ -13,7 +14,10 @@ use Illuminate\Support\Facades\File;
|
||||
* 운영 환경에서 실패를 확인할 방법이 사실상 없었다(`/dev` 대시보드는 `app.debug` 가 필요한데
|
||||
* 게시는 프로덕션 전용이다). 이 커맨드가 그 통로다.
|
||||
*
|
||||
* 출력: 현재 버전 / 게시 여부 / manifest 파일 수 / 게시 트리 쓰기 가능성 /
|
||||
* 판정은 `ExtensionStaticCacheService::statusReport()` 가 소유한다 — 관리자 화면의
|
||||
* 「초기 화면 정적 파일」 카드와 같은 값을 소비하며, 이 커맨드는 문구·조치 힌트만 담당한다.
|
||||
*
|
||||
* 출력: 실행 계정 / 현재 버전 / 게시 여부 / manifest 파일 수 / 게시 트리 쓰기 가능성 /
|
||||
* 최근 실패 마커 / 잔존 버전 목록.
|
||||
*
|
||||
* 종료 코드: 이상이 하나라도 있으면 비-0. 비-0 은 "커맨드 실행 실패" 가 아니라
|
||||
@@ -41,23 +45,30 @@ class StatusExtensionStaticCacheCommand extends Command
|
||||
{
|
||||
$problems = [];
|
||||
|
||||
$enabled = $service->isEnabled();
|
||||
$version = ExtensionStaticCacheService::getExtensionCacheVersion();
|
||||
$report = $service->statusReport();
|
||||
$base = $service->baseDir();
|
||||
$versionDir = $service->versionDir($version);
|
||||
$published = $service->isPublished($version);
|
||||
$publishHint = self::publishCommandHint();
|
||||
|
||||
$this->line('');
|
||||
$this->line('<options=bold>부트스트랩 리소스 정적 게시 상태</>');
|
||||
$this->line('');
|
||||
|
||||
$this->line(' 실행 계정 : '.ExtensionStaticCacheService::currentProcessUser());
|
||||
$this->line(' kill-switch : '.($enabled ? '활성 (게시함)' : '<comment>비활성 (게시 안 함)</comment>'));
|
||||
$this->line(' 현재 캐시 버전 : '.$version);
|
||||
$this->line(' 실행 계정 : '.$report['process_user']);
|
||||
|
||||
// root 로 실행 중이면 여기서 게시하지 말라고 먼저 알린다 — 캐시 락 샤드·병합 번들이 root
|
||||
// 소유로 남아 이후 웹 요청의 캐시 쓰기가 Permission denied 로 죽는다(전면 500 실사례).
|
||||
// 중단하지는 않는다 — 상태 점검은 읽기 전용이다. (#651 C1)
|
||||
if (self::isRootProcess()) {
|
||||
$this->line(' <comment>root 로 실행 중 — 이 계정으로 게시하면 캐시 폴더가 root 소유가 될 수 있습니다. '
|
||||
.'웹 계정으로 실행하세요: '.$publishHint.'</comment>');
|
||||
}
|
||||
|
||||
$this->line(' kill-switch : '.($report['enabled'] ? '활성 (게시함)' : '<comment>비활성 (게시 안 함)</comment>'));
|
||||
$this->line(' 현재 캐시 버전 : '.$report['version']);
|
||||
$this->line(' 게시 루트 : '.$base);
|
||||
|
||||
// kill-switch 가 꺼져 있으면 미게시는 정상이다 — 이상으로 세지 않는다.
|
||||
if (! $enabled) {
|
||||
if (! $report['enabled']) {
|
||||
$this->line('');
|
||||
$this->comment('kill-switch 가 꺼져 있어 게시 상태는 점검하지 않습니다 (core.static_cache.enabled).');
|
||||
|
||||
@@ -65,9 +76,7 @@ class StatusExtensionStaticCacheCommand extends Command
|
||||
}
|
||||
|
||||
// 1) 게시 트리 쓰기 가능성 — 제보 본건(P1/P2)의 직접 지표
|
||||
$writable = File::isDirectory($base) ? is_writable($base) : $this->parentIsWritable($base);
|
||||
|
||||
if ($writable) {
|
||||
if ($report['tree_writable']) {
|
||||
$this->line(' 트리 쓰기 : 가능');
|
||||
} else {
|
||||
$this->line(' 트리 쓰기 : <error>불가</error>');
|
||||
@@ -81,28 +90,27 @@ class StatusExtensionStaticCacheCommand extends Command
|
||||
}
|
||||
|
||||
// 2) 현재 버전 게시 여부
|
||||
if ($published) {
|
||||
$manifest = json_decode((string) @file_get_contents($versionDir.'/manifest.json'), true);
|
||||
$fileCount = is_array($manifest) ? count($manifest['files'] ?? []) : 0;
|
||||
|
||||
if ($report['published']) {
|
||||
$this->line(' 게시 상태 : 완료');
|
||||
$this->line(' manifest 파일 : '.$fileCount.'건');
|
||||
$this->line(' 게시 시각 : '.(is_array($manifest) ? ($manifest['published_at'] ?? '?') : '?'));
|
||||
$this->line(' manifest 파일 : '.$report['files'].'건');
|
||||
$this->line(' 게시 시각 : '.($report['published_at'] ?? '?'));
|
||||
|
||||
if ($fileCount === 0) {
|
||||
if ($report['files'] === 0) {
|
||||
$problems[] = 'manifest 에 기록된 파일이 0건입니다 — 게시가 비어 있습니다.';
|
||||
}
|
||||
} else {
|
||||
$this->line(' 게시 상태 : <comment>미게시</comment>');
|
||||
$problems[] = sprintf(
|
||||
'현재 버전(%d)이 게시되지 않았습니다. 다음 웹 렌더의 자가 치유가 시도하며, '
|
||||
.'즉시 게시하려면 `php artisan ext-static:publish` 를 실행하세요.',
|
||||
$version
|
||||
.'즉시 게시하려면 웹 계정으로 `%s` 를 실행하거나 관리자 > 환경설정 > 일반 의 '
|
||||
.'「지금 다시 만들기」를 사용하세요.',
|
||||
$report['version'],
|
||||
$publishHint
|
||||
);
|
||||
}
|
||||
|
||||
// 3) 최근 실패 마커 — 원인별로 조치가 다르다
|
||||
$marker = ExtensionStaticCacheService::failureMarker();
|
||||
$marker = $report['failure'];
|
||||
|
||||
if ($marker !== null) {
|
||||
$this->line('');
|
||||
@@ -117,7 +125,7 @@ class StatusExtensionStaticCacheCommand extends Command
|
||||
}
|
||||
|
||||
// 4) 잔존 버전 목록 — 누적은 삭제 실패(소유권 불일치)의 지표다
|
||||
$versions = $this->publishedVersions($base);
|
||||
$versions = $report['retained_versions'];
|
||||
|
||||
$this->line('');
|
||||
$this->line(' 잔존 버전 : '.($versions === [] ? '(없음)' : implode(', ', $versions)));
|
||||
@@ -148,16 +156,35 @@ class StatusExtensionStaticCacheCommand extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시 루트가 아직 없을 때, 만들 수 있는지(부모 쓰기 가능) 판정합니다.
|
||||
* 수동 게시 명령을 웹 계정 실행 형태로 안내합니다 — `ext-static:publish` 와 공유.
|
||||
*
|
||||
* @param string $base 게시 루트 절대 경로
|
||||
* @return bool 생성 가능 여부
|
||||
* root 가 아니면 명령만 그대로다(일반 SSH 사용자 = 파일 소유자 / 공유 호스팅 대칭 구성 / posix
|
||||
* 미지원). root 이면 웹서버 계정을 식별해 `sudo -u {계정}` 을 붙이고, 식별하지 못하면
|
||||
* placeholder 와 확인 방법을 함께 적는다 — 판정은 `FilePermissionHelper::describeWebServerAccount()`
|
||||
* 가 소유한다(`core:update` 재실행 안내와 같은 4분기).
|
||||
*
|
||||
* @return string 안내 명령 문자열
|
||||
*/
|
||||
private function parentIsWritable(string $base): bool
|
||||
public static function publishCommandHint(): string
|
||||
{
|
||||
$parent = dirname($base);
|
||||
$command = 'php artisan ext-static:publish';
|
||||
$account = FilePermissionHelper::describeWebServerAccount();
|
||||
|
||||
return File::isDirectory($parent) && is_writable($parent);
|
||||
return match ($account['mode']) {
|
||||
'root_web_known' => 'sudo -u '.$account['name'].' '.$command,
|
||||
'root_web_unknown' => 'sudo -u <웹서버계정> '.$command.' (웹서버 계정은 storage 디렉토리 소유자로 확인)',
|
||||
default => $command,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 프로세스가 root(euid 0)로 실행 중인지 판정합니다 — 안내 분기 전용.
|
||||
*
|
||||
* @return bool root 실행 여부
|
||||
*/
|
||||
public static function isRootProcess(): bool
|
||||
{
|
||||
return FilePermissionHelper::describeWebServerAccount()['mode'] !== 'non_root';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,37 +199,11 @@ class StatusExtensionStaticCacheCommand extends Command
|
||||
{
|
||||
return match ($reason) {
|
||||
'parent_not_writable' => '게시 트리 권한 문제입니다. CLI 계정과 웹 계정이 그룹을 공유하고 '
|
||||
.'`public/build` 가 그룹 쓰기(g+w)인지 확인하세요.',
|
||||
.'`public/build` 가 그룹 쓰기(g+w)인지 확인하세요. 수동 게시는 웹 계정으로: '
|
||||
.self::publishCommandHint(),
|
||||
'write_failed' => '쓰기 도중 실패했습니다. 디스크 여유 공간과 quota 를 확인하세요.',
|
||||
'lock_unavailable' => '캐시 락을 얻지 못했습니다. 캐시 저장소(파일 캐시 디렉토리 권한 등)를 확인하세요.',
|
||||
default => '상세 메시지를 확인하세요.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시 루트에 남아 있는 버전 디렉토리 목록을 반환합니다 (오름차순).
|
||||
*
|
||||
* @param string $base 게시 루트 절대 경로
|
||||
* @return array<int, int> 버전 목록
|
||||
*/
|
||||
private function publishedVersions(string $base): array
|
||||
{
|
||||
if (! File::isDirectory($base)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$versions = [];
|
||||
|
||||
foreach (File::directories($base) as $dir) {
|
||||
$name = basename($dir);
|
||||
|
||||
if (ctype_digit($name)) {
|
||||
$versions[] = (int) $name;
|
||||
}
|
||||
}
|
||||
|
||||
sort($versions);
|
||||
|
||||
return $versions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -822,6 +822,31 @@ class ExtensionPendingHelper
|
||||
return $basePath.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.$identifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 업데이트용 임시 디렉토리(다운로드·추출)를 부모 소유권까지 정합화해 확보합니다.
|
||||
*
|
||||
* 세 매니저(모듈·플러그인·템플릿)가 같은 경로 규약(`storage/app/temp/{type}_update_{uid}`)을 쓰므로
|
||||
* 확보 절차도 한 곳에 둔다 — 복사본은 서로 다른 하드닝을 갖고 갈라진다.
|
||||
*
|
||||
* @param string $tempDir 임시 디렉토리 절대 경로
|
||||
*/
|
||||
public static function ensureUpdateTempDirectory(string $tempDir): void
|
||||
{
|
||||
// 부모(`storage/app/temp`)는 최초 1회만 만들어지고 자식만 삭제된다 — sudo 코어 업데이트의
|
||||
// 번들 확장 업데이트가 그 최초 생성자면 부모가 root 소유로 굳어, 이후 관리자 화면(웹 계정)의
|
||||
// 확장 업데이트가 임시 폴더를 만들지 못한다 (#651 F14). 부모는 소유권 상속·그룹 쓰기까지
|
||||
// 정합화하는 프리미티브로 확보하고, 자식은 만든 뒤 부모 소유권을 상속시킨다.
|
||||
// 확보 실패는 종전 동작(`ensureDirectoryExists` 의 예외 흐름)으로 폴백해 계약을 바꾸지 않는다.
|
||||
if (! FilePermissionHelper::ensureWritableDirectory(dirname($tempDir))) {
|
||||
File::ensureDirectoryExists($tempDir);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
File::ensureDirectoryExists($tempDir);
|
||||
FilePermissionHelper::inheritOwnershipFromParent($tempDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 업데이트 스테이징용 타임스탬프 디렉토리를 생성합니다.
|
||||
*
|
||||
|
||||
@@ -566,6 +566,73 @@ class FilePermissionHelper
|
||||
return [$baseOwner, $baseGroup, 'base_path (대칭 구성)'];
|
||||
}
|
||||
|
||||
/** 테스트 전용 — `describeWebServerAccount()` 판정 오버라이드 (null = 실판정) */
|
||||
private static ?array $webServerAccountForTesting = null;
|
||||
|
||||
/**
|
||||
* 테스트 전용 — 실행 환경 판정을 강제합니다 (posix 부재 환경에서 root 분기를 재현하기 위해).
|
||||
*
|
||||
* @param array{mode: string, name: string|null}|null $account 강제할 판정 (null 로 실판정 복귀)
|
||||
*/
|
||||
public static function fakeWebServerAccountForTesting(?array $account): void
|
||||
{
|
||||
self::$webServerAccountForTesting = $account;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 프로세스 기준으로 "웹서버 계정" 안내에 필요한 실행 환경을 분류합니다.
|
||||
*
|
||||
* root(sudo) 로 artisan 을 실행하면 그 명령이 만드는 캐시 샤드·번들·임시 파일이 root 소유로 남아
|
||||
* 이후 웹 프로세스의 쓰기가 실패한다. 그래서 root 실행을 감지한 명령은 "웹서버 계정으로 실행"
|
||||
* 을 안내해야 하는데, 그 판정을 명령마다 다시 쓰면 한 곳만 어긋나도 서로 다른 안내가 나간다.
|
||||
* `core:update` 의 재실행 안내가 처음 세운 4분기를 그대로 옮겨 공유한다 — 동작 불변.
|
||||
*
|
||||
* 반환 모드:
|
||||
* - `non_root` : posix 미지원(Windows 등) 또는 현재 유효 사용자가 root 가 아님
|
||||
* (일반 SSH 사용자 = 파일 소유자, 공유 호스팅 대칭 구성 포함).
|
||||
* - `root_web_known` : root 실행 + 웹서버 계정 식별 성공 + 실행 사용자(root)와 다름.
|
||||
* - `root_web_symmetric` : root 실행 + 웹서버 계정이 root 로 추정됨 (root 서비스 구성).
|
||||
* - `root_web_unknown` : root 실행 + 웹서버 계정 추정 실패 (스냅샷/추정 불가).
|
||||
*
|
||||
* 웹서버 계정은 `inferWebServerOwnership()` 이 storage/bootstrap 쓰기 영역 소유자로 추정한다.
|
||||
*
|
||||
* @return array{mode: string, name: string|null} 모드와 웹서버 계정명(`root_web_known` 일 때만)
|
||||
*/
|
||||
public static function describeWebServerAccount(): array
|
||||
{
|
||||
if (self::$webServerAccountForTesting !== null) {
|
||||
return self::$webServerAccountForTesting;
|
||||
}
|
||||
|
||||
if (! function_exists('posix_geteuid') || ! function_exists('posix_getpwuid')) {
|
||||
return ['mode' => 'non_root', 'name' => null];
|
||||
}
|
||||
|
||||
if (posix_geteuid() !== 0) {
|
||||
return ['mode' => 'non_root', 'name' => null];
|
||||
}
|
||||
|
||||
[$owner] = static::inferWebServerOwnership();
|
||||
|
||||
if ($owner === false) {
|
||||
return ['mode' => 'root_web_unknown', 'name' => null];
|
||||
}
|
||||
|
||||
if ($owner === 0) {
|
||||
return ['mode' => 'root_web_symmetric', 'name' => null];
|
||||
}
|
||||
|
||||
$entry = posix_getpwuid($owner);
|
||||
$name = $entry['name'] ?? null;
|
||||
|
||||
// uid 는 나왔지만 이름 해석 실패 — 미상 경로로 처리 (uid 노출은 오히려 혼란).
|
||||
if ($name === null) {
|
||||
return ['mode' => 'root_web_unknown', 'name' => null];
|
||||
}
|
||||
|
||||
return ['mode' => 'root_web_known', 'name' => $name];
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로와 그 하위 항목의 소유자·그룹을 재귀적으로 복원합니다.
|
||||
*
|
||||
|
||||
@@ -357,9 +357,13 @@ class SettingsMigrator
|
||||
return false;
|
||||
}
|
||||
|
||||
// 디렉토리 확인 및 생성
|
||||
// 디렉토리 확인 및 생성 — 파일(`writeJsonFile`)과 대칭으로 디렉토리도 부모 소유권을 상속한다.
|
||||
// sudo 코어 업데이트의 업그레이드 스텝이 이 디렉토리를 root 로 만들면 `storage/app/{modules,
|
||||
// plugins}` 는 restore_ownership 의도적 제외 경로라 코어 업데이트로도 되돌려지지 않고, 이후
|
||||
// 웹 프로세스의 그 모듈 설정 저장이 영구 실패한다 (#651 F13).
|
||||
if (! File::isDirectory($settingsDir)) {
|
||||
File::makeDirectory($settingsDir, 0755, true);
|
||||
FilePermissionHelper::inheritOwnershipFromParent($settingsDir);
|
||||
}
|
||||
|
||||
// 카테고리 파일 생성
|
||||
|
||||
@@ -27,6 +27,7 @@ use App\Extension\Helpers\ExtensionPendingHelper;
|
||||
use App\Extension\Helpers\ExtensionRoleSyncHelper;
|
||||
use App\Extension\Helpers\ExtensionStatusGuard;
|
||||
use App\Extension\Helpers\ExtensionUpgradeGuardHelper;
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Helpers\GithubHelper;
|
||||
use App\Extension\Helpers\IdentityMessageSyncHelper;
|
||||
use App\Extension\Helpers\IdentityPolicySyncHelper;
|
||||
@@ -1829,9 +1830,11 @@ class ModuleManager implements ModuleManagerInterface
|
||||
return;
|
||||
}
|
||||
|
||||
// 디렉토리 생성
|
||||
// 디렉토리 생성 — sudo 코어 업데이트(번들 확장 업데이트 프롬프트) 경로에서 root 로 만들어지면
|
||||
// `storage/app/modules` 는 restore_ownership 제외 경로라 되돌려지지 않는다 → 부모 소유권 상속 (#651 F13)
|
||||
if (! File::isDirectory($settingsDir)) {
|
||||
File::makeDirectory($settingsDir, 0755, true);
|
||||
FilePermissionHelper::inheritOwnershipFromParent($settingsDir);
|
||||
}
|
||||
|
||||
// 카테고리별로 설정 파일 생성
|
||||
@@ -1846,6 +1849,7 @@ class ModuleManager implements ModuleManagerInterface
|
||||
|
||||
$jsonContent = json_encode($categoryData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($filePath, $jsonContent);
|
||||
FilePermissionHelper::inheritOwnershipFromParent($filePath);
|
||||
$createdFiles[] = $category.'.json';
|
||||
}
|
||||
|
||||
@@ -4140,7 +4144,7 @@ class ModuleManager implements ModuleManagerInterface
|
||||
$tempDir = storage_path('app/temp/module_update_'.uniqid());
|
||||
|
||||
try {
|
||||
File::ensureDirectoryExists($tempDir);
|
||||
ExtensionPendingHelper::ensureUpdateTempDirectory($tempDir);
|
||||
|
||||
// GitHub에서 다운로드 및 추출 (코어와 동일한 폴백 체인)
|
||||
$extractedDir = $this->extensionManager->downloadAndExtractFromGitHub(
|
||||
|
||||
@@ -27,6 +27,7 @@ use App\Extension\Helpers\ExtensionPendingHelper;
|
||||
use App\Extension\Helpers\ExtensionRoleSyncHelper;
|
||||
use App\Extension\Helpers\ExtensionStatusGuard;
|
||||
use App\Extension\Helpers\ExtensionUpgradeGuardHelper;
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Helpers\GithubHelper;
|
||||
use App\Extension\Helpers\IdentityMessageSyncHelper;
|
||||
use App\Extension\Helpers\IdentityPolicySyncHelper;
|
||||
@@ -2637,14 +2638,17 @@ class PluginManager implements PluginManagerInterface
|
||||
return;
|
||||
}
|
||||
|
||||
// 디렉토리 생성
|
||||
// 디렉토리 생성 — sudo 코어 업데이트 경로에서 root 로 만들어지면 `storage/app/plugins` 는
|
||||
// restore_ownership 제외 경로라 되돌려지지 않는다 → 부모 소유권 상속 (#651 F13)
|
||||
if (! File::isDirectory($settingsDir)) {
|
||||
File::makeDirectory($settingsDir, 0755, true);
|
||||
FilePermissionHelper::inheritOwnershipFromParent($settingsDir);
|
||||
}
|
||||
|
||||
// 기본값 저장
|
||||
$content = json_encode($defaults, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($settingsPath, $content);
|
||||
FilePermissionHelper::inheritOwnershipFromParent($settingsPath);
|
||||
|
||||
Log::info('플러그인 기본 설정 파일 생성 완료', [
|
||||
'plugin' => $identifier,
|
||||
@@ -4369,7 +4373,7 @@ class PluginManager implements PluginManagerInterface
|
||||
$tempDir = storage_path('app/temp/plugin_update_'.uniqid());
|
||||
|
||||
try {
|
||||
File::ensureDirectoryExists($tempDir);
|
||||
ExtensionPendingHelper::ensureUpdateTempDirectory($tempDir);
|
||||
|
||||
// GitHub에서 다운로드 및 추출 (코어와 동일한 폴백 체인)
|
||||
$extractedDir = $this->extensionManager->downloadAndExtractFromGitHub(
|
||||
|
||||
@@ -2854,7 +2854,7 @@ class TemplateManager implements TemplateManagerInterface
|
||||
$tempDir = storage_path('app/temp/template_update_'.uniqid());
|
||||
|
||||
try {
|
||||
File::ensureDirectoryExists($tempDir);
|
||||
ExtensionPendingHelper::ensureUpdateTempDirectory($tempDir);
|
||||
|
||||
// GitHub에서 다운로드 및 추출 (코어와 동일한 폴백 체인)
|
||||
$extractedDir = $this->extensionManager->downloadAndExtractFromGitHub(
|
||||
@@ -3183,11 +3183,14 @@ class TemplateManager implements TemplateManagerInterface
|
||||
|
||||
$this->clearAllTemplateLanguageCaches();
|
||||
$this->clearAllTemplateRoutesCaches();
|
||||
// refreshTemplateLayouts() 내부에서 변경 시 incrementExtensionCacheVersion() 호출됨
|
||||
// 비활성 템플릿이라 refreshTemplateLayouts()를 건너뛴 경우에만 여기서 증가
|
||||
if ($previousStatus !== ExtensionStatus::Active->value) {
|
||||
$this->incrementExtensionCacheVersion();
|
||||
}
|
||||
// 모듈(`updateModule`)·플러그인(`updatePlugin`)과 동형으로 **무조건** 올린다 —
|
||||
// lang/routes/components/dist 만 바뀐 릴리스도 정적 게시본(#122)을 갱신해야 한다.
|
||||
// 종전에는 활성 템플릿이면 `refreshTemplateLayouts()` 의 조건부 bump(레이아웃
|
||||
// 변경 건수 > 0)에 위임했는데, 그 조건은 게시 입력 중 레이아웃 축만 보므로
|
||||
// 레이아웃이 그대로인 릴리스는 게시본이 immutable 로 stale 하게 남았다(#651 F1).
|
||||
// `refreshTemplateLayouts()` 내부의 조건부 bump 는 단독 호출처(refresh-layout)가
|
||||
// 있어 그대로 두며, 이중 bump 는 put 1회 비용이고 terminating 게시는 1회로 병합된다.
|
||||
$this->incrementExtensionCacheVersion();
|
||||
self::invalidateTemplateStatusCache();
|
||||
|
||||
// 훅 발행: 템플릿 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거)
|
||||
@@ -3237,6 +3240,15 @@ class TemplateManager implements TemplateManagerInterface
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// 실패 경로도 bump 한다 — 백업 복원이 실패했거나 부분 반영된 디스크가 남을 수
|
||||
// 있어, 게시본을 새 버전으로 다시 굽는 쪽이 옛 게시본을 그대로 두는 쪽보다
|
||||
// 안전하다(#651 F2). 원래 예외를 가리지 않도록 bump 실패는 삼킨다.
|
||||
try {
|
||||
$this->incrementExtensionCacheVersion();
|
||||
} catch (\Throwable) {
|
||||
// 원래 예외(아래 throw)가 진짜 원인이다 — bump 실패는 로그(트레이트)로 충분
|
||||
}
|
||||
|
||||
throw new \RuntimeException(
|
||||
__('templates.errors.update_failed', [
|
||||
'template' => $identifier,
|
||||
|
||||
@@ -21,6 +21,27 @@ trait ClearsTemplateCaches
|
||||
*/
|
||||
private static string $extensionCacheVersionKey = 'ext.cache_version';
|
||||
|
||||
/**
|
||||
* 확장 캐시 버전·custom 서명 키의 저장 TTL (초) — 10년.
|
||||
*
|
||||
* 이 키들은 **만료로 재생성되어서는 안 된다.** 버전 키가 만료되면 다음 독자가
|
||||
* `regenerateExtensionCacheVersion()` 으로 새 `time()` 을 만들고, 그것은 정적 게시본
|
||||
* (`public/build/ext/{v}/`, 실측 715파일·40MB) **전체 재생성** + 병합 번들 재병합 + 전
|
||||
* 방문자의 자산 URL 변경이다. TTL 을 넘기지 않으면 `AbstractCacheDriver::put()` 이
|
||||
* `cache.default_ttl`(기본 86400) 을 적용해 그 재생성이 **매일** 우발적으로 일어났다(#651 F11).
|
||||
*
|
||||
* 영구 저장의 두 후보를 쓰지 않는 이유:
|
||||
* - `forever()` 는 `CacheInterface` 밖이다 — 인터페이스 확장은 확장 공개 표면 변경이라
|
||||
* 번들 확장 전수의 `g7_version` 동기화 의무를 낳는다.
|
||||
* - `put(…, 0)` 은 Laravel `Repository::put` 이 `seconds <= 0` 을 **forget** 으로 처리해
|
||||
* 키를 지운다. 파일 스토어는 만료를 `9999999999` 로 캡하므로 큰 TTL 은 안전하다.
|
||||
*
|
||||
* 트레이트 안에서는 `self::` 로 읽지 않는다 — 트레이트 정적 메서드를 트레이트 이름으로 직접
|
||||
* 부르는 호출(테스트·레거시)에서 `self` 가 트레이트 자신으로 해석되어 "Cannot access trait
|
||||
* constant directly" 가 난다. 트레이트를 조합한 코어 서비스 클래스 경유로 읽는다.
|
||||
*/
|
||||
public const PERSISTENT_TTL_SECONDS = 315360000;
|
||||
|
||||
/**
|
||||
* 프로세스 1회 메모이즈된 확장 좌표 캐시 스토어 이름 (write/read 스토어 일관성).
|
||||
* `extensionCacheStore()` 가 최초 1회 채우며, 테스트는 `resetExtensionCacheStoreMemo()`
|
||||
@@ -39,7 +60,7 @@ trait ClearsTemplateCaches
|
||||
{
|
||||
try {
|
||||
$newVersion = time();
|
||||
self::resolveExtensionCache()->put(self::$extensionCacheVersionKey, $newVersion);
|
||||
self::resolveExtensionCache()->put(self::$extensionCacheVersionKey, $newVersion, ExtensionStaticCacheService::PERSISTENT_TTL_SECONDS);
|
||||
|
||||
Log::info('확장 기능 캐시 버전 증가', [
|
||||
'new_version' => $newVersion,
|
||||
@@ -91,6 +112,9 @@ trait ClearsTemplateCaches
|
||||
* 대신 동일 로직을 직접 수행한다. 저장 실패 시에도 0 으로 붕괴하지 않도록
|
||||
* 생성한 `time()` 값을 반환한다(다음 요청이 다시 생성·저장 시도).
|
||||
*
|
||||
* 키는 `PERSISTENT_TTL_SECONDS` 로 저장되므로 만료로 이 경로에 오지 않는다 — 키 부재는
|
||||
* `php artisan cache:clear` 또는 캐시 스토어 소실 때만이다.
|
||||
*
|
||||
* @return int 새로 생성된 유효 캐시 버전 (타임스탬프)
|
||||
*/
|
||||
private static function regenerateExtensionCacheVersion(): int
|
||||
@@ -98,7 +122,7 @@ trait ClearsTemplateCaches
|
||||
$newVersion = time();
|
||||
|
||||
try {
|
||||
self::resolveExtensionCache()->put(self::$extensionCacheVersionKey, $newVersion);
|
||||
self::resolveExtensionCache()->put(self::$extensionCacheVersionKey, $newVersion, ExtensionStaticCacheService::PERSISTENT_TTL_SECONDS);
|
||||
|
||||
Log::info('확장 기능 캐시 버전 재생성 (키 부재/무효)', [
|
||||
'new_version' => $newVersion,
|
||||
@@ -182,6 +206,21 @@ trait ClearsTemplateCaches
|
||||
return new CoreCacheDriver(self::extensionCacheStore());
|
||||
}
|
||||
|
||||
/**
|
||||
* custom 자산 변경 서명(`ext.custom_signature`)용 캐시 드라이버를 반환합니다.
|
||||
*
|
||||
* 버전 키와 **같은 고정 스토어·같은 코어 네임스페이스**를 쓴다. 서명이 버전 키와 다른
|
||||
* 스토어에 저장되면(`app(CacheInterface::class)` 재바인딩 등) "첫 관측은 기록만" 규칙이
|
||||
* 스토어마다 따로 성립해 실제 변경 1회를 조용히 삼킨다(#651 F8). 서명을 쓰는 뷰 컴포저와
|
||||
* 지우는 관리 API 가 같은 통로를 써야 하므로 트레이트가 공급한다.
|
||||
*
|
||||
* @return CacheInterface 코어 네임스페이스 캐시 드라이버
|
||||
*/
|
||||
protected static function customSignatureCache(): CacheInterface
|
||||
{
|
||||
return new CoreCacheDriver(self::extensionCacheStore());
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 좌표 키(`ext.cache_version`)의 고정 캐시 스토어 이름을 반환합니다.
|
||||
*
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Repositories\LayoutRepositoryInterface;
|
||||
use App\Enums\LayoutSourceType;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@@ -120,7 +121,10 @@ trait InvalidatesLayoutCache
|
||||
// 받는다. 레이아웃 저장 경로(LayoutService::clearPublicServingCache)는 이미 두 키를
|
||||
// 지우므로 정합을 맞춘다.
|
||||
if ($templateIdentifier) {
|
||||
$cacheVersion = (int) $cache->get('ext.cache_version', 0);
|
||||
// 트레이트 게터 경유 — 이 트레이트만 조합한 클래스가 있을 수 있어 `self::` 가 아니라
|
||||
// `ClearsTemplateCaches` 를 조합한 서비스 클래스를 통해 부른다(트레이트 정적 직접 호출은
|
||||
// PHP 8.1+ E_DEPRECATED). 원시 키 읽기는 `cache:clear` 직후 0 을 돌려주어 실제 키를 못 지운다.
|
||||
$cacheVersion = ExtensionStaticCacheService::getExtensionCacheVersion();
|
||||
$cache->forget("layout.{$templateIdentifier}.{$layout->name}.v{$cacheVersion}");
|
||||
$cache->forget("layout.{$templateIdentifier}.{$layout->name}.v{$cacheVersion}.meta");
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Extension\Helpers\EditorSpecAssembler;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use App\Services\PermissionService;
|
||||
use App\Services\TemplateService;
|
||||
use App\Support\AssetUrl;
|
||||
@@ -88,8 +88,10 @@ class AdminTemplateAssetController extends AdminBaseController
|
||||
);
|
||||
}
|
||||
|
||||
$extensionCacheVersion = (int) app(CacheInterface::class)->get('ext.cache_version', 0);
|
||||
$version = $extensionCacheVersion > 0 ? $extensionCacheVersion : null;
|
||||
// 확장 캐시 버전은 트레이트 게터로만 읽는다 — 원시 키 읽기는 `cache:clear` 직후 0 을
|
||||
// 돌려주어 버전 없는 URL 을 만들고, 컨테이너 바인딩은 확장 네임스페이스로 누수될 수 있다.
|
||||
// 게터는 키 부재 시 재생성하므로 항상 유효 버전이다 (`AssetUrl::staticExtBase` 와 동형).
|
||||
$version = ExtensionStaticCacheService::getExtensionCacheVersion();
|
||||
|
||||
return $this->success(
|
||||
__('templates.messages.editor_assets_retrieved'),
|
||||
@@ -228,7 +230,7 @@ class AdminTemplateAssetController extends AdminBaseController
|
||||
// 바인딩은 확장 컨텍스트로 누수될 수 있어(메모리 feedback_core_cache_no_container_binding)
|
||||
// 코어 캐시 store 를 빗나갈 수 있다.
|
||||
$cache = new CoreCacheDriver;
|
||||
$cacheVersion = (int) $cache->get('ext.cache_version', 0);
|
||||
$cacheVersion = ExtensionStaticCacheService::getExtensionCacheVersion();
|
||||
// 캐시 키 — 템플릿 + 확장 캐시 버전 + 파일 mtime(빌드 변경 즉시 무효화).
|
||||
$mtime = (int) @filemtime($cssPath);
|
||||
$cacheKey = "template.editor_css.{$identifier}.v{$cacheVersion}.m{$mtime}";
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Http\Requests\Settings\UpdateSettingRequest;
|
||||
use App\Http\Resources\SettingsResource;
|
||||
use App\Services\DriverConnectionTester;
|
||||
use App\Services\DriverRegistryService;
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use App\Services\OutboundProxyTester;
|
||||
use App\Services\SettingsService;
|
||||
use App\Support\EnvPriority;
|
||||
@@ -32,7 +33,8 @@ class SettingsController extends AdminBaseController
|
||||
private SettingsService $settingsService,
|
||||
private DriverConnectionTester $driverConnectionTester,
|
||||
private DriverRegistryService $driverRegistryService,
|
||||
private OutboundProxyTester $outboundProxyTester
|
||||
private OutboundProxyTester $outboundProxyTester,
|
||||
private ExtensionStaticCacheService $staticCacheService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -190,6 +192,39 @@ class SettingsController extends AdminBaseController
|
||||
return $this->success('common.success', TrustedProxyDiagnostic::forRequest(request()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기 화면 정적 파일(부트스트랩 리소스 정적 게시) 상태를 조회합니다 (#651).
|
||||
*
|
||||
* CLI `ext-static:status` 와 **같은 판정**(`ExtensionStaticCacheService::statusReport`)을 돌려준다.
|
||||
* 읽기 전용이며 입력이 없어 FormRequest 를 두지 않는다 (`trustedProxy` 와 동형).
|
||||
*
|
||||
* @return JsonResponse 상태 보고서 JSON 응답
|
||||
*/
|
||||
public function staticCacheStatus(): JsonResponse
|
||||
{
|
||||
return $this->success('settings.static_cache_status_loaded', $this->staticCacheService->statusReport());
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기 화면 정적 파일을 지금 다시 만듭니다 (관리자 수동 복구, #651).
|
||||
*
|
||||
* 캐시 버전을 올리고 현재 버전을 강제 재게시한다. 게시 실패·게시가 쓰이지 않는 환경은
|
||||
* 요청 처리 실패가 아니라 **진단 결과**라 HTTP 200 으로 돌려주고 성공 여부는 페이로드
|
||||
* (`republished`)가 말한다 — 연결 테스트·드라이버 테스트와 같은 규약. 사이트는 어느 경우에도
|
||||
* API 폴백으로 정상이다.
|
||||
*
|
||||
* @return JsonResponse 재게시 결과 + 상태 보고서 JSON 응답
|
||||
*/
|
||||
public function republishStaticCache(): JsonResponse
|
||||
{
|
||||
$result = $this->staticCacheService->republish();
|
||||
|
||||
return $this->success(
|
||||
($result['republished'] ?? false) ? 'settings.static_cache_republished' : 'settings.static_cache_republish_failed',
|
||||
$result
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 캐시를 정리합니다.
|
||||
*
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
namespace App\Http\View\Composers\Traits;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Extension\ModuleManagerInterface;
|
||||
use App\Contracts\Extension\PluginManagerInterface;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use App\Support\CustomAssets;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
@@ -53,7 +53,7 @@ trait CollectsCustomAssets
|
||||
// 한 번을 더 해야 반영된다 — 원인을 알 수 없는 한 박자 지연으로만 나타난다.
|
||||
// 새 버전으로 다시 해석한다. 게시본은 아직 없으므로 URL 은 API 형태로 떨어지고,
|
||||
// 그 응답은 디스크의 최신 내용을 그대로 준다. 다음 요청부터 정적 경로가 된다.
|
||||
if ($this->syncCustomAssetCacheVersion($assets, $templateIdentifier)) {
|
||||
if ($this->syncCustomAssetCacheVersion($templateIdentifier)) {
|
||||
CustomAssets::flushCache();
|
||||
$assets = $this->resolveCustomAssets($templateIdentifier);
|
||||
}
|
||||
@@ -114,30 +114,29 @@ trait CollectsCustomAssets
|
||||
* 쓰기 뒤 이 키 하나를 `forget` 하는 것에 의존하므로(같은 키를 보는 두 소비자),
|
||||
* 키를 쪼개면 그 소비자가 일부만 지우게 되어 같은 결함군이 재생산된다.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $assets 수집된 서술자 목록
|
||||
* 서명 재료는 **게시와 같은 열거자**(`CustomAssets::publishableFiles`) — 활성 모듈·플러그인과
|
||||
* 이 렌더의 템플릿의 `custom/**` 전체(하위 디렉토리·글꼴·이미지 포함)의 경로·수정 시각·
|
||||
* 크기다. 종전에는 로드 목록(최상위 css/js)의 mtime 만 봤는데, 게시는 재귀 복사라
|
||||
* 글꼴만 교체한 변경이 영영 감지되지 않았다(#651 F7). 크기를 함께 넣는 이유는 mtime 해상도
|
||||
* (1초·일부 파일시스템 2초) 안의 재작성과 mtime 보존 복사(rsync -t)를 잡기 위해서다.
|
||||
*
|
||||
* 스코프 키에는 **호스트명**을 붙인다. 다중 웹서버가 캐시를 공유하면 같은 파일이라도 서버마다
|
||||
* 배포 시각(mtime)이 달라, 하나의 자리를 서버들이 번갈아 덮어쓰며 요청마다 "변경" 으로
|
||||
* 읽혀 전체 재게시가 왕복한다(#651 F9). 서버별로 기억하면 각 서버는 자기 파일만 대조한다.
|
||||
*
|
||||
* @param string|null $scope 렌더 스코프 (활성 템플릿 식별자)
|
||||
* @return bool 확장 캐시 버전을 올렸으면 true
|
||||
*/
|
||||
private function syncCustomAssetCacheVersion(array $assets, ?string $scope = null): bool
|
||||
private function syncCustomAssetCacheVersion(?string $scope = null): bool
|
||||
{
|
||||
try {
|
||||
$parts = [];
|
||||
$signature = $this->customAssetSignature($scope);
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
// 파일 출처만 본다 — 외부 URL·훅이 더한 항목은 파일 서명이 없다
|
||||
if (($asset['source'] ?? null) !== 'file') {
|
||||
continue;
|
||||
}
|
||||
$scopeKey = ($scope ?? '').'@'.(gethostname() ?: 'host');
|
||||
|
||||
$parts[] = ($asset['id'] ?? '').'@'.($asset['version'] ?? '');
|
||||
}
|
||||
|
||||
sort($parts, SORT_STRING);
|
||||
$signature = $parts === [] ? 'empty' : md5(implode('|', $parts));
|
||||
|
||||
$scopeKey = $scope ?? '';
|
||||
|
||||
$cache = app(CacheInterface::class);
|
||||
// 버전 키와 같은 고정 스토어·코어 네임스페이스 — 컨테이너 바인딩을 거치면 재바인딩
|
||||
// 컨텍스트에서 다른 네임스페이스에 저장되어 첫 관측 규칙이 실제 변경을 삼킨다.
|
||||
$cache = self::customSignatureCache();
|
||||
|
||||
// 구 배포본이 남긴 스칼라 값은 맵이 아니다 — 어느 스코프의 것인지 알 수 없으므로
|
||||
// 서명으로 쓰지 않고 미관측으로 취급한다(첫 관측은 기록만 하니 헛된 bump 가 없다).
|
||||
@@ -151,7 +150,9 @@ trait CollectsCustomAssets
|
||||
}
|
||||
|
||||
$storedMap[$scopeKey] = $signature;
|
||||
$cache->put(CustomAssets::SIGNATURE_CACHE_KEY, $storedMap);
|
||||
// 버전 키와 같은 영구 TTL — 기본 TTL 로 만료되면 그 경계의 첫 관측이 "기록만" 이라
|
||||
// 실제 변경 1회를 놓친다.
|
||||
$cache->put(CustomAssets::SIGNATURE_CACHE_KEY, $storedMap, ExtensionStaticCacheService::PERSISTENT_TTL_SECONDS);
|
||||
|
||||
if ($stored === null) {
|
||||
return false;
|
||||
@@ -174,6 +175,44 @@ trait CollectsCustomAssets
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 렌더 스코프의 custom 파일 서명을 만듭니다.
|
||||
*
|
||||
* 재료 = 활성 모듈 → 활성 플러그인 → 렌더 템플릿 순으로 `publishableFiles()` 전체의
|
||||
* `{type}/{id}/{relative}@{mtime}@{size}`. 정렬 뒤 md5. 파일이 하나도 없으면 `empty`.
|
||||
*
|
||||
* @param string|null $scope 렌더 스코프 (활성 템플릿 식별자)
|
||||
* @return string 서명
|
||||
*/
|
||||
private function customAssetSignature(?string $scope): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
$targets = [];
|
||||
|
||||
foreach ($this->activeExtensionIdentifiers('modules') as $identifier) {
|
||||
$targets[] = ['modules', $identifier];
|
||||
}
|
||||
|
||||
foreach ($this->activeExtensionIdentifiers('plugins') as $identifier) {
|
||||
$targets[] = ['plugins', $identifier];
|
||||
}
|
||||
|
||||
if (! empty($scope)) {
|
||||
$targets[] = ['templates', $scope];
|
||||
}
|
||||
|
||||
foreach ($targets as [$type, $identifier]) {
|
||||
foreach (CustomAssets::publishableFiles($type, $identifier) as $file) {
|
||||
$parts[] = $type.'/'.$identifier.'/'.$file['relative'].'@'.$file['mtime'].'@'.$file['size'];
|
||||
}
|
||||
}
|
||||
|
||||
sort($parts, SORT_STRING);
|
||||
|
||||
return $parts === [] ? 'empty' : md5(implode('|', $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* 이번 요청이 사용자 추가 에셋을 끄도록 요구했는지 판정합니다.
|
||||
*
|
||||
|
||||
@@ -91,6 +91,13 @@ class StaticPublishFailureAlertListener implements HookListenerInterface
|
||||
? TimezoneHelper::toUserCarbon(Carbon::parse($marker['at']))?->diffForHumans()
|
||||
: null,
|
||||
'read' => false,
|
||||
// 알림에서 바로 복구한다 (#651 D11) — 운영자가 결함을 처음 만나는 곳이 알림이므로 그 자리에서
|
||||
// 끝나야 한다. 대시보드 렌더러는 `recover_endpoint` 가 있는 알림에 버튼을 그리고 POST 한다
|
||||
// (기존 재호환 알림과 같은 배선). 성공 문구는 재호환 알림의 기본 문구가 이 알림에는 맞지
|
||||
// 않으므로 리스너가 함께 싣는다.
|
||||
'recover_endpoint' => '/api/admin/settings/static-cache/republish',
|
||||
'recover_label' => __('extensions.alerts.static_publish_recover_label'),
|
||||
'recover_success_message' => __('extensions.alerts.static_publish_recovered'),
|
||||
];
|
||||
|
||||
return $alerts;
|
||||
|
||||
@@ -2215,6 +2215,12 @@ class CoreUpdateService
|
||||
storage_path('framework/cache'),
|
||||
base_path('bootstrap/cache'),
|
||||
storage_path('app/ext-bundles'),
|
||||
// 확장 업데이트의 다운로드·추출 임시 폴더. 부모 `storage/app/temp` 가 sudo 업데이트에서 root 로
|
||||
// 최초 생성되면 이후 관리자 화면의 확장 업데이트가 임시 폴더를 만들지 못한다 (#651 F14).
|
||||
storage_path('app/temp'),
|
||||
// `restore_ownership` 은 `storage/logs` 를 포함하지만 그 복원은 흐름 **중간**(Step 11)이라,
|
||||
// 그 뒤에 만들어지는 daily 롤오버·신규 로그 파일은 root 로 남는다 (#651 F15).
|
||||
storage_path('logs'),
|
||||
];
|
||||
|
||||
foreach ($targets as $dir) {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Exceptions\CustomAssetOperationException;
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\HookManager;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Rules\AllowedTemplateFileType;
|
||||
@@ -279,7 +279,9 @@ class CustomAssetService
|
||||
CustomAssets::flushCache();
|
||||
|
||||
try {
|
||||
app(CacheInterface::class)->forget(CustomAssets::SIGNATURE_CACHE_KEY);
|
||||
// 서명을 쓰는 뷰 컴포저와 **같은 통로**(고정 스토어·코어 네임스페이스)로 지운다 —
|
||||
// 다른 통로로 지우면 그쪽 서명이 남아 다음 렌더가 같은 변경을 한 번 더 bump 한다.
|
||||
self::customSignatureCache()->forget(CustomAssets::SIGNATURE_CACHE_KEY);
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('사용자 추가 에셋 서명 캐시 삭제 실패', ['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -385,8 +387,24 @@ class CustomAssetService
|
||||
return;
|
||||
}
|
||||
|
||||
if (! @mkdir($directory, 0755, true) && ! is_dir($directory)) {
|
||||
throw new CustomAssetOperationException('custom_assets.errors.directory_failed', ['path' => $directory]);
|
||||
// 확보는 코어 공통 프리미티브가 맡는다 — 소유권 상속·그룹 쓰기까지 정합화하고, 실패는 예외가 아니라
|
||||
// 사유로 올라온다. `custom/` 은 지연 생성이라 `deploy:deploy 0755` 로 배포된 확장 디렉토리에서
|
||||
// 첫 저장이 여기서 실패하는데, 경로만 적으면 운영자가 무엇을 고쳐야 하는지 알 수 없다(#651 D6).
|
||||
// 사유·소유자·권한·실행 계정·조치 예시를 함께 싣는다 (정적 게시 프리플라이트와 같은 식).
|
||||
if (FilePermissionHelper::ensureWritableDirectory($directory, 0775, $failure)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$failedPath = (string) ($failure['path'] ?? $directory);
|
||||
$reason = (string) ($failure['reason'] ?? 'create_failed');
|
||||
|
||||
throw new CustomAssetOperationException('custom_assets.errors.directory_failed', [
|
||||
'path' => $directory,
|
||||
'reason' => __('custom_assets.errors.reason.'.$reason),
|
||||
'owner' => (string) (@fileowner($failedPath) ?: 'unknown'),
|
||||
'perms' => file_exists($failedPath) ? substr(sprintf('%o', @fileperms($failedPath)), -4) : 'absent',
|
||||
'process_user' => ExtensionStaticCacheService::currentProcessUser(),
|
||||
'hint' => __('custom_assets.errors.directory_failed_hint', ['path' => $failedPath]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,6 +531,166 @@ class ExtensionStaticCacheService
|
||||
return (bool) config('core.static_cache.enabled', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지금 이 설치에서 게시가 의미 있는지 판정합니다 — kill-switch 켜짐 + 프로덕션.
|
||||
*
|
||||
* `publishCurrent()` 자체에는 환경 게이트가 없다(명시적 `ext-static:publish` 가 어느 환경에서든
|
||||
* 돌 수 있어야 하므로). 환경 판정은 자가 치유(`AssetUrl::staticExtBase`)와 terminating 예약
|
||||
* (`schedulePublishOnTerminate`)에만 있었는데, 관리자 화면의 수동 복구는 두 판정을 **한 값**으로
|
||||
* 보여 줘야 하므로 여기서 묶는다. 비프로덕션은 blade 가 정적 URL 을 방출하지 않아 게시해도
|
||||
* 아무도 참조하지 않는다.
|
||||
*
|
||||
* @return bool 게시가 실제로 쓰이는 환경이면 true
|
||||
*/
|
||||
public function isPublishable(): bool
|
||||
{
|
||||
return $this->isEnabled() && app()->environment('production');
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시 상태 보고서 — CLI(`ext-static:status`)·관리자 API·화면이 **같은 판정**을 소비한다.
|
||||
*
|
||||
* 판정을 소비처마다 다시 쓰면 한 곳만 어긋나도 서로 다른 답을 내놓는다(`TrustedProxyDiagnostic`
|
||||
* 이 CLI·대시보드·API 에 같은 판정을 공급하는 것과 같은 패턴). 문구·힌트는 소비처가 정한다.
|
||||
*
|
||||
* @return array{
|
||||
* enabled: bool,
|
||||
* publishable: bool,
|
||||
* environment: string,
|
||||
* version: int,
|
||||
* published: bool,
|
||||
* files: int,
|
||||
* published_at: string|null,
|
||||
* tree_writable: bool,
|
||||
* process_user: string,
|
||||
* failure: array{version:int, at:string, reason:string, count:int, message:string}|null,
|
||||
* retained_versions: array<int, int>
|
||||
* } 상태 보고서
|
||||
*/
|
||||
public function statusReport(): array
|
||||
{
|
||||
$version = self::getExtensionCacheVersion();
|
||||
$base = $this->baseDir();
|
||||
|
||||
unset($this->publishedMemo[$version]);
|
||||
$published = $this->isPublished($version);
|
||||
|
||||
$files = 0;
|
||||
$publishedAt = null;
|
||||
|
||||
if ($published) {
|
||||
$manifest = json_decode((string) @file_get_contents($this->versionDir($version).DIRECTORY_SEPARATOR.self::MANIFEST_FILE), true);
|
||||
|
||||
if (is_array($manifest)) {
|
||||
$files = count($manifest['files'] ?? []);
|
||||
$publishedAt = is_string($manifest['published_at'] ?? null) ? $manifest['published_at'] : null;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => $this->isEnabled(),
|
||||
'publishable' => $this->isPublishable(),
|
||||
'environment' => (string) app()->environment(),
|
||||
'version' => $version,
|
||||
'published' => $published,
|
||||
'files' => $files,
|
||||
'published_at' => $publishedAt,
|
||||
'tree_writable' => $this->isPublishTreeWritable($base),
|
||||
'process_user' => self::currentProcessUser(),
|
||||
'failure' => self::failureMarker(),
|
||||
'retained_versions' => $this->publishedVersions($base),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 수동 복구 — 캐시 버전을 올리고 현재 버전을 **강제** 재게시합니다.
|
||||
*
|
||||
* 자동 경로(수명주기 bump → terminating 게시 · blade 자가 치유)가 놓친 갱신을 운영자가 즉시
|
||||
* 되돌리는 통로다. 캐시 버전이 만료로 재생성되지 않으므로(`PERSISTENT_TTL_SECONDS`) 재게시
|
||||
* 누락은 무기한 stale 이 되는데, 이 복구가 그 안전망이다.
|
||||
*
|
||||
* - 게시가 쓰이지 않는 환경(kill-switch 꺼짐 / 비프로덕션)이면 bump 하지 않고 사유만 돌려준다.
|
||||
* - `force: true` 가 필수다 — 직전 게시와 같은 초면 버전이 같아 manifest 존재로 skip 되어
|
||||
* 아무것도 다시 만들지 않는다.
|
||||
* - 게시 실패는 예외가 아니라 결과다(`republished=false` + 실패 마커). 사이트는 API 폴백으로 정상.
|
||||
* - 715파일·40MB 복사 + 번들 재병합이 웹 요청 안에서 돈다 — 게시 락 TTL(300초)만큼 실행 시간을
|
||||
* 확보하고, 클라이언트가 끊어도 게시는 끝낸다(중간에 죽으면 `.tmp` 만 남고 다음 GC 가 치운다).
|
||||
*
|
||||
* @return array<string, mixed> `republished`·`version_changed`·`previous_version` + `statusReport()`
|
||||
*/
|
||||
public function republish(): array
|
||||
{
|
||||
$previous = self::getExtensionCacheVersion();
|
||||
|
||||
if (! $this->isPublishable()) {
|
||||
return [
|
||||
'republished' => false,
|
||||
'version_changed' => false,
|
||||
'previous_version' => $previous,
|
||||
'reason' => $this->isEnabled() ? 'not_production' : 'disabled',
|
||||
] + $this->statusReport();
|
||||
}
|
||||
|
||||
@set_time_limit(300);
|
||||
@ignore_user_abort(true);
|
||||
|
||||
$this->incrementExtensionCacheVersion();
|
||||
|
||||
$republished = $this->publishCurrent(force: true);
|
||||
$current = self::getExtensionCacheVersion();
|
||||
|
||||
return [
|
||||
'republished' => $republished,
|
||||
'version_changed' => $current !== $previous,
|
||||
'previous_version' => $previous,
|
||||
'reason' => $republished ? null : (self::failureMarker()['reason'] ?? 'publish_failed'),
|
||||
] + $this->statusReport();
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시 트리에 쓸 수 있는지(없으면 만들 수 있는지) 판정합니다.
|
||||
*
|
||||
* @param string $base 게시 루트 절대 경로
|
||||
* @return bool 쓰기 가능 여부
|
||||
*/
|
||||
private function isPublishTreeWritable(string $base): bool
|
||||
{
|
||||
if (File::isDirectory($base)) {
|
||||
return is_writable($base);
|
||||
}
|
||||
|
||||
$parent = dirname($base);
|
||||
|
||||
return File::isDirectory($parent) && is_writable($parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시 루트에 남아 있는 버전 디렉토리 목록을 반환합니다 (오름차순).
|
||||
*
|
||||
* @param string $base 게시 루트 절대 경로
|
||||
* @return array<int, int> 버전 목록
|
||||
*/
|
||||
private function publishedVersions(string $base): array
|
||||
{
|
||||
if (! File::isDirectory($base)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$versions = [];
|
||||
|
||||
foreach (File::directories($base) as $dir) {
|
||||
$name = basename($dir);
|
||||
|
||||
if (ctype_digit($name)) {
|
||||
$versions[] = (int) $name;
|
||||
}
|
||||
}
|
||||
|
||||
sort($versions);
|
||||
|
||||
return $versions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 버전의 게시를 실제 수행합니다 (tmp 쓰기 → rename → manifest → GC).
|
||||
*
|
||||
@@ -556,6 +716,10 @@ class ExtensionStaticCacheService
|
||||
File::deleteDirectory($tmp);
|
||||
$this->makeDirectory($tmp);
|
||||
|
||||
// custom 열거 메모를 비우고 디스크를 다시 읽는다 — 게시는 요청 뒤(terminating)나 장수 프로세스에서
|
||||
// 돌 수 있어, 앉아 있는 메모가 이미 지워진 파일을 가리키면 복사가 실패해 게시 전체가 중단된다.
|
||||
CustomAssets::flushCache();
|
||||
|
||||
$files = [];
|
||||
|
||||
$this->writeHtaccess($tmp, $files);
|
||||
@@ -909,17 +1073,48 @@ class ExtensionStaticCacheService
|
||||
// URL 은 **public 아래 실제 파일일 때만** 200 이 되므로, 문서가 안내하는
|
||||
// "폰트·이미지를 custom/ 에 두고 상대 경로로 참조" 를 성립시키는 방법은 게시뿐이다.
|
||||
//
|
||||
// 갱신 축은 확장 자산과 동일하다 — 운영자가 파일을 고치면 `CustomAssets` 가
|
||||
// 갱신 축은 확장 자산과 동일하다 — 운영자가 파일을 고치면 `CollectsCustomAssets` 가
|
||||
// 그것을 감지해 `ext.cache_version` 을 올리고, 그 단일 지점이 재게시까지 예약한다.
|
||||
$this->publishDistAssets(
|
||||
base_path("templates/{$identifier}/".CustomAssets::DIRECTORY),
|
||||
// 게시 집합은 감지와 **같은 열거자**(`CustomAssets::publishableFiles`)가 정한다.
|
||||
$this->publishCustomAssets(
|
||||
'templates',
|
||||
$identifier,
|
||||
$templateDir.DIRECTORY_SEPARATOR.'assets'.DIRECTORY_SEPARATOR.CustomAssets::DIRECTORY,
|
||||
$files,
|
||||
$tmp,
|
||||
excludeCustom: false
|
||||
$tmp
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 하나의 운영자 소유 디렉토리(`custom/`)를 게시합니다.
|
||||
*
|
||||
* 파일 집합은 `CustomAssets::publishableFiles()` 가 정한다 — 변경 감지(`CollectsCustomAssets`)
|
||||
* 와 같은 열거자다. 게시와 감지가 서로 다른 코드로 범위를 정하면 어긋나고, 그 어긋남은
|
||||
* "글꼴을 바꿨는데 반영되지 않는다" 로만 나타난다(#651 F7).
|
||||
*
|
||||
* @param string $extensionType `templates` | `modules` | `plugins`
|
||||
* @param string $identifier 확장 식별자
|
||||
* @param string $targetDir 게시 대상 절대 경로 (`…/assets/custom`)
|
||||
* @param array<string> $files 기록된 상대 경로 누적 (참조)
|
||||
* @param string $tmp tmp 루트 (상대 경로 계산용)
|
||||
*/
|
||||
private function publishCustomAssets(
|
||||
string $extensionType,
|
||||
string $identifier,
|
||||
string $targetDir,
|
||||
array &$files,
|
||||
string $tmp
|
||||
): void {
|
||||
foreach (CustomAssets::publishableFiles($extensionType, $identifier) as $file) {
|
||||
$this->copyFile(
|
||||
$file['absolute'],
|
||||
$targetDir.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $file['relative']),
|
||||
$files,
|
||||
$tmp
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 dist 디렉토리를 재귀 복사합니다 (허용 확장자만, 소스맵 제외).
|
||||
*
|
||||
@@ -927,7 +1122,8 @@ class ExtensionStaticCacheService
|
||||
* @param string $targetDir 게시 대상 절대 경로
|
||||
* @param array<string> $files 기록된 상대 경로 누적 (참조)
|
||||
* @param string $tmp tmp 루트 (상대 경로 계산용)
|
||||
* @param bool $excludeCustom 원본 안의 `custom/` 하위를 건너뛸지 (dist 원본에서만 참)
|
||||
* @param bool $excludeCustom 원본 안의 `custom/` 하위를 건너뛸지 (dist 원본은 참 — `custom/` 은
|
||||
* `publishCustomAssets()` 가 공유 열거자로 따로 게시한다)
|
||||
*/
|
||||
private function publishDistAssets(
|
||||
string $sourceDir,
|
||||
@@ -1064,13 +1260,13 @@ class ExtensionStaticCacheService
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->publishDistAssets(
|
||||
base_path("{$root}/{$identifier}/".CustomAssets::DIRECTORY),
|
||||
$this->publishCustomAssets(
|
||||
$root,
|
||||
$identifier,
|
||||
$tmp.DIRECTORY_SEPARATOR.$root.DIRECTORY_SEPARATOR.$identifier
|
||||
.DIRECTORY_SEPARATOR.'assets'.DIRECTORY_SEPARATOR.CustomAssets::DIRECTORY,
|
||||
$files,
|
||||
$tmp,
|
||||
excludeCustom: false
|
||||
$tmp
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1135,7 +1135,9 @@ class LayoutService
|
||||
}
|
||||
|
||||
$identifier = $template->identifier;
|
||||
$cacheVersion = (int) $this->cache->get('ext.cache_version', 0);
|
||||
// 원시 키 읽기는 `cache:clear` 직후 0 을 돌려주어 `.v0` 키를 지우고 실제 키는 남긴다 —
|
||||
// 트레이트 게터는 부재 시 재생성하므로 실제 서빙 키와 같은 버전을 본다.
|
||||
$cacheVersion = self::getExtensionCacheVersion();
|
||||
|
||||
// PublicLayoutController::serve() 가 일반 응답과 편집 모드(`with_source_meta=1`) 응답을
|
||||
// 별도 캐시 키로 저장한다 (`.meta` 접미사). 본 PR Phase 3 S5a-1 에서 편집 모드 응답 캐시
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Repositories\AttachmentRepositoryInterface;
|
||||
use App\Contracts\Repositories\ConfigRepositoryInterface;
|
||||
use App\Extension\HookManager;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Http\Resources\AttachmentResource;
|
||||
use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use App\Support\ConfigCacheHelper;
|
||||
@@ -28,6 +29,10 @@ use Illuminate\Validation\ValidationException;
|
||||
*/
|
||||
class SettingsService
|
||||
{
|
||||
// 자산 URL 방식 변경 시 확장 캐시 버전 bump 용. 주입된 `$this->cache` 로 직접 put 하지
|
||||
// 않는다 — 트레이트가 고정 `CoreCacheDriver` + 메모이즈 스토어를 쓰는 이유가 그 안에 있다.
|
||||
use ClearsTemplateCaches;
|
||||
|
||||
public function __construct(
|
||||
private ConfigRepositoryInterface $configRepository,
|
||||
private AttachmentRepositoryInterface $attachmentRepository,
|
||||
@@ -706,6 +711,7 @@ class SettingsService
|
||||
// CLI(`g7:asset-url-mode`)와 동일한 처리 (계획서 §알려진 한계).
|
||||
if ($assetUrlModeChanged) {
|
||||
$this->clearSeoCacheForAssetUrlMode();
|
||||
$this->bumpExtensionCacheForAssetUrlMode();
|
||||
}
|
||||
|
||||
// drivers 탭은 queue/broadcasting/cache 등 long-running worker에 영향
|
||||
@@ -1090,6 +1096,7 @@ class SettingsService
|
||||
|
||||
if ($assetUrlModeChanged) {
|
||||
$this->clearSeoCacheForAssetUrlMode();
|
||||
$this->bumpExtensionCacheForAssetUrlMode();
|
||||
}
|
||||
|
||||
if (str_starts_with($key, 'drivers.') || $key === 'drivers') {
|
||||
@@ -1126,17 +1133,42 @@ class SettingsService
|
||||
*/
|
||||
public function restoreSettings(string $backupPath): bool
|
||||
{
|
||||
// 자산 URL 방식은 복원 **전**에 읽어 둔다 — 복원 뒤에는 이전 값을 알 수 없어
|
||||
// 변경 여부를 판별할 수 없다 (saveSettings/setSetting 과 같은 사유).
|
||||
$previousAssetUrlMode = $this->configRepository->get('general.asset_url_mode');
|
||||
|
||||
$result = $this->configRepository->restore($backupPath);
|
||||
|
||||
if ($result) {
|
||||
// 복원도 설정 전체를 갈아엎는 쓰기다 — 캐시만 비우고 미러를 두면
|
||||
// 같은 프로세스가 복원 전 값을 계속 읽는다 (저장 경로와 동일 결함, 공개이슈 #109).
|
||||
$this->invalidateSettingsCache();
|
||||
|
||||
// 복원으로 자산 URL 방식이 바뀌었으면 두 저장 경로와 동일하게 처리한다 —
|
||||
// SEO 프리렌더와 병합 번들 CSS 양쪽에 구워진 URL 형태가 어긋난다.
|
||||
if ($this->configRepository->get('general.asset_url_mode') !== $previousAssetUrlMode) {
|
||||
$this->clearSeoCacheForAssetUrlMode();
|
||||
$this->bumpExtensionCacheForAssetUrlMode();
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 자산 URL 방식이 바뀌었을 때 확장 캐시 버전을 올립니다 (정적 게시본 재생성).
|
||||
*
|
||||
* 병합 번들 CSS 는 내부 `url()` 참조가 그 시점의 자산 URL **형태**(확장자 / `?file=`)로
|
||||
* 본문에 구워진다(`ExtensionBundleService` 의 `AssetCssUrlRewriter`). 디스크 번들과 정적
|
||||
* 게시본은 캐시 버전으로 키드되어 있어, 버전이 오르지 않으면 모드를 바꿔도 옛 형태의
|
||||
* URL 이 남고 그 참조(글꼴·이미지)가 서버에서 404 가 된다(#651 F5). bump 단일 지점이
|
||||
* 번들 재병합과 정적 재게시를 함께 예약한다.
|
||||
*/
|
||||
private function bumpExtensionCacheForAssetUrlMode(): void
|
||||
{
|
||||
$this->incrementExtensionCacheVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트 메일을 발송합니다.
|
||||
*
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Container\Container;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -52,7 +53,7 @@ class ConfigCacheHelper
|
||||
}
|
||||
|
||||
try {
|
||||
Artisan::call('config:cache');
|
||||
self::withPreservedContainer(static fn () => Artisan::call('config:cache'));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('config 캐시 재생성 실패 (config:clear 로 stale 은 제거됨 — 다음 요청은 비캐시 부팅)', [
|
||||
'error' => $e->getMessage(),
|
||||
@@ -60,6 +61,30 @@ class ConfigCacheHelper
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 콜백 실행 뒤 전역 컨테이너 인스턴스를 원래 앱으로 되돌립니다.
|
||||
*
|
||||
* `config:cache` 는 신선한 설정을 얻기 위해 **새 Application 을 부팅**하는데, `Application` 생성자가
|
||||
* `Container::setInstance()` 를 호출하므로 그 순간부터 `app()` 헬퍼가 실행 중인 앱이 아니라 그
|
||||
* 일회용 앱을 가리킨다(파사드는 별도 참조라 그대로다). 같은 프로세스에서 그 뒤에 등록되는
|
||||
* `app()->terminating()` 콜백은 종료되지 않는 앱에 걸려 **영원히 실행되지 않는다** — 설정 저장
|
||||
* 뒤의 확장 캐시 버전 bump 가 예약한 정적 재게시가 그렇게 조용히 사라졌다(#651 F5 실측: 버전은
|
||||
* 올랐는데 게시는 다음 렌더의 자가 치유까지 미뤄짐). 예외도 로그도 없고, 자가 치유가 한 렌더
|
||||
* 뒤에 덮어 주므로 "한 박자 늦게 반영" 으로만 나타난다.
|
||||
*
|
||||
* @param callable $callback 전역 인스턴스를 바꿔 놓을 수 있는 작업
|
||||
*/
|
||||
public static function withPreservedContainer(callable $callback): void
|
||||
{
|
||||
$app = Container::getInstance();
|
||||
|
||||
try {
|
||||
$callback();
|
||||
} finally {
|
||||
Container::setInstance($app);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* config 캐시만 제거합니다 (재생성 없음).
|
||||
*
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Support;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Rules\AllowedTemplateFileType;
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
@@ -53,6 +54,14 @@ class CustomAssets
|
||||
/** 요청 스코프 메모이즈 (같은 요청에서 같은 확장을 여러 번 묻는 경로 대비) */
|
||||
private static array $cache = [];
|
||||
|
||||
/**
|
||||
* 게시 대상 파일 열거 결과의 요청 스코프 메모이즈 (키 형식은 `$cache` 와 같다 — `{type}|{id}`).
|
||||
*
|
||||
* 서술자 메모(`$cache`)와 자리를 나눈다 — 서술자는 **로드 목록**(최상위 css/js), 이것은
|
||||
* **게시·변경 감지 집합**(하위 디렉토리 포함 전 허용 확장자)이라 의미가 다르다.
|
||||
*/
|
||||
private static array $fileCache = [];
|
||||
|
||||
/**
|
||||
* 확장 하나의 사용자 추가 에셋 목록을 돌려줍니다.
|
||||
*
|
||||
@@ -87,6 +96,110 @@ class CustomAssets
|
||||
public static function flushCache(): void
|
||||
{
|
||||
self::$cache = [];
|
||||
self::$fileCache = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 하나의 `custom/` 아래 **게시 대상 파일 전체**를 열거합니다.
|
||||
*
|
||||
* 정적 게시(`ExtensionStaticCacheService`)와 변경 감지(`CollectsCustomAssets`)가 **같은
|
||||
* 열거자**를 쓴다. 종전에는 게시가 `custom/**` 를 재귀로 복사하고 감지는 최상위 css/js 의
|
||||
* mtime 만 서명해 범위가 어긋났다 — 문서가 권장하는 `url('./fonts/x.woff2')` 의 글꼴만
|
||||
* 교체하면 게시본이 영영 갱신되지 않았다(#651 F7). 감지 범위와 게시 범위를 한 코드가
|
||||
* 정의해야 다시 어긋나지 않는다.
|
||||
*
|
||||
* 규약 스캔의 **로드 목록**(`forExtension()` — 최상위 css/js) 과는 별개다. 글꼴·이미지는
|
||||
* CSS 가 상대 경로로 참조하는 대상이지 그 자체로 로드할 것이 아니므로 로드 목록은 그대로다.
|
||||
*
|
||||
* 허용 확장자는 종전 게시와 동일하게 `AllowedTemplateFileType::allowedExtensions()`(환경
|
||||
* 무관 게터)에서 소스맵(`map`)을 뺀 목록이다 — 배포 금지 정책(`*.map` gitignore)과 같다.
|
||||
*
|
||||
* @param string $extensionType `templates` | `modules` | `plugins`
|
||||
* @param string $identifier 확장 식별자
|
||||
* @return array<int, array{relative: string, absolute: string, mtime: int, size: int}> 상대 경로 오름차순
|
||||
*/
|
||||
public static function publishableFiles(string $extensionType, string $identifier): array
|
||||
{
|
||||
$cacheKey = $extensionType.'|'.$identifier;
|
||||
|
||||
if (array_key_exists($cacheKey, self::$fileCache)) {
|
||||
return self::$fileCache[$cacheKey];
|
||||
}
|
||||
|
||||
return self::$fileCache[$cacheKey] = self::enumeratePublishableFiles($extensionType, $identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* `publishableFiles()` 의 실제 열거 (메모 없음).
|
||||
*
|
||||
* @param string $extensionType 확장 타입
|
||||
* @param string $identifier 확장 식별자
|
||||
* @return array<int, array{relative: string, absolute: string, mtime: int, size: int}> 상대 경로 오름차순
|
||||
*/
|
||||
private static function enumeratePublishableFiles(string $extensionType, string $identifier): array
|
||||
{
|
||||
$directory = self::directory($extensionType, $identifier);
|
||||
|
||||
if ($directory === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$realRoot = realpath($directory);
|
||||
|
||||
if ($realRoot === false || ! is_dir($realRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$allowed = array_diff(AllowedTemplateFileType::allowedExtensions(), ['map']);
|
||||
|
||||
try {
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($realRoot, \FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
} catch (\UnexpectedValueException $e) {
|
||||
Log::warning('사용자 추가 에셋 디렉토리를 열 수 없습니다.', [
|
||||
'identifier' => $identifier,
|
||||
'directory' => $realRoot,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = [];
|
||||
|
||||
/** @var \SplFileInfo $file */
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$extension = strtolower($file->getExtension());
|
||||
|
||||
if (! in_array($extension, $allowed, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 컨테인먼트 검증 — 심볼릭 링크 등으로 custom 밖을 가리키는 실경로 차단 (게시와 동일)
|
||||
$realFile = $file->getRealPath();
|
||||
|
||||
if ($realFile === false || ! str_starts_with($realFile, $realRoot.DIRECTORY_SEPARATOR)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relative = str_replace('\\', '/', substr($realFile, strlen($realRoot) + 1));
|
||||
|
||||
$files[$relative] = [
|
||||
'relative' => $relative,
|
||||
'absolute' => $realFile,
|
||||
'mtime' => (int) (@$file->getMTime() ?: 0),
|
||||
'size' => (int) (@$file->getSize() ?: 0),
|
||||
];
|
||||
}
|
||||
|
||||
ksort($files, SORT_STRING);
|
||||
|
||||
return array_values($files);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+7
-5
@@ -270,7 +270,7 @@ return [
|
||||
'github_url' => env('G7_UPDATE_GITHUB_URL', 'https://github.com/gnuboard/g7'),
|
||||
'github_token' => env('G7_UPDATE_GITHUB_TOKEN', ''),
|
||||
'pending_path' => env('G7_UPDATE_PENDING_PATH') ?: storage_path('app/core_pending'),
|
||||
'targets' => array_filter(array_map('trim', explode(',', env('G7_UPDATE_TARGETS', 'app,bootstrap,config,database,docs,lang,lang-packs/_bundled,resources,routes,public,tests,upgrades,artisan,composer.json,composer.json.default,composer.lock,package.json,package-lock.json,vite.config.js,vite.config.core.js,vite.config.editor.js,vite.config.devtools.js,vitest.config.ts,playwright.config.ts,tsconfig.json,phpunit.xml,pint.json,.editorconfig,.gitattributes,.gitignore,README.md,README.ko.md,CHANGELOG.md,modules/_bundled,plugins/_bundled,templates/_bundled')))),
|
||||
'targets' => array_filter(array_map('trim', explode(',', env('G7_UPDATE_TARGETS', 'app,bootstrap,config,database,docs,lang,lang-packs/_bundled,resources,routes,public,tests,upgrades,scripts,artisan,composer.json,composer.json.default,composer.lock,package.json,package-lock.json,vite.config.js,vite.config.core.js,vite.config.editor.js,vite.config.devtools.js,vite.config.devdashboard.js,vitest.config.ts,playwright.config.ts,tsconfig.json,phpunit.xml,pint.json,.editorconfig,.gitattributes,.gitignore,README.md,README.ko.md,CHANGELOG.md,modules/_bundled,plugins/_bundled,templates/_bundled')))),
|
||||
// build/ext: 부트스트랩 리소스 정적 게시본(#122) — 각 서버가 재생성하는 로컬 파생물이라
|
||||
// 릴리즈 소스에 없다. 제외하지 않으면 --prune 업데이트가 orphan 으로 삭제해
|
||||
// 업데이트 완료까지 정적 fast path 가 불필요하게 끊긴다 (백업 대상에서도 제외).
|
||||
@@ -312,10 +312,12 @@ return [
|
||||
// 백업 내부 모듈 storage 에 `.preserve-ownership` 마커가 있으면 그 서브트리만
|
||||
// 자동 skip 되어 부작용 없음.
|
||||
//
|
||||
// `public/build/ext`: 부트스트랩 리소스 정적 게시본(#122) — sudo update 종료 시
|
||||
// terminating 게시가 root 소유 버전 디렉토리를 만들면 이후 php-fpm 의 재게시·GC 가
|
||||
// Permission denied 로 실패해 정적 fast path 가 영구 꺼진다(사이트는 API 폴백으로 정상).
|
||||
// 임시 산출물(사용자 데이터 아님)이므로 extension_backups 와 같은 근거로 chown 대상.
|
||||
// `public/build/ext`: 부트스트랩 리소스 정적 게시본(#122). root 프로세스는 자동 게시를
|
||||
// 예약하지 않는다(`schedulePublishOnTerminate` 의 root 가드) — 이 항목은 **실존하는** 게시
|
||||
// 트리(이미 웹 계정이 게시한 사이트)의 소유권을 코어 업데이트 뒤 되돌리기 위한 것이다.
|
||||
// 트리가 root 소유가 되면 이후 php-fpm 의 재게시·GC 가 Permission denied 로 실패해 정적
|
||||
// fast path 가 영구 꺼진다(사이트는 API 폴백으로 정상). 임시 산출물(사용자 데이터 아님)이므로
|
||||
// extension_backups 와 같은 근거로 chown 대상.
|
||||
//
|
||||
// 환경변수 `G7_UPDATE_RESTORE_OWNERSHIP` 로 공유 호스팅 등 축소 필요 시 재정의 가능.
|
||||
'restore_ownership' => array_filter(array_map('trim', explode(',', env(
|
||||
|
||||
@@ -254,7 +254,7 @@ CSS 가 아닌 자산은 바이트 그대로 서빙됩니다. 정적 게시본(`
|
||||
| [schedules.md](schedules.md) | `schedules` | 12 |
|
||||
| [search.md](search.md) | `search` | 1 |
|
||||
| [seo.md](seo.md) | `seo` | 5 |
|
||||
| [settings.md](settings.md) | `settings` | 16 |
|
||||
| [settings.md](settings.md) | `settings` | 18 |
|
||||
| [system.md](system.md) | `system` | 2 |
|
||||
| [templates.md](templates.md) | `templates` | 57 |
|
||||
| [users.md](users.md) | `users` | 12 |
|
||||
|
||||
@@ -1127,6 +1127,169 @@ HTTP/1.1 200
|
||||
서버 실행 환경 정보를 한 번에 조회합니다. OS/웹서버/PHP/DB/Laravel/코어 버전, CPU·메모리·디스크 사용량, PHP 주요 설정값(memory_limit·max_execution_time·upload_max_filesize), 주요 경로, PHP 확장 로드 상태, DB 연결 구성 요약 등을 포함합니다. 관리자 시스템 정보 화면과 요구사항 점검용 진단 데이터로 사용됩니다.
|
||||
|
||||
|
||||
### GET /api/admin/settings/static-cache
|
||||
<!-- @generated:start:api.admin.settings.static-cache -->
|
||||
- **라우트명**: `api.admin.settings.static-cache`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\SettingsController@staticCacheStatus`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.settings.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/settings/static-cache HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| enabled | boolean | `true` | kill-switch(`G7_STATIC_CACHE`) 상태. `false` 면 게시하지 않고 모든 화면이 API 로 동작한다 |
|
||||
| publishable | boolean | `true` | 지금 이 설치에서 게시가 실제로 쓰이는지 — `enabled` 이고 운영 모드(`APP_ENV=production`)일 때만 `true`. 화면의 [지금 다시 만들기] 활성 조건 |
|
||||
| environment | string | `production` | 현재 앱 환경 |
|
||||
| version | integer | `1788656566` | 현재 확장 캐시 버전 (정적 게시 디렉토리 이름). 키가 없으면 새로 만들어 돌려주므로 항상 유효값 |
|
||||
| published | boolean | `true` | 현재 버전의 게시 완료(manifest 존재) 여부 |
|
||||
| files | integer | `714` | manifest 에 기록된 게시 파일 수 (미게시면 0) |
|
||||
| published_at | string\|null | `2026-09-06T01:02:49+00:00` | 마지막 게시 시각 (ISO 8601, 미게시면 `null`) |
|
||||
| tree_writable | boolean | `true` | 게시 트리(`public/build/ext`)에 쓸 수 있는지 — 없으면 부모에 만들 수 있는지 |
|
||||
| process_user | string | `www-data` | 웹 프로세스 계정명 (posix 미지원 환경은 `unknown`). 게시 산출물의 소유자가 될 계정 |
|
||||
| failure | object\|null | `null` | 최근 게시 실패 마커 `{version, at, reason, count, message}`. `reason` 은 `parent_not_writable` / `write_failed` / `lock_unavailable` |
|
||||
| retained_versions | array | `[1788171201,1788656566]` | 게시 루트에 남아 있는 버전 디렉토리 목록 (오름차순). 정상은 현재 + 직전 1개 |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "초기 화면 정적 파일 상태를 불러왔습니다.",
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"publishable": true,
|
||||
"environment": "production",
|
||||
"version": 1788656566,
|
||||
"published": true,
|
||||
"files": 714,
|
||||
"published_at": "2026-09-06T01:02:49+00:00",
|
||||
"tree_writable": true,
|
||||
"process_user": "www-data",
|
||||
"failure": null,
|
||||
"retained_versions": [
|
||||
1788171201,
|
||||
1788656566
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
부트스트랩 리소스 정적 게시(초기 화면 정적 파일)의 상태를 돌려줍니다. 판정은 서비스 한 곳이 소유하며 CLI `php artisan ext-static:status` 와 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」 카드가 같은 값을 소비합니다. 읽기 전용이며 입력이 없습니다.
|
||||
|
||||
`publishable` 이 `false` 이면 게시가 쓰이지 않는 환경(kill-switch 꺼짐 또는 비프로덕션)이라 화면은 [지금 다시 만들기] 를 비활성으로 두고 그 이유를 안내합니다. `failure` 는 게시가 거듭 실패했을 때만 채워지며, 대시보드 알림과 같은 마커를 읽습니다. 상세: [static-asset-publishing.md](../static-asset-publishing.md) "5-1. 관리자 화면에서의 수동 복구".
|
||||
|
||||
|
||||
### POST /api/admin/settings/static-cache/republish
|
||||
<!-- @generated:start:api.admin.settings.static-cache.republish -->
|
||||
- **라우트명**: `api.admin.settings.static-cache.republish`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\SettingsController@republishStaticCache`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.settings.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/settings/static-cache/republish HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드 — 아래 4개 + `GET /api/admin/settings/static-cache` 의 상태 보고서 필드 전부._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| republished | boolean | `true` | 새 버전 게시가 완료됐는지. `false` 여도 HTTP 200 으로 응답한다 — 게시 실패는 요청 처리 실패가 아니라 진단 결과다 (사이트는 API 폴백으로 정상) |
|
||||
| version_changed | boolean | `true` | 캐시 버전이 이전과 달라졌는지. 같은 초에 연속 호출하면 `false` 일 수 있으나 내용은 강제로 다시 만들어진다 |
|
||||
| previous_version | integer | `1788656566` | 호출 전 캐시 버전 |
|
||||
| reason | string\|null | `null` | `republished=false` 일 때의 사유. `disabled`(kill-switch 꺼짐) / `not_production`(비프로덕션 — 이 둘은 버전을 올리지 않는다) / 실패 마커 사유(`parent_not_writable` · `write_failed` · `lock_unavailable`) / `publish_failed` |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "초기 화면 정적 파일을 다시 만들었습니다.",
|
||||
"data": {
|
||||
"republished": true,
|
||||
"version_changed": true,
|
||||
"previous_version": 1788656566,
|
||||
"reason": null,
|
||||
"enabled": true,
|
||||
"publishable": true,
|
||||
"environment": "production",
|
||||
"version": 1788657022,
|
||||
"published": true,
|
||||
"files": 714,
|
||||
"published_at": "2026-09-06T01:10:22+00:00",
|
||||
"tree_writable": true,
|
||||
"process_user": "www-data",
|
||||
"failure": null,
|
||||
"retained_versions": [
|
||||
1788656566,
|
||||
1788657022
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
초기 화면 정적 파일을 지금 다시 만듭니다(관리자 수동 복구). 확장 캐시 버전을 올리고 현재 버전을 **강제** 재게시하므로, 같은 초에 다시 호출해도 내용을 새로 만듭니다. 캐시 버전은 만료로 재생성되지 않으므로(영구 번호) 재게시 트리거가 빠진 상태의 안전망이 이 엔드포인트입니다 — 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」의 [지금 다시 만들기] 와 대시보드 「초기 화면 파일 생성 실패」 알림의 [다시 만들기] 가 호출합니다.
|
||||
|
||||
게시가 쓰이지 않는 환경(`publishable=false`)에서는 버전을 올리지 않고 `republished=false` + `reason` 으로 돌아옵니다. 게시 실패도 HTTP 200 으로 돌아오며(연결 테스트·드라이버 테스트와 같은 규약) 실패 마커가 `failure` 에 실립니다.
|
||||
|
||||
715파일·40MB 복사와 확장 번들 재병합이 요청 안에서 동기로 수행됩니다. 서버는 실행 시간을 게시 락 TTL(300초)만큼 확보하고 클라이언트가 끊어도 게시를 끝내지만, FPM·nginx 타임아웃이 그보다 짧으면 응답이 먼저 끊길 수 있습니다 — 그 경우에도 게시는 계속되므로 상태 조회로 결과를 확인합니다.
|
||||
|
||||
|
||||
### GET /api/admin/settings/trusted-proxy
|
||||
<!-- @generated:start:api.admin.settings.trusted-proxy -->
|
||||
- **라우트명**: `api.admin.settings.trusted-proxy`
|
||||
|
||||
@@ -781,9 +781,15 @@ php artisan core:check-updates
|
||||
'backup_only' => ['vendor'], // 백업/복원 전용 (applyUpdate 제외)
|
||||
'backup_extra' => [...], // 추가 백업 대상
|
||||
'excludes' => [...], // 제외 패턴
|
||||
'restore_ownership' => [...], // sudo 실행 후 소유권을 원상 복원할 경로 (Step 11)
|
||||
'restore_ownership_group_writable' => [...], // 복원 직후 그룹 쓰기(g+w)까지 동기화할 경로
|
||||
],
|
||||
```
|
||||
|
||||
`restore_ownership` 복원은 흐름 **중간**(Step 11)이므로 그 뒤에 만들어지는 런타임 산출물은 흐름 **마지막**의 런타임 소유권 정상화가 덮는다. 대상은 다섯 곳이다: `storage/framework/cache`(캐시 키 인덱스·락 샤드), `bootstrap/cache`, `storage/app/ext-bundles`(병합 번들), `storage/app/temp`(확장 업데이트 임시 폴더 — 부모가 root 로 최초 생성되면 이후 관리자 화면의 확장 업데이트가 실패한다), `storage/logs`(daily 롤오버·신규 로그 파일). `storage/app/{modules,plugins}` 는 사용자 데이터 영역이라 의도적으로 제외되어 있으므로, 그 아래에 파일·디렉토리를 만드는 코드(설정 시드·업그레이드 마이그레이션)는 스스로 부모 소유권을 상속시킨다.
|
||||
|
||||
`.env` 에서 `G7_UPDATE_EXCLUDES` · `G7_UPDATE_TARGETS` · `G7_UPDATE_PROTECTED_PATHS` · `G7_UPDATE_RESTORE_OWNERSHIP` · `G7_UPDATE_RESTORE_OWNERSHIP_GROUP_WRITABLE` 로 재정의할 수 있다. 재정의 값은 기본 목록을 **통째로 대체**하므로 전체 목록을 다시 적는다 — 예를 들어 `G7_UPDATE_EXCLUDES` 에서 `build/ext` 가 빠지면 `--prune` 업데이트가 정적 게시본을 지운다. 기본값은 `.env.example` 에 주석으로 실려 있다.
|
||||
|
||||
---
|
||||
|
||||
## 12. 에러 처리 및 로깅
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 게시물: public/build/ext/{cache_version}/ — 수명주기 이벤트와 운영자 custom 파일 변경마다 terminating 훅이 재생성 (활성 템플릿 + 활성 모듈·플러그인 custom + 병합 번들)
|
||||
1. 게시물: public/build/ext/{cache_version}/ — 수명주기 이벤트·자산 URL 방식 변경·운영자 custom 파일 변경(하위 폴더 포함)마다 terminating 훅이 재생성 (활성 템플릿 + 활성 모듈·플러그인 custom + 병합 번들)
|
||||
2. 서빙 게이트 3조건: 프로덕션 + G7_STATIC_CACHE(기본 on) + 게시 완료(manifest 존재)
|
||||
3. 폴백 2층: 태그 계층은 파일 단위 file_exists + 파샬 역변환, fetch 계층은 fetchStaticFirst
|
||||
4. 무효화는 버전 디렉토리 — 포인터(cache_version)가 바뀔 뿐 파일 덮어쓰기가 없다
|
||||
4. 무효화는 버전 디렉토리 — 포인터(cache_version)가 바뀔 뿐 파일 덮어쓰기가 없다. 버전은 만료되지 않는다 — 재게시는 변경이 있을 때만, 누락은 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」의 [지금 다시 만들기] 로 복구
|
||||
5. .json/.js/.css 로 끝나는 신규 동적(Laravel) 라우트를 만들지 않는다 — 실파일만 정적 확장자
|
||||
```
|
||||
|
||||
@@ -64,6 +64,13 @@ public/build/ext/{cache_version}/
|
||||
다시 해석하므로 고친 내용이 그 화면부터 반영됩니다(게시본이 아직 없는 동안에는 API 경로로
|
||||
떨어지고, 그 응답이 디스크 최신 내용을 줍니다).
|
||||
|
||||
감지 범위는 **게시 범위와 같은 열거자**가 정합니다 — `custom/` 아래를 재귀로 훑어 허용 확장자
|
||||
전부(CSS·JS 뿐 아니라 글꼴·이미지)의 경로·수정 시각·크기를 서명 재료로 씁니다. 그래서 CSS 는
|
||||
그대로 두고 `url('./fonts/x.woff2')` 가 가리키는 글꼴만 교체해도 다시 게시됩니다. 크기를 함께
|
||||
보는 이유는 수정 시각 해상도 안의 재작성과 시각을 보존하는 복사(`rsync -t`)를 잡기 위해서입니다.
|
||||
로드 목록(최상위 CSS·JS 만 페이지에 싣는 규약)은 그대로입니다 — 글꼴·이미지는 CSS 가 참조하는
|
||||
대상이지 그 자체로 로드할 것이 아닙니다.
|
||||
|
||||
모듈·플러그인은 **활성 확장의 `custom/` 만** 게시합니다 — 자산 서빙이 활성 확장에만 응답하므로
|
||||
비활성 확장의 파일을 게시해 봐야 아무도 참조하지 않는 사본이 버전 디렉토리마다 쌓입니다.
|
||||
확장의 빌드 산출물은 개별 파일이 아니라 병합 번들로 게시되므로, `custom/` 이 그 확장에서
|
||||
@@ -95,9 +102,17 @@ public/build/ext/{cache_version}/
|
||||
|---|---|---|
|
||||
| 수명주기 전체 | `incrementExtensionCacheVersion()` 내부 | terminating 게시 예약 — 프로세스당 1회, **실행 시점의 최종 버전**으로 게시 (연속 bump 자연 병합) |
|
||||
| 자가 치유 | blade 렌더의 `staticExtBase()` 게이트 | 현재 버전 미게시 감지 시 terminating 게시 예약. 이번 응답은 API URL — 아래 「자가 치유 창의 실제 비용」 참조 |
|
||||
| 수동/워밍 | `php artisan ext-static:publish [--force]` | 설치기 완료 단계에서도 호출 |
|
||||
| 수동/워밍 | `php artisan ext-static:publish [--force]` (웹 계정으로) | 설치기 완료 단계에서도 호출. 관리자 화면의 [지금 다시 만들기](§5-1)가 같은 일을 한다 |
|
||||
| GC | `php artisan ext-static:cleanup` + 게시 성공 직후 인라인 GC | 현재 + 직전 1개 보존. 스케줄 일 1회 등록 |
|
||||
|
||||
게시 입력이 되는 **설정값 변경**도 수명주기와 같은 단일 지점을 탄다. `general.asset_url_mode`(자산 URL 방식)는 병합 번들 CSS 안의 `url()` 형태로 본문에 구워지므로, 관리자 화면 저장·단건 저장·설정 복원·`g7:asset-url-mode` 어느 경로로 바뀌어도 확장 캐시 버전이 올라 번들과 게시본이 다시 만들어진다. 템플릿 업데이트는 레이아웃이 바뀌지 않아도(다국어·라우트·컴포넌트·dist 만 바뀐 릴리스) 모듈·플러그인 업데이트와 같이 **무조건** 버전을 올린다.
|
||||
|
||||
### 버전은 만료되지 않는다
|
||||
|
||||
확장 캐시 버전 키는 영구(10년) TTL 로 저장된다. 재게시는 수명주기 이벤트·설정 변경·custom 파일 변경·관리자 수동 복구·`php artisan cache:clear`(키 소실 → 재생성) 에서만 일어난다. 종전에는 키가 기본 TTL(24시간)로 만료되어 다음 방문마다 새 버전이 만들어졌고, 그것은 정적 파일 전체(실측 715파일·40MB) 재생성과 전 방문자의 자산 URL 변경이었다 — 안전망이 아니라 우발이었다.
|
||||
|
||||
버전이 만료되지 않으므로 재게시 트리거가 하나라도 빠지면 게시본은 무기한 stale 이 된다. 그 안전망이 §5-1 의 관리자 수동 복구다. `.env` 전용 값(`APP_FALLBACK_LOCALE` 등)을 바꿨을 때도 같은 통로로 다시 만든다.
|
||||
|
||||
### 자가 치유 창의 실제 비용
|
||||
|
||||
확장 수명주기 작업(설치·활성화·비활성화·삭제·업데이트)은 CLI 에서 캐시 버전만 올리고 게시는 다음 웹 렌더에 위임한다. 그래서 그 **직후 첫 페이지 로드 1회**는 API URL 로 나간다.
|
||||
@@ -111,7 +126,7 @@ public/build/ext/{cache_version}/
|
||||
|
||||
기능이 사라지지는 않는다(조작 수단인 아이콘은 인라인이라 영향 없음). 화면이 한 번 다른 글꼴로 보이고, 그 뒤 게시가 끝나면 정상으로 돌아온다.
|
||||
|
||||
이 창을 없애려면 수명주기 작업 뒤에 `php artisan ext-static:publish` 를 명시적으로 실행한다. 다만 그 명령을 **웹 계정이 아닌 사용자로** 돌리면 게시 산출물 소유권이 어긋날 수 있으므로, 위 「소유권」 절의 주의사항을 함께 본다.
|
||||
이 창을 없애려면 수명주기 작업 뒤에 `php artisan ext-static:publish` 를 **웹 계정으로**(`sudo -u {웹계정}`) 실행하거나, 관리자 > 환경설정 > 일반 의 [지금 다시 만들기](§5-1)를 누른다. 그 명령을 웹 계정이 아닌 사용자로 돌리면 게시 산출물 소유권이 어긋난다 — §6 참조.
|
||||
|
||||
- 동시성: 게시는 캐시 락으로 단일 실행. manifest 존재 시 skip(멱등).
|
||||
- 실패 정책: 쓰기 실패는 로그만 남기고 tmp 정리 — 사이트는 API 폴백으로 정상 ("정적 fast path 미적용" 상태이지 장애가 아니다). 다음 렌더의 자가 치유가 재시도한다.
|
||||
@@ -146,6 +161,25 @@ public/build/ext/{cache_version}/
|
||||
마커 자체를 쓰지 못하는 환경(캐시 저장소 불능)에서는 백오프가 걸리지 않는다 — best-effort 다. 그 경우에도 프로세스 안의 예약 가드는 그대로 유효해 한 요청 안에서 반복되지는 않는다.
|
||||
- routes 병합이 열화 상태(확장 업데이트 진행 중 등)면 그 산출물은 게시하지 않는다 — 정적 파일은 스스로 회복되지 않으므로 열화가 다음 bump 까지 박제된다. 같은 규율이 폴백 API 의 HTTP 캐시 헤더에도 적용된다 — 열화 응답에는 `public, max-age` 를 부여하지 않는다 (브라우저/CDN 박제 방지).
|
||||
|
||||
## 5-1. 관리자 화면에서의 수동 복구
|
||||
|
||||
관리자 > 환경설정 > 일반 의 「초기 화면 정적 파일」 카드가 게시 상태를 보여 주고, 즉시 다시 만들 수 있게 한다. 상태 판정은 서비스 한 곳(`ExtensionStaticCacheService::statusReport()`)이 소유하며 CLI(`ext-static:status`)·API·화면이 같은 값을 소비한다.
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| 상태 배지 | 게시됨 / 미게시 / 비활성(kill-switch 꺼짐) / 개발 모드(비프로덕션) |
|
||||
| 현재 버전 · 파일 수 · 마지막 게시 시각 | 현재 캐시 버전과 그 버전의 manifest 내용 |
|
||||
| 실행 계정 | 웹 프로세스 계정 — 게시 산출물의 소유자가 될 계정 |
|
||||
| 최근 실패 | 실패 마커(사유·연속 횟수). 사유별 조치 안내가 붙는다 |
|
||||
|
||||
[지금 다시 만들기] → 확인 모달 → `POST /api/admin/settings/static-cache/republish`. 서버는 캐시 버전을 올리고 현재 버전을 **강제** 재게시한다(같은 초에 다시 눌러도 내용을 다시 만든다). 게시 실패는 요청 실패가 아니라 진단 결과라 HTTP 200 + `republished=false` 로 돌아오고, 카드가 재조회되어 실패 사유를 보인다. 게시가 쓰이지 않는 환경(kill-switch 꺼짐·비프로덕션)에서는 버튼이 비활성이고 서버도 버전을 올리지 않는다. 권한은 조회 `core.settings.read`, 재게시 `core.settings.update`.
|
||||
|
||||
같은 엔드포인트가 대시보드의 「초기 화면 파일 생성 실패」 알림에도 [다시 만들기] 버튼으로 붙는다 — 운영자가 결함을 처음 만나는 곳에서 복구가 끝나도록.
|
||||
|
||||
715파일·40MB 복사와 번들 재병합이 **웹 요청 안에서** 돈다. 서버는 게시 락 TTL(300초)만큼 실행 시간을 확보하고 클라이언트가 끊어도 게시를 끝내지만, FPM `request_terminate_timeout`·nginx `fastcgi_read_timeout` 이 그보다 짧은 서버에서는 응답이 먼저 끊길 수 있다. 그 경우에도 게시는 계속되므로 카드를 다시 열어 결과를 확인한다.
|
||||
|
||||
이 통로가 있으므로 kill-switch 를 관리자 UI 로 두지 않는 방침(§5)은 그대로다 — 복구는 화면에서, 끄기는 서버에서.
|
||||
|
||||
## 5. 운영자 kill-switch
|
||||
|
||||
`.env` 에 `G7_STATIC_CACHE=false` 를 두면 게시가 중단되고 blade 가 정적 URL 을 방출하지 않아 전면 API 폴백(종전 동작)으로 돌아간다. 기본값은 활성이며 관리자 UI 는 두지 않는다 (내부 인프라 — 파일시스템 상태를 화면 토글이 즉시 반영한다고 오해할 소지가 있고, 문제 상황의 조치는 서버 접근을 전제한다).
|
||||
@@ -156,7 +190,7 @@ public/build/ext/{cache_version}/
|
||||
|
||||
- **웹 프로세스 계정의 `public/build` 쓰기 권한**: 게시의 정상 주체는 terminating 훅/자가 치유 = php-fpm(웹 계정)이다. 시스템 요구사항의 그룹 공유(방식 A) 구성이라면 `public/build` 도 같은 원칙(g+w + 공용 그룹)을 적용한다. 게시 코드가 디렉토리를 0775 로 생성하므로 umask 동조 환경에서 그룹 쓰기가 유지된다.
|
||||
- **설치 시**: 설치기 완료 단계가 `ext-static:publish --force` 를 best-effort 로 실행한다 — 설치기는 웹 요청 컨텍스트에서 돌므로 산출물은 웹 계정 소유가 되어 이후 재게시와 자연 정합한다. 실패해도 설치는 완료되고 첫 방문의 자가 치유가 재시도한다.
|
||||
- **sudo 코어 업데이트 시**: 업데이트가 root 로 실행되면 종료 시점(소유권 복원 `app.update.restore_ownership` **이후**)에 도는 terminating 게시가 root 소유 산출물을 만들 수 있다. 산출물은 게시 트리만이 아니다 — 게시는 캐시 락(`storage/framework/cache/data/xx` 샤드 디렉토리 신설)과 병합 번들 빌드(`storage/app/ext-bundles`)까지 **간접적으로 쓴다**. root 소유 캐시 샤드가 남으면 이후 웹 프로세스의 캐시 쓰기가 그 샤드에 해시되는 순간 Permission denied 로 죽어 전면 500 이 된다(7.0.10 업그레이드 실사례). 따라서 **root 프로세스에서는 terminating 자동 게시를 예약하지 않고** 다음 웹 렌더의 자가 치유(웹 계정)에 위임한다. 명시적 `ext-static:publish` 는 root 로도 실행 가능하며 게시 트리(`public/build/ext`)는 부모 소유권 상속으로 정상화되지만, storage 측 간접 산출물의 소유권은 운영자 책임이다 — sudo 로 수동 게시했다면 `storage`/`bootstrap/cache` 의 그룹 쓰기(`chgrp -R {웹그룹}` + `chmod -R g+w`)를 확인한다.
|
||||
- **sudo 코어 업데이트 시**: 업데이트가 root 로 실행되면 게시 산출물이 root 소유가 될 수 있다. 산출물은 게시 트리만이 아니다 — 게시는 캐시 락(`storage/framework/cache/data/xx` 샤드 디렉토리 신설)과 병합 번들 빌드(`storage/app/ext-bundles`)까지 **간접적으로 쓴다**. root 소유 캐시 샤드가 남으면 이후 웹 프로세스의 캐시 쓰기가 그 샤드에 해시되는 순간 Permission denied 로 죽어 전면 500 이 된다(7.0.10 업그레이드 실사례). 따라서 **root 프로세스에서는 terminating 자동 게시를 예약하지 않고** 다음 웹 렌더의 자가 치유(웹 계정)에 위임한다. 명시적 `ext-static:publish` 도 **웹 계정으로 실행한다**(`sudo -u {웹계정} php artisan ext-static:publish`) — root 로 실행하면 명령이 경고와 함께 웹 계정 실행 형태를 안내하고 계속 진행한다(중단하지는 않는다). 캐시 샤드 소유권은 이 명령에만 있는 문제가 아니라 캐시를 건드리는 **모든 artisan 명령에 공통**이므로 INSTALL.md 「명령줄·cron 실행 계정」 규율을 따른다. `ext-static:status` 도 root 로 실행 중이면 같은 경고를 낸다.
|
||||
- **코어 업데이트의 orphan 정리**: 게시본은 릴리즈 소스에 없는 로컬 파생물이므로 `app.update.excludes` 에 `build/ext` 로 등록되어 있다 — `--prune` 업데이트가 orphan 으로 삭제하지 않고, 백업 대상에서도 제외된다.
|
||||
|
||||
### CLI 계정과 웹 계정이 다를 때
|
||||
@@ -178,7 +212,7 @@ public/build/ext/{cache_version}/
|
||||
php artisan ext-static:status
|
||||
```
|
||||
|
||||
현재 버전 / 게시 여부 / manifest 파일 수 / 트리 쓰기 가능성 / 최근 실패 마커(사유별 조치 힌트 포함) / 잔존 버전 목록을 출력한다. **이상이 있으면 비-0 으로 종료**한다 — 이는 "커맨드 실행 실패" 가 아니라 조치할 대상이 있다는 신호다.
|
||||
실행 계정 / 현재 버전 / 게시 여부 / manifest 파일 수 / 트리 쓰기 가능성 / 최근 실패 마커(사유별 조치 힌트 포함) / 잔존 버전 목록을 출력한다. **이상이 있으면 비-0 으로 종료**한다 — 이는 "커맨드 실행 실패" 가 아니라 조치할 대상이 있다는 신호다. root 로 실행 중이면 웹 계정 실행 형태(`sudo -u {웹계정} php artisan ext-static:publish`)를 함께 안내한다. 같은 판정을 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」 카드에서도 볼 수 있다(§5-1).
|
||||
|
||||
권한 문제로 게시가 계속 실패하는 환경(공유 호스팅 등)에서는 `G7_STATIC_CACHE=false` 로 기능을 끄면 경고 로그도 남지 않는다.
|
||||
|
||||
@@ -186,6 +220,8 @@ php artisan ext-static:status
|
||||
|
||||
게시물은 **로컬 디스크** 파생물이다 (확장 병합 번들 캐시와 동일한 제약). 다중 웹서버 스케일아웃 구성에서는 서버마다 게시가 필요하다 — terminating 트리거는 요청을 받은 서버에서만 실행되므로, 나머지 서버는 각자의 blade 자가 치유가 첫 렌더에서 보충한다. 공유 스토리지에 `public/build/ext` 를 올리는 구성은 rename 원자성이 보장되는 파일시스템에서만 사용한다.
|
||||
|
||||
custom 파일 변경 서명은 **서버(호스트명)별로** 기억한다. 캐시 저장소를 여러 서버가 공유하면 같은 파일이라도 서버마다 배포 시각(수정 시각)이 달라, 한 자리를 서버들이 번갈아 덮어쓰며 요청마다 "변경" 으로 읽혀 전체 재게시가 왕복한다. 서버별로 기억하면 각 서버는 자기 파일만 대조한다.
|
||||
|
||||
## 8. nginx 권장 설정 (선택)
|
||||
|
||||
버전 디렉토리라 내용이 불변이므로, 서버 기본 재검증(ETag/Last-Modified)만으로도 충분하다. 다만 **압축은 서버 몫이다** — 정적 서빙은 Laravel 의 응답 압축(GzipEncodeResponse)을 우회하므로, 서버에 gzip 설정이 없으면 종전 API 대비 전송량이 회귀한다 (실측: 병합 lang JSON 약 525KB 비압축). 권장:
|
||||
|
||||
@@ -208,6 +208,21 @@ php artisan hooks:clear # 캐시 삭제 (삭제 후
|
||||
|
||||
> 훅 캐시는 확장 install/update 및 코어 업데이트(`clearAllCaches` → `extension:update-autoload`) 시 자동 재생성됩니다. 코어 리스너 코드 배포 시에만 `hooks:cache` 수동 실행. 상세: [extension/hooks.md "정적 훅 매핑 캐시"](extension/hooks.md).
|
||||
|
||||
### 부트스트랩 리소스 정적 게시
|
||||
|
||||
```bash
|
||||
# 상태 점검 — 이상 발견 시 비-0 종료 (실행 계정·버전·게시 여부·실패 마커·잔존 버전)
|
||||
php artisan ext-static:status
|
||||
|
||||
# 수동 게시 (웹 계정으로 — root 로 실행하면 캐시 폴더가 root 소유가 되어 이후 웹 요청이 500 을 낼 수 있다)
|
||||
sudo -u www-data php artisan ext-static:publish [--force]
|
||||
|
||||
# 구버전 게시 디렉토리 정리 (현재 + 직전 1개 보존, 스케줄 일 1회 자동)
|
||||
php artisan ext-static:cleanup
|
||||
```
|
||||
|
||||
> 관리자 화면에서는 환경설정 > 일반 「초기 화면 정적 파일」 카드에서 같은 상태를 보고 [지금 다시 만들기] 로 즉시 재게시할 수 있습니다. 상세: [backend/static-asset-publishing.md](backend/static-asset-publishing.md).
|
||||
|
||||
### 단발성 결함 보정 (hotfix)
|
||||
|
||||
`hotfix:*` prefix 는 특정 버전의 결함 회복을 위해 신설되는 단발성 도구를 위한 표준 prefix 다. `core:*` (영구 운영 도구) 와 명확히 구분되며 dev-dashboard 자동 노출 면제 대상이다.
|
||||
|
||||
@@ -776,6 +776,8 @@ CSS 는 나중에 온 규칙이 이긴다. 운영자가 덧붙인 스타일이
|
||||
|
||||
이 게시가 필요한 이유는 성능이 아니라 **상대 경로**다. API 경로에서는 CSS 내부 `url('./font.woff2')` 가 해석되지 않는다(기준 URL 이 자산 디렉토리가 아니다). 정적 확장자 URL 은 public 아래 실제 파일일 때만 200 이 되므로, 게시본만이 상대 참조를 성립시킨다.
|
||||
|
||||
변경 감지 범위는 게시 범위와 같다 — `custom/` 아래 **하위 디렉토리 포함** 허용 확장자 전부(CSS·JS·글꼴·이미지)의 경로·수정 시각·크기를 본다. CSS 를 그대로 두고 참조 대상 글꼴만 교체해도 감지된다. 페이지에 실리는 로드 목록(최상위 CSS·JS)은 별개 규약이며 바뀌지 않았다. 서명은 서버(호스트명)별로 기억하므로 다중 서버가 캐시를 공유해도 서버 간 수정 시각 차이로 재게시가 왕복하지 않는다.
|
||||
|
||||
**모듈·플러그인**도 같다. 다만 게시 대상은 **활성** 확장의 `custom/` 뿐이다 — 자산 서빙이 활성 확장에만 응답하므로 비활성 확장의 파일을 게시해 봐야 아무도 참조하지 않는 사본이 쌓인다. 확장의 빌드 산출물은 개별 파일이 아니라 병합 번들로 게시되므로, `custom/` 이 그 확장에서 유일한 개별 게시 대상이다.
|
||||
|
||||
### 확장하기
|
||||
|
||||
@@ -338,10 +338,12 @@ Composer 설치 방식을 선택할 때만 필요하다.
|
||||
| WebSocket | `php artisan reverb:start` | Supervisor 등 |
|
||||
|
||||
```bash
|
||||
# cron 예시 (스케줄러)
|
||||
* * * * * cd /path/to/g7 && php artisan schedule:run >> /dev/null 2>&1
|
||||
# cron 예시 (스케줄러) — 웹 서버 계정으로 실행 (root crontab 에 그대로 두면 캐시 파일이 root 소유가 된다)
|
||||
* * * * * cd /path/to/g7 && sudo -u www-data php artisan schedule:run >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
`php artisan` 명령과 cron 은 웹 서버 실행 계정으로 실행한다 — 캐시·병합 번들·임시 파일이 다른 계정 소유로 남으면 이후 웹 요청이 `Permission denied` 로 실패한다. 예외는 소유권 복원이 내장된 `core:update` 만이다. 상세: [INSTALL.md](../INSTALL.md) 「명령줄·cron 실행 계정」.
|
||||
|
||||
---
|
||||
|
||||
## 7. 지원 브라우저
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
- 리버스 프록시 뒤에서 신뢰 프록시가 지정되지 않았을 때 관리자 대시보드에 표시되는 안내의 일본어 번역을 추가했습니다.
|
||||
- 사이트 설정 문제로 화면 구성 파일이 차단됐을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다 — 방문자 쪽 문제가 아니라는 안내가 일본어 로케일에서 표시됩니다.
|
||||
- 확장 프로그램의 스타일 묶음을 끝내 불러오지 못했을 때 안내에 표시되는 항목명의 일본어 번역을 추가했습니다 — 종전에는 그 자리에 내부 구분 이름이 그대로 보였고, 이제 「모듈 스타일」·「플러그인 스타일」에 해당하는 일본어가 표시됩니다.
|
||||
- 관리자 환경설정 > 일반의 「초기 화면 정적 파일」 상태·재작성 결과 안내 문구의 일본어 번역을 추가했습니다.
|
||||
- 초기 화면 파일 생성 실패 알림의 [다시 만들기] 버튼 라벨과 복구 완료 안내의 일본어 번역을 추가했습니다.
|
||||
- 운영자 추가 파일을 둘 폴더를 만들지 못했을 때 표시되는 실패 사유(같은 이름의 파일이 있음·상위 폴더에 쓸 수 없음 등)와 조치 안내의 일본어 번역을 추가했습니다.
|
||||
|
||||
## [1.0.8] - 2026-08-24
|
||||
|
||||
|
||||
@@ -13,6 +13,13 @@ return [
|
||||
'invalid_extension_target' => 'ターゲット拡張が見つかりません: :identifier',
|
||||
'extension_not_allowed' => '許可されていないファイル形式です: :extension',
|
||||
'upload_too_large' => 'アップロードファイルが大きすぎます。(最大 :limit バイト)',
|
||||
'directory_failed_hint' => 'サーバーで `sudo chown -R {ウェブアカウント}:{ウェブアカウント} :path` を実行してウェブアカウントに所有者を変更するか、グループ書き込み権限(`chmod -R g+w :path`)を付与してから再度お試しください。',
|
||||
'reason' => [
|
||||
'occupied_by_file' => '同じ名前のファイルが既に存在します',
|
||||
'ancestor_not_writable' => '親ディレクトリへの書き込みができません',
|
||||
'create_failed' => 'ディレクトリの作成に失敗しました',
|
||||
'not_writable' => 'ディレクトリは存在しますが、書き込みができません',
|
||||
],
|
||||
],
|
||||
'validation' => [
|
||||
'path_required' => 'ファイルパスを指定してください。',
|
||||
|
||||
@@ -37,6 +37,8 @@ return [
|
||||
'static_publish_failed_parent_not_writable' => '初期画面ファイルを保存するフォルダに書き込めず、:count 回連続で失敗しました。サイトは正常に動作しますが、最初の画面の表示が遅くなります。サーバーで `php artisan ext-static:status` を実行して原因を確認してください。',
|
||||
'static_publish_failed_write_failed' => '初期画面ファイルの生成が :count 回連続で失敗しました。ディスクの空き容量を確認してください。サイトは正常に動作しますが、最初の画面の表示が遅くなります。',
|
||||
'static_publish_failed_lock_unavailable' => '初期画面ファイルの生成が :count 回連続でスキップされました。キャッシュストアの状態を確認してください。サイトは正常に動作しますが、最初の画面の表示が遅くなります。',
|
||||
'static_publish_recover_label' => '再作成',
|
||||
'static_publish_recovered' => '初期画面ファイルを再作成しました。',
|
||||
],
|
||||
'badges' => [
|
||||
'incompatible' => 'コアアップグレード必要',
|
||||
|
||||
@@ -76,8 +76,6 @@ return [
|
||||
'driver_test_partial' => '一部のドライバ接続テストが失敗しました。',
|
||||
'driver_test_error' => 'ドライバ接続テスト中にエラーが発生しました。',
|
||||
'unknown_driver' => '不明なドライバです。',
|
||||
|
||||
// アウトバウンドプロキシ接続テストメッセージ
|
||||
'outbound_proxy_test_success' => 'プロキシ接続に成功しました。外部サービスにはこの IP アドレスとして見えます。',
|
||||
'outbound_proxy_test_failed' => 'プロキシ経由で接続できませんでした。アドレスとプロキシサーバーの状態を確認してください。',
|
||||
'outbound_proxy_test_invalid_url' => 'プロキシアドレスの形式が正しくありません。',
|
||||
@@ -174,10 +172,11 @@ return [
|
||||
'source_vendor_missing' => 'ソースディレクトリに vendor がありません。composer install が実行されていない可能性があります。',
|
||||
'composer_failed_with_output' => 'composer install 実行に失敗しました。:output',
|
||||
],
|
||||
|
||||
'trusted_proxy' => [
|
||||
'alert_title' => '信頼するプロキシが設定されていません',
|
||||
'alert_message' => 'プロキシヘッダー(:headers)を受信していますが、信頼するプロキシが設定されていないため、すべての訪問者が同じアドレス(:ip)として記録されています。.env に TRUSTED_PROXIES を指定してください。詳細: https://github.com/gnuboard/g7/blob/main/docs/backend/reverse-proxy.md',
|
||||
],
|
||||
|
||||
'static_cache_status_loaded' => 'ホーム画面の静的ファイルの状態を読み込みました。',
|
||||
'static_cache_republished' => 'ホーム画面の静的ファイルを再作成しました。',
|
||||
'static_cache_republish_failed' => 'ホーム画面の静的ファイルを再作成できませんでした。サイトは正常に動作しています。',
|
||||
];
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
- 레이아웃 편집기 「필터 보이기/숨기기」·「필터 표시 초기화」 동작의 저장 키·필터 ID 입력 항목 이름을 일본어로 추가했습니다.
|
||||
- 사이드바 하위 메뉴 펼치기/접기 버튼의 화면 낭독기 라벨을 일본어로 추가했습니다.
|
||||
- 환경설정 화면의 `.env` 우선 모드 안내와 「`.env` 고정」 배지 문구의 일본어 번역을 추가했습니다.
|
||||
- 환경설정 > 일반의 「초기 화면 정적 파일」 카드 전체(제목·설명, 게시됨·미게시·비활성·개발 모드 상태 배지, 버전·파일 수·마지막 게시 시각·실행 계정·실패 사유 행, 「지금 다시 만들기」 버튼과 확인 모달)의 일본어 번역을 추가했습니다.
|
||||
- 레이아웃 편집기의 「초기 화면 정적 파일 상태」 데이터 항목 이름을 일본어로 추가했습니다.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -1352,7 +1352,34 @@
|
||||
"asset_url_mode_detecting": "アセットファイル配信方式を検出中です...",
|
||||
"asset_url_mode_detect_extension": "拡張子を使用できる環境です。",
|
||||
"asset_url_mode_detect_extensionless": "静的ファイル最適化ルール(nginx の location ~* \\.(js|css|json|png|jpg|jpeg|gif|ico|svg|woff2?)$ ブロックなど)が拡張子アドレスをインターセプトしています。「拡張子を使用しない」に変更してから保存してください。",
|
||||
"asset_url_mode_detect_unavailable": "検出に失敗しました。サーバーが応答しないため、方式を判定できません。"
|
||||
"asset_url_mode_detect_unavailable": "検出に失敗しました。サーバーが応答しないため、方式を判定できません。",
|
||||
"static_cache_title": "初期画面静的ファイル",
|
||||
"static_cache_description": "最初の画面を素早く表示するために、多言語·ルート·コンポーネント·拡張バンドルを静的ファイルとして事前に作成します。拡張機能のインストール·アップデートや設定変更の後、自動的に再作成され、画面が古い内容を表示する場合はここから即座に再作成できます。",
|
||||
"static_cache_status": "ステータス",
|
||||
"static_cache_status_published": "公開",
|
||||
"static_cache_status_unpublished": "非公開",
|
||||
"static_cache_status_disabled": "無効化",
|
||||
"static_cache_status_dev_mode": "開発モード",
|
||||
"static_cache_version": "現在のバージョン",
|
||||
"static_cache_files": "ファイル数",
|
||||
"static_cache_published_at": "最後の公開時刻",
|
||||
"static_cache_never": "まだありません",
|
||||
"static_cache_process_user": "実行アカウント",
|
||||
"static_cache_failure": "最近{{count}}回連続失敗 —",
|
||||
"static_cache_failure_parent_not_writable": "保存フォルダに書き込めません。サーバーでフォルダの所有者·権限を確認してください。",
|
||||
"static_cache_failure_write_failed": "ファイルの書き込みに失敗しました。ディスクの空き容量を確認してください。",
|
||||
"static_cache_failure_lock_unavailable": "キャッシュストレージからロックを取得できませんでした。キャッシュストレージの状態を確認してください。",
|
||||
"static_cache_hint_disabled": "G7_STATIC_CACHE=false でオフになっているため、静的ファイルを作成しません。すべての画面が API で動作します。",
|
||||
"static_cache_hint_dev_mode": "運用モード(APP_ENV=production)でのみ静的ファイルを作成します。現在、すべての画面が API で動作しています。",
|
||||
"static_cache_republish_now": "今すぐ再作成",
|
||||
"static_cache_republish": "再作成",
|
||||
"static_cache_republishing": "再作成中...",
|
||||
"static_cache_republish_confirm_title": "初期画面静的ファイルの再作成",
|
||||
"static_cache_republish_confirm_body": "静的ファイルを新しいバージョンで再作成します。すべての訪問者がアセットを再度ダウンロードします。",
|
||||
"static_cache_republish_confirm_warning": "完了まで少しお時間がかかる場合があります。その間、サイトは正常に動作します。",
|
||||
"static_cache_republished": "初期画面静的ファイルを再作成しました。",
|
||||
"static_cache_republish_not_publishable": "現在の環境では静的ファイルを作成しません(運用モードではないか、オフになっています)。",
|
||||
"static_cache_republish_failed": "初期画面静的ファイルを再作成できませんでした。下記の失敗理由を確認してください。サイトは正常に動作しています。"
|
||||
},
|
||||
"mail": {
|
||||
"smtp_settings": "SMTP設定",
|
||||
@@ -2791,7 +2818,8 @@
|
||||
"settings_password_confirm_modal": "アプリキー再生成パスワード確認",
|
||||
"core_changelog_modal": "コア変更履歴",
|
||||
"core_update_guide_modal": "コアアップデートお知らせ",
|
||||
"core_update_result_modal": "コアアップデート確認結果"
|
||||
"core_update_result_modal": "コアアップデート確認結果",
|
||||
"settings_static_cache_republish_modal": "初期画面静的ファイルの再作成確認"
|
||||
},
|
||||
"admin_template_list": {
|
||||
"template_activate_modal": "テンプレート有効化確認",
|
||||
|
||||
@@ -1579,7 +1579,8 @@
|
||||
"users": "ユーザーリスト",
|
||||
"dashboard_recent_notifications": "ダッシュボード最近の通知",
|
||||
"sitemap_status": "サイトマップ生成ステータス",
|
||||
"sitemap_progress_ws": "サイトマップ生成進行中(リアルタイム)"
|
||||
"sitemap_progress_ws": "サイトマップ生成進行中(リアルタイム)",
|
||||
"staticCacheStatus": "初期画面静的ファイル状態"
|
||||
},
|
||||
"array_group": {
|
||||
"chart_labels": "項目ラベル",
|
||||
|
||||
@@ -8,7 +8,14 @@ return [
|
||||
'read_failed' => 'Failed to read the file: :path',
|
||||
'write_failed' => 'Failed to save the file: :path',
|
||||
'delete_failed' => 'Failed to delete the file: :path',
|
||||
'directory_failed' => 'Failed to create the directory: :path',
|
||||
'directory_failed' => 'Failed to create the directory: :path — reason :reason, owner :owner, permissions :perms, process user :process_user. Action: :hint',
|
||||
'directory_failed_hint' => 'On the server, change the owner to the web account (`sudo chown -R {web-user}:{web-user} :path`) or grant group write (`chmod -R g+w :path`), then try again.',
|
||||
'reason' => [
|
||||
'occupied_by_file' => 'a file with the same name occupies the path',
|
||||
'ancestor_not_writable' => 'the parent directory is not writable',
|
||||
'create_failed' => 'the directory could not be created',
|
||||
'not_writable' => 'the directory exists but is not writable',
|
||||
],
|
||||
'invalid_path' => 'The path is not allowed: :path',
|
||||
'invalid_extension_target' => 'Target extension not found: :identifier',
|
||||
'extension_not_allowed' => 'File type not allowed: :extension',
|
||||
|
||||
@@ -37,9 +37,11 @@ return [
|
||||
'recover_action' => 'Re-activate',
|
||||
'dismiss_action' => 'Dismiss alert',
|
||||
'static_publish_failed_title' => 'Failed to generate startup files',
|
||||
'static_publish_failed_parent_not_writable' => 'Could not write to the folder for startup files — :count consecutive failures. The site works normally but the first screen loads more slowly. Run `php artisan ext-static:status` on the server to see the cause.',
|
||||
'static_publish_failed_write_failed' => 'Generating startup files failed :count times in a row. Check available disk space. The site works normally but the first screen loads more slowly.',
|
||||
'static_publish_failed_lock_unavailable' => 'Startup file generation was skipped :count times in a row. Check the cache store. The site works normally but the first screen loads more slowly.',
|
||||
'static_publish_failed_parent_not_writable' => 'Could not write to the folder for startup files — :count consecutive failures. The site works normally but the first screen loads more slowly. Run `php artisan ext-static:status` on the server to see the cause. You can retry right away with "Rebuild now" under Admin > Settings > General.',
|
||||
'static_publish_failed_write_failed' => 'Generating startup files failed :count times in a row. Check available disk space. The site works normally but the first screen loads more slowly. You can retry right away with "Rebuild now" under Admin > Settings > General.',
|
||||
'static_publish_failed_lock_unavailable' => 'Startup file generation was skipped :count times in a row. Check the cache store. The site works normally but the first screen loads more slowly. You can retry right away with "Rebuild now" under Admin > Settings > General.',
|
||||
'static_publish_recover_label' => 'Rebuild',
|
||||
'static_publish_recovered' => 'Startup files were rebuilt.',
|
||||
],
|
||||
|
||||
'badges' => [
|
||||
|
||||
@@ -27,6 +27,9 @@ return [
|
||||
'restore_failed' => 'Failed to restore settings.',
|
||||
'restore_error' => 'An error occurred while restoring settings.',
|
||||
'save_individual_failed' => 'Settings save failed: :error',
|
||||
'static_cache_status_loaded' => 'Startup file status loaded.',
|
||||
'static_cache_republished' => 'Startup files were rebuilt.',
|
||||
'static_cache_republish_failed' => 'Startup files could not be rebuilt. The site keeps working normally.',
|
||||
|
||||
// App key related messages
|
||||
'invalid_password' => 'Password does not match.',
|
||||
|
||||
@@ -8,7 +8,14 @@ return [
|
||||
'read_failed' => '파일을 읽지 못했습니다: :path',
|
||||
'write_failed' => '파일을 저장하지 못했습니다: :path',
|
||||
'delete_failed' => '파일을 삭제하지 못했습니다: :path',
|
||||
'directory_failed' => '디렉토리를 만들지 못했습니다: :path',
|
||||
'directory_failed' => '디렉토리를 만들지 못했습니다: :path — 사유 :reason, 소유자 :owner, 권한 :perms, 실행 계정 :process_user. 조치: :hint',
|
||||
'directory_failed_hint' => '서버에서 `sudo chown -R {웹계정}:{웹계정} :path` 로 소유자를 웹 계정으로 바꾸거나, 그룹 쓰기(`chmod -R g+w :path`)를 부여한 뒤 다시 시도하세요.',
|
||||
'reason' => [
|
||||
'occupied_by_file' => '같은 이름의 파일이 자리를 차지하고 있습니다',
|
||||
'ancestor_not_writable' => '상위 디렉토리에 쓸 수 없습니다',
|
||||
'create_failed' => '디렉토리 생성에 실패했습니다',
|
||||
'not_writable' => '디렉토리가 있지만 쓸 수 없습니다',
|
||||
],
|
||||
'invalid_path' => '허용되지 않는 경로입니다: :path',
|
||||
'invalid_extension_target' => '대상 확장을 찾을 수 없습니다: :identifier',
|
||||
'extension_not_allowed' => '허용되지 않는 파일 형식입니다: :extension',
|
||||
|
||||
@@ -37,9 +37,11 @@ return [
|
||||
'recover_action' => '다시 활성화',
|
||||
'dismiss_action' => '알림 닫기',
|
||||
'static_publish_failed_title' => '초기 화면 파일 생성 실패',
|
||||
'static_publish_failed_parent_not_writable' => '초기 화면 파일을 저장할 폴더에 쓸 수 없어 :count회 연속 실패했습니다. 사이트는 정상 동작하지만 첫 화면이 느려집니다. 서버에서 `php artisan ext-static:status` 로 원인을 확인하세요.',
|
||||
'static_publish_failed_write_failed' => '초기 화면 파일을 만드는 중 :count회 연속 실패했습니다. 디스크 여유 공간을 확인하세요. 사이트는 정상 동작하지만 첫 화면이 느려집니다.',
|
||||
'static_publish_failed_lock_unavailable' => '초기 화면 파일 생성이 :count회 연속 건너뛰어졌습니다. 캐시 저장소 상태를 확인하세요. 사이트는 정상 동작하지만 첫 화면이 느려집니다.',
|
||||
'static_publish_failed_parent_not_writable' => '초기 화면 파일을 저장할 폴더에 쓸 수 없어 :count회 연속 실패했습니다. 사이트는 정상 동작하지만 첫 화면이 느려집니다. 서버에서 `php artisan ext-static:status` 로 원인을 확인하세요. 관리자 > 환경설정 > 일반 의 「지금 다시 만들기」로 즉시 재시도할 수 있습니다.',
|
||||
'static_publish_failed_write_failed' => '초기 화면 파일을 만드는 중 :count회 연속 실패했습니다. 디스크 여유 공간을 확인하세요. 사이트는 정상 동작하지만 첫 화면이 느려집니다. 관리자 > 환경설정 > 일반 의 「지금 다시 만들기」로 즉시 재시도할 수 있습니다.',
|
||||
'static_publish_failed_lock_unavailable' => '초기 화면 파일 생성이 :count회 연속 건너뛰어졌습니다. 캐시 저장소 상태를 확인하세요. 사이트는 정상 동작하지만 첫 화면이 느려집니다. 관리자 > 환경설정 > 일반 의 「지금 다시 만들기」로 즉시 재시도할 수 있습니다.',
|
||||
'static_publish_recover_label' => '다시 만들기',
|
||||
'static_publish_recovered' => '초기 화면 파일을 다시 만들었습니다.',
|
||||
],
|
||||
|
||||
'badges' => [
|
||||
|
||||
@@ -27,6 +27,9 @@ return [
|
||||
'restore_failed' => '설정 복원에 실패했습니다.',
|
||||
'restore_error' => '설정 복원 중 오류가 발생했습니다.',
|
||||
'save_individual_failed' => '설정 저장에 실패했습니다: :error',
|
||||
'static_cache_status_loaded' => '초기 화면 정적 파일 상태를 불러왔습니다.',
|
||||
'static_cache_republished' => '초기 화면 정적 파일을 다시 만들었습니다.',
|
||||
'static_cache_republish_failed' => '초기 화면 정적 파일을 다시 만들지 못했습니다. 사이트는 정상 동작합니다.',
|
||||
|
||||
// 앱 키 관련 메시지
|
||||
'invalid_password' => '비밀번호가 일치하지 않습니다.',
|
||||
|
||||
@@ -160,6 +160,7 @@ CRUD 를 바꾸고 싶으면 이 4종 중 하나를 잡으면 되고, 이 모듈
|
||||
- [ ] `_bundled` 에서만 수정하고 `php artisan module:update sirsoft-ecommerce --force` 로 반영
|
||||
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
|
||||
- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝
|
||||
- [ ] 업그레이드 스텝·데이터 마이그레이션이 설정 파일을 새로 쓰면(`File::put`) 바로 뒤에 `FilePermissionHelper::inheritOwnershipFromParent($path)` — `sudo` 코어 업데이트 안에서 root 로 실행되어 그 파일이 root 소유로 남으면 이후 웹 프로세스의 설정 저장이 영구 실패한다. 번들 확장 `upgrades/**` 전수를 파일시스템에서 파생해 검사하는 패리티 테스트가 누락을 잡는다
|
||||
- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
|
||||
- [ ] API 표면 변경 시 `php artisan api:docgen --scope=module:sirsoft-ecommerce` 재실행 + `docs/api/**` 갱신
|
||||
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
## [1.2.1] - 2026-09-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- `sudo` 로 코어를 업데이트할 때 업그레이드 단계가 저장하는 설정 파일이 root 소유로 남을 수 있던 문제를 수정했습니다. 저장 뒤 폴더 소유자와 같게 맞춥니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 입금 기한 만료 주문 자동취소가 카드 등 결제창 결제까지 대상에 포함합니다. 종전에는 무통장입금·가상계좌만 정리되어, 결제창까지 갔으나 결제가 끝나지 않은 주문은 정리하는 주체가 없어 계속 남았습니다. 이제 주문 후 24시간이 지나면 함께 취소되며 미리 차감된 마일리지도 돌아옵니다. 결제가 이미 완료된 주문과 입금 대기 중인 가상계좌 주문은 대상이 아니며, 주문설정의 「만료 주문 자동취소」를 끄면 종전처럼 동작합니다.
|
||||
|
||||
+3
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_0\Migrations;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
@@ -69,6 +70,8 @@ class AbsorbLegacyDueDaysIntoAutoCancelDays implements DataMigration
|
||||
unset($settings['vbank_due_days'], $settings['dbank_due_days']);
|
||||
|
||||
File::put($path, json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
// sudo 코어 업데이트 경로에서 root 로 실행되면 새로 만든 파일이 root 소유로 남는다 — 부모 소유권 상속 (#651)
|
||||
FilePermissionHelper::inheritOwnershipFromParent($path);
|
||||
|
||||
$context->logger->info('[ecommerce:1.0.0] 구 입금기한 키 정리 완료');
|
||||
}
|
||||
|
||||
+3
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_0\Migrations;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
@@ -100,6 +101,8 @@ class NormalizeCurrencyBaseUnit implements DataMigration
|
||||
$settings['currencies'] = $currencies;
|
||||
|
||||
File::put($path, json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
// sudo 코어 업데이트 경로에서 root 로 실행되면 새로 만든 파일이 root 소유로 남는다 — 부모 소유권 상속 (#651)
|
||||
FilePermissionHelper::inheritOwnershipFromParent($path);
|
||||
|
||||
$context->logger->info(sprintf(
|
||||
'[ecommerce:1.0.0] 통화 base_unit 정규화 완료 (기본=%s, base_unit=%d, 환율 스케일=%s)',
|
||||
|
||||
+3
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_0\Migrations;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
@@ -94,6 +95,8 @@ class NormalizeCurrencyLocales implements DataMigration
|
||||
$settings['currencies'] = $currencies;
|
||||
|
||||
File::put($path, json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
// sudo 코어 업데이트 경로에서 root 로 실행되면 새로 만든 파일이 root 소유로 남는다 — 부모 소유권 상속 (#651)
|
||||
FilePermissionHelper::inheritOwnershipFromParent($path);
|
||||
|
||||
$context->logger->info(sprintf(
|
||||
'[ecommerce:1.0.0] 통화 locales 정합화 완료 (지원 언어=%s)',
|
||||
|
||||
+3
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_0\Migrations;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
@@ -50,6 +51,8 @@ class RemoveLegacyDefaultLanguage implements DataMigration
|
||||
unset($settings['default_language']);
|
||||
|
||||
File::put($path, json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
// sudo 코어 업데이트 경로에서 root 로 실행되면 새로 만든 파일이 root 소유로 남는다 — 부모 소유권 상속 (#651)
|
||||
FilePermissionHelper::inheritOwnershipFromParent($path);
|
||||
|
||||
$context->logger->info('[ecommerce:1.0.0] 구 default_language 키 정리 완료 (코어 언어 일원화)');
|
||||
}
|
||||
|
||||
+3
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_2\Migrations;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
@@ -84,6 +85,8 @@ class PruneEmptyShippingCountryNameLocales implements DataMigration
|
||||
// 인코딩 플래그는 EcommerceSettingsService 의 저장부와 동일해야 한다.
|
||||
// 다르면 이 스텝이 국가명과 무관한 값(URL 등)의 이스케이프까지 조용히 바꿔 쓴다.
|
||||
File::put($path, json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
// sudo 코어 업데이트 경로에서 root 로 실행되면 새로 만든 파일이 root 소유로 남는다 — 부모 소유권 상속 (#651)
|
||||
FilePermissionHelper::inheritOwnershipFromParent($path);
|
||||
|
||||
$context->logger->info(sprintf(
|
||||
'[ecommerce:1.0.2] 국가명 빈 로케일 키 %d개 제거 완료 (국가 %d개 검사)',
|
||||
|
||||
+3
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_5\Migrations;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
@@ -104,6 +105,8 @@ class BackfillMileageEarnRounding implements DataMigration
|
||||
$settings['currency_rules'] = $rules;
|
||||
|
||||
File::put($path, json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
// sudo 코어 업데이트 경로에서 root 로 실행되면 새로 만든 파일이 root 소유로 남는다 — 부모 소유권 상속 (#651)
|
||||
FilePermissionHelper::inheritOwnershipFromParent($path);
|
||||
|
||||
$context->logger->info(sprintf(
|
||||
'[ecommerce:1.0.5] 적립 절사 기준 백필 완료 (%d개 통화 규칙 → %s / %s)',
|
||||
|
||||
@@ -135,6 +135,7 @@ immutable append-only)으로 이중 기록합니다 — 지금 상태 조회와
|
||||
- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-gdpr --force` 로 반영
|
||||
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
|
||||
- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝
|
||||
- [ ] 업그레이드 스텝·데이터 마이그레이션이 설정 파일을 새로 쓰면(`File::put`) 바로 뒤에 `FilePermissionHelper::inheritOwnershipFromParent($path)` — `sudo` 코어 업데이트 안에서 root 로 실행되어 그 파일이 root 소유로 남으면 이후 웹 프로세스의 설정 저장이 영구 실패한다. 번들 확장 `upgrades/**` 전수를 파일시스템에서 파생해 검사하는 패리티 테스트가 누락을 잡는다
|
||||
- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
|
||||
- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-gdpr` 재실행 + `docs/api/**` 갱신
|
||||
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- `sudo` 로 코어를 업데이트할 때 업그레이드 단계가 저장하는 설정 파일이 root 소유로 남을 수 있던 문제를 수정했습니다. 저장 뒤 폴더 소유자와 같게 맞춥니다.
|
||||
- 회원탈퇴로 자동 철회된 동의 이력이 관리자 「GDPR 동의 이력」 화면의 출처 필터로 걸러지지 않던 문제를 수정했습니다.
|
||||
- 쿠키 동의 저장 요청이 서버만 기록해야 하는 출처(회원가입 시 동의)를 직접 지정할 수 있던 문제를 수정했습니다 — 동의 이력의 출처가 실제 동의 경로와 일치합니다.
|
||||
- 화면 테마(밝게/어둡게/시스템 설정) 선택이 저장되지 않던 문제를 수정했습니다. 기능 쿠키에 동의하기 전에는 테마를 바꿔도 새로고침하면 원래대로 돌아갔고, 관리자·사용자 화면 모두 같았습니다. 테마는 사용자가 화면에서 직접 고른 표시 환경이므로 언어 설정과 같은 필수 항목으로 분류해 동의 여부와 무관하게 저장합니다.
|
||||
|
||||
+3
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Plugins\SirsoftGdpr\V1_0_4\Migrations;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
@@ -141,6 +142,8 @@ final class RetagThemeAsStrictlyNecessary implements DataMigration
|
||||
: $categories;
|
||||
|
||||
File::put($path, json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
// sudo 코어 업데이트 경로에서 root 로 실행되면 새로 만든 파일이 root 소유로 남는다 — 부모 소유권 상속 (#651)
|
||||
FilePermissionHelper::inheritOwnershipFromParent($path);
|
||||
|
||||
$context->logger->info('[sirsoft-gdpr] 쿠키 카테고리 안내 문구 정정: '.implode(', ', $changed));
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ CBT 인증 URL 로 폼 POST → KG 이니시스가 `sid` 를 콜백으로 전달
|
||||
|
||||
- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-pay_kginicis --force` 로 반영
|
||||
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
|
||||
- [ ] 업그레이드 스텝·데이터 마이그레이션이 설정 파일을 새로 쓰면(`File::put`) 바로 뒤에 `FilePermissionHelper::inheritOwnershipFromParent($path)` — `sudo` 코어 업데이트 안에서 root 로 실행되어 그 파일이 root 소유로 남으면 이후 웹 프로세스의 설정 저장이 영구 실패한다. 번들 확장 `upgrades/**` 전수를 파일시스템에서 파생해 검사하는 패리티 테스트가 누락을 잡는다
|
||||
- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
|
||||
- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-pay_kginicis` 재실행 + `docs/api/**` 갱신
|
||||
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
|
||||
- 확장 문서(README · AGENTS.md · docs/README.md)의 제목에 「그누보드7」과 확장 유형을 함께 표기했습니다. 제목만 보고도 그누보드7의 어떤 종류 확장인지 알 수 있습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `sudo` 로 코어를 업데이트할 때 업그레이드 단계가 저장하는 설정 파일이 root 소유로 남을 수 있던 문제를 수정했습니다. 저장 뒤 폴더 소유자와 같게 맞춥니다.
|
||||
|
||||
## [1.1.2] - 2026-08-22
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Plugins\Sirsoft\PayKginicis\Upgrades;
|
||||
|
||||
use App\Contracts\Extension\UpgradeStepInterface;
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Facades\File;
|
||||
@@ -120,6 +121,8 @@ class Upgrade_1_0_0_beta_4 implements UpgradeStepInterface
|
||||
// 7. 저장
|
||||
$settings['payment_methods'] = $merged;
|
||||
File::put($path, json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||
// sudo 코어 업데이트 경로에서 root 로 실행되면 새로 만든 파일이 root 소유로 남는다 — 부모 소유권 상속 (#651)
|
||||
FilePermissionHelper::inheritOwnershipFromParent($path);
|
||||
|
||||
$context->logger->info('[v1.0.0-beta.4] KG 이니시스 간편결제 결제수단을 phone 뒤에 삽입 완료', [
|
||||
'inserted' => $missingIds,
|
||||
|
||||
@@ -33,6 +33,27 @@ if (! function_exists('sendSSEEvent')) {
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('bestEffortFailureMessage')) {
|
||||
/**
|
||||
* best-effort 작업 실패 안내 문구를 만듭니다.
|
||||
*
|
||||
* 종전에는 작업 종류와 무관하게 언어팩 전용 문구(`warning_language_pack_install_partial`)를 재사용해,
|
||||
* 대상(`target`)이 없는 `static_publish`·`config_cache` 실패가 "언어팩 일부 설치에 실패했습니다: (계속 진행)"
|
||||
* 으로 찍혔다(#651 F20). 작업 라벨(`task_{id}`)에 대상이 있으면 괄호로 덧붙인다.
|
||||
*
|
||||
* @param string $taskId 작업 식별자 (예: static_publish)
|
||||
* @param string $target 작업 대상 (없으면 빈 문자열)
|
||||
* @return string 안내 문구
|
||||
*/
|
||||
function bestEffortFailureMessage(string $taskId, string $target): string
|
||||
{
|
||||
$taskName = lang("task_{$taskId}");
|
||||
$display = $target !== '' ? "{$taskName} ({$target})" : $taskName;
|
||||
|
||||
return lang('warning_best_effort_task_failed', ['task' => $display]);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('sendRollbackOutputSSE')) {
|
||||
/**
|
||||
* 롤백 실행 결과를 로그 이벤트로 출력합니다.
|
||||
@@ -1935,7 +1956,7 @@ if (! function_exists('runInstallationTasks')) {
|
||||
if (! $result['success']) {
|
||||
// best-effort task (예: 번들 언어팩 설치) — 실패 시 경고 로그만 남기고 계속 진행
|
||||
if ($bestEffort) {
|
||||
$warnMsg = lang('warning_language_pack_install_partial', ['identifier' => (string) $target]);
|
||||
$warnMsg = bestEffortFailureMessage((string) $taskId, (string) $target);
|
||||
sendSSEEvent('log', ['message' => $warnMsg]);
|
||||
addLog($warnMsg);
|
||||
|
||||
|
||||
@@ -380,6 +380,7 @@ return [
|
||||
'error_language_pack_install_failed' => 'Language pack installation failed',
|
||||
'log_language_pack_install_success' => 'Language pack installation completed',
|
||||
'warning_language_pack_install_partial' => 'Language pack install partially failed: :identifier (continuing)',
|
||||
'warning_best_effort_task_failed' => ':task failed (installation continues)',
|
||||
|
||||
// Error Messages - Worker (Cache)
|
||||
'error_cache_clear_failed' => 'Cache clearing failed',
|
||||
|
||||
@@ -380,6 +380,7 @@ return [
|
||||
'error_language_pack_install_failed' => '언어팩 설치에 실패했습니다',
|
||||
'log_language_pack_install_success' => '언어팩 설치 완료',
|
||||
'warning_language_pack_install_partial' => '언어팩 일부 설치에 실패했습니다: :identifier (계속 진행)',
|
||||
'warning_best_effort_task_failed' => ':task 에 실패했습니다 (설치는 계속 진행됩니다)',
|
||||
|
||||
// 에러 메시지 - Worker (Cache)
|
||||
'error_cache_clear_failed' => '캐시 클리어에 실패했습니다',
|
||||
|
||||
@@ -657,6 +657,9 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'check.user_status', 'admin'
|
||||
// 읽기 전용 진단 (#124). 값 편집 엔드포인트는 두지 않는다 — 편집은 .env 전용.
|
||||
// `{key}` 와일드카드보다 먼저 등록해야 그 라우트에 흡수되지 않는다.
|
||||
Route::get('trusted-proxy', [AdminSettingsController::class, 'trustedProxy'])->middleware('permission:admin,core.settings.read')->name('api.admin.settings.trusted-proxy');
|
||||
// 초기 화면 정적 파일(정적 게시) 상태 + 관리자 수동 복구 (#651). 상태는 `{key}` 와일드카드보다 먼저.
|
||||
Route::get('static-cache', [AdminSettingsController::class, 'staticCacheStatus'])->middleware('permission:admin,core.settings.read')->name('api.admin.settings.static-cache');
|
||||
Route::post('static-cache/republish', [AdminSettingsController::class, 'republishStaticCache'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.static-cache.republish');
|
||||
Route::get('app-key', [AdminSettingsController::class, 'getAppKey'])->middleware('permission:admin,core.settings.read')->name('api.admin.settings.app-key');
|
||||
Route::post('regenerate-app-key', [AdminSettingsController::class, 'regenerateAppKey'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.regenerate-app-key');
|
||||
Route::post('clear-cache', [AdminSettingsController::class, 'clearCache'])->middleware('permission:admin,core.settings.update')->name('api.admin.settings.clear-cache');
|
||||
|
||||
@@ -78,7 +78,7 @@ admin/`)이 이 템플릿의 베이스(`_admin_base`)를 extends 하고 이 템
|
||||
| 확장점 | 수 | 상세 |
|
||||
|---|---|---|
|
||||
| 제공 컴포넌트 | 125개 | [제공 컴포넌트](docs/components.md#제공-컴포넌트) |
|
||||
| 레이아웃 | 145개 | [레이아웃 목록](docs/layouts.md#레이아웃-목록) |
|
||||
| 레이아웃 | 146개 | [레이아웃 목록](docs/layouts.md#레이아웃-목록) |
|
||||
| 전용 핸들러 | 17개 | [템플릿 전용 핸들러](docs/handlers.md#템플릿-전용-핸들러) |
|
||||
| 확장 오버라이드 | 0개 | [확장 오버라이드](docs/layouts.md#확장-오버라이드) |
|
||||
<!-- @generated:extension-points-summary END -->
|
||||
@@ -122,7 +122,7 @@ admin/`)이 이 템플릿의 베이스(`_admin_base`)를 extends 하고 이 템
|
||||
| 종류 | 개수 | 위치 |
|
||||
|---|---|---|
|
||||
| PHPUnit | 0개 | — |
|
||||
| Vitest | 207개 | `vitest.config.ts` |
|
||||
| Vitest | 208개 | `vitest.config.ts` |
|
||||
| Playwright | 9개 | `tests/Playwright` |
|
||||
| 시나리오 매니페스트 | 2개 | `tests/scenarios` |
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
### Added
|
||||
|
||||
- 환경설정 > 일반 에 「초기 화면 정적 파일」 카드가 추가되었습니다. 게시 상태(게시됨·미게시·비활성·개발 모드)와 현재 버전·파일 수·마지막 게시 시각·실행 계정·최근 실패 사유를 보여 주고, 「지금 다시 만들기」로 확인 후 즉시 다시 만들 수 있습니다. 게시가 쓰이지 않는 환경에서는 버튼이 비활성으로 표시되고 그 이유를 안내합니다.
|
||||
- 대시보드의 「초기 화면 파일 생성 실패」 알림에 「다시 만들기」 버튼이 붙어, 알림에서 바로 다시 만들 수 있습니다.
|
||||
|
||||
- 아이콘(Font Awesome), 본문 글꼴(Pretendard), 국기 아이콘, 레이아웃 코드 편집기(Monaco)를 템플릿에 함께 담았습니다. 관리자 화면 전체가 외부 CDN 없이 동작합니다.
|
||||
- 이미지 업로드 시 쓰는 압축 라이브러리도 함께 담았습니다.
|
||||
- 코드 편집기를 불러오지 못하면 안내와 함께 일반 입력창으로 전환되어, 레이아웃을 계속 편집하고 저장할 수 있습니다.
|
||||
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* @file admin-settings-static-cache.test.tsx
|
||||
* @description 환경설정 > 일반 「초기 화면 정적 파일」 카드 + 다시 만들기 확인 모달 (#651 D4·D7)
|
||||
*
|
||||
* 캐시 버전이 만료로 재생성되지 않으므로(영구 번호) 재게시 누락은 무기한 stale 이 된다 —
|
||||
* 이 카드의 [지금 다시 만들기] 가 그 안전망이다. 두 축으로 잠근다:
|
||||
*
|
||||
* (a) 스키마 — admin_settings.json 의 `staticCacheStatus` 데이터소스(일반 탭 게이트·엔드포인트·
|
||||
* transition_overlay 대기 목록·모달 등록)와 모달의 apiCall 체인
|
||||
* (refetchDataSource → toast → closeModal 순서)
|
||||
* (b) 실제 _tab_general.json 파샬 렌더 — 상태 배지 4분기·버튼 disabled 분기·입력 컨트롤 0개 계약
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { createLayoutTest } from '@core/template-engine/__tests__/utils/layoutTestUtils';
|
||||
import { ComponentRegistry } from '@core/template-engine/ComponentRegistry';
|
||||
|
||||
const layout = require('../../layouts/admin_settings.json');
|
||||
const generalPartial = JSON.parse(
|
||||
readFileSync(resolve(__dirname, '../../layouts/partials/admin_settings/_tab_general.json'), 'utf-8')
|
||||
);
|
||||
const republishModal = JSON.parse(
|
||||
readFileSync(resolve(__dirname, '../../layouts/partials/admin_settings/_modal_static_cache_republish.json'), 'utf-8')
|
||||
);
|
||||
|
||||
interface AnyJson { [k: string]: any }
|
||||
|
||||
const sources: AnyJson[] = layout.data_sources;
|
||||
const byId = (id: string) => sources.find(s => s.id === id);
|
||||
|
||||
function findNodeById(node: any, id: string): any {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
if (node.id === id) return node;
|
||||
for (const child of node.children ?? []) {
|
||||
const found = findNodeById(child, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findInPartial(partial: any, id: string): any {
|
||||
for (const root of partial.components ?? partial.children ?? [partial]) {
|
||||
const found = findNodeById(root, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// (a) 스키마
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('admin_settings — staticCacheStatus 데이터소스 배선', () => {
|
||||
// @scenario permission=granted, publishable=production_enabled, outcome=success
|
||||
// @effects card_renders_status_rows
|
||||
it('일반 탭 게이트 + 상태 엔드포인트를 가진다', () => {
|
||||
const s = byId('staticCacheStatus');
|
||||
expect(s).toBeTruthy();
|
||||
expect(s!.type).toBe('api');
|
||||
expect(s!.endpoint).toBe('/api/admin/settings/static-cache');
|
||||
expect(s!.method).toBe('GET');
|
||||
expect(s!.if).toContain("'general'");
|
||||
expect(s!.if).toContain('query.tab');
|
||||
expect(s!.auto_fetch).not.toBe(false);
|
||||
expect(s!.auth_required).toBe(true);
|
||||
});
|
||||
|
||||
it('fallback 은 버튼을 비활성으로 두는 안전 기본값이다 (publishable:false)', () => {
|
||||
const s = byId('staticCacheStatus')!;
|
||||
expect(s.fallback.data.publishable).toBe(false);
|
||||
expect(s.fallback.data.published).toBe(false);
|
||||
expect(s.fallback.data.failure).toBeNull();
|
||||
});
|
||||
|
||||
it('탭 전환 오버레이가 이 소스를 기다린다', () => {
|
||||
expect(layout.transition_overlay.wait_for).toContain('staticCacheStatus');
|
||||
});
|
||||
|
||||
it('다시 만들기 확인 모달이 등록되어 있다', () => {
|
||||
const partials = (layout.modals as AnyJson[]).map(m => m.partial);
|
||||
expect(partials).toContain('partials/admin_settings/_modal_static_cache_republish.json');
|
||||
expect(republishModal.id).toBe('settings_static_cache_republish_modal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('_tab_general — 초기 화면 정적 파일 카드 스키마', () => {
|
||||
it('카드가 에셋 서빙 방식 카드 바로 다음에 있다', () => {
|
||||
const ids = (generalPartial.children as AnyJson[]).map(c => c.id);
|
||||
const assetIdx = ids.indexOf('card_asset_serving');
|
||||
const cardIdx = ids.indexOf('card_static_cache');
|
||||
expect(assetIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(cardIdx).toBe(assetIdx + 1);
|
||||
});
|
||||
|
||||
// @scenario permission=granted, publishable=non_production, outcome=success
|
||||
// @effects republish_button_disabled_when_not_publishable
|
||||
it('버튼은 publishable 이 아니면 비활성이고 확인 모달을 연다', () => {
|
||||
const btn = findInPartial(generalPartial, 'static_cache_republish_button');
|
||||
expect(btn).not.toBeNull();
|
||||
expect(btn.props.type).toBe('button');
|
||||
expect(btn.props.disabled).toContain('staticCacheStatus?.data?.publishable');
|
||||
expect(btn.props.disabled).toContain('_computed.isReadOnly');
|
||||
const open = btn.actions.find((a: AnyJson) => a.handler === 'openModal');
|
||||
expect(open).toBeTruthy();
|
||||
expect(open.target).toBe('settings_static_cache_republish_modal');
|
||||
});
|
||||
|
||||
it('상태 배지는 게시됨/미게시/비활성/개발 모드 4분기를 갖는다', () => {
|
||||
const badge = findInPartial(generalPartial, 'static_cache_status_badge');
|
||||
expect(badge).not.toBeNull();
|
||||
const variants = Object.keys(badge.props.classMap.variants).sort();
|
||||
expect(variants).toEqual(['dev_mode', 'disabled', 'published', 'unpublished']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_modal_static_cache_republish — 확인 → POST → 후속 체인', () => {
|
||||
const confirmBtn = findNodeById(republishModal, 'static_cache_republish_confirm_button');
|
||||
const seq = confirmBtn?.actions?.[0];
|
||||
const apiCall = (seq?.actions as AnyJson[] | undefined)?.find(a => a.handler === 'apiCall');
|
||||
|
||||
// @scenario permission=granted, publishable=production_enabled, outcome=success
|
||||
// @effects confirm_modal_then_toast_and_refetch
|
||||
it('확인 버튼이 재게시 엔드포인트를 POST 한다 (top-level target)', () => {
|
||||
expect(confirmBtn).not.toBeNull();
|
||||
expect(seq.handler).toBe('sequence');
|
||||
expect(apiCall).toBeTruthy();
|
||||
expect(apiCall!.target).toBe('/api/admin/settings/static-cache/republish');
|
||||
expect(apiCall!.params.method).toBe('POST');
|
||||
expect(apiCall!.auth_required).toBe(true);
|
||||
});
|
||||
|
||||
// @scenario permission=granted, publishable=production_enabled, outcome=publish_failed_marker
|
||||
// @effects confirm_modal_then_toast_and_refetch
|
||||
it('성공 시 상태 재조회 → 토스트 → 모달 닫기 순서다', () => {
|
||||
const handlers = (apiCall!.onSuccess as AnyJson[]).map(a => a.handler);
|
||||
const refetchIdx = handlers.indexOf('refetchDataSource');
|
||||
const toastIdx = handlers.indexOf('toast');
|
||||
const closeIdx = handlers.indexOf('closeModal');
|
||||
expect(refetchIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(toastIdx).toBeGreaterThan(refetchIdx);
|
||||
expect(closeIdx).toBeGreaterThan(toastIdx);
|
||||
|
||||
const refetch = (apiCall!.onSuccess as AnyJson[])[refetchIdx];
|
||||
expect(refetch.params.dataSourceId).toBe('staticCacheStatus');
|
||||
|
||||
// 게시 실패도 HTTP 200 이다 — 토스트가 페이로드(republished)로 분기해야 한다
|
||||
const toast = (apiCall!.onSuccess as AnyJson[])[toastIdx];
|
||||
expect(toast.params.message).toContain('response?.data?.republished');
|
||||
expect(toast.params.type).toContain('response?.data?.republished');
|
||||
});
|
||||
|
||||
it('실패 시 에러 토스트 후 모달을 닫는다', () => {
|
||||
const handlers = (apiCall!.onError as AnyJson[]).map(a => a.handler);
|
||||
expect(handlers).toContain('toast');
|
||||
expect(handlers).toContain('closeModal');
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// (b) 실제 파샬 렌더
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const TestDiv: React.FC<any> = ({ id, className, children }) => (
|
||||
<div id={id} className={className}>{children}</div>
|
||||
);
|
||||
const TestSpan: React.FC<any> = ({ id, className, children, text }) => (
|
||||
<span id={id} className={className}>{children || text}</span>
|
||||
);
|
||||
const TestH3: React.FC<any> = ({ id, className, children, text }) => (
|
||||
<h3 id={id} className={className}>{children || text}</h3>
|
||||
);
|
||||
const TestP: React.FC<any> = ({ id, className, children, text }) => (
|
||||
<p id={id} className={className}>{children || text}</p>
|
||||
);
|
||||
const TestInput: React.FC<any> = ({ name, type }) => <input name={name} type={type} />;
|
||||
const TestSelect: React.FC<any> = ({ name }) => <select name={name} />;
|
||||
const TestTextarea: React.FC<any> = ({ name }) => <textarea name={name} />;
|
||||
const TestToggle: React.FC<any> = ({ name }) => <input type="checkbox" role="switch" name={name} />;
|
||||
const TestButton: React.FC<any> = ({ id, disabled, className, children, text }) => (
|
||||
<button id={id} type="button" disabled={!!disabled} aria-disabled={!!disabled} className={className}>
|
||||
{children || text}
|
||||
</button>
|
||||
);
|
||||
const TestFragment: React.FC<any> = ({ children }) => <>{children}</>;
|
||||
|
||||
function setupTestRegistry(): ComponentRegistry {
|
||||
const registry = ComponentRegistry.getInstance();
|
||||
(registry as any).registry = {
|
||||
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
|
||||
Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
|
||||
H3: { component: TestH3, metadata: { name: 'H3', type: 'basic' } },
|
||||
P: { component: TestP, metadata: { name: 'P', type: 'basic' } },
|
||||
Input: { component: TestInput, metadata: { name: 'Input', type: 'basic' } },
|
||||
Select: { component: TestSelect, metadata: { name: 'Select', type: 'basic' } },
|
||||
Textarea: { component: TestTextarea, metadata: { name: 'Textarea', type: 'basic' } },
|
||||
Toggle: { component: TestToggle, metadata: { name: 'Toggle', type: 'composite' } },
|
||||
Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } },
|
||||
Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
|
||||
};
|
||||
return registry;
|
||||
}
|
||||
|
||||
const translations = {
|
||||
admin: {
|
||||
settings: {
|
||||
general: {
|
||||
static_cache_title: '초기 화면 정적 파일',
|
||||
static_cache_description: '설명',
|
||||
static_cache_status: '상태',
|
||||
static_cache_status_published: '게시됨',
|
||||
static_cache_status_unpublished: '미게시',
|
||||
static_cache_status_disabled: '비활성',
|
||||
static_cache_status_dev_mode: '개발 모드',
|
||||
static_cache_version: '현재 버전',
|
||||
static_cache_files: '파일 수',
|
||||
static_cache_published_at: '마지막 게시 시각',
|
||||
static_cache_never: '아직 없음',
|
||||
static_cache_process_user: '실행 계정',
|
||||
static_cache_failure: '최근 {{count}}회 연속 실패 —',
|
||||
static_cache_failure_parent_not_writable: '저장 폴더에 쓸 수 없습니다.',
|
||||
static_cache_failure_write_failed: '파일을 쓰는 중 실패했습니다.',
|
||||
static_cache_failure_lock_unavailable: '캐시 잠금을 얻지 못했습니다.',
|
||||
static_cache_hint_disabled: '꺼져 있음 힌트',
|
||||
static_cache_hint_dev_mode: '개발 모드 힌트',
|
||||
static_cache_republish_now: '지금 다시 만들기',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function renderCard(report: Record<string, unknown>) {
|
||||
const card = findInPartial(generalPartial, 'card_static_cache');
|
||||
expect(card, 'card_static_cache 노드가 _tab_general.json 에 없습니다').not.toBeNull();
|
||||
|
||||
const testUtils = createLayoutTest(
|
||||
{
|
||||
version: '1.0.0',
|
||||
layout_name: 'test_static_cache_card',
|
||||
computed: { isReadOnly: '{{false}}' },
|
||||
data_sources: [
|
||||
{
|
||||
id: 'staticCacheStatus',
|
||||
type: 'api',
|
||||
endpoint: '/api/admin/settings/static-cache',
|
||||
method: 'GET',
|
||||
auto_fetch: true,
|
||||
auth_required: true,
|
||||
},
|
||||
],
|
||||
components: [card],
|
||||
} as any,
|
||||
{ translations, locale: 'ko' }
|
||||
);
|
||||
|
||||
testUtils.mockApi('staticCacheStatus', { response: { data: report } });
|
||||
|
||||
return testUtils;
|
||||
}
|
||||
|
||||
const PUBLISHED = {
|
||||
enabled: true, publishable: true, environment: 'production', version: 1788656566, published: true,
|
||||
files: 714, published_at: '2026-09-06T01:02:49+00:00', tree_writable: true, process_user: 'www-data',
|
||||
failure: null, retained_versions: [1788656566],
|
||||
};
|
||||
|
||||
describe('환경설정 일반 — 초기 화면 정적 파일 카드 렌더', () => {
|
||||
let registry: ComponentRegistry;
|
||||
|
||||
beforeEach(() => { registry = setupTestRegistry(); });
|
||||
afterEach(() => { (registry as any).registry = {}; });
|
||||
|
||||
it('입력 컨트롤을 하나도 렌더하지 않는다 (진단 + 동작 패널 — 저장 필드 없음)', async () => {
|
||||
const testUtils = renderCard(PUBLISHED);
|
||||
await testUtils.render();
|
||||
|
||||
expect(document.querySelector('#static_cache_status_badge')).not.toBeNull();
|
||||
expect(document.querySelectorAll('input, select, textarea').length).toBe(0);
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
|
||||
// @scenario permission=granted, publishable=production_enabled, outcome=success
|
||||
// @effects card_renders_status_rows
|
||||
it('게시됨: 배지 「게시됨」 + 버전·파일 수 표시 + 버튼 활성', async () => {
|
||||
const testUtils = renderCard(PUBLISHED);
|
||||
await testUtils.render();
|
||||
|
||||
expect(document.querySelector('#static_cache_status_badge')!.textContent!.trim()).toBe('게시됨');
|
||||
expect(document.querySelector('#static_cache_version_row')!.textContent).toContain('1788656566');
|
||||
expect(document.querySelector('#static_cache_files_row')!.textContent).toContain('714');
|
||||
expect(document.querySelector('#static_cache_process_user_row')!.textContent).toContain('www-data');
|
||||
expect(document.querySelector('#static_cache_failure_row')).toBeNull();
|
||||
expect(document.querySelector('#static_cache_not_publishable_hint')).toBeNull();
|
||||
|
||||
const btn = document.querySelector('#static_cache_republish_button') as HTMLButtonElement;
|
||||
expect(btn).not.toBeNull();
|
||||
expect(btn.disabled).toBe(false);
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
|
||||
// @scenario permission=granted, publishable=production_enabled, outcome=publish_failed_marker
|
||||
// @effects card_renders_status_rows
|
||||
it('미게시 + 실패 마커: 배지 「미게시」 + 실패 행 노출', async () => {
|
||||
const testUtils = renderCard({
|
||||
...PUBLISHED, published: false, files: 0, published_at: null,
|
||||
failure: { version: 1, at: '2026-09-06T00:00:00+00:00', reason: 'parent_not_writable', count: 3, message: 'denied' },
|
||||
});
|
||||
await testUtils.render();
|
||||
|
||||
expect(document.querySelector('#static_cache_status_badge')!.textContent!.trim()).toBe('미게시');
|
||||
const failure = document.querySelector('#static_cache_failure_row');
|
||||
expect(failure).not.toBeNull();
|
||||
expect(failure!.textContent).toContain('3');
|
||||
expect(failure!.textContent).toContain('저장 폴더에 쓸 수 없습니다.');
|
||||
expect(document.querySelector('#static_cache_published_at_never')).not.toBeNull();
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
|
||||
// @scenario permission=granted, publishable=kill_switch_off, outcome=publish_failed_marker
|
||||
// @effects republish_button_disabled_when_not_publishable
|
||||
it('kill-switch 꺼짐: 배지 「비활성」 + 버튼 비활성 + 사유 힌트', async () => {
|
||||
const testUtils = renderCard({ ...PUBLISHED, enabled: false, publishable: false, published: false });
|
||||
await testUtils.render();
|
||||
|
||||
expect(document.querySelector('#static_cache_status_badge')!.textContent!.trim()).toBe('비활성');
|
||||
const btn = document.querySelector('#static_cache_republish_button') as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(true);
|
||||
expect(btn.getAttribute('aria-disabled')).toBe('true');
|
||||
expect(document.querySelector('#static_cache_not_publishable_hint')!.textContent!.trim()).toBe('꺼져 있음 힌트');
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
|
||||
// @scenario permission=granted, publishable=non_production, outcome=publish_failed_marker
|
||||
// @effects republish_button_disabled_when_not_publishable
|
||||
it('개발 모드: 배지 「개발 모드」 + 버튼 비활성', async () => {
|
||||
const testUtils = renderCard({ ...PUBLISHED, environment: 'local', publishable: false, published: false });
|
||||
await testUtils.render();
|
||||
|
||||
expect(document.querySelector('#static_cache_status_badge')!.textContent!.trim()).toBe('개발 모드');
|
||||
expect((document.querySelector('#static_cache_republish_button') as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(document.querySelector('#static_cache_not_publishable_hint')!.textContent!.trim()).toBe('개발 모드 힌트');
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
|
||||
it('다국어 키가 화면에 그대로 노출되지 않는다', async () => {
|
||||
const testUtils = renderCard(PUBLISHED);
|
||||
await testUtils.render();
|
||||
|
||||
const text = document.querySelector('#card_static_cache')!.textContent ?? '';
|
||||
expect(text).not.toContain('$t:');
|
||||
expect(text).not.toContain('admin.settings');
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
});
|
||||
+1
@@ -30,6 +30,7 @@ const TAB_SCOPED: Record<string, string[]> = {
|
||||
adminIdentityPolicies: ['identity'],
|
||||
policies: ['identity'],
|
||||
sitemap_status: ['seo'],
|
||||
staticCacheStatus: ['general'],
|
||||
};
|
||||
|
||||
const sources: AnyJson[] = layout.data_sources;
|
||||
|
||||
+49
-1
@@ -240,7 +240,9 @@ describe('어드민 대시보드 - 재호환 알림 (recovery_available)', () =>
|
||||
it('recover 버튼 액션이 alert.recover_endpoint 를 POST 로 호출하도록 정의되어 있다', () => {
|
||||
const recoverBtn = findById(dashboardLayout, 'alert_recover_button');
|
||||
expect(recoverBtn).not.toBeNull();
|
||||
expect(recoverBtn.if).toBe("{{alert.subtype === 'recovery_available'}}");
|
||||
// 버튼 조건은 subtype 이 아니라 **복구 엔드포인트의 존재**다 — 정적 게시 실패 알림처럼
|
||||
// 다른 subtype 의 알림도 recover_endpoint 만 싣으면 같은 배선으로 복구된다 (#651 D11)
|
||||
expect(recoverBtn.if).toBe('{{!!alert.recover_endpoint}}');
|
||||
|
||||
const action = recoverBtn.actions[0];
|
||||
expect(action.type).toBe('click');
|
||||
@@ -258,6 +260,8 @@ describe('어드민 대시보드 - 재호환 알림 (recovery_available)', () =>
|
||||
const toastAction = action.onSuccess.find((a: any) => a.handler === 'toast');
|
||||
expect(toastAction).toBeDefined();
|
||||
expect(toastAction.params.type).toBe('success');
|
||||
// 성공 문구는 알림이 실어 준 것을 우선하고, 없으면 재호환 기본 문구로 떨어진다
|
||||
expect(toastAction.params.message).toBe("{{alert.recover_success_message ?? $t('extensions.alerts.recovered_success')}}");
|
||||
|
||||
const refetch = action.onSuccess.find(
|
||||
(a: any) => a.handler === 'refetchDataSource',
|
||||
@@ -307,4 +311,48 @@ describe('어드민 대시보드 - 재호환 알림 (recovery_available)', () =>
|
||||
expect(screen.getByTestId('recover-btn')).toBeTruthy();
|
||||
expect(screen.getByTestId('dismiss-btn')).toBeTruthy();
|
||||
});
|
||||
// @scenario permission=granted, publishable=production_enabled, outcome=publish_failed_marker
|
||||
// @effects dashboard_alert_points_to_admin_republish
|
||||
it('recover_endpoint 가 없는 알림에는 버튼이 없고, 있는 알림(정적 게시 실패)에는 그 라벨로 버튼이 있다', async () => {
|
||||
testUtils = createLayoutTest(dashboardLayout, {
|
||||
translations,
|
||||
locale: 'ko',
|
||||
componentRegistry: registry,
|
||||
auth: { isAuthenticated: true, user: { id: 1, name: 'Admin' }, authType: 'admin' },
|
||||
});
|
||||
setupDefaultMocks(testUtils);
|
||||
testUtils.mockApi('dashboard_alerts', {
|
||||
response: {
|
||||
data: [
|
||||
{
|
||||
type: 'warning',
|
||||
subtype: 'static_publish_parent_not_writable',
|
||||
title: '초기 화면 파일 생성 실패',
|
||||
message: '폴더에 쓸 수 없습니다',
|
||||
time: '방금 전',
|
||||
icon: 'exclamation-triangle',
|
||||
recover_endpoint: '/api/admin/settings/static-cache/republish',
|
||||
recover_label: '다시 만들기',
|
||||
recover_success_message: '초기 화면 파일을 다시 만들었습니다.',
|
||||
},
|
||||
{
|
||||
type: 'warning',
|
||||
subtype: 'incompatible_deactivated',
|
||||
title: '복구 불가 알림',
|
||||
message: '엔드포인트 없음',
|
||||
time: '방금 전',
|
||||
icon: 'exclamation-triangle',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await testUtils.render();
|
||||
|
||||
expect(screen.getByText('초기 화면 파일 생성 실패')).toBeTruthy();
|
||||
expect(screen.getByText('복구 불가 알림')).toBeTruthy();
|
||||
// 정적 게시 실패 알림은 자기 라벨로 버튼을 갖고, 엔드포인트 없는 알림은 버튼이 없다
|
||||
expect(screen.getAllByTestId('recover-btn').length).toBe(1);
|
||||
expect(screen.getByText('다시 만들기')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> templates/_bundled/sirsoft-admin_basic · 템플릿
|
||||
|
||||
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
**훅 수**: 0 · **구독 훅 수**: 0 · **라우트 수**: 29 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 145 · **핸들러 수**: 17
|
||||
**훅 수**: 0 · **구독 훅 수**: 0 · **라우트 수**: 29 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 146 · **핸들러 수**: 17
|
||||
<!-- @generated:stats END -->
|
||||
|
||||
## 문서 목차
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
| 스타일 시스템 | `tailwind` |
|
||||
| 다크 모드 전략 | `ancestor-class` |
|
||||
|
||||
> 분할 13블록 · 팔레트 79 · 스타일 컨트롤 303 · 편집 역량 86 · 중첩 컨테이너 19 · 프리뷰 샘플 71 · 페이지 상태 10 · 액션 레시피 14
|
||||
> 분할 13블록 · 팔레트 79 · 스타일 컨트롤 303 · 편집 역량 86 · 중첩 컨테이너 19 · 프리뷰 샘플 72 · 페이지 상태 10 · 액션 레시피 14
|
||||
<!-- @generated:editor-spec-summary END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
@@ -39,7 +39,7 @@
|
||||
| `componentCapabilities` | 컴포넌트별 편집 역량(어떤 속성을 편집기가 다루는가) | 86 | `editor-spec/componentCapabilities.json` |
|
||||
| `nesting.draggable` | 캔버스에서 끌어 옮길 수 있는 컴포넌트 | 84 | `editor-spec/nesting.json` |
|
||||
| `nesting.containers` | 자식을 담을 수 있는 컴포넌트와 그 허용 규칙 | 19 | `editor-spec/nesting.json` |
|
||||
| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 71 | `editor-spec/sampleData.json` |
|
||||
| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 72 | `editor-spec/sampleData.json` |
|
||||
| `sampleGlobal` | `_global.*` 프리뷰 baseline 시드 | 5 | `editor-spec/sampleGlobal.json` |
|
||||
| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 10 | `editor-spec/states.json` |
|
||||
| `stateLabels` | 상태값 친화 명칭 카탈로그 | 8 | `editor-spec/stateLabels.json` |
|
||||
@@ -91,7 +91,7 @@
|
||||
<!-- @generated:editor-spec-samples START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 자리 | 역할 | 개수 | ID |
|
||||
|---|---|---|---|
|
||||
| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 71 | `trustedProxy` · `sitemap_status` · `sitemap_progress_ws` · `locales` · `me` · `installed_modules` · `active_plugins` · `users` · `roles` · `availableChannels` · `identityProviders` · `identityPurposes` … 외 59개 |
|
||||
| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 72 | `trustedProxy` · `sitemap_status` · `sitemap_progress_ws` · `staticCacheStatus` · `locales` · `me` · `installed_modules` · `active_plugins` · `users` · `roles` · `availableChannels` · `identityProviders` … 외 60개 |
|
||||
| `sampleData.byEndpointPattern` | 엔드포인트 패턴으로 붙는 프리뷰 응답 | 미선언 | - |
|
||||
| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 10 | `_admin_base` · `*/admin/users` · `*/admin/users/:id/edit` · `*/admin/settings` · `*/admin/reset-password` · `*/admin/roles/:id/edit` · `*/admin/identity/challenge` · `*/admin/templates/:type` · `*/admin/login` · `*/admin/forgot-password` |
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## 레이아웃 목록
|
||||
|
||||
<!-- @generated:layouts START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
레이아웃 145개 (루트: `layouts`).
|
||||
레이아웃 146개 (루트: `layouts`).
|
||||
|
||||
| 그룹 | 개수 |
|
||||
|---|---|
|
||||
@@ -13,7 +13,7 @@
|
||||
| `auth` | 1개 |
|
||||
| `errors` | 6개 |
|
||||
| `overrides` | 1개 |
|
||||
| `partials` | 110개 |
|
||||
| `partials` | 111개 |
|
||||
|
||||
| 레이아웃 | 그룹 | 종류 | extends |
|
||||
|---|---|---|---|
|
||||
@@ -125,6 +125,7 @@
|
||||
| `_modal_notification_template_form` | `partials` | partial | - |
|
||||
| `_modal_notification_template_preview` | `partials` | partial | - |
|
||||
| `_modal_password_confirm` | `partials` | partial | - |
|
||||
| `_modal_static_cache_republish` | `partials` | partial | - |
|
||||
| `_tab_advanced` | `partials` | partial | - |
|
||||
| `_tab_drivers` | `partials` | partial | - |
|
||||
| `_tab_general` | `partials` | partial | - |
|
||||
|
||||
@@ -36,6 +36,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"staticCacheStatus": {
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"publishable": true,
|
||||
"environment": "production",
|
||||
"version": 1788656566,
|
||||
"published": true,
|
||||
"files": 714,
|
||||
"published_at": "2026-09-06T01:02:49+00:00",
|
||||
"tree_writable": true,
|
||||
"process_user": "www-data",
|
||||
"failure": null,
|
||||
"retained_versions": [1788171201, 1788656566]
|
||||
}
|
||||
},
|
||||
"locales": {
|
||||
"data": [
|
||||
{
|
||||
|
||||
@@ -1348,7 +1348,34 @@
|
||||
"asset_url_mode_detecting": "Detecting asset file serving mode...",
|
||||
"asset_url_mode_detect_extension": "File extensions work fine on this server.",
|
||||
"asset_url_mode_detect_extensionless": "A static-file optimization rule (e.g. nginx location ~* \\.(js|css|json|png|jpg|jpeg|gif|ico|svg|woff2?)$) is intercepting extension URLs. Switch to \"No file extensions\" and save.",
|
||||
"asset_url_mode_detect_unavailable": "Detection failed. The server did not respond, so the style could not be determined."
|
||||
"asset_url_mode_detect_unavailable": "Detection failed. The server did not respond, so the style could not be determined.",
|
||||
"static_cache_title": "Startup static files",
|
||||
"static_cache_description": "Translations, routes, component definitions and extension bundles are pre-built as static files so the first screen loads fast. They are rebuilt automatically after extension installs/updates and setting changes; if a screen shows stale content, rebuild them here right away.",
|
||||
"static_cache_status": "Status",
|
||||
"static_cache_status_published": "Published",
|
||||
"static_cache_status_unpublished": "Not published",
|
||||
"static_cache_status_disabled": "Disabled",
|
||||
"static_cache_status_dev_mode": "Development mode",
|
||||
"static_cache_version": "Current version",
|
||||
"static_cache_files": "Files",
|
||||
"static_cache_published_at": "Last published",
|
||||
"static_cache_never": "Never",
|
||||
"static_cache_process_user": "Process user",
|
||||
"static_cache_failure": "{{count}} consecutive failures recently —",
|
||||
"static_cache_failure_parent_not_writable": "The target folder is not writable. Check the folder owner and permissions on the server.",
|
||||
"static_cache_failure_write_failed": "Writing files failed. Check available disk space.",
|
||||
"static_cache_failure_lock_unavailable": "Could not acquire the cache lock. Check the cache store.",
|
||||
"static_cache_hint_disabled": "Disabled by G7_STATIC_CACHE=false — no static files are built. Every screen is served through the API.",
|
||||
"static_cache_hint_dev_mode": "Static files are built only in production mode (APP_ENV=production). Every screen is currently served through the API.",
|
||||
"static_cache_republish_now": "Rebuild now",
|
||||
"static_cache_republish": "Rebuild",
|
||||
"static_cache_republishing": "Rebuilding...",
|
||||
"static_cache_republish_confirm_title": "Rebuild startup static files",
|
||||
"static_cache_republish_confirm_body": "The static files will be rebuilt as a new version. Every visitor will download the assets again.",
|
||||
"static_cache_republish_confirm_warning": "This may take a moment. The site keeps working in the meantime.",
|
||||
"static_cache_republished": "Startup static files were rebuilt.",
|
||||
"static_cache_republish_not_publishable": "Static files are not built in this environment (not production, or disabled).",
|
||||
"static_cache_republish_failed": "Startup static files could not be rebuilt. See the failure reason below. The site keeps working normally."
|
||||
},
|
||||
"mail": {
|
||||
"smtp_settings": "SMTP Settings",
|
||||
@@ -2784,6 +2811,7 @@
|
||||
"modal_notification_template_form": "Edit Notification Template",
|
||||
"modal_notification_template_preview": "Preview Notification Template",
|
||||
"settings_cache_delete_modal": "Clear Cache",
|
||||
"settings_static_cache_republish_modal": "Rebuild startup static files",
|
||||
"settings_password_confirm_modal": "Verify Password for App Key Regeneration",
|
||||
"core_changelog_modal": "Core Changelog",
|
||||
"core_update_guide_modal": "Core Update Guide",
|
||||
|
||||
@@ -1648,6 +1648,7 @@
|
||||
"schedules": "Schedules",
|
||||
"settings": "Settings",
|
||||
"sitemap_status": "Sitemap Generation Status",
|
||||
"staticCacheStatus": "Startup static file status",
|
||||
"sitemap_progress_ws": "Sitemap Generation Progress (Live)",
|
||||
"systemInfo": "System Info",
|
||||
"templateDetail": "Template Detail",
|
||||
|
||||
@@ -1352,7 +1352,34 @@
|
||||
"asset_url_mode_detecting": "에셋 파일 서빙 방식을 감지하는 중입니다...",
|
||||
"asset_url_mode_detect_extension": "확장자를 사용할 수 있는 환경입니다.",
|
||||
"asset_url_mode_detect_extensionless": "정적 파일 최적화 규칙(nginx의 location ~* \\.(js|css|json|png|jpg|jpeg|gif|ico|svg|woff2?)$ 블록 등)이 확장자 주소를 가로채고 있습니다. '확장자 미사용'으로 바꾼 뒤 저장하세요.",
|
||||
"asset_url_mode_detect_unavailable": "감지에 실패했습니다. 서버가 응답하지 않아 방식을 판정할 수 없습니다."
|
||||
"asset_url_mode_detect_unavailable": "감지에 실패했습니다. 서버가 응답하지 않아 방식을 판정할 수 없습니다.",
|
||||
"static_cache_title": "초기 화면 정적 파일",
|
||||
"static_cache_description": "첫 화면을 빠르게 띄우기 위해 다국어·라우트·컴포넌트·확장 번들을 정적 파일로 미리 만들어 둡니다. 확장 설치·업데이트나 설정 변경 뒤 자동으로 다시 만들어지며, 화면이 옛 내용을 보이면 여기서 즉시 다시 만들 수 있습니다.",
|
||||
"static_cache_status": "상태",
|
||||
"static_cache_status_published": "게시됨",
|
||||
"static_cache_status_unpublished": "미게시",
|
||||
"static_cache_status_disabled": "비활성",
|
||||
"static_cache_status_dev_mode": "개발 모드",
|
||||
"static_cache_version": "현재 버전",
|
||||
"static_cache_files": "파일 수",
|
||||
"static_cache_published_at": "마지막 게시 시각",
|
||||
"static_cache_never": "아직 없음",
|
||||
"static_cache_process_user": "실행 계정",
|
||||
"static_cache_failure": "최근 {{count}}회 연속 실패 —",
|
||||
"static_cache_failure_parent_not_writable": "저장 폴더에 쓸 수 없습니다. 서버에서 폴더 소유자·권한을 확인하세요.",
|
||||
"static_cache_failure_write_failed": "파일을 쓰는 중 실패했습니다. 디스크 여유 공간을 확인하세요.",
|
||||
"static_cache_failure_lock_unavailable": "캐시 저장소에서 잠금을 얻지 못했습니다. 캐시 저장소 상태를 확인하세요.",
|
||||
"static_cache_hint_disabled": "G7_STATIC_CACHE=false 로 꺼져 있어 정적 파일을 만들지 않습니다. 모든 화면이 API 로 동작합니다.",
|
||||
"static_cache_hint_dev_mode": "운영 모드(APP_ENV=production)에서만 정적 파일을 만듭니다. 지금은 모든 화면이 API 로 동작합니다.",
|
||||
"static_cache_republish_now": "지금 다시 만들기",
|
||||
"static_cache_republish": "다시 만들기",
|
||||
"static_cache_republishing": "다시 만드는 중...",
|
||||
"static_cache_republish_confirm_title": "초기 화면 정적 파일 다시 만들기",
|
||||
"static_cache_republish_confirm_body": "정적 파일을 새 버전으로 다시 만듭니다. 모든 방문자가 자산을 다시 내려받습니다.",
|
||||
"static_cache_republish_confirm_warning": "완료까지 잠시 걸릴 수 있습니다. 그동안 사이트는 정상 동작합니다.",
|
||||
"static_cache_republished": "초기 화면 정적 파일을 다시 만들었습니다.",
|
||||
"static_cache_republish_not_publishable": "지금 환경에서는 정적 파일을 만들지 않습니다 (운영 모드가 아니거나 꺼져 있음).",
|
||||
"static_cache_republish_failed": "초기 화면 정적 파일을 다시 만들지 못했습니다. 아래 실패 사유를 확인하세요. 사이트는 정상 동작합니다."
|
||||
},
|
||||
"mail": {
|
||||
"smtp_settings": "SMTP 설정",
|
||||
@@ -2788,6 +2815,7 @@
|
||||
"modal_notification_template_form": "알림 템플릿 편집",
|
||||
"modal_notification_template_preview": "알림 템플릿 미리보기",
|
||||
"settings_cache_delete_modal": "캐시 삭제 확인",
|
||||
"settings_static_cache_republish_modal": "초기 화면 정적 파일 다시 만들기 확인",
|
||||
"settings_password_confirm_modal": "앱 키 재생성 비밀번호 확인",
|
||||
"core_changelog_modal": "코어 변경 이력",
|
||||
"core_update_guide_modal": "코어 업데이트 안내",
|
||||
|
||||
@@ -1649,6 +1649,7 @@
|
||||
"settings": "환경설정",
|
||||
"sitemap_status": "Sitemap 생성 상태",
|
||||
"sitemap_progress_ws": "Sitemap 생성 진행 (실시간)",
|
||||
"staticCacheStatus": "초기 화면 정적 파일 상태",
|
||||
"systemInfo": "시스템 정보",
|
||||
"templateDetail": "템플릿 상세",
|
||||
"templateInstallPreview": "템플릿 설치 미리보기",
|
||||
|
||||
@@ -429,7 +429,7 @@
|
||||
"id": "top_alert_recover_button_{{$idx}}",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"if": "{{alert.subtype === 'recovery_available'}}",
|
||||
"if": "{{!!alert.recover_endpoint}}",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "inline-flex items-center gap-1 px-3 py-1 text-xs bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 dark:hover:bg-blue-600 text-white rounded"
|
||||
@@ -448,7 +448,7 @@
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "success",
|
||||
"message": "$t:extensions.alerts.recovered_success"
|
||||
"message": "{{alert.recover_success_message ?? $t('extensions.alerts.recovered_success')}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -481,6 +481,13 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!!alert.recover_label}}",
|
||||
"text": "{{alert.recover_label}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!alert.recover_label}}",
|
||||
"text": "$t:extensions.alerts.recover_action"
|
||||
}
|
||||
]
|
||||
@@ -1708,7 +1715,7 @@
|
||||
"id": "alert_recover_button_{{$idx}}",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"if": "{{alert.subtype === 'recovery_available'}}",
|
||||
"if": "{{!!alert.recover_endpoint}}",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "inline-flex items-center gap-1 px-3 py-1 text-xs bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 dark:hover:bg-blue-600 text-white rounded"
|
||||
@@ -1727,7 +1734,7 @@
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "success",
|
||||
"message": "$t:extensions.alerts.recovered_success"
|
||||
"message": "{{alert.recover_success_message ?? $t('extensions.alerts.recovered_success')}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1760,6 +1767,13 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!!alert.recover_label}}",
|
||||
"text": "{{alert.recover_label}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!alert.recover_label}}",
|
||||
"text": "$t:extensions.alerts.recover_action"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"adminIdentityPolicies",
|
||||
"policies",
|
||||
"language_packs",
|
||||
"sitemap_status"
|
||||
"sitemap_status",
|
||||
"staticCacheStatus"
|
||||
]
|
||||
},
|
||||
"data_sources": [
|
||||
@@ -127,6 +128,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "staticCacheStatus",
|
||||
"label_key": "$t:editor.data_source.staticCacheStatus",
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/settings/static-cache",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeSettingsTab || 'general') === 'general'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"fallback": {
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"publishable": false,
|
||||
"environment": null,
|
||||
"version": 0,
|
||||
"published": false,
|
||||
"files": 0,
|
||||
"published_at": null,
|
||||
"tree_writable": null,
|
||||
"process_user": null,
|
||||
"failure": null,
|
||||
"retained_versions": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "trustedProxy",
|
||||
"label_key": "$t:editor.data_source.trustedProxy",
|
||||
@@ -894,6 +920,9 @@
|
||||
{
|
||||
"partial": "partials/admin_settings/_modal_cache_delete.json"
|
||||
},
|
||||
{
|
||||
"partial": "partials/admin_settings/_modal_static_cache_republish.json"
|
||||
},
|
||||
{
|
||||
"partial": "partials/admin_settings/_modal_notification_template_form.json"
|
||||
},
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"meta": {
|
||||
"editor_label": "$t:admin.modal_label.admin_settings.settings_static_cache_republish_modal",
|
||||
"is_partial": true,
|
||||
"description": "초기 화면 정적 파일 다시 만들기 확인 모달"
|
||||
},
|
||||
"id": "settings_static_cache_republish_modal",
|
||||
"type": "composite",
|
||||
"name": "Modal",
|
||||
"props": {
|
||||
"title": "$t:admin.settings.general.static_cache_republish_confirm_title",
|
||||
"size": "sm"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "py-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "text-gray-700 dark:text-gray-300"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_republish_confirm_body"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "mt-2 text-label-subtle"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_republish_confirm_warning"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "modal-footer-buttons"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "btn btn-secondary",
|
||||
"disabled": "{{_global.isRepublishingStaticCache}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "closeModal"
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:common.cancel"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "static_cache_republish_confirm_button",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "flex-center gap-2 px-4 py-2 bg-blue-600 dark:bg-blue-500 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"disabled": "{{_global.isRepublishingStaticCache}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"isRepublishingStaticCache": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/admin/settings/static-cache/republish",
|
||||
"params": {
|
||||
"method": "POST"
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"isRepublishingStaticCache": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "staticCacheStatus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "{{response?.data?.republished ? 'success' : 'warning'}}",
|
||||
"message": "{{response?.data?.republished ? $t('admin.settings.general.static_cache_republished') : (response?.data?.publishable === false ? $t('admin.settings.general.static_cache_republish_not_publishable') : $t('admin.settings.general.static_cache_republish_failed'))}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "closeModal"
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"isRepublishingStaticCache": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{error.message ?? $t('admin.settings.general.static_cache_republish_failed')}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "closeModal"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"if": "{{_global.isRepublishingStaticCache}}",
|
||||
"props": {
|
||||
"name": "spinner",
|
||||
"className": "spinner-sm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "{{_global.isRepublishingStaticCache ? '$t:admin.settings.general.static_cache_republishing' : '$t:admin.settings.general.static_cache_republish'}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+322
@@ -646,6 +646,328 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card_static_cache",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "admin-card"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "H3",
|
||||
"props": {
|
||||
"className": "card-title"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_title"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "card-description"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_description"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "row-stack"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "static_cache_summary_row",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex flex-wrap items-center gap-x-6 gap-y-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "static_cache_status_row",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_status"
|
||||
},
|
||||
{
|
||||
"id": "static_cache_status_badge",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
|
||||
"classMap": {
|
||||
"key": "{{staticCacheStatus?.data?.enabled === false ? 'disabled' : (staticCacheStatus?.data?.publishable !== true ? 'dev_mode' : (staticCacheStatus?.data?.published ? 'published' : 'unpublished'))}}",
|
||||
"variants": {
|
||||
"published": "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
|
||||
"unpublished": "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
|
||||
"disabled": "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300",
|
||||
"dev_mode": "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"
|
||||
}
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "static_cache_status_text_disabled",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{staticCacheStatus?.data?.enabled === false}}",
|
||||
"text": "$t:admin.settings.general.static_cache_status_disabled"
|
||||
},
|
||||
{
|
||||
"id": "static_cache_status_text_dev_mode",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!!staticCacheStatus?.data && staticCacheStatus?.data?.enabled !== false && staticCacheStatus?.data?.publishable !== true}}",
|
||||
"text": "$t:admin.settings.general.static_cache_status_dev_mode"
|
||||
},
|
||||
{
|
||||
"id": "static_cache_status_text_published",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{staticCacheStatus?.data?.publishable === true && !!staticCacheStatus?.data?.published}}",
|
||||
"text": "$t:admin.settings.general.static_cache_status_published"
|
||||
},
|
||||
{
|
||||
"id": "static_cache_status_text_unpublished",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{staticCacheStatus?.data?.publishable === true && !staticCacheStatus?.data?.published}}",
|
||||
"text": "$t:admin.settings.general.static_cache_status_unpublished"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "static_cache_version_row",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_version"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-heading"
|
||||
},
|
||||
"text": "{{Number(staticCacheStatus?.data?.version ?? 0) > 0 ? staticCacheStatus?.data?.version : '-'}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "static_cache_files_row",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_files"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-heading"
|
||||
},
|
||||
"text": "{{staticCacheStatus?.data?.published ? Number(staticCacheStatus?.data?.files ?? 0).toLocaleString() : '-'}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "static_cache_published_at_row",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_published_at"
|
||||
},
|
||||
{
|
||||
"id": "static_cache_published_at_value",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!!staticCacheStatus?.data?.published_at}}",
|
||||
"props": {
|
||||
"className": "text-heading"
|
||||
},
|
||||
"text": "{{staticCacheStatus.data.published_at | datetime('YYYY-MM-DD HH:mm:ss')}}"
|
||||
},
|
||||
{
|
||||
"id": "static_cache_published_at_never",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!staticCacheStatus?.data?.published_at}}",
|
||||
"props": {
|
||||
"className": "text-heading"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_never"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "static_cache_process_user_row",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_process_user"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-heading"
|
||||
},
|
||||
"text": "{{staticCacheStatus?.data?.process_user ?? '-'}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "static_cache_actions",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "ml-auto"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "static_cache_republish_button",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"disabled": "{{_computed.isReadOnly || !(staticCacheStatus?.data?.publishable ?? false)}}",
|
||||
"aria-disabled": "{{_computed.isReadOnly || !(staticCacheStatus?.data?.publishable ?? false)}}",
|
||||
"className": "px-4 py-2 bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 dark:hover:bg-blue-600 disabled:bg-gray-400 dark:disabled:bg-gray-600 text-white dark:text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_republish_now",
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "openModal",
|
||||
"target": "settings_static_cache_republish_modal"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "static_cache_failure_row",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!!staticCacheStatus?.data?.failure}}",
|
||||
"props": {
|
||||
"className": "form-hint text-red-600 dark:text-red-400 break-words"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:admin.settings.general.static_cache_failure|count={{staticCacheStatus?.data?.failure?.count ?? 0}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": " "
|
||||
},
|
||||
{
|
||||
"id": "static_cache_failure_reason_parent_not_writable",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{staticCacheStatus?.data?.failure?.reason === 'parent_not_writable'}}",
|
||||
"text": "$t:admin.settings.general.static_cache_failure_parent_not_writable"
|
||||
},
|
||||
{
|
||||
"id": "static_cache_failure_reason_lock_unavailable",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{staticCacheStatus?.data?.failure?.reason === 'lock_unavailable'}}",
|
||||
"text": "$t:admin.settings.general.static_cache_failure_lock_unavailable"
|
||||
},
|
||||
{
|
||||
"id": "static_cache_failure_reason_write_failed",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!['parent_not_writable', 'lock_unavailable'].includes(staticCacheStatus?.data?.failure?.reason ?? '')}}",
|
||||
"text": "$t:admin.settings.general.static_cache_failure_write_failed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "static_cache_not_publishable_hint",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!!staticCacheStatus?.data && staticCacheStatus?.data?.publishable !== true}}",
|
||||
"props": {
|
||||
"className": "form-hint"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "static_cache_hint_disabled",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{staticCacheStatus?.data?.enabled === false}}",
|
||||
"text": "$t:admin.settings.general.static_cache_hint_disabled"
|
||||
},
|
||||
{
|
||||
"id": "static_cache_hint_dev_mode",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{staticCacheStatus?.data?.enabled !== false}}",
|
||||
"text": "$t:admin.settings.general.static_cache_hint_dev_mode"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\Admin;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 초기 화면 정적 파일(정적 게시) 상태 조회 + 관리자 수동 복구 API 테스트 (#651 U1·U2·U6).
|
||||
*
|
||||
* 캐시 버전이 만료로 재생성되지 않으므로(영구 번호) 재게시 누락은 무기한 stale 이 된다 —
|
||||
* 관리자 화면의 [지금 다시 만들기] 가 그 안전망이다. 이 테스트는 그 통로(권한 경계 · 진단 결과
|
||||
* 200 규약 · 버전 증가 + 게시)를 잠근다.
|
||||
*/
|
||||
class SettingsStaticCacheTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const VERSION = 987650000;
|
||||
|
||||
private string $isolatedPublicPath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// 실 게시 트리(public/build/ext) 격리 — 운영 중 사이트의 게시본을 건드리지 않는다
|
||||
$this->isolatedPublicPath = storage_path('framework/testing/public-api-'.getmypid());
|
||||
File::ensureDirectoryExists($this->isolatedPublicPath);
|
||||
$this->app->usePublicPath($this->isolatedPublicPath);
|
||||
|
||||
ClearsTemplateCaches::resetExtensionCacheStoreMemo();
|
||||
Cache::put('g7:core:ext.cache_version', self::VERSION);
|
||||
Cache::forget('g7:core:ext.static.publish_failure');
|
||||
Cache::lock('ext-static.publish.'.self::VERSION, 300)->forceRelease();
|
||||
|
||||
ExtensionStaticCacheService::resetPublishScheduleForTesting();
|
||||
File::deleteDirectory(public_path('build/ext'));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
File::deleteDirectory(public_path('build/ext'));
|
||||
File::deleteDirectory($this->isolatedPublicPath);
|
||||
ExtensionStaticCacheService::resetPublishScheduleForTesting();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한을 가진 관리자를 만들고 Bearer 토큰을 돌려줍니다.
|
||||
*
|
||||
* @param array<int, string> $permissions 권한 식별자 목록
|
||||
* @return string plainText 토큰
|
||||
*/
|
||||
private function tokenWith(array $permissions): string
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$permissionIds = [];
|
||||
foreach ($permissions as $identifier) {
|
||||
$permissionIds[] = Permission::firstOrCreate(
|
||||
['identifier' => $identifier],
|
||||
[
|
||||
'name' => json_encode(['ko' => $identifier, 'en' => $identifier]),
|
||||
'description' => json_encode(['ko' => $identifier, 'en' => $identifier]),
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'type' => 'admin',
|
||||
]
|
||||
)->id;
|
||||
}
|
||||
|
||||
$role = Role::create([
|
||||
'identifier' => 'static_cache_test_'.uniqid(),
|
||||
'name' => json_encode(['ko' => '테스트', 'en' => 'Test']),
|
||||
'description' => json_encode(['ko' => '테스트', 'en' => 'Test']),
|
||||
'is_active' => true,
|
||||
]);
|
||||
$role->permissions()->sync($permissionIds);
|
||||
|
||||
$adminRole = Role::firstOrCreate(
|
||||
['identifier' => 'admin'],
|
||||
[
|
||||
'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
|
||||
'description' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
|
||||
$user->roles()->attach($role->id, ['assigned_at' => now(), 'assigned_by' => null]);
|
||||
|
||||
return $user->fresh()->createToken('test')->plainTextToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 조회는 CLI 와 같은 보고서 필드를 돌려준다.
|
||||
*
|
||||
* @scenario permission=granted, publishable=non_production, outcome=success
|
||||
*
|
||||
* @effects status_endpoint_returns_report_fields
|
||||
*/
|
||||
public function test_status_returns_report_fields(): void
|
||||
{
|
||||
$response = $this->withToken($this->tokenWith(['core.settings.read']))
|
||||
->getJson('/api/admin/settings/static-cache');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.version', self::VERSION)
|
||||
->assertJsonPath('data.published', false)
|
||||
->assertJsonPath('data.publishable', false)
|
||||
->assertJsonStructure(['data' => [
|
||||
'enabled', 'publishable', 'environment', 'version', 'published', 'files',
|
||||
'published_at', 'tree_writable', 'process_user', 'failure', 'retained_versions',
|
||||
]]);
|
||||
|
||||
// 응답 메시지가 키 그대로 새어 나가지 않는다
|
||||
$this->assertStringNotContainsString('settings.', (string) $response->json('message'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로덕션에서 재게시하면 버전이 오르고 새 버전이 게시된다.
|
||||
*
|
||||
* @scenario permission=granted, publishable=production_enabled, outcome=success
|
||||
*
|
||||
* @effects republish_bumps_version_and_publishes
|
||||
*/
|
||||
public function test_republish_bumps_version_and_publishes_in_production(): void
|
||||
{
|
||||
$this->app['env'] = 'production';
|
||||
ExtensionStaticCacheService::fakeRootProcessForTesting(false);
|
||||
|
||||
$response = $this->withToken($this->tokenWith(['core.settings.read', 'core.settings.update']))
|
||||
->postJson('/api/admin/settings/static-cache/republish');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('data.republished', true)
|
||||
->assertJsonPath('data.published', true)
|
||||
->assertJsonPath('data.publishable', true)
|
||||
->assertJsonPath('data.previous_version', self::VERSION);
|
||||
|
||||
$newVersion = (int) $response->json('data.version');
|
||||
$this->assertGreaterThan(self::VERSION, $newVersion, '재게시가 캐시 버전을 올리지 않았다');
|
||||
$this->assertTrue((bool) $response->json('data.version_changed'));
|
||||
$this->assertFileExists(public_path('build/ext/'.$newVersion.'/manifest.json'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 비프로덕션에서는 bump 하지 않고 200 + `republished=false` 로 사유를 돌려준다 (진단 결과 규약).
|
||||
*
|
||||
* @scenario permission=granted, publishable=non_production, outcome=publish_failed_marker
|
||||
*
|
||||
* @effects republish_disabled_when_not_publishable
|
||||
*/
|
||||
public function test_republish_outside_production_is_a_diagnostic_result_not_an_error(): void
|
||||
{
|
||||
$response = $this->withToken($this->tokenWith(['core.settings.read', 'core.settings.update']))
|
||||
->postJson('/api/admin/settings/static-cache/republish');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.republished', false)
|
||||
->assertJsonPath('data.publishable', false)
|
||||
->assertJsonPath('data.reason', 'not_production')
|
||||
->assertJsonPath('data.version', self::VERSION);
|
||||
|
||||
$this->assertSame(self::VERSION, (int) Cache::get('g7:core:ext.cache_version'), '게시가 쓰이지 않는 환경에서 버전을 올렸다');
|
||||
}
|
||||
|
||||
/**
|
||||
* kill-switch 가 꺼져 있으면 `enabled=false` 이고 재게시는 `disabled` 사유로 거절된다.
|
||||
*
|
||||
* @scenario permission=granted, publishable=kill_switch_off, outcome=publish_failed_marker
|
||||
*
|
||||
* @effects republish_disabled_when_not_publishable
|
||||
*/
|
||||
public function test_kill_switch_off_reports_disabled(): void
|
||||
{
|
||||
config(['core.static_cache.enabled' => false]);
|
||||
$this->app['env'] = 'production';
|
||||
|
||||
$token = $this->tokenWith(['core.settings.read', 'core.settings.update']);
|
||||
|
||||
$this->withToken($token)->getJson('/api/admin/settings/static-cache')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.enabled', false)
|
||||
->assertJsonPath('data.publishable', false);
|
||||
|
||||
$this->withToken($token)->postJson('/api/admin/settings/static-cache/republish')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.republished', false)
|
||||
->assertJsonPath('data.reason', 'disabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* 읽기 권한만으로는 재게시할 수 없다.
|
||||
*
|
||||
* @scenario permission=denied, publishable=production_enabled, outcome=success
|
||||
*
|
||||
* @effects republish_denied_without_settings_update_permission
|
||||
*/
|
||||
public function test_republish_requires_settings_update_permission(): void
|
||||
{
|
||||
$this->withToken($this->tokenWith(['core.settings.read']))
|
||||
->postJson('/api/admin/settings/static-cache/republish')
|
||||
->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비인증 요청은 401 이다.
|
||||
*
|
||||
* @scenario permission=denied, publishable=non_production, outcome=success
|
||||
*
|
||||
* @effects republish_denied_without_settings_update_permission
|
||||
*/
|
||||
public function test_unauthenticated_requests_are_rejected(): void
|
||||
{
|
||||
$this->getJson('/api/admin/settings/static-cache')->assertStatus(401);
|
||||
$this->postJson('/api/admin/settings/static-cache/republish')->assertStatus(401);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Console;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* `ext-static:status` / `ext-static:publish` 의 root 실행 안내 + 상태 보고서 소비 테스트 (#651 C1·D3).
|
||||
*
|
||||
* 상태 명령은 종전에 "즉시 게시하려면 `php artisan ext-static:publish`" 를 실행 계정과 무관하게
|
||||
* 권했다. 운영자가 그 안내를 sudo 셸에서 그대로 따르면 캐시 락 샤드가 root 소유가 되어 이후 웹
|
||||
* 요청이 500 이 된다. 이제 root 이면 경고와 `sudo -u {웹계정}` 형태의 힌트를 함께 낸다 — 중단은
|
||||
* 하지 않는다(D3).
|
||||
*/
|
||||
class ExtStaticStatusCommandTest extends TestCase
|
||||
{
|
||||
private string $isolatedPublicPath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->isolatedPublicPath = storage_path('framework/testing/public-cmd-'.getmypid());
|
||||
File::ensureDirectoryExists($this->isolatedPublicPath);
|
||||
$this->app->usePublicPath($this->isolatedPublicPath);
|
||||
|
||||
ClearsTemplateCaches::resetExtensionCacheStoreMemo();
|
||||
Cache::put('g7:core:ext.cache_version', 987600000);
|
||||
Cache::forget('g7:core:ext.static.publish_failure');
|
||||
ExtensionStaticCacheService::resetPublishScheduleForTesting();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
FilePermissionHelper::fakeWebServerAccountForTesting(null);
|
||||
File::deleteDirectory($this->isolatedPublicPath);
|
||||
ExtensionStaticCacheService::resetPublishScheduleForTesting();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* root 가 아니면 경고 없이 종전 문구 그대로다 (미게시 → 비-0 종료).
|
||||
*
|
||||
* @effects status_command_warns_root_and_hints_web_account
|
||||
*/
|
||||
public function test_non_root_status_prints_plain_publish_hint_without_root_warning(): void
|
||||
{
|
||||
FilePermissionHelper::fakeWebServerAccountForTesting(['mode' => 'non_root', 'name' => null]);
|
||||
|
||||
$this->artisan('ext-static:status')
|
||||
->expectsOutputToContain('부트스트랩 리소스 정적 게시 상태')
|
||||
->expectsOutputToContain('현재 캐시 버전 : 987600000')
|
||||
->expectsOutputToContain('게시 상태 : 미게시')
|
||||
->expectsOutputToContain('php artisan ext-static:publish')
|
||||
->doesntExpectOutputToContain('root 로 실행 중')
|
||||
->doesntExpectOutputToContain('sudo -u')
|
||||
->assertExitCode(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* root + 웹 계정 식별 → 경고 + `sudo -u {계정}` 힌트. 종료 코드는 종전과 같다.
|
||||
*
|
||||
* @effects status_command_warns_root_and_hints_web_account
|
||||
*/
|
||||
public function test_root_status_warns_and_hints_sudo_with_web_account(): void
|
||||
{
|
||||
FilePermissionHelper::fakeWebServerAccountForTesting(['mode' => 'root_web_known', 'name' => 'www-data']);
|
||||
|
||||
$this->artisan('ext-static:status')
|
||||
->expectsOutputToContain('root 로 실행 중')
|
||||
->expectsOutputToContain('sudo -u www-data php artisan ext-static:publish')
|
||||
->assertExitCode(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* root + 웹 계정 미상 → placeholder 와 확인 방법.
|
||||
*
|
||||
* @effects status_command_warns_root_and_hints_web_account
|
||||
*/
|
||||
public function test_root_status_with_unknown_web_account_uses_placeholder(): void
|
||||
{
|
||||
FilePermissionHelper::fakeWebServerAccountForTesting(['mode' => 'root_web_unknown', 'name' => null]);
|
||||
|
||||
// 한 줄에 두 부분 문자열을 따로 기대하면 Mockery 가 첫 기대만 소비한다 — 한 문자열로 잠근다
|
||||
$this->artisan('ext-static:status')
|
||||
->expectsOutputToContain('sudo -u <웹서버계정> php artisan ext-static:publish (웹서버 계정은 storage 디렉토리 소유자로 확인)')
|
||||
->assertExitCode(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* root 서비스 구성(웹 계정도 root)은 sudo 접두 없이 안내한다 — 재실행도 root 로 무해.
|
||||
*
|
||||
* @effects status_command_warns_root_and_hints_web_account
|
||||
*/
|
||||
public function test_root_symmetric_hint_has_no_sudo_prefix(): void
|
||||
{
|
||||
FilePermissionHelper::fakeWebServerAccountForTesting(['mode' => 'root_web_symmetric', 'name' => null]);
|
||||
|
||||
$this->artisan('ext-static:status')
|
||||
->expectsOutputToContain('root 로 실행 중')
|
||||
->doesntExpectOutputToContain('sudo -u')
|
||||
->assertExitCode(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시 명령은 root 여도 **중단하지 않고** 경고 뒤 계속 실행한다 (D3 — 명시적 명령은 운영자 책임).
|
||||
*
|
||||
* @effects publish_command_warns_root_but_continues
|
||||
*/
|
||||
public function test_publish_warns_root_but_still_publishes(): void
|
||||
{
|
||||
FilePermissionHelper::fakeWebServerAccountForTesting(['mode' => 'root_web_known', 'name' => 'www-data']);
|
||||
Cache::lock('ext-static.publish.987600000', 300)->forceRelease();
|
||||
|
||||
$this->artisan('ext-static:publish')
|
||||
->expectsOutputToContain('root 로 실행 중')
|
||||
->expectsOutputToContain('sudo -u www-data php artisan ext-static:publish')
|
||||
->expectsOutputToContain('정적 게시가 완료되었습니다')
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertFileExists(public_path('build/ext/987600000/manifest.json'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 명령은 서비스 보고서를 소비한다 — 게시 뒤에는 완료·파일 수·게시 시각을 그대로 보인다.
|
||||
*
|
||||
* @effects status_command_consumes_service_report
|
||||
*/
|
||||
public function test_status_reflects_published_report(): void
|
||||
{
|
||||
FilePermissionHelper::fakeWebServerAccountForTesting(['mode' => 'non_root', 'name' => null]);
|
||||
Cache::lock('ext-static.publish.987600000', 300)->forceRelease();
|
||||
|
||||
$this->assertTrue(app(ExtensionStaticCacheService::class)->publishCurrent());
|
||||
|
||||
$this->artisan('ext-static:status')
|
||||
->expectsOutputToContain('게시 상태 : 완료')
|
||||
->expectsOutputToContain('manifest 파일 : 1건')
|
||||
->expectsOutputToContain('이상 없음.')
|
||||
->assertExitCode(0);
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,29 @@ class StaticPublishAlertTest extends TestCase
|
||||
$this->assertStringNotContainsString(':count', $alerts[0]['message']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림에서 바로 복구한다 — 관리자 수동 복구 엔드포인트와 버튼 라벨·성공 문구를 싣는다 (#651 D11).
|
||||
*
|
||||
* 대시보드 렌더러는 `recover_endpoint` 가 있는 알림에 [다시 만들기] 버튼을 그리고 그 주소로
|
||||
* POST 한다. 운영자가 결함을 처음 만나는 곳이 알림이므로 그 자리에서 복구가 끝나야 한다.
|
||||
*
|
||||
* @effects dashboard_alert_points_to_admin_republish
|
||||
*/
|
||||
public function test_alert_carries_admin_republish_recover_wiring(): void
|
||||
{
|
||||
$this->seedMarker(2);
|
||||
|
||||
$alerts = $this->listener()->addStaticPublishAlert([]);
|
||||
|
||||
$this->assertSame('/api/admin/settings/static-cache/republish', $alerts[0]['recover_endpoint']);
|
||||
$this->assertNotSame('', $alerts[0]['recover_label']);
|
||||
$this->assertStringNotContainsString('extensions.alerts.', $alerts[0]['recover_label']);
|
||||
$this->assertStringNotContainsString('extensions.alerts.', $alerts[0]['recover_success_message']);
|
||||
|
||||
// 문구도 관리자 화면 경로를 안내한다 — CLI 만 안내하면 수동 복구 UI 가 있어도 운영자가 모른다
|
||||
$this->assertStringContainsString('환경설정', $alerts[0]['message']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사유별로 subtype 과 문구가 갈린다 — 조치할 곳이 다르기 때문이다.
|
||||
*
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Services\ExtensionBundleService;
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use App\Services\LanguagePackService;
|
||||
use App\Services\TemplateService;
|
||||
use App\Support\CustomAssets;
|
||||
use Illuminate\Cache\ArrayStore;
|
||||
use Illuminate\Cache\Repository;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
@@ -288,6 +289,132 @@ class ExtensionStaticCacheServiceTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시된 custom 파일 집합 == `CustomAssets::publishableFiles()` 집합 (#651 F7 패리티).
|
||||
*
|
||||
* 변경 감지가 이 열거자로 서명을 만들므로, 게시가 다른 집합을 복사하면 "감지됐는데 게시본에
|
||||
* 없다" 또는 "게시본에 있는데 바꿔도 감지되지 않는다" 가 된다. 두 소비자가 같은 열거자를
|
||||
* 쓰는지를 산출물로 잠근다.
|
||||
*
|
||||
* @effects custom_signature_covers_publishable_tree
|
||||
*/
|
||||
public function test_published_custom_set_matches_publishable_files(): void
|
||||
{
|
||||
$template = $this->createActiveTemplate();
|
||||
$identifier = (string) $template->identifier;
|
||||
$dir = base_path("templates/{$identifier}/custom");
|
||||
|
||||
File::ensureDirectoryExists($dir.'/img/deep');
|
||||
File::put($dir.'/custom.css', "body { background-image: url('./img/deep/m.svg'); }");
|
||||
File::put($dir.'/img/deep/m.svg', '<svg/>');
|
||||
File::put($dir.'/fonts.woff2', 'font');
|
||||
File::put($dir.'/notes.txt', 'not an asset');
|
||||
|
||||
try {
|
||||
CustomAssets::flushCache();
|
||||
$service = $this->service();
|
||||
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
|
||||
|
||||
$customRoot = $service->versionDir(self::VERSION)."/templates/{$identifier}/assets/custom";
|
||||
$published = [];
|
||||
foreach (File::allFiles($customRoot) as $file) {
|
||||
$published[] = str_replace('\\', '/', $file->getRelativePathname());
|
||||
}
|
||||
sort($published, SORT_STRING);
|
||||
|
||||
$enumerated = array_column(CustomAssets::publishableFiles('templates', $identifier), 'relative');
|
||||
|
||||
$this->assertSame($enumerated, $published, '게시된 custom 집합이 공유 열거자 집합과 다르다 — 감지와 게시 범위가 어긋난다');
|
||||
$this->assertSame(['custom.css', 'fonts.woff2', 'img/deep/m.svg'], $published);
|
||||
} finally {
|
||||
File::deleteDirectory($dir);
|
||||
// 요청 스코프 메모가 지운 파일을 가리키지 않도록 비운다 (같은 프로세스의 다음 테스트가 게시한다)
|
||||
CustomAssets::flushCache();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 보고서는 미게시/게시 상태를 필드로 구분해 돌려준다 — CLI·API·화면이 같은 판정을 소비한다.
|
||||
*
|
||||
* @effects status_endpoint_returns_report_fields
|
||||
*/
|
||||
public function test_status_report_reflects_publish_state(): void
|
||||
{
|
||||
$service = $this->service();
|
||||
|
||||
$before = $service->statusReport();
|
||||
$this->assertSame(self::VERSION, $before['version']);
|
||||
$this->assertFalse($before['published']);
|
||||
$this->assertSame(0, $before['files']);
|
||||
$this->assertNull($before['published_at']);
|
||||
$this->assertFalse($before['publishable'], 'testing 환경은 publishable 이 아니다');
|
||||
$this->assertTrue($before['enabled']);
|
||||
$this->assertNull($before['failure']);
|
||||
$this->assertSame([], $before['retained_versions']);
|
||||
|
||||
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
|
||||
|
||||
$after = $service->statusReport();
|
||||
$this->assertTrue($after['published']);
|
||||
$this->assertGreaterThan(0, $after['files']);
|
||||
$this->assertNotNull($after['published_at']);
|
||||
$this->assertSame([self::VERSION], $after['retained_versions']);
|
||||
$this->assertTrue($after['tree_writable']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 복구는 버전을 올리고 새 버전을 강제 게시한다 (`force` 없으면 같은 초 재게시가 skip 된다).
|
||||
*
|
||||
* @effects republish_bumps_version_and_publishes
|
||||
*/
|
||||
public function test_republish_bumps_version_and_force_publishes(): void
|
||||
{
|
||||
$this->app['env'] = 'production';
|
||||
ExtensionStaticCacheService::fakeRootProcessForTesting(false);
|
||||
|
||||
$service = $this->service();
|
||||
$result = $service->republish();
|
||||
|
||||
$this->assertTrue($result['republished'], json_encode($result));
|
||||
$this->assertSame(self::VERSION, $result['previous_version']);
|
||||
$this->assertGreaterThan(self::VERSION, $result['version']);
|
||||
$this->assertTrue($result['version_changed']);
|
||||
$this->assertTrue($result['published']);
|
||||
$this->assertNull($result['reason']);
|
||||
$this->assertFileExists($service->versionDir($result['version']).'/manifest.json');
|
||||
|
||||
// 같은 초의 연속 복구도 **내용을 다시 만든다** — manifest 시각이 갱신된다
|
||||
$firstManifest = (string) file_get_contents($service->versionDir($result['version']).'/manifest.json');
|
||||
sleep(1);
|
||||
$second = $service->republish();
|
||||
$this->assertTrue($second['republished']);
|
||||
$this->assertNotSame($firstManifest, (string) file_get_contents($service->versionDir($second['version']).'/manifest.json'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시가 쓰이지 않는 환경(비프로덕션 / kill-switch)에서는 bump 하지 않고 사유를 돌려준다.
|
||||
*
|
||||
* @effects republish_disabled_when_not_publishable
|
||||
*/
|
||||
public function test_republish_refuses_without_bump_when_not_publishable(): void
|
||||
{
|
||||
$service = $this->service();
|
||||
|
||||
$result = $service->republish();
|
||||
$this->assertFalse($result['republished']);
|
||||
$this->assertSame('not_production', $result['reason']);
|
||||
$this->assertSame(self::VERSION, $result['version']);
|
||||
$this->assertFalse($result['version_changed']);
|
||||
|
||||
config(['core.static_cache.enabled' => false]);
|
||||
$this->app['env'] = 'production';
|
||||
|
||||
$result = $service->republish();
|
||||
$this->assertFalse($result['republished']);
|
||||
$this->assertSame('disabled', $result['reason']);
|
||||
$this->assertSame(self::VERSION, ExtensionStaticCacheService::getExtensionCacheVersion());
|
||||
}
|
||||
|
||||
/**
|
||||
* 비활성 확장의 custom 은 게시하지 않는다.
|
||||
*
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Extension\PluginManager;
|
||||
use App\Http\View\Composers\TemplateComposer;
|
||||
use App\Support\CustomAssets;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use ReflectionMethod;
|
||||
@@ -285,9 +286,9 @@ class CustomAssetInjectionTest extends TestCase
|
||||
* `?custom=off` 진동은 `customAssetsDisabledByRequest` 가 이미 막고 있다 — 같은
|
||||
* 진동의 다른 원인인 admin/user 템플릿 비대칭만 열려 있었다.
|
||||
*
|
||||
* @scenario custom_source=convention_scan, custom_ext_type=template
|
||||
* @scenario custom_source=convention_scan, custom_asset=css, custom_change_detection=top_level_asset
|
||||
*
|
||||
* @effects custom_asset_signature_is_scoped_per_render
|
||||
* @effects custom_asset_signature_is_scoped_per_render, custom_signature_scoped_per_host
|
||||
*/
|
||||
#[Test]
|
||||
public function 관리자_사용자_렌더가_서명_키를_서로_덮어쓰지_않는다(): void
|
||||
@@ -322,21 +323,23 @@ class CustomAssetInjectionTest extends TestCase
|
||||
$collect->invoke($composer, $identifier);
|
||||
}
|
||||
|
||||
// 두 렌더의 서명이 **각자** 기억된다 — 하나의 자리를 서로 덮어쓰지 않는다
|
||||
// 두 렌더의 서명이 **각자** 기억된다 — 하나의 자리를 서로 덮어쓰지 않는다.
|
||||
// 스코프 키는 `{템플릿}@{호스트명}` — 다중 서버 공유 캐시에서 서버별로 기억한다(#651 F9).
|
||||
$host = gethostname() ?: 'host';
|
||||
$stored = $cache->get(CustomAssets::SIGNATURE_CACHE_KEY);
|
||||
|
||||
$this->assertIsArray($stored, '서명이 스코프별로 기억되지 않는다 (단일 값으로 덮어쓰기)');
|
||||
$this->assertArrayHasKey($adminTemplate, $stored);
|
||||
$this->assertArrayHasKey($userTemplate, $stored);
|
||||
$this->assertNotSame($stored[$adminTemplate], $stored[$userTemplate]);
|
||||
$this->assertArrayHasKey($adminTemplate.'@'.$host, $stored);
|
||||
$this->assertArrayHasKey($userTemplate.'@'.$host, $stored);
|
||||
$this->assertNotSame($stored[$adminTemplate.'@'.$host], $stored[$userTemplate.'@'.$host]);
|
||||
|
||||
// 파일은 그대로다 — 렌더를 오간다고 버전이 올라서는 안 된다
|
||||
$this->assertFalse(
|
||||
$sync->invoke($composer, $adminAssets, $adminTemplate),
|
||||
$sync->invoke($composer, $adminTemplate),
|
||||
'관리자 렌더가 사용자 렌더의 서명을 덮어써 파일 변경으로 오독했다'
|
||||
);
|
||||
$this->assertFalse(
|
||||
$sync->invoke($composer, $userAssets, $userTemplate),
|
||||
$sync->invoke($composer, $userTemplate),
|
||||
'사용자 렌더가 관리자 렌더의 서명을 덮어써 파일 변경으로 오독했다'
|
||||
);
|
||||
|
||||
@@ -346,7 +349,7 @@ class CustomAssetInjectionTest extends TestCase
|
||||
CustomAssets::flushCache();
|
||||
|
||||
$this->assertTrue(
|
||||
$sync->invoke($composer, $resolve->invoke($composer, $userTemplate), $userTemplate),
|
||||
$sync->invoke($composer, $userTemplate),
|
||||
'파일을 고쳤는데 변경 감지가 발화하지 않았다'
|
||||
);
|
||||
} finally {
|
||||
@@ -356,4 +359,92 @@ class CustomAssetInjectionTest extends TestCase
|
||||
CustomAssets::flushCache();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `custom/` **하위 디렉토리**의 글꼴·이미지 변경도 감지한다 — 게시와 같은 범위 (#651 F7).
|
||||
*
|
||||
* 종전 서명은 로드 목록(최상위 css/js)의 mtime 만 봤다. 게시는 `custom/**` 를 재귀로
|
||||
* 복사하므로, 문서가 권장하는 `url('./img/marker.svg')` 의 대상 파일만 교체하면 게시본이
|
||||
* 영영 갱신되지 않았다 — 실측(g7_2.dev): svg 62B→78B 교체 뒤 버전 불변, 게시본 62B.
|
||||
* mtime 이 같은 재작성도 크기로 잡는다.
|
||||
*
|
||||
* @scenario custom_source=convention_scan, custom_asset=static_file, custom_change_detection=nested_static_file
|
||||
*
|
||||
* @effects custom_signature_covers_publishable_tree
|
||||
*/
|
||||
#[Test]
|
||||
public function custom_하위_파일_크기_변경은_버전을_올린다(): void
|
||||
{
|
||||
$template = 'g7test-nested_tpl';
|
||||
$dir = base_path('templates/'.$template.'/custom');
|
||||
|
||||
File::ensureDirectoryExists($dir.'/img');
|
||||
File::put($dir.'/custom.css', ".x{background:url('./img/marker.svg')}");
|
||||
File::put($dir.'/img/marker.svg', '<svg/>');
|
||||
|
||||
try {
|
||||
$composer = app(TemplateComposer::class);
|
||||
$sync = new ReflectionMethod(TemplateComposer::class, 'syncCustomAssetCacheVersion');
|
||||
$cache = app(CacheInterface::class);
|
||||
|
||||
$cache->forget(CustomAssets::SIGNATURE_CACHE_KEY);
|
||||
$cache->put('ext.cache_version', 1000);
|
||||
|
||||
CustomAssets::flushCache();
|
||||
$this->assertFalse($sync->invoke($composer, $template), '첫 관측은 기록만 해야 한다');
|
||||
$this->assertFalse($sync->invoke($composer, $template), '변경이 없으면 올리지 않는다');
|
||||
|
||||
// 최상위 css 는 그대로, 하위 svg 만 내용(크기)이 바뀐다 — mtime 은 그대로 둔다
|
||||
$mtime = filemtime($dir.'/img/marker.svg');
|
||||
File::put($dir.'/img/marker.svg', '<svg><!-- v2 --></svg>');
|
||||
touch($dir.'/img/marker.svg', $mtime);
|
||||
CustomAssets::flushCache();
|
||||
|
||||
$this->assertTrue(
|
||||
$sync->invoke($composer, $template),
|
||||
'하위 디렉토리 파일의 크기 변경이 감지되지 않았다 — 게시본의 글꼴·이미지가 영영 갱신되지 않는다'
|
||||
);
|
||||
$this->assertGreaterThan(1000, (int) $cache->get('ext.cache_version'));
|
||||
} finally {
|
||||
File::deleteDirectory(base_path('templates/'.$template));
|
||||
CustomAssets::flushCache();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명 키는 만료되지 않는다 — 기본 TTL 로 만료되면 그 경계의 첫 관측이 "기록만" 이라 변경 1회를 놓친다 (#651 F11).
|
||||
*
|
||||
* @scenario custom_source=convention_scan, custom_asset=css, custom_change_detection=no_change
|
||||
*
|
||||
* @effects cache_version_key_does_not_expire
|
||||
*/
|
||||
#[Test]
|
||||
public function 서명_키는_이틀_뒤에도_남아_있다(): void
|
||||
{
|
||||
$this->mock(PluginManager::class, function ($mock) {
|
||||
$mock->shouldReceive('getActivePlugins')->andReturn([self::FAKE_PLUGIN => []]);
|
||||
$mock->shouldReceive('loadPlugins')->andReturnNull();
|
||||
});
|
||||
|
||||
$composer = app(TemplateComposer::class);
|
||||
$collect = new ReflectionMethod(TemplateComposer::class, 'collectCustomAssets');
|
||||
$cache = app(CacheInterface::class);
|
||||
|
||||
$cache->forget(CustomAssets::SIGNATURE_CACHE_KEY);
|
||||
CustomAssets::flushCache();
|
||||
$collect->invoke($composer, null);
|
||||
|
||||
$this->assertIsArray($cache->get(CustomAssets::SIGNATURE_CACHE_KEY));
|
||||
|
||||
try {
|
||||
Carbon::setTestNow(now()->addDays(2));
|
||||
|
||||
$this->assertIsArray(
|
||||
$cache->get(CustomAssets::SIGNATURE_CACHE_KEY),
|
||||
'서명 키가 기본 TTL 로 만료됐다 — 만료 경계의 실제 변경 1회가 조용히 삼켜진다'
|
||||
);
|
||||
} finally {
|
||||
Carbon::setTestNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* E2E: 환경설정 > 일반 — 초기 화면 정적 파일 상태 카드 + 지금 다시 만들기 (#651 D4·D7)
|
||||
*
|
||||
* 시나리오 매니페스트: tests/scenarios/static-cache-admin-rebuild.yaml — 마킹은 각 테스트의
|
||||
* scenario(k=v 조합)·effects 주석이 담당한다.
|
||||
*
|
||||
* 배경: 확장 캐시 버전이 만료로 재생성되지 않으므로(영구 번호) 재게시 누락은 무기한 stale 이 된다.
|
||||
* 관리자 화면의 [지금 다시 만들기] 가 그 안전망이다 — 상태를 보고, 확인 모달을 거쳐, POST 하고,
|
||||
* 토스트와 카드 재조회로 결과를 확인한다.
|
||||
*
|
||||
* 환경 가드: 대상 사이트가 운영 모드(production)가 아니면 카드는 「개발 모드」 배지와 비활성 버튼을
|
||||
* 보인다 — 그 경우 재게시 케이스는 skip 하고 비활성 계약만 잠근다.
|
||||
*/
|
||||
import { test, expect, issueToken, issueScopedToken, authenticatePage } from '../../fixtures/auth';
|
||||
|
||||
/** 관리자 환경설정 일반 탭 진입 + 상태 카드 도착 대기 */
|
||||
async function gotoGeneralTab(page: import('@playwright/test').Page): Promise<void> {
|
||||
// 배지 문구는 데이터소스 도착 전에는 비어 있다 — 상태 GET 응답을 기다려야 publishable 판정이 실제 값이 된다
|
||||
const statusArrived = page.waitForResponse(
|
||||
(r) => r.request().method() === 'GET' && /\/api\/admin\/settings\/static-cache$/.test(new URL(r.url()).pathname),
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
await page.goto('/admin/settings');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await expect(page.locator('#card_static_cache')).toBeAttached({ timeout: 20_000 });
|
||||
await statusArrived;
|
||||
await expect(page.locator('#static_cache_status_badge')).not.toHaveText('', { timeout: 20_000 });
|
||||
}
|
||||
|
||||
/** 카드가 「게시됨/미게시」(publishable) 상태인지 — 운영 모드 + kill-switch 켜짐 */
|
||||
async function isPublishable(page: import('@playwright/test').Page): Promise<boolean> {
|
||||
const text = (await page.locator('#static_cache_status_badge').textContent()) ?? '';
|
||||
return /게시됨|미게시|Published|Not published/.test(text);
|
||||
}
|
||||
|
||||
// @scenario permission=granted, publishable=production_enabled, outcome=success
|
||||
// @effects card_renders_status_rows
|
||||
test('@smoke #651 - 일반 탭에 초기 화면 정적 파일 카드가 상태 행과 함께 렌더된다', async ({ page }) => {
|
||||
const token = issueToken('core.settings.read', 'core.settings.update');
|
||||
await authenticatePage(page, token);
|
||||
|
||||
const statusResponse = page.waitForResponse(
|
||||
(r) => r.request().method() === 'GET' && /\/api\/admin\/settings\/static-cache$/.test(new URL(r.url()).pathname),
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
|
||||
await gotoGeneralTab(page);
|
||||
|
||||
const response = await statusResponse;
|
||||
expect(response.status()).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toHaveProperty('publishable');
|
||||
expect(body.data).toHaveProperty('version');
|
||||
|
||||
// 카드는 에셋 서빙 카드 다음에 있고, 행 텍스트에 다국어 키가 노출되지 않는다
|
||||
await expect(page.locator('#card_asset_serving')).toBeAttached();
|
||||
const cardText = (await page.locator('#card_static_cache').textContent()) ?? '';
|
||||
expect(cardText).not.toContain('$t:');
|
||||
expect(cardText).not.toContain('admin.settings');
|
||||
|
||||
await expect(page.locator('#static_cache_version_row')).toBeAttached();
|
||||
await expect(page.locator('#static_cache_files_row')).toBeAttached();
|
||||
await expect(page.locator('#static_cache_published_at_row')).toBeAttached();
|
||||
await expect(page.locator('#static_cache_process_user_row')).toBeAttached();
|
||||
});
|
||||
|
||||
// @scenario permission=granted, publishable=non_production, outcome=success
|
||||
// @effects republish_button_disabled_when_not_publishable
|
||||
test('#651 - 게시가 쓰이지 않는 환경이면 버튼이 비활성이고 사유 힌트가 있다', async ({ page }) => {
|
||||
const token = issueToken('core.settings.read', 'core.settings.update');
|
||||
await authenticatePage(page, token);
|
||||
|
||||
await gotoGeneralTab(page);
|
||||
|
||||
const button = page.locator('#static_cache_republish_button');
|
||||
await expect(button).toBeAttached();
|
||||
|
||||
if (await isPublishable(page)) {
|
||||
// 운영 모드 사이트 — 버튼은 활성이어야 하고 힌트는 없다 (비활성 분기는 단위 테스트가 덮는다)
|
||||
await expect(button).toBeEnabled();
|
||||
await expect(page.locator('#static_cache_not_publishable_hint')).toHaveCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(button).toBeDisabled();
|
||||
await expect(button).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(page.locator('#static_cache_not_publishable_hint')).toBeAttached();
|
||||
});
|
||||
|
||||
// @scenario permission=granted, publishable=production_enabled, outcome=success
|
||||
// @effects confirm_modal_then_toast_and_refetch
|
||||
test('#651 - 지금 다시 만들기 → 확인 모달 → POST 200 → 토스트 + 카드 재조회', async ({ page }) => {
|
||||
const token = issueToken('core.settings.read', 'core.settings.update');
|
||||
await authenticatePage(page, token);
|
||||
|
||||
await gotoGeneralTab(page);
|
||||
test.skip(!(await isPublishable(page)), '대상 사이트가 운영 모드가 아니어서 재게시를 수행할 수 없다 (비활성 계약은 별도 케이스가 잠근다)');
|
||||
|
||||
const versionBefore = ((await page.locator('#static_cache_version_row').textContent()) ?? '').replace(/\D/g, '');
|
||||
|
||||
await page.locator('#static_cache_republish_button').click();
|
||||
await expect(page.locator('#static_cache_republish_confirm_button')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const republish = page.waitForResponse(
|
||||
(r) => r.request().method() === 'POST' && /\/api\/admin\/settings\/static-cache\/republish$/.test(new URL(r.url()).pathname),
|
||||
{ timeout: 300_000 }
|
||||
);
|
||||
const refetch = page.waitForResponse(
|
||||
(r) => r.request().method() === 'GET' && /\/api\/admin\/settings\/static-cache$/.test(new URL(r.url()).pathname),
|
||||
{ timeout: 300_000 }
|
||||
);
|
||||
|
||||
await page.locator('#static_cache_republish_confirm_button').click();
|
||||
|
||||
const response = await republish;
|
||||
expect(response.status()).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.republished).toBe(true);
|
||||
expect(Number(body.data.version)).toBeGreaterThan(Number(versionBefore));
|
||||
|
||||
// 상태 재조회가 뒤따르고 모달이 닫힌다
|
||||
expect((await refetch).status()).toBe(200);
|
||||
await expect(page.locator('#static_cache_republish_confirm_button')).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
// 카드에 새 버전이 반영된다
|
||||
await expect
|
||||
.poll(async () => ((await page.locator('#static_cache_version_row').textContent()) ?? '').replace(/\D/g, ''), { timeout: 20_000 })
|
||||
.toBe(String(body.data.version));
|
||||
|
||||
// 토스트 문구가 키 그대로 새어 나가지 않는다
|
||||
const toast = page.locator('[role="alert"], [role="status"], .toast').filter({ hasText: /다시 만들|rebuilt/ }).first();
|
||||
await expect(toast).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
// @scenario permission=denied, publishable=production_enabled, outcome=success
|
||||
// @effects republish_denied_without_settings_update_permission
|
||||
test('#651 - 읽기 권한만 있으면 상태는 보이지만 재게시 POST 는 403 이다', async ({ page }) => {
|
||||
const token = issueScopedToken('core.settings.read');
|
||||
await authenticatePage(page, token);
|
||||
|
||||
await gotoGeneralTab(page);
|
||||
|
||||
// 읽기 전용 계정에는 버튼이 비활성으로 렌더된다 (isReadOnly)
|
||||
await expect(page.locator('#static_cache_republish_button')).toBeDisabled();
|
||||
|
||||
// 서버 경계 — 화면 비활성과 무관하게 API 자체가 거부한다
|
||||
const status = await page.evaluate(async (t) => {
|
||||
const r = await fetch('/api/admin/settings/static-cache/republish', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${t}`, Accept: 'application/json' },
|
||||
});
|
||||
return { status: r.status, body: await r.json().catch(() => null) };
|
||||
}, token);
|
||||
|
||||
expect(status.status).toBe(403);
|
||||
expect(status.body?.success).toBe(false);
|
||||
expect(JSON.stringify(status.body)).not.toContain('settings.');
|
||||
});
|
||||
@@ -26,6 +26,19 @@ class BackupDirectoryRestoreOwnershipConfigTest extends TestCase
|
||||
$this->assertContains('storage/app/core_backups', $paths);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적 게시 트리(`public/build/ext`)가 두 목록에 포함된다 (#651 F22).
|
||||
*
|
||||
* root 프로세스는 자동 게시를 예약하지 않으므로 이 항목의 목적은 "terminating 게시가 만든 root
|
||||
* 디렉토리" 가 아니라 **실존하는** 게시 트리(웹 계정이 게시한 사이트)의 소유권을 코어 업데이트
|
||||
* 뒤 되돌리는 것이다 — 빠지면 재게시·GC 가 영구 실패해 정적 fast path 가 꺼진다.
|
||||
*/
|
||||
public function test_restore_ownership_includes_static_publish_tree(): void
|
||||
{
|
||||
$this->assertContains('public/build/ext', config('app.update.restore_ownership'));
|
||||
$this->assertContains('public/build/ext', config('app.update.restore_ownership_group_writable'));
|
||||
}
|
||||
|
||||
/**
|
||||
* restore_ownership_group_writable 목록에 백업 2경로가 포함됨.
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Models\Template;
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -287,6 +288,49 @@ class ClearsTemplateCachesTest extends TestCase
|
||||
$this->assertLessThanOrEqual(time(), $newVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 버전 키는 만료되지 않는다 (#651 F11).
|
||||
*
|
||||
* 종전에는 `put()` 에 TTL 을 넘기지 않아 `cache.default_ttl`(86400) 로 **매일 만료**됐고,
|
||||
* 다음 독자가 `regenerateExtensionCacheVersion()` 으로 새 버전을 만들어 정적 게시본 전체
|
||||
* (715파일·40MB)를 재생성하고 전 방문자의 자산 URL 을 바꿨다. 재생성은 `cache:clear` 나
|
||||
* 스토어 소실 때만 일어나야 한다.
|
||||
*
|
||||
* @effects cache_version_key_does_not_expire
|
||||
*/
|
||||
public function test_version_key_survives_two_days(): void
|
||||
{
|
||||
$this->traitUser->callIncrementExtensionCacheVersion();
|
||||
$version = ClearsTemplateCaches::getExtensionCacheVersion();
|
||||
$this->assertGreaterThan(0, $version);
|
||||
|
||||
try {
|
||||
Carbon::setTestNow(now()->addDays(2));
|
||||
|
||||
$this->assertSame(
|
||||
$version,
|
||||
(int) Cache::get('g7:core:ext.cache_version'),
|
||||
'캐시 버전 키가 이틀 뒤 만료됐다 — 매일 정적 게시본 전체가 재생성되고 전 방문자의 자산 URL 이 바뀐다'
|
||||
);
|
||||
$this->assertSame($version, ClearsTemplateCaches::getExtensionCacheVersion());
|
||||
} finally {
|
||||
Carbon::setTestNow();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 영구 TTL 상수는 `put(…, 0)`(forget) 도, 기본 TTL 도 아닌 명시 양수여야 한다.
|
||||
*
|
||||
* @effects cache_version_key_does_not_expire
|
||||
*/
|
||||
public function test_persistent_ttl_constant_is_positive_and_longer_than_default(): void
|
||||
{
|
||||
$this->assertGreaterThan(
|
||||
(int) config('cache.default_ttl', 86400) * 365,
|
||||
ExtensionStaticCacheService::PERSISTENT_TTL_SECONDS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 버전 **재생성** 경로도 정적 게시를 예약한다 (D1).
|
||||
*
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Extension\Helpers\ExtensionPendingHelper;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\TemplateManager;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 확장 설정 시드·업데이트 임시 폴더의 소유권 상속 회귀 테스트 (#651 B2·B4).
|
||||
*
|
||||
* sudo 코어 업데이트(번들 확장 업데이트 프롬프트)가 root 로 `storage/app/{modules,plugins}/{id}/settings/`
|
||||
* 를 만들면 그 서브트리는 restore_ownership 의도적 제외 경로라 코어 업데이트로도 되돌려지지 않고,
|
||||
* 이후 웹 프로세스의 설정 저장이 영구 실패한다. 임시 폴더의 부모 `storage/app/temp` 도 최초 생성자가
|
||||
* root 면 같은 결말이다(자식만 삭제된다). 소유권 축은 Windows 에서 실측할 수 없으므로 저장소 관례
|
||||
* (소스 훑기)로 상속 호출의 존재를 잠근다.
|
||||
*/
|
||||
class ExtensionSettingsDirectoryOwnershipTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 클래스 소스에서 주석을 제거한 본문을 돌려줍니다.
|
||||
*/
|
||||
private function strippedSource(string $class): string
|
||||
{
|
||||
$path = (new \ReflectionClass($class))->getFileName();
|
||||
$stripped = '';
|
||||
|
||||
foreach (token_get_all((string) file_get_contents($path)) as $token) {
|
||||
if (is_array($token) && in_array($token[0], [T_COMMENT, T_DOC_COMMENT], true)) {
|
||||
continue;
|
||||
}
|
||||
$stripped .= is_array($token) ? $token[1] : $token;
|
||||
}
|
||||
|
||||
return $stripped;
|
||||
}
|
||||
|
||||
/**
|
||||
* `makeDirectory($settingsDir …)` 뒤 5줄 안에 상속 호출이 있는지 판정합니다.
|
||||
*/
|
||||
private function assertMakeDirectoryInheritsOwnership(string $class): void
|
||||
{
|
||||
$source = $this->strippedSource($class);
|
||||
$offset = 0;
|
||||
$found = 0;
|
||||
|
||||
while (($pos = strpos($source, 'File::makeDirectory($settingsDir', $offset)) !== false) {
|
||||
$window = substr($source, $pos, 400);
|
||||
$lines = array_slice(explode("\n", $window), 0, 6);
|
||||
|
||||
$this->assertStringContainsString(
|
||||
'FilePermissionHelper::inheritOwnershipFromParent($settingsDir)',
|
||||
implode("\n", $lines),
|
||||
$class.': settings 디렉토리 생성 뒤 부모 소유권 상속이 없다 — sudo 코어 업데이트 뒤 그 확장의 설정 저장이 영구 실패한다'
|
||||
);
|
||||
|
||||
$found++;
|
||||
$offset = $pos + 1;
|
||||
}
|
||||
|
||||
$this->assertGreaterThan(0, $found, $class.': settings 디렉토리 생성 지점을 찾지 못했다');
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects settings_directory_seed_inherits_ownership
|
||||
*/
|
||||
#[Test]
|
||||
public function 모듈_설정_시드_디렉토리와_파일은_부모_소유권을_상속한다(): void
|
||||
{
|
||||
$this->assertMakeDirectoryInheritsOwnership(ModuleManager::class);
|
||||
|
||||
$source = $this->strippedSource(ModuleManager::class);
|
||||
$this->assertStringContainsString(
|
||||
"File::put(\$filePath, \$jsonContent);\n FilePermissionHelper::inheritOwnershipFromParent(\$filePath);",
|
||||
$source,
|
||||
'ModuleManager: 설정 시드 파일 쓰기 뒤 부모 소유권 상속이 없다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @effects settings_directory_seed_inherits_ownership
|
||||
*/
|
||||
#[Test]
|
||||
public function 플러그인_설정_시드_디렉토리와_파일은_부모_소유권을_상속한다(): void
|
||||
{
|
||||
$this->assertMakeDirectoryInheritsOwnership(PluginManager::class);
|
||||
|
||||
$source = $this->strippedSource(PluginManager::class);
|
||||
$this->assertStringContainsString(
|
||||
"File::put(\$settingsPath, \$content);\n FilePermissionHelper::inheritOwnershipFromParent(\$settingsPath);",
|
||||
$source,
|
||||
'PluginManager: 설정 시드 파일 쓰기 뒤 부모 소유권 상속이 없다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 세 매니저의 업데이트 임시 폴더 확보가 공유 헬퍼를 경유한다 — 복사본은 갈라진다.
|
||||
*
|
||||
* @effects update_temp_directory_inherits_ownership
|
||||
*/
|
||||
#[Test]
|
||||
public function 업데이트_임시_폴더는_공유_헬퍼로_확보한다(): void
|
||||
{
|
||||
foreach ([ModuleManager::class, PluginManager::class, TemplateManager::class] as $class) {
|
||||
$source = $this->strippedSource($class);
|
||||
|
||||
$this->assertStringContainsString(
|
||||
'ExtensionPendingHelper::ensureUpdateTempDirectory($tempDir)',
|
||||
$source,
|
||||
$class.': 업데이트 임시 폴더를 공유 헬퍼로 확보하지 않는다'
|
||||
);
|
||||
$this->assertStringNotContainsString(
|
||||
"File::ensureDirectoryExists(\$tempDir);\n",
|
||||
$source,
|
||||
$class.': 임시 폴더를 직접 생성하는 옛 경로가 남아 있다 — 부모 storage/app/temp 가 root 로 굳는다'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공유 헬퍼는 부모를 먼저 확보하고 자식에 소유권을 상속시킨다 (동작).
|
||||
*
|
||||
* @effects update_temp_directory_inherits_ownership
|
||||
*/
|
||||
#[Test]
|
||||
public function 공유_헬퍼는_부모와_자식을_함께_확보한다(): void
|
||||
{
|
||||
$parent = storage_path('framework/testing/temp-parent-'.uniqid());
|
||||
$child = $parent.'/module_update_'.uniqid();
|
||||
|
||||
try {
|
||||
ExtensionPendingHelper::ensureUpdateTempDirectory($child);
|
||||
|
||||
$this->assertDirectoryExists($parent);
|
||||
$this->assertDirectoryExists($child);
|
||||
$this->assertTrue(is_writable($child));
|
||||
} finally {
|
||||
File::deleteDirectory($parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,76 @@ class TemplateUpdateConfigCacheInvalidationTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 업데이트 tail 은 활성/비활성과 무관하게 **무조건** 캐시 버전을 올린다 (#651 F1).
|
||||
*
|
||||
* 종전에는 `if ($previousStatus !== ExtensionStatus::Active->value)` 가드 안에서만 올렸다.
|
||||
* 활성 템플릿은 `refreshTemplateLayouts()` 의 조건부 bump(레이아웃 변경 건수 > 0)에
|
||||
* 위임됐는데, 그 조건은 게시 입력 중 레이아웃 축만 보므로 lang/routes/dist 만 바뀐
|
||||
* 릴리스는 정적 게시본이 immutable 로 stale 하게 남았다. 모듈·플러그인 update 는
|
||||
* 무조건 bump 다 — 템플릿만 다를 이유가 없다.
|
||||
*
|
||||
* @scenario cache_state=warm_current,lifecycle_trigger=template_update,template_type=admin
|
||||
*
|
||||
* @effects template_update_always_bumps_cache_version
|
||||
*/
|
||||
#[Test]
|
||||
public function update_template_tail_bumps_cache_version_unconditionally(): void
|
||||
{
|
||||
$body = $this->methodSource(
|
||||
base_path('app/Extension/TemplateManager.php'),
|
||||
'updateTemplate'
|
||||
);
|
||||
|
||||
$this->assertStringNotContainsString(
|
||||
'if ($previousStatus !== ExtensionStatus::Active->value)',
|
||||
$body,
|
||||
'updateTemplate() 의 bump 가 previousStatus 가드 안에 있으면 활성 템플릿의 lang/routes/dist 변경이 게시본에 반영되지 않습니다.'
|
||||
);
|
||||
|
||||
// 성공 tail + 실패 catch 두 자리 — 최소 2회
|
||||
$this->assertGreaterThanOrEqual(
|
||||
2,
|
||||
substr_count($body, '$this->incrementExtensionCacheVersion();'),
|
||||
'updateTemplate() 는 성공 tail 과 실패 복원 경로 양쪽에서 캐시 버전을 올려야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패·복원 경로도 캐시 버전을 올린다 (#651 F2).
|
||||
*
|
||||
* 백업 복원이 실패했거나 부분 반영된 디스크가 남을 수 있어, 게시본을 새 버전으로 다시
|
||||
* 굽는 쪽이 옛 게시본을 그대로 두는 쪽보다 안전하다. bump 실패는 원래 예외를 가리지
|
||||
* 않도록 삼킨다.
|
||||
*
|
||||
* @scenario cache_state=warm_current,lifecycle_trigger=template_update,template_type=user
|
||||
*
|
||||
* @effects template_update_failure_bumps_after_restore
|
||||
*/
|
||||
#[Test]
|
||||
public function update_template_failure_path_bumps_after_restore(): void
|
||||
{
|
||||
$body = $this->methodSource(
|
||||
base_path('app/Extension/TemplateManager.php'),
|
||||
'updateTemplate'
|
||||
);
|
||||
|
||||
$catchAt = strrpos($body, 'catch (\Throwable $e)');
|
||||
$this->assertNotFalse($catchAt, 'updateTemplate() 의 최외곽 catch 를 찾지 못했습니다.');
|
||||
|
||||
$catchBody = substr($body, $catchAt);
|
||||
|
||||
$restoreAt = strpos($catchBody, "'status' => \$previousStatus");
|
||||
$bumpAt = strpos($catchBody, '$this->incrementExtensionCacheVersion();');
|
||||
$throwAt = strpos($catchBody, 'throw new \RuntimeException(');
|
||||
|
||||
$this->assertNotFalse($restoreAt, '실패 경로의 상태 복원 코드를 찾지 못했습니다.');
|
||||
$this->assertNotFalse($bumpAt, '실패 경로에 캐시 버전 bump 가 없습니다 — 부분 반영된 디스크가 옛 게시본과 함께 남습니다.');
|
||||
$this->assertNotFalse($throwAt, '실패 경로의 재던지기를 찾지 못했습니다.');
|
||||
|
||||
$this->assertTrue($restoreAt < $bumpAt && $bumpAt < $throwAt, 'bump 는 상태 복원 뒤, 재던지기 앞에 있어야 합니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 메서드 본문을 주석 제거 후 반환합니다.
|
||||
*
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Tests\Unit\Helpers;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Helpers\SettingsMigrator;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -96,6 +97,31 @@ class SettingsMigratorOwnershipTest extends TestCase
|
||||
$this->assertSame($parentOwnerBefore, fileowner($child), '자식 owner 가 부모와 동일');
|
||||
}
|
||||
|
||||
/**
|
||||
* settings 디렉토리 생성도 파일과 대칭으로 부모 소유권을 상속한다 (#651 B1).
|
||||
*
|
||||
* 파일(`writeJsonFile`)만 상속하고 디렉토리(`makeDirectory`)는 상속하지 않던 비대칭 — sudo
|
||||
* 업그레이드 스텝이 디렉토리를 root 로 만들면 `storage/app/{modules,plugins}` 는 restore_ownership
|
||||
* 의도적 제외 경로라 되돌려지지 않고, 이후 웹 프로세스의 그 모듈 설정 저장이 영구 실패한다.
|
||||
*
|
||||
* @effects settings_directory_seed_inherits_ownership
|
||||
*/
|
||||
public function test_settings_migrator_make_directory_invokes_inherit_ownership(): void
|
||||
{
|
||||
$body = (string) file_get_contents((new \ReflectionClass(SettingsMigrator::class))->getFileName());
|
||||
|
||||
$pos = strpos($body, 'File::makeDirectory($settingsDir');
|
||||
$this->assertNotFalse($pos, 'SettingsMigrator 의 settings 디렉토리 생성 지점을 찾지 못했다');
|
||||
|
||||
$window = implode("\n", array_slice(explode("\n", substr($body, $pos)), 0, 4));
|
||||
|
||||
$this->assertStringContainsString(
|
||||
'FilePermissionHelper::inheritOwnershipFromParent($settingsDir)',
|
||||
$window,
|
||||
'SettingsMigrator: settings 디렉토리 생성 뒤 부모 소유권 상속이 없다 (파일만 상속하는 비대칭)'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SettingsMigrator::writeJsonFile 호출 후 *.json 의 owner 가 부모와 일치.
|
||||
*
|
||||
@@ -107,7 +133,7 @@ class SettingsMigratorOwnershipTest extends TestCase
|
||||
*/
|
||||
public function test_settings_migrator_write_json_file_invokes_inherit_ownership(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass(\App\Extension\Helpers\SettingsMigrator::class);
|
||||
$reflection = new \ReflectionClass(SettingsMigrator::class);
|
||||
$method = $reflection->getMethod('writeJsonFile');
|
||||
$body = file_get_contents($method->getFileName());
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Installer;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 설치기 best-effort 작업 실패 문구 테스트 (#651 C3).
|
||||
*
|
||||
* 종전에는 작업 종류와 무관하게 언어팩 전용 문구를 재사용해, 대상이 없는 `static_publish`·
|
||||
* `config_cache` 실패가 "언어팩 일부 설치에 실패했습니다: (계속 진행)" 으로 찍혔다.
|
||||
*
|
||||
* `task-runner.php` 는 진입과 함께 작업을 실행하는 파일이라 테스트에서 include 할 수 없다 —
|
||||
* 조립 함수의 존재·호출과 문구 키의 형태를 소스로 잠근다.
|
||||
*/
|
||||
class InstallerBestEffortMessageTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @effects installer_best_effort_message_names_the_task
|
||||
*/
|
||||
#[Test]
|
||||
public function best_effort_분기는_작업_라벨_기반_문구_조립_함수를_쓴다(): void
|
||||
{
|
||||
$source = (string) file_get_contents(base_path('public/install/includes/task-runner.php'));
|
||||
|
||||
$this->assertStringContainsString('function bestEffortFailureMessage(string $taskId, string $target): string', $source);
|
||||
$this->assertStringContainsString('lang("task_{$taskId}")', $source);
|
||||
$this->assertStringContainsString("lang('warning_best_effort_task_failed', ['task' => \$display])", $source);
|
||||
|
||||
// 실패 분기가 새 조립 함수를 부른다 — 언어팩 전용 키를 직접 부르는 옛 줄은 남아 있지 않다
|
||||
$this->assertStringContainsString('$warnMsg = bestEffortFailureMessage((string) $taskId, (string) $target);', $source);
|
||||
$this->assertStringNotContainsString("lang('warning_language_pack_install_partial', ['identifier' => (string) \$target])", $source);
|
||||
}
|
||||
|
||||
/**
|
||||
* 문구 키가 `:task` 치환 자리를 갖고, best-effort 작업 3종의 라벨 키가 양 로케일에 있다.
|
||||
*
|
||||
* @effects installer_best_effort_message_names_the_task
|
||||
*/
|
||||
#[Test]
|
||||
public function 문구_키와_작업_라벨_키가_양_로케일에_있다(): void
|
||||
{
|
||||
foreach (['ko', 'en'] as $locale) {
|
||||
$messages = require base_path("public/install/lang/{$locale}.php");
|
||||
|
||||
$this->assertStringContainsString(':task', $messages['warning_best_effort_task_failed'] ?? '', "{$locale}: :task 치환 자리가 없다");
|
||||
|
||||
foreach (['task_static_publish', 'task_config_cache', 'task_language_pack_install'] as $key) {
|
||||
$this->assertNotEmpty($messages[$key] ?? null, "{$locale}: {$key} 라벨이 없다");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,6 +293,7 @@ class InstallerJsonOutputTest extends TestCase
|
||||
'error_empty_server_response',
|
||||
'error_invalid_server_response',
|
||||
'error_polling_response_invalid',
|
||||
'warning_best_effort_task_failed',
|
||||
];
|
||||
|
||||
foreach (['ko', 'en'] as $locale) {
|
||||
|
||||
@@ -28,6 +28,32 @@ class CoreUpdateRuntimeOwnershipTest extends TestCase
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정상화 타깃에 확장 업데이트 임시 폴더와 로그 디렉토리가 포함된다 (#651 B5).
|
||||
*
|
||||
* `storage/app/temp` 의 부모는 sudo 업데이트가 최초 생성자가 되면 root 로 굳어 이후 관리자 화면의
|
||||
* 확장 업데이트가 임시 폴더를 만들지 못하고, `storage/logs` 는 restore_ownership 복원(Step 11)
|
||||
* 뒤에 만들어지는 daily 롤오버·신규 로그 파일이 root 로 남는다.
|
||||
*
|
||||
* @effects runtime_ownership_targets_cover_temp_and_logs
|
||||
*/
|
||||
public function test_runtime_ownership_targets_include_temp_and_logs(): void
|
||||
{
|
||||
$method = new \ReflectionMethod(CoreUpdateService::class, 'normalizeRuntimeOwnershipAfterRootRun');
|
||||
$lines = file($method->getFileName()) ?: [];
|
||||
$body = implode('', array_slice($lines, $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1));
|
||||
|
||||
foreach ([
|
||||
"storage_path('framework/cache')",
|
||||
"base_path('bootstrap/cache')",
|
||||
"storage_path('app/ext-bundles')",
|
||||
"storage_path('app/temp')",
|
||||
"storage_path('logs')",
|
||||
] as $target) {
|
||||
$this->assertStringContainsString($target, $body, "런타임 소유권 정상화 타깃에 {$target} 이 없다");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부모(CoreUpdateCommand)·자식(ExecuteUpgradeStepsCommand) 흐름 종료부가
|
||||
* 정상화를 호출한다 — 로그 소유권 정상화(restoreUpgradeLogOwnership)와
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Exceptions\CustomAssetOperationException;
|
||||
use App\Services\CustomAssetService;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* `custom/` 디렉토리 생성 실패 안내 테스트 (#651 D6·C2).
|
||||
*
|
||||
* `custom/` 은 지연 생성이라 `deploy:deploy 0755` 로 배포된 확장 디렉토리에서 운영자의 첫 저장이
|
||||
* 여기서 실패한다. 종전 문구는 경로만 적어 무엇을 고쳐야 하는지 알 수 없었다 — 사유·소유자·권한·
|
||||
* 실행 계정·조치 예시를 함께 싣는다(정적 게시 프리플라이트와 같은 식).
|
||||
*/
|
||||
class CustomAssetServiceDirectoryFailureTest extends TestCase
|
||||
{
|
||||
/** 테스트용 가짜 플러그인 식별자 */
|
||||
private const FAKE_PLUGIN = 'g7test-dirfail';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
File::deleteDirectory(base_path('plugins/'.self::FAKE_PLUGIN));
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 이름의 **파일**이 `custom/` 자리를 차지하면 사유·조치가 실린 메시지로 실패한다.
|
||||
*
|
||||
* (POSIX 권한 거부는 Windows 에서 재현할 수 없어 파일 점유 사유로 실패 경로를 태운다 — 사유 코드는
|
||||
* 공통 프리미티브가 돌려주므로 매핑 경로는 같다.)
|
||||
*
|
||||
* @scenario custom_source=convention_scan, custom_asset=css
|
||||
*
|
||||
* @effects custom_directory_failure_explains_reason_and_action
|
||||
*/
|
||||
#[Test]
|
||||
public function 디렉토리_생성_실패는_사유와_조치를_함께_알린다(): void
|
||||
{
|
||||
$pluginDir = base_path('plugins/'.self::FAKE_PLUGIN);
|
||||
File::ensureDirectoryExists($pluginDir);
|
||||
// `custom` 자리를 파일로 점유 — mkdir 이 실패하는 결정적 조건
|
||||
File::put($pluginDir.'/custom', 'not a directory');
|
||||
|
||||
try {
|
||||
app(CustomAssetService::class)->save('plugins', self::FAKE_PLUGIN, 'custom.css', '/* x */');
|
||||
$this->fail('디렉토리를 만들 수 없는데 예외가 나지 않았다');
|
||||
} catch (CustomAssetOperationException $e) {
|
||||
$this->assertSame('custom_assets.errors.directory_failed', $e->errorKey);
|
||||
|
||||
foreach (['path', 'reason', 'owner', 'perms', 'process_user', 'hint'] as $param) {
|
||||
$this->assertArrayHasKey($param, $e->params, "실패 메시지 파라미터 {$param} 이 없다");
|
||||
}
|
||||
|
||||
// 사유 코드가 번역된 문장으로 실려야 한다 (키 노출 금지)
|
||||
$this->assertStringNotContainsString('custom_assets.', (string) $e->params['reason']);
|
||||
$this->assertStringNotContainsString('custom_assets.', (string) $e->params['hint']);
|
||||
$this->assertStringContainsString('chown', (string) $e->params['hint']);
|
||||
|
||||
// 최종 문장에 치환 자리가 남아 있지 않다
|
||||
$this->assertStringNotContainsString(':reason', $e->getMessage());
|
||||
$this->assertStringNotContainsString(':hint', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사유 코드 4종은 ko/en 양쪽에 번역이 있다 — 없는 코드는 키 문자열이 화면에 나간다.
|
||||
*
|
||||
* @effects custom_directory_failure_explains_reason_and_action
|
||||
*/
|
||||
#[Test]
|
||||
public function 사유_코드_4종은_양_로케일에_번역이_있다(): void
|
||||
{
|
||||
foreach (['ko', 'en'] as $locale) {
|
||||
$messages = require base_path("lang/{$locale}/custom_assets.php");
|
||||
|
||||
foreach (['occupied_by_file', 'ancestor_not_writable', 'create_failed', 'not_writable'] as $code) {
|
||||
$this->assertNotEmpty($messages['errors']['reason'][$code] ?? null, "{$locale}: reason.{$code} 번역이 없다");
|
||||
}
|
||||
|
||||
$this->assertNotEmpty($messages['errors']['directory_failed_hint'] ?? null, "{$locale}: directory_failed_hint 가 없다");
|
||||
$this->assertStringContainsString(':hint', $messages['errors']['directory_failed']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ namespace Tests\Unit\Services;
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Repositories\AttachmentRepositoryInterface;
|
||||
use App\Contracts\Repositories\ConfigRepositoryInterface;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use App\Services\SettingsService;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
@@ -144,6 +146,140 @@ class SettingsServiceSetSettingSideEffectsTest extends TestCase
|
||||
$this->assertFalse($service->setSetting('drivers.queue_driver', 'redis'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 자산 URL 방식을 단건으로 바꾸면 확장 캐시 버전이 오른다 — 실제 키 관측 (#651 F5).
|
||||
*
|
||||
* 병합 번들 CSS 에는 그 시점의 자산 URL 형태(확장자 / `?file=`)가 구워져 있고, 디스크 번들과
|
||||
* 정적 게시본은 캐시 버전으로 키드된다. 버전이 오르지 않으면 모드를 바꿔도 옛 형태의
|
||||
* `url()` 이 남아 글꼴·이미지가 404 가 된다.
|
||||
*
|
||||
* @effects asset_url_mode_change_bumps_extension_cache_version
|
||||
*/
|
||||
public function test_asset_url_mode_change_bumps_extension_cache_version(): void
|
||||
{
|
||||
$this->mock(SeoCacheManagerInterface::class)->shouldIgnoreMissing();
|
||||
Artisan::shouldReceive('call')->zeroOrMoreTimes();
|
||||
|
||||
ClearsTemplateCaches::resetExtensionCacheStoreMemo();
|
||||
Cache::put('g7:core:ext.cache_version', 1000);
|
||||
|
||||
$service = $this->makeService(existingValue: 'extensionless');
|
||||
$this->assertTrue($service->setSetting('general.asset_url_mode', 'extension'));
|
||||
|
||||
$this->assertGreaterThan(
|
||||
1000,
|
||||
(int) Cache::get('g7:core:ext.cache_version'),
|
||||
'자산 URL 방식 단건 변경이 확장 캐시 버전을 올리지 않았다 — 번들 CSS 의 옛 url() 형태가 남는다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 값이 그대로면 확장 캐시 버전도 그대로다.
|
||||
*
|
||||
* @effects asset_url_mode_change_bumps_extension_cache_version
|
||||
*/
|
||||
public function test_same_asset_url_mode_keeps_extension_cache_version(): void
|
||||
{
|
||||
$this->mock(SeoCacheManagerInterface::class)->shouldIgnoreMissing();
|
||||
Artisan::shouldReceive('call')->zeroOrMoreTimes();
|
||||
|
||||
ClearsTemplateCaches::resetExtensionCacheStoreMemo();
|
||||
Cache::put('g7:core:ext.cache_version', 1000);
|
||||
|
||||
$service = $this->makeService(existingValue: 'extension');
|
||||
$this->assertTrue($service->setSetting('general.asset_url_mode', 'extension'));
|
||||
|
||||
$this->assertSame(1000, (int) Cache::get('g7:core:ext.cache_version'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 벌크 저장(saveSettings) 경로도 같은 부수효과를 갖는다 — 화면 [저장] 버튼이 타는 경로다.
|
||||
*
|
||||
* @effects asset_url_mode_change_bumps_extension_cache_version
|
||||
*/
|
||||
public function test_bulk_save_asset_url_mode_change_bumps_extension_cache_version(): void
|
||||
{
|
||||
$this->mock(SeoCacheManagerInterface::class)->shouldIgnoreMissing();
|
||||
Artisan::shouldReceive('call')->zeroOrMoreTimes();
|
||||
|
||||
$existing = ['asset_url_mode' => 'extensionless'];
|
||||
$configRepository = $this->mock(ConfigRepositoryInterface::class)->shouldIgnoreMissing();
|
||||
$configRepository->shouldReceive('getCategory')->with('general')->andReturnUsing(function () use (&$existing): array {
|
||||
return $existing;
|
||||
});
|
||||
$configRepository->shouldReceive('saveCategory')->andReturn(true);
|
||||
$this->mock(AttachmentRepositoryInterface::class)->shouldIgnoreMissing();
|
||||
$this->mock(CacheInterface::class)->shouldIgnoreMissing();
|
||||
|
||||
ClearsTemplateCaches::resetExtensionCacheStoreMemo();
|
||||
Cache::put('g7:core:ext.cache_version', 1000);
|
||||
|
||||
$service = app(SettingsService::class);
|
||||
$this->assertTrue($service->saveSettings(['_tab' => 'general', 'general' => ['asset_url_mode' => 'extension']]));
|
||||
|
||||
$this->assertGreaterThan(1000, (int) Cache::get('g7:core:ext.cache_version'));
|
||||
|
||||
// 같은 값 재저장은 올리지 않는다
|
||||
Cache::put('g7:core:ext.cache_version', 2000);
|
||||
$existing = ['asset_url_mode' => 'extension'];
|
||||
$this->assertTrue($service->saveSettings(['_tab' => 'general', 'general' => ['asset_url_mode' => 'extension']]));
|
||||
$this->assertSame(2000, (int) Cache::get('g7:core:ext.cache_version'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 복원으로 자산 URL 방식이 바뀌면 SEO 캐시 삭제 + 확장 캐시 버전 bump 가 따른다.
|
||||
*
|
||||
* 복원은 설정 전체를 갈아엎는 쓰기라 두 저장 경로와 같은 부수효과를 받아야 한다 —
|
||||
* 종전에는 변경 감지 자체가 없었다.
|
||||
*
|
||||
* @effects asset_url_mode_change_bumps_extension_cache_version
|
||||
*/
|
||||
public function test_restore_asset_url_mode_change_bumps_extension_cache_version(): void
|
||||
{
|
||||
$this->mock(SeoCacheManagerInterface::class)->shouldReceive('clearAll')->once();
|
||||
Artisan::shouldReceive('call')->zeroOrMoreTimes();
|
||||
|
||||
$configRepository = $this->mock(ConfigRepositoryInterface::class)->shouldIgnoreMissing();
|
||||
// 복원 전 / 복원 후 순서대로 읽힌다
|
||||
$configRepository->shouldReceive('get')->with('general.asset_url_mode')->andReturn('extensionless', 'extension');
|
||||
$configRepository->shouldReceive('restore')->andReturn(true);
|
||||
$this->mock(AttachmentRepositoryInterface::class)->shouldIgnoreMissing();
|
||||
$this->mock(CacheInterface::class)->shouldIgnoreMissing();
|
||||
|
||||
ClearsTemplateCaches::resetExtensionCacheStoreMemo();
|
||||
Cache::put('g7:core:ext.cache_version', 1000);
|
||||
|
||||
$service = app(SettingsService::class);
|
||||
$this->assertTrue($service->restoreSettings('/tmp/backup.json'));
|
||||
|
||||
$this->assertGreaterThan(1000, (int) Cache::get('g7:core:ext.cache_version'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 복원 전후 값이 같으면 두 부수효과 모두 없다.
|
||||
*
|
||||
* @effects asset_url_mode_change_bumps_extension_cache_version
|
||||
*/
|
||||
public function test_restore_with_same_asset_url_mode_has_no_side_effects(): void
|
||||
{
|
||||
$this->mock(SeoCacheManagerInterface::class)->shouldReceive('clearAll')->never();
|
||||
Artisan::shouldReceive('call')->zeroOrMoreTimes();
|
||||
|
||||
$configRepository = $this->mock(ConfigRepositoryInterface::class)->shouldIgnoreMissing();
|
||||
$configRepository->shouldReceive('get')->with('general.asset_url_mode')->andReturn('extensionless');
|
||||
$configRepository->shouldReceive('restore')->andReturn(true);
|
||||
$this->mock(AttachmentRepositoryInterface::class)->shouldIgnoreMissing();
|
||||
$this->mock(CacheInterface::class)->shouldIgnoreMissing();
|
||||
|
||||
ClearsTemplateCaches::resetExtensionCacheStoreMemo();
|
||||
Cache::put('g7:core:ext.cache_version', 1000);
|
||||
|
||||
$service = app(SettingsService::class);
|
||||
$this->assertTrue($service->restoreSettings('/tmp/backup.json'));
|
||||
|
||||
$this->assertSame(1000, (int) Cache::get('g7:core:ext.cache_version'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 단건 저장은 받은 키를 변형 없이 저장소에 넘긴다. (벌크 위임 금지 — 계약 고정)
|
||||
*
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Support;
|
||||
|
||||
use App\Support\ConfigCacheHelper;
|
||||
use Illuminate\Container\Container;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* `ConfigCacheHelper` 가 `config:cache` 뒤 전역 컨테이너 인스턴스를 되돌리는지 (#651 F2 실측 파생).
|
||||
*
|
||||
* `config:cache` 는 새 Application 을 부팅하고 그 생성자가 `Container::setInstance()` 를 호출한다.
|
||||
* 되돌리지 않으면 같은 프로세스의 후속 `app()->terminating()` 이 종료되지 않는 일회용 앱에 등록되어
|
||||
* 정적 재게시 예약이 조용히 사라진다 — 예외도 로그도 없이 "다음 렌더에야 반영" 으로만 나타난다.
|
||||
*/
|
||||
class ConfigCacheHelperContainerTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 콜백이 전역 인스턴스를 바꿔 놓아도 실행 후 원래 앱으로 돌아온다.
|
||||
*
|
||||
* @effects config_cache_rebuild_preserves_container_instance
|
||||
*/
|
||||
#[Test]
|
||||
public function 콜백이_전역_인스턴스를_바꿔도_원래_앱으로_되돌린다(): void
|
||||
{
|
||||
$original = Container::getInstance();
|
||||
$stray = new Container;
|
||||
|
||||
ConfigCacheHelper::withPreservedContainer(static function () use ($stray): void {
|
||||
Container::setInstance($stray);
|
||||
});
|
||||
|
||||
$this->assertSame($original, Container::getInstance(), 'config:cache 뒤 app() 이 일회용 앱을 가리킨 채 남는다 — 후속 terminating 예약이 사라진다');
|
||||
$this->assertSame($original, app());
|
||||
}
|
||||
|
||||
/**
|
||||
* 콜백이 예외를 던져도 인스턴스는 되돌린다.
|
||||
*
|
||||
* @effects config_cache_rebuild_preserves_container_instance
|
||||
*/
|
||||
#[Test]
|
||||
public function 콜백이_실패해도_원래_앱으로_되돌린다(): void
|
||||
{
|
||||
$original = Container::getInstance();
|
||||
|
||||
try {
|
||||
ConfigCacheHelper::withPreservedContainer(static function (): void {
|
||||
Container::setInstance(new Container);
|
||||
|
||||
throw new \RuntimeException('boom');
|
||||
});
|
||||
$this->fail('예외가 전파되어야 한다');
|
||||
} catch (\RuntimeException) {
|
||||
// 기대한 예외
|
||||
}
|
||||
|
||||
$this->assertSame($original, Container::getInstance());
|
||||
}
|
||||
|
||||
/**
|
||||
* `rebuild()` 가 `config:cache` 호출을 보존 래퍼로 감싼다 (소스 구조).
|
||||
*
|
||||
* @effects config_cache_rebuild_preserves_container_instance
|
||||
*/
|
||||
#[Test]
|
||||
public function rebuild_는_config_cache_를_보존_래퍼로_감싼다(): void
|
||||
{
|
||||
$method = new \ReflectionMethod(ConfigCacheHelper::class, 'rebuild');
|
||||
$lines = file($method->getFileName()) ?: [];
|
||||
$body = implode('', array_slice($lines, $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1));
|
||||
|
||||
$this->assertStringContainsString("withPreservedContainer(static fn () => Artisan::call('config:cache'))", $body);
|
||||
$this->assertStringNotContainsString("\n Artisan::call('config:cache');", $body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Support;
|
||||
|
||||
use App\Support\CustomAssets;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* `CustomAssets::publishableFiles()` — 게시·변경 감지가 공유하는 열거자 테스트 (#651 F7).
|
||||
*
|
||||
* 정적 게시는 `custom/**` 를 재귀로 복사하고, 변경 감지는 종전에 최상위 css/js 의 mtime 만
|
||||
* 서명했다. 두 범위를 서로 다른 코드가 정의하면 어긋나고, 그 어긋남은 "글꼴을 바꿨는데
|
||||
* 반영되지 않는다" 로만 나타난다. 이 열거자 하나가 두 소비자의 집합을 정한다.
|
||||
*
|
||||
* DB 를 쓰지 않는다 — 파일 시스템 해석만 검증한다.
|
||||
*/
|
||||
class CustomAssetsPublishableFilesTest extends TestCase
|
||||
{
|
||||
/** 테스트용 가짜 플러그인 식별자 */
|
||||
private const FAKE_PLUGIN = 'g7test-publishable';
|
||||
|
||||
private function pluginDir(): string
|
||||
{
|
||||
return base_path('plugins/'.self::FAKE_PLUGIN);
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
CustomAssets::flushCache();
|
||||
File::ensureDirectoryExists($this->pluginDir().'/custom');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
File::deleteDirectory($this->pluginDir());
|
||||
CustomAssets::flushCache();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function writeCustom(string $relative, string $contents = '/* x */'): void
|
||||
{
|
||||
$path = $this->pluginDir().'/custom/'.$relative;
|
||||
File::ensureDirectoryExists(dirname($path));
|
||||
File::put($path, $contents);
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 디렉토리의 글꼴·이미지까지 열거한다 — 게시와 같은 범위.
|
||||
*
|
||||
* @scenario custom_source=convention_scan, custom_asset=static_file, custom_change_detection=nested_static_file
|
||||
*
|
||||
* @effects custom_signature_covers_publishable_tree
|
||||
*/
|
||||
#[Test]
|
||||
public function 하위_디렉토리의_정적_파일까지_열거한다(): void
|
||||
{
|
||||
$this->writeCustom('custom.css');
|
||||
$this->writeCustom('fonts/x.woff2', 'font');
|
||||
$this->writeCustom('img/deep/marker.svg', '<svg/>');
|
||||
|
||||
$relatives = array_column(CustomAssets::publishableFiles('plugins', self::FAKE_PLUGIN), 'relative');
|
||||
|
||||
$this->assertSame(['custom.css', 'fonts/x.woff2', 'img/deep/marker.svg'], $relatives);
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용 확장자 밖(텍스트·PHP)과 소스맵은 뺀다 — 게시 화이트리스트와 동일.
|
||||
*
|
||||
* @scenario custom_source=convention_scan, custom_asset=static_file, custom_change_detection=nested_static_file
|
||||
*
|
||||
* @effects custom_signature_covers_publishable_tree
|
||||
*/
|
||||
#[Test]
|
||||
public function 허용_확장자_밖과_소스맵은_열거하지_않는다(): void
|
||||
{
|
||||
$this->writeCustom('custom.css');
|
||||
$this->writeCustom('notes.txt', 'not an asset');
|
||||
$this->writeCustom('evil.php', '<?php');
|
||||
$this->writeCustom('custom.css.map', '{}');
|
||||
|
||||
$relatives = array_column(CustomAssets::publishableFiles('plugins', self::FAKE_PLUGIN), 'relative');
|
||||
|
||||
$this->assertSame(['custom.css'], $relatives);
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목마다 절대 경로·수정 시각·크기를 싣는다 — 서명 재료이자 게시 복사 원본이다.
|
||||
*
|
||||
* @scenario custom_source=convention_scan, custom_asset=css, custom_change_detection=top_level_asset
|
||||
*
|
||||
* @effects custom_signature_covers_publishable_tree
|
||||
*/
|
||||
#[Test]
|
||||
public function 항목은_절대경로_mtime_크기를_갖는다(): void
|
||||
{
|
||||
$this->writeCustom('custom.css', '/* twelve b */');
|
||||
|
||||
$files = CustomAssets::publishableFiles('plugins', self::FAKE_PLUGIN);
|
||||
|
||||
$this->assertCount(1, $files);
|
||||
$this->assertSame('custom.css', $files[0]['relative']);
|
||||
$this->assertFileExists($files[0]['absolute']);
|
||||
$this->assertSame(14, $files[0]['size']);
|
||||
$this->assertGreaterThan(0, $files[0]['mtime']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 스코프 메모이즈 — 같은 요청에서 두 번 물어도 디스크를 다시 훑지 않고, `flushCache()` 가 비운다.
|
||||
*
|
||||
* @scenario custom_source=convention_scan, custom_asset=css, custom_change_detection=no_change
|
||||
*
|
||||
* @effects custom_signature_covers_publishable_tree
|
||||
*/
|
||||
#[Test]
|
||||
public function 결과는_요청_스코프로_메모이즈되고_flush_로_비워진다(): void
|
||||
{
|
||||
$this->writeCustom('custom.css');
|
||||
|
||||
$first = CustomAssets::publishableFiles('plugins', self::FAKE_PLUGIN);
|
||||
|
||||
// 메모가 살아 있는 동안 추가한 파일은 보이지 않는다
|
||||
$this->writeCustom('fonts/x.woff2', 'font');
|
||||
$this->assertSame($first, CustomAssets::publishableFiles('plugins', self::FAKE_PLUGIN));
|
||||
|
||||
CustomAssets::flushCache();
|
||||
|
||||
$this->assertCount(2, CustomAssets::publishableFiles('plugins', self::FAKE_PLUGIN));
|
||||
}
|
||||
|
||||
/**
|
||||
* 디렉토리가 없거나 타입이 유효하지 않으면 빈 목록이다.
|
||||
*
|
||||
* @scenario custom_source=none, custom_asset=css, custom_change_detection=no_change
|
||||
*
|
||||
* @effects custom_signature_covers_publishable_tree
|
||||
*/
|
||||
#[Test]
|
||||
public function 디렉토리_부재와_무효_타입은_빈_목록이다(): void
|
||||
{
|
||||
File::deleteDirectory($this->pluginDir());
|
||||
|
||||
$this->assertSame([], CustomAssets::publishableFiles('plugins', self::FAKE_PLUGIN));
|
||||
$this->assertSame([], CustomAssets::publishableFiles('unknown', self::FAKE_PLUGIN));
|
||||
$this->assertSame([], CustomAssets::publishableFiles('plugins', '../escape'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 로드 목록(`forExtension`)은 그대로 최상위 css/js 만 싣는다 — 열거자 도입이 로드 의미를 바꾸지 않는다.
|
||||
*
|
||||
* @scenario custom_source=convention_scan, custom_asset=static_file, custom_change_detection=nested_static_file
|
||||
*
|
||||
* @effects custom_signature_covers_publishable_tree
|
||||
*/
|
||||
#[Test]
|
||||
public function 로드_목록은_여전히_최상위_css_js_만_싣는다(): void
|
||||
{
|
||||
$this->writeCustom('custom.css');
|
||||
$this->writeCustom('fonts/inner.css');
|
||||
$this->writeCustom('img/marker.svg', '<svg/>');
|
||||
|
||||
$loaded = CustomAssets::forExtension('plugins', self::FAKE_PLUGIN);
|
||||
$publishable = CustomAssets::publishableFiles('plugins', self::FAKE_PLUGIN);
|
||||
|
||||
$this->assertCount(1, $loaded);
|
||||
$this->assertStringContainsString('custom.css', $loaded[0]['url']);
|
||||
$this->assertCount(3, $publishable);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user