diff --git a/.env.example b/.env.example index e80ed460..9139cdda 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ APP_ENV=production APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.9 +APP_VERSION=7.0.10 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/.env.testing.example b/.env.testing.example index ae970d1e..7cd187ef 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -3,7 +3,7 @@ APP_ENV=testing APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.9 +APP_VERSION=7.0.10 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/.gitignore b/.gitignore index 30b9125b..9de07a62 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,9 @@ test-results/ # 배포용 빌드는 `--production` 이 G7_BUILD_SOURCEMAP=0 을 주입해 생성 자체를 막는다. # 경로 한정 시 새 확장이 추가될 때 조용히 누락되므로 전역 규칙으로 둔다. *.map + +# ===== 부트스트랩 리소스 정적 게시 (bake, #122) ===== +# 병합 산출물의 버전 디렉토리 사본 — 각 서버가 수명주기 이벤트마다 재생성하는 +# 로컬 파생물이라 저장소·release 페이로드에 유입되면 안 된다. +# `public/build/core/` 는 계속 추적한다 (배포 산출물) — 혼동 금지. +/public/build/ext/ diff --git a/AGENTS.md b/AGENTS.md index 45fae97e..2b2bfebe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ -### 백엔드 [backend/](docs/backend/) (34개) +### 백엔드 [backend/](docs/backend/) (35개) | 문서 | 설명 | TL;DR 핵심 | |------|------|-----------| @@ -41,6 +41,7 @@ | [service-provider.md](docs/backend/service-provider.md) | 서비스 프로바이더 안전성 | DB 접근 전 .env 파일 존재 확인 필수 | | [service-repository.md](docs/backend/service-repository.md) | Service-Repository 패턴 | RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지) | | [settings-multilingual-enrichment.md](docs/backend/settings-multilingual-enrichment.md) | Settings 카탈로그 다국어 자동 보강 | settings JSON 의 다국어 카탈로그 라벨(_cached_name 등)은 카탈로그 빌드 시점에 보강 | +| [static-asset-publishing.md](docs/backend/static-asset-publishing.md) | 부트스트랩 리소스 정적 게시 (Static Asset Publishing) | 게시물: public/build/ext/{cache_version}/ — 수명주기 이벤트마다 termi... | | [translatable-seeders.md](docs/backend/translatable-seeders.md) | 다국어 시더 인터페이스 (Translatable Seeders) | 다국어 JSON 컬럼(name 등)을 시드하는 확장 entity 시더는 TranslatableSeede... | | [user-overrides.md](docs/backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array $trackable... | | [validation.md](docs/backend/validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ad31cc4..f41a8665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [7.0.10] - 2026-08-25 + +### Added + +- 초기 화면에 필요한 다국어·컴포넌트 정의·라우트 정보·확장 번들·템플릿 에셋을 정적 파일로 미리 만들어 웹서버가 직접 전달합니다. 확장 설치/활성화나 레이아웃 편집 시 자동으로 다시 생성되며, 파일이 없으면 기존 방식으로 동작합니다. 초기 화면 표시가 빨라집니다. (#122 @glitter-gim 님께서 건의해주셨습니다.) + +### Changed + +- 템플릿 컴포넌트 정의·다국어·라우트 응답에 조건부 캐시(ETag)가 적용되어, 변경이 없으면 본문 전송 없이 캐시를 재사용합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.) + +### Fixed + +- 사이트 첫 접속 시 확장 캐시 버전이 어긋나 있으면 라우트·다국어 데이터를 두 번 내려받던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.) +- 레이아웃 props 의 `$switch` 조건 분기 값이 검색엔진(봇) 화면에서는 해석되지 않아 해당 속성이 표시되지 않던 문제를 수정했습니다. 이제 일반 화면과 봇 화면이 동일하게 분기 값을 렌더링합니다. +- sudo(root) 로 코어를 업데이트하면 업데이트 과정이 만든 캐시 파일이 root 소유로 남아, 이후 웹 화면 전체가 서버 오류(500)가 되거나 캐시가 동작하지 않을 수 있던 문제를 수정했습니다. 업데이트 종료 시 캐시·번들 디렉토리 소유권을 자동 정상화하고, 캐시 쓰기 실패는 화면을 중단시키지 않고 경고 로그와 함께 무캐시로 계속 동작합니다. + ## [7.0.9] - 2026-08-24 ### Added diff --git a/INSTALL.md b/INSTALL.md index 257047c5..46f2ef5c 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -289,7 +289,7 @@ unzip g7-release.zip # 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경 ls -la -# (필요 시) mv g7-7.0.9 g7 +# (필요 시) mv g7-7.0.10 g7 # ZIP 파일 정리 (선택) rm g7-release.zip diff --git a/README.ko.md b/README.ko.md index b06a1440..ae61f7be 100644 --- a/README.ko.md +++ b/README.ko.md @@ -10,7 +10,7 @@

- Version + Version PHP Laravel React diff --git a/README.md b/README.md index 2cb73c03..55ed9323 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version + Version PHP Laravel React diff --git a/app/Console/Commands/CleanupExtensionStaticCacheCommand.php b/app/Console/Commands/CleanupExtensionStaticCacheCommand.php new file mode 100644 index 00000000..f5f10546 --- /dev/null +++ b/app/Console/Commands/CleanupExtensionStaticCacheCommand.php @@ -0,0 +1,42 @@ +cleanup(); + + $this->info("오래된 정적 게시 디렉토리 {$deleted}건이 삭제되었습니다."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/Core/CoreUpdateCommand.php b/app/Console/Commands/Core/CoreUpdateCommand.php index bb15fdb2..06297296 100644 --- a/app/Console/Commands/Core/CoreUpdateCommand.php +++ b/app/Console/Commands/Core/CoreUpdateCommand.php @@ -593,6 +593,10 @@ class CoreUpdateCommand extends Command // fallback(spawn 실패)로 부모가 upgrade step 을 직접 실행한 경우, 부모가 만든 // upgrade 로그가 root 로 남는다 — 모든 로그 쓰기가 끝난 이 시점에 정합한다. $this->restoreUpgradeLogOwnership(); + // root 업데이트가 종료 시점까지 만든 캐시/번들 산출물 소유권 정상화 — + // restoreOwnership(흐름 중간) 이후의 root 쓰기가 웹 캐시 쓰기를 죽이는 + // 전면 500 차단 (7.0.9→7.0.10 실사례) + app(CoreUpdateService::class)->normalizeRuntimeOwnershipAfterRootRun(); return Command::SUCCESS; @@ -677,6 +681,10 @@ class CoreUpdateCommand extends Command } $this->restoreUpgradeLogOwnership(); + // root 업데이트가 종료 시점까지 만든 캐시/번들 산출물 소유권 정상화 — + // restoreOwnership(흐름 중간) 이후의 root 쓰기가 웹 캐시 쓰기를 죽이는 + // 전면 500 차단 (7.0.9→7.0.10 실사례) + app(CoreUpdateService::class)->normalizeRuntimeOwnershipAfterRootRun(); return Command::SUCCESS; @@ -772,6 +780,10 @@ class CoreUpdateCommand extends Command } $this->restoreUpgradeLogOwnership(); + // root 업데이트가 종료 시점까지 만든 캐시/번들 산출물 소유권 정상화 — + // restoreOwnership(흐름 중간) 이후의 root 쓰기가 웹 캐시 쓰기를 죽이는 + // 전면 500 차단 (7.0.9→7.0.10 실사례) + app(CoreUpdateService::class)->normalizeRuntimeOwnershipAfterRootRun(); return Command::FAILURE; } diff --git a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php index 66c09a83..c4674adb 100644 --- a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php +++ b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php @@ -206,6 +206,7 @@ class ExecuteUpgradeStepsCommand extends Command ]); $this->restoreUpgradeLogOwnership(); + $service->normalizeRuntimeOwnershipAfterRootRun(); return UpgradeHandoffException::EXIT_CODE; } catch (\Throwable $e) { @@ -217,6 +218,7 @@ class ExecuteUpgradeStepsCommand extends Command $this->error($e->getMessage()); $this->restoreUpgradeLogOwnership(); + $service->normalizeRuntimeOwnershipAfterRootRun(); return self::FAILURE; } @@ -278,6 +280,10 @@ class ExecuteUpgradeStepsCommand extends Command } $this->restoreUpgradeLogOwnership(); + // 단독 실행(sudo core:execute-upgrade-steps)이 만든 캐시/번들 root 산출물 + // 소유권 정상화 — spawn 자식 모드에서도 무해(멱등)하며, 부모(CoreUpdateCommand) + // 종료부의 동일 호출이 부모 측 후속 쓰기를 담당한다 + $service->normalizeRuntimeOwnershipAfterRootRun(); return self::SUCCESS; } diff --git a/app/Console/Commands/PublishExtensionStaticCacheCommand.php b/app/Console/Commands/PublishExtensionStaticCacheCommand.php new file mode 100644 index 00000000..687ac28e --- /dev/null +++ b/app/Console/Commands/PublishExtensionStaticCacheCommand.php @@ -0,0 +1,53 @@ +isEnabled()) { + $this->warn('정적 게시가 비활성화되어 있습니다 (G7_STATIC_CACHE=false). 게시를 건너뜁니다.'); + + return self::SUCCESS; + } + + $published = $service->publishCurrent(force: (bool) $this->option('force')); + + if (! $published) { + $this->error('정적 게시에 실패했습니다. 로그를 확인하세요 — 사이트는 API 폴백으로 정상 동작합니다.'); + + return self::FAILURE; + } + + $this->info('부트스트랩 리소스 정적 게시가 완료되었습니다.'); + + return self::SUCCESS; + } +} diff --git a/app/Exceptions/StaticCachePublishException.php b/app/Exceptions/StaticCachePublishException.php new file mode 100644 index 00000000..6a3c3e38 --- /dev/null +++ b/app/Exceptions/StaticCachePublishException.php @@ -0,0 +1,12 @@ +getPrefix() . ':' . $key; + return $this->getPrefix().':'.$key; } /** * Laravel Cache 스토어 인스턴스를 반환합니다. * - * @return \Illuminate\Cache\Repository 캐시 스토어 인스턴스 + * @return Repository 캐시 스토어 인스턴스 */ - protected function store(): \Illuminate\Cache\Repository + protected function store(): Repository { return Cache::store($this->store); } @@ -122,7 +123,13 @@ abstract class AbstractCacheDriver implements CacheInterface $resolvedKey = $this->resolveKey($key); $ttl = $ttl ?? $this->getDefaultTtl(); - return $this->store()->put($resolvedKey, $value, $ttl); + try { + return $this->store()->put($resolvedKey, $value, $ttl); + } catch (\Throwable $e) { + $this->warnWriteFailure('put', $resolvedKey, $e); + + return false; + } } /** @@ -144,7 +151,13 @@ abstract class AbstractCacheDriver implements CacheInterface */ public function forget(string $key): bool { - return $this->store()->forget($this->resolveKey($key)); + try { + return $this->store()->forget($this->resolveKey($key)); + } catch (\Throwable $e) { + $this->warnWriteFailure('forget', $this->resolveKey($key), $e); + + return false; + } } // === Remember 패턴 === @@ -170,8 +183,25 @@ abstract class AbstractCacheDriver implements CacheInterface // 항상 일반 remember + 키 인덱스에 태그 매핑 기록 // Laravel 네이티브 태그 저장은 사용하지 않음 (get/has와의 일관성 보장) - $result = $this->store()->remember($resolvedKey, $ttl, $callback); - $this->recordKeyTags($resolvedKey, $allTags); + // + // 저장 실패는 fail-soft — 캐시는 최적화이므로 콜백 결과를 그대로 반환한다. + // Repository::remember 를 쓰지 않고 get→callback→put 을 직접 수행하는 이유: + // put 예외를 잡아도 콜백을 재실행하지 않기 위해서다 (부수효과 이중 실행 금지). + // 실사례: sudo 업데이트가 키 인덱스 파일을 root 소유로 남기면 웹의 모든 + // remember 가 put 예외로 죽어 부팅 전면 500 이 됐다 (7.0.9→7.0.10). + $cached = $this->store()->get($resolvedKey); + if ($cached !== null) { + return $cached; + } + + $result = $callback(); + + try { + $this->store()->put($resolvedKey, $result, $ttl); + $this->recordKeyTags($resolvedKey, $allTags); + } catch (\Throwable $e) { + $this->warnWriteFailure('remember', $resolvedKey, $e); + } return $result; } @@ -187,7 +217,7 @@ abstract class AbstractCacheDriver implements CacheInterface */ public function rememberQuery(string $queryHash, callable $callback, ?int $ttl = null, array $tags = []): mixed { - return $this->remember('query:' . $queryHash, $callback, $ttl, $tags); + return $this->remember('query:'.$queryHash, $callback, $ttl, $tags); } // === 벌크 연산 === @@ -237,7 +267,13 @@ abstract class AbstractCacheDriver implements CacheInterface $resolved[$this->resolveKey($key)] = $value; } - return $this->store()->putMany($resolved, $ttl); + try { + return $this->store()->putMany($resolved, $ttl); + } catch (\Throwable $e) { + $this->warnWriteFailure('putMany', implode(',', array_keys($resolved)), $e); + + return false; + } } // === 무효화 === @@ -291,6 +327,42 @@ abstract class AbstractCacheDriver implements CacheInterface // === 키 인덱스 (태그 미지원 드라이버용) === + /** + * 캐시 쓰기 실패를 경고 로그로 강등합니다 (fail-soft). + * + * 캐시는 최적화다 — 쓰기 실패(권한/디스크)가 페이지를 죽이면 안 된다. 실사례: + * sudo 코어 업데이트가 키 인덱스 파일을 root 소유로 남겨 웹 프로세스의 모든 + * 캐시 쓰기가 Permission denied 로 죽고 부팅 경로가 전면 500 이 됐다 + * (예외가 로거 도달 전에 발생해 laravel.log 도 비어 있었다). + * + * 로그 폭주 방지: 같은 (연산, 예외 메시지) 조합은 프로세스당 1회만 기록한다. + * + * @param string $operation 실패한 연산 (put/forget/putMany/remember/index) + * @param string $key 대상 키 (진단용) + * @param \Throwable $e 원인 예외 + */ + protected function warnWriteFailure(string $operation, string $key, \Throwable $e): void + { + static $warned = []; + + $signature = $operation.'|'.$e->getMessage(); + if (isset($warned[$signature])) { + return; + } + $warned[$signature] = true; + + try { + Log::warning('캐시 쓰기 실패 — 무캐시로 계속 동작합니다 (스토리지 권한/디스크 확인 필요)', [ + 'operation' => $operation, + 'key' => $key, + 'store' => $this->store, + 'error' => $e->getMessage(), + ]); + } catch (\Throwable) { + // 로그 기록조차 불가한 환경(로그 디렉토리 권한 등) — 조용히 계속 + } + } + /** * 키-태그 매핑을 인덱스에 기록합니다. * @@ -318,16 +390,22 @@ abstract class AbstractCacheDriver implements CacheInterface */ private function flushByIndex(): bool { - $indexKey = $this->getIndexKey(); - $index = $this->store()->get($indexKey, []); + try { + $indexKey = $this->getIndexKey(); + $index = $this->store()->get($indexKey, []); - foreach (array_keys($index) as $key) { - $this->store()->forget($key); + foreach (array_keys($index) as $key) { + $this->store()->forget($key); + } + + $this->store()->forget($indexKey); + + return true; + } catch (\Throwable $e) { + $this->warnWriteFailure('flush', $this->getIndexKey(), $e); + + return false; } - - $this->store()->forget($indexKey); - - return true; } /** @@ -338,20 +416,26 @@ abstract class AbstractCacheDriver implements CacheInterface */ private function flushTagsByIndex(array $tags): bool { - $indexKey = $this->getIndexKey(); - $index = $this->store()->get($indexKey, []); - $tagsSet = array_flip($tags); + try { + $indexKey = $this->getIndexKey(); + $index = $this->store()->get($indexKey, []); + $tagsSet = array_flip($tags); - foreach ($index as $key => $keyTags) { - if (array_intersect_key(array_flip($keyTags), $tagsSet)) { - $this->store()->forget($key); - unset($index[$key]); + foreach ($index as $key => $keyTags) { + if (array_intersect_key(array_flip($keyTags), $tagsSet)) { + $this->store()->forget($key); + unset($index[$key]); + } } + + $this->store()->put($indexKey, $index, 86400 * 30); + + return true; + } catch (\Throwable $e) { + $this->warnWriteFailure('flushTags', implode(',', $tags), $e); + + return false; } - - $this->store()->put($indexKey, $index, 86400 * 30); - - return true; } /** @@ -361,6 +445,6 @@ abstract class AbstractCacheDriver implements CacheInterface */ private function getIndexKey(): string { - return 'g7:_idx:' . $this->getPrefix(); + return 'g7:_idx:'.$this->getPrefix(); } } diff --git a/app/Extension/Traits/ClearsTemplateCaches.php b/app/Extension/Traits/ClearsTemplateCaches.php index 73bfb440..2cf403e2 100644 --- a/app/Extension/Traits/ClearsTemplateCaches.php +++ b/app/Extension/Traits/ClearsTemplateCaches.php @@ -4,6 +4,7 @@ namespace App\Extension\Traits; use App\Contracts\Extension\CacheInterface; use App\Extension\Cache\CoreCacheDriver; +use App\Services\ExtensionStaticCacheService; use Illuminate\Support\Facades\Log; /** @@ -48,6 +49,12 @@ trait ClearsTemplateCaches 'error' => $e->getMessage(), ]); } + + // 부트스트랩 리소스 정적 게시(bake) 예약 — 모든 bump 호출부(수명주기 전체)가 + // 이 단일 지점을 경유하므로 재게시 트리거 누락이 구조적으로 불가능하다 (#122). + // terminating 시점에 프로세스당 1회, 실행 시점의 최종 버전으로 게시된다 + // (연속 bump 자연 병합). 실패해도 사이트는 API 폴백으로 정상. + ExtensionStaticCacheService::schedulePublishOnTerminate(); } /** diff --git a/app/Http/Controllers/Api/Base/BaseApiController.php b/app/Http/Controllers/Api/Base/BaseApiController.php index 3b489b6f..c98b95fd 100644 --- a/app/Http/Controllers/Api/Base/BaseApiController.php +++ b/app/Http/Controllers/Api/Base/BaseApiController.php @@ -210,17 +210,37 @@ abstract class BaseApiController extends Controller } /** - * JSON 응답을 반환합니다 (캐싱 헤더 포함). + * JSON 응답을 반환합니다 (조건부 캐싱 헤더 포함). + * + * ETag 기반 조건부 캐시(If-None-Match → 304)와 환경별 Cache-Control 분기를 + * 적용한다 — 프로덕션은 `public, max-age`, 그 외 환경은 `no-cache`(파일 수정 + * 즉시 반영 — `fileResponse` 의 환경 분기와 동일 사상). * * @param mixed $data JSON으로 변환할 데이터 * @param int $maxAge 캐시 유지 시간 (초, 기본: 1시간) * @param int $status HTTP 상태 코드 - * @return JsonResponse JSON 응답 + * @return JsonResponse|Response JSON 응답 또는 304 응답 */ - protected function cachedJsonResponse(mixed $data, int $maxAge = 3600, int $status = 200): JsonResponse + protected function cachedJsonResponse(mixed $data, int $maxAge = 3600, int $status = 200): JsonResponse|Response { + $etag = $this->generateETag($data); + + $cacheControl = app()->environment('production') + ? "public, max-age={$maxAge}" + : 'no-cache'; + + if ($status === 200 && $this->isNotModified($etag)) { + $response = $this->notModifiedResponse($etag, $maxAge); + $response->headers->set('Cache-Control', $cacheControl); + $response->headers->set('Vary', 'Accept-Encoding'); + + return $response; + } + return response()->json($data, $status, [ - 'Cache-Control' => "public, max-age={$maxAge}", + 'Cache-Control' => $cacheControl, + 'ETag' => $etag, + 'Vary' => 'Accept-Encoding', ], ResponseHelper::JSON_ENCODE_OPTIONS); } @@ -283,9 +303,18 @@ abstract class BaseApiController extends Controller ): JsonResponse|Response { $etag = $this->generateETag($data); + // 환경별 캐싱 정책 — 프로덕션 외 환경은 no-cache 로 파일/데이터 수정 즉시 반영 + // (`fileResponse`/`cachedJsonResponse` 와 동일 사상, #122 작업 D) + $cacheControl = app()->environment('production') + ? "public, max-age={$maxAge}" + : 'no-cache'; + // 304 Not Modified 처리 if ($this->isNotModified($etag)) { - return $this->notModifiedResponse($etag, $maxAge); + $response = $this->notModifiedResponse($etag, $maxAge); + $response->headers->set('Cache-Control', $cacheControl); + + return $response; } return response()->json([ @@ -294,7 +323,7 @@ abstract class BaseApiController extends Controller 'data' => $data, ], 200, [], ResponseHelper::JSON_ENCODE_OPTIONS) ->header('ETag', $etag) - ->header('Cache-Control', "public, max-age={$maxAge}") + ->header('Cache-Control', $cacheControl) ->header('Vary', 'Accept-Encoding, Accept-Language'); } } diff --git a/app/Http/Controllers/Api/Public/PublicModuleController.php b/app/Http/Controllers/Api/Public/PublicModuleController.php index ffecc46e..1604afec 100644 --- a/app/Http/Controllers/Api/Public/PublicModuleController.php +++ b/app/Http/Controllers/Api/Public/PublicModuleController.php @@ -140,9 +140,9 @@ class PublicModuleController extends PublicBaseController * 폴백한다(무손실 보존 디그레이드). * * @param string $identifier 모듈 식별자 - * @return JsonResponse 컴포넌트 정의 응답 + * @return JsonResponse|Response 컴포넌트 정의 응답 (If-None-Match 일치 시 304) */ - public function serveComponents(string $identifier): JsonResponse + public function serveComponents(string $identifier): JsonResponse|Response { $this->logApiUsage('modules.components', ['identifier' => $identifier]); diff --git a/app/Http/Controllers/Api/Public/PublicPluginController.php b/app/Http/Controllers/Api/Public/PublicPluginController.php index b062b2a6..2305cfe2 100644 --- a/app/Http/Controllers/Api/Public/PublicPluginController.php +++ b/app/Http/Controllers/Api/Public/PublicPluginController.php @@ -139,9 +139,9 @@ class PublicPluginController extends PublicBaseController * 폴백한다(무손실 보존 디그레이드). * * @param string $identifier 플러그인 식별자 - * @return JsonResponse 컴포넌트 정의 응답 + * @return JsonResponse|Response 컴포넌트 정의 응답 (If-None-Match 일치 시 304) */ - public function serveComponents(string $identifier): JsonResponse + public function serveComponents(string $identifier): JsonResponse|Response { $this->logApiUsage('plugins.components', ['identifier' => $identifier]); diff --git a/app/Http/Controllers/Api/Public/PublicTemplateController.php b/app/Http/Controllers/Api/Public/PublicTemplateController.php index b51cbd3b..5639762a 100644 --- a/app/Http/Controllers/Api/Public/PublicTemplateController.php +++ b/app/Http/Controllers/Api/Public/PublicTemplateController.php @@ -35,9 +35,9 @@ class PublicTemplateController extends PublicBaseController * 템플릿 라우트 정보 조회 (활성화된 모듈의 routes 포함) * * @param string $identifier 템플릿 식별자 (vendor-name 형식) - * @return JsonResponse 라우트 정보 응답 + * @return JsonResponse|Response 라우트 정보 응답 (`?v` 명시 + If-None-Match 일치 시 304) */ - public function getRoutes(string $identifier): JsonResponse + public function getRoutes(string $identifier): JsonResponse|Response { // API 사용량 기록 $this->logApiUsage('templates.routes', ['identifier' => $identifier]); @@ -101,6 +101,15 @@ class PublicTemplateController extends PublicBaseController return $this->error(__('templates.errors.invalid_cache_data'), 500); } + // 버전 키드 URL(`?v` 명시)은 bump 시 URL 자체가 바뀌므로 조건부 공개 캐시가 안전하다. + // 무버전 요청(핸드셰이크 폴백 등)은 종전대로 캐시 헤더 없이 신선 응답 (#122 작업 D). + // 열화 스냅샷은 공개 캐시 금지 — 서버측 캐시 회피(#493)와 동일 규율로, 같은 `?v` + // URL 에 public max-age 가 붙으면 브라우저/CDN 이 열화 응답을 1시간 박제한다 + // (버전은 이미 올라간 뒤라 스스로 회복되지 않음. 정적 게시의 열화 제외와 대칭). + if ($rawVersion !== null && ! $this->templateService->lastRouteMergeWasDegraded()) { + return $this->successWithCache('templates.messages.routes_retrieved', $routesData['data'], 3600); + } + return $this->success( __('templates.messages.routes_retrieved'), $routesData['data'] @@ -145,9 +154,9 @@ class PublicTemplateController extends PublicBaseController * 컴포넌트 정의 파일 서빙 * * @param string $identifier 템플릿 식별자 - * @return JsonResponse 컴포넌트 정의 응답 + * @return JsonResponse|Response 컴포넌트 정의 응답 (If-None-Match 일치 시 304) */ - public function serveComponents(string $identifier): JsonResponse + public function serveComponents(string $identifier): JsonResponse|Response { // API 사용량 기록 $this->logApiUsage('templates.components', ['identifier' => $identifier]); @@ -244,9 +253,9 @@ class PublicTemplateController extends PublicBaseController * * @param string $identifier 템플릿 식별자 * @param string $locale 로케일 (ko, en 등) - * @return JsonResponse 다국어 데이터 응답 + * @return JsonResponse|Response 다국어 데이터 응답 (If-None-Match 일치 시 304) */ - public function serveLanguage(string $identifier, string $locale): JsonResponse + public function serveLanguage(string $identifier, string $locale): JsonResponse|Response { // API 사용량 기록 $this->logApiUsage('templates.language', [ diff --git a/app/Seo/ComponentHtmlMapper.php b/app/Seo/ComponentHtmlMapper.php index 07a237d5..39a1054a 100644 --- a/app/Seo/ComponentHtmlMapper.php +++ b/app/Seo/ComponentHtmlMapper.php @@ -1457,6 +1457,9 @@ class ComponentHtmlMapper // 표현식 해석: 문자열/숫자는 evaluate, 배열/객체는 evaluateRaw $resolved = $evaluator->evaluateRaw($value, $context); $data[$key] = $resolved; + } elseif ($this->isSwitchDefinition($value)) { + // $switch 선언적 분기 (React resolveObject 와 동일 위치에서 해석) + $data[$key] = $this->resolveSwitchValue($value, $context, $evaluator); } elseif (is_array($value)) { // 중첩 객체 (예: socialLinks): 재귀적으로 해석 $data[$key] = $this->resolveAllPropsRecursive($value, $context, $evaluator); @@ -1482,6 +1485,8 @@ class ComponentHtmlMapper foreach ($values as $k => $v) { if (is_string($v) && str_contains($v, '{{')) { $result[$k] = $evaluator->evaluateRaw($v, $context); + } elseif ($this->isSwitchDefinition($v)) { + $result[$k] = $this->resolveSwitchValue($v, $context, $evaluator); } elseif (is_array($v)) { $result[$k] = $this->resolveAllPropsRecursive($v, $context, $evaluator); } else { @@ -1492,6 +1497,71 @@ class ComponentHtmlMapper return $result; } + /** + * 값이 `$switch` 선언적 분기 객체인지 판정합니다. + * + * React `DataBindingEngine.isSwitchExpression` 과 동일 — `$switch` 와 `$cases` + * 키를 모두 가진 객체(연관 배열)만 해당한다. + * + * @param mixed $value 판정 대상 값 + * @return bool $switch 정의 여부 + */ + private function isSwitchDefinition(mixed $value): bool + { + return is_array($value) + && array_key_exists('$switch', $value) + && array_key_exists('$cases', $value); + } + + /** + * `$switch` 선언적 분기 객체를 해석합니다. + * + * React `DataBindingEngine.resolveSwitch` 와 동일 의미론 (engine-v1.56.0 패리티): + * ① `$switch` 키 표현식을 평가해 문자열 키로 정규화(trim, 실패 시 빈 문자열) + * ② `$cases` 에서 키 일치 값 선택, 없으면 `$default`, 그것도 없으면 null(React undefined) + * ③ 결과가 `{{}}` 포함 문자열이면 재해석, 객체면 재귀 해석(중첩 $switch 포함) + * + * 레이아웃 최상위 `computed` 의 $switch 는 `SeoRenderer::resolveComputedSwitch` 가 + * 별도 처리한다 — 이 메서드는 노드 props 값 축 담당. + * + * @param array $definition $switch 정의 { "$switch", "$cases", "$default"? } + * @param array $context 데이터 컨텍스트 + * @param ExpressionEvaluator $evaluator 표현식 평가기 + * @return mixed 해석된 값 (매칭·기본값 모두 없으면 null) + */ + private function resolveSwitchValue(array $definition, array $context, ExpressionEvaluator $evaluator): mixed + { + try { + $keyValue = trim((string) $evaluator->evaluate((string) ($definition['$switch'] ?? ''), $context)); + } catch (\Throwable) { + $keyValue = ''; + } + + $cases = is_array($definition['$cases'] ?? null) ? $definition['$cases'] : []; + + if ($keyValue !== '' && array_key_exists($keyValue, $cases)) { + $result = $cases[$keyValue]; + } elseif (array_key_exists('$default', $definition)) { + $result = $definition['$default']; + } else { + return null; + } + + if (is_string($result)) { + return str_contains($result, '{{') ? $evaluator->evaluate($result, $context) : $result; + } + + if ($this->isSwitchDefinition($result)) { + return $this->resolveSwitchValue($result, $context, $evaluator); + } + + if (is_array($result)) { + return $this->resolveAllPropsRecursive($result, $context, $evaluator); + } + + return $result; + } + /** * {field|alt_field} 패턴에서 아이템 값을 해석합니다. * @@ -1688,6 +1758,12 @@ class ComponentHtmlMapper continue; } + // $switch 선언적 분기 객체 (engine-v1.56.0 React 패리티) — 해석하지 않으면 + // 배열이라는 이유로 속성이 조용히 사라진다 (예외·경고 없음) + if ($this->isSwitchDefinition($value)) { + $value = $this->resolveSwitchValue($value, $context, $evaluator); + } + if (is_string($value)) { $evaluated = $evaluator->evaluate($value, $context); if ($evaluated !== '') { diff --git a/app/Seo/SeoRenderer.php b/app/Seo/SeoRenderer.php index 22c17fd3..1cf9fc91 100644 --- a/app/Seo/SeoRenderer.php +++ b/app/Seo/SeoRenderer.php @@ -730,7 +730,12 @@ class SeoRenderer implements SeoRendererInterface foreach ($cssPaths as $cssPath) { // dist/ 접두사 제거 (서빙 경로에서는 dist가 자동 추가됨) $servePath = preg_replace('#^dist/#', '', $cssPath); - $urls[] = AssetUrl::templateAsset($templateIdentifier, $servePath); + + // 정적 게시본(bake) 경로 금지 — 이 URL 은 SeoCacheManager(`seo.page.*`, + // 키에 cache_version 미포함)에 캐시된 HTML 에 박제되는데, 정적 디렉토리는 + // GC 가 현재+직전 1개만 보존해 캐시 수명 안에 404 가 될 수 있다. SEO HTML 은 + // asset-url-recovery 파샬도 없어 자가 복구가 불가하므로 무버전 API URL 고정. + $urls[] = AssetUrl::templateAsset($templateIdentifier, $servePath, allowStatic: false); } return $urls; @@ -954,6 +959,11 @@ class SeoRenderer implements SeoRendererInterface * 프론트엔드 TemplateApp이 레이아웃 레벨 initLocal/initGlobal을 상태에 적용하는 것과 * 동일하게, 각 값의 {{}} 표현식을 해석해 반환합니다. * + * **데이터소스 레벨 `initLocal` 옵션은 의도적으로 처리하지 않는다** (2026-08-25 확정) — + * 그 옵션을 쓰는 화면(장바구니·주문서·프로필 수정·게시판 작성 폼 등)은 인증·인터랙션 + * 화면이라 봇 렌더 가치가 없다. 봇 노출이 필요한 상태 시드는 레이아웃 최상위 + * `initLocal`/`state` 를 사용한다 (docs/backend/seo-system.md 지원 노드 키 표 참조). + * * @param mixed $block 초기 상태 블록 (키 → 값) * @param array $context 현재 컨텍스트 (route, query 등 포함) * @return array 평가된 초기 상태 diff --git a/app/Services/CoreUpdateService.php b/app/Services/CoreUpdateService.php index a7bcf6fc..e17d1014 100644 --- a/app/Services/CoreUpdateService.php +++ b/app/Services/CoreUpdateService.php @@ -2188,6 +2188,56 @@ class CoreUpdateService } } + /** + * root 로 실행된 업데이트가 종료된 뒤, 런타임 쓰기 디렉토리의 소유권을 정상화합니다. + * + * `restoreOwnership()` 은 흐름 **중간**의 한 단계라, 그 이후에 일어나는 캐시 + * 쓰기(버전 bump·상태/훅 캐시 재생성·키 인덱스 갱신·번들 빌드)가 root 소유 + * 파일을 새로 만든다. 그 파일들이 남으면 웹 프로세스의 캐시 쓰기가 Permission + * denied 로 죽어 전면 500 이 된다 (실사례: 7.0.9→7.0.10 sudo 업데이트 — + * 치명점은 모든 remember 가 갱신하는 캐시 키 인덱스 파일). + * + * 따라서 이 메서드는 **흐름의 마지막**(restoreUpgradeLogOwnership 과 같은 + * 지점)에서 호출되어, 대상 디렉토리 자신의 소유자(웹 쓰기 소유)를 기준으로 + * 내용물을 재귀 정상화하고 그룹 쓰기를 동기화한다. 과거 업데이트가 남긴 + * root 잔재도 함께 정리된다 (재귀 전체 대상). + * + * 비-root 프로세스는 chown 자체가 불가능하고 필요도 없으므로 즉시 no-op. + * 기준 디렉토리 자체가 root 소유(비정상 배포)면 상속 근거가 없어 스킵한다. + */ + public function normalizeRuntimeOwnershipAfterRootRun(): void + { + if (! function_exists('chown') || ! function_exists('posix_geteuid') || posix_geteuid() !== 0) { + return; + } + + $targets = [ + storage_path('framework/cache'), + base_path('bootstrap/cache'), + storage_path('app/ext-bundles'), + ]; + + foreach ($targets as $dir) { + if (! is_dir($dir)) { + continue; + } + + $owner = @fileowner($dir); + $group = @filegroup($dir); + + if ($owner === false || $owner === 0) { + Log::channel('upgrade')->warning('런타임 소유권 정상화 스킵 — 기준 디렉토리가 root/판독불가 소유', [ + 'dir' => $dir, + ]); + + continue; + } + + FilePermissionHelper::chownRecursive($dir, $owner, $group); + FilePermissionHelper::syncGroupWritability($dir); + } + } + /** * 업데이트 경로의 소유권을 스냅샷 기준으로 복원합니다. * diff --git a/app/Services/ExtensionStaticCacheService.php b/app/Services/ExtensionStaticCacheService.php new file mode 100644 index 00000000..5b76aa98 --- /dev/null +++ b/app/Services/ExtensionStaticCacheService.php @@ -0,0 +1,645 @@ + 존재 여부) */ + private array $publishedMemo = []; + + public function __construct( + private TemplateService $templateService, + private TemplateRepositoryInterface $templateRepository, + private ExtensionBundleService $bundleService, + private LanguagePackService $languagePackService, + ) {} + + /** + * 현재 확장 캐시 버전 기준으로 게시합니다. + * + * 이미 게시 완료(manifest 존재) 상태면 skip(멱등). `Cache::lock` 으로 단일 + * 실행을 보장하며, 락 미획득 시 다른 프로세스가 게시 중인 것으로 보고 skip. + * + * @param bool $force 게시 완료 상태여도 강제 재게시 + * @return bool 게시 완료 상태로 끝났으면 true (skip 포함), 실패/비활성이면 false + */ + public function publishCurrent(bool $force = false): bool + { + if (! $this->isEnabled()) { + return false; + } + + $version = self::getExtensionCacheVersion(); + + if (! $force && $this->isPublished($version)) { + return true; + } + + $lock = Cache::lock(self::LOCK_PREFIX.$version, 300); + + if (! $lock->get()) { + return false; + } + + try { + // 락 대기 중 다른 프로세스가 완료했을 수 있다 (멱등 재확인) + unset($this->publishedMemo[$version]); + if (! $force && $this->isPublished($version)) { + return true; + } + + return $this->publishVersion($version); + } finally { + $lock->release(); + } + } + + /** + * 해당 버전이 게시 완료 상태인지 확인합니다 (manifest 존재 = 완료). + * + * AssetUrl 게이트가 요청당 여러 번 호출하므로 메모이즈한다. + * + * @param int $version 확장 캐시 버전 + * @return bool 게시 완료 여부 + */ + public function isPublished(int $version): bool + { + return $this->publishedMemo[$version] ??= is_file( + $this->versionDir($version).DIRECTORY_SEPARATOR.self::MANIFEST_FILE + ); + } + + /** + * 현재 버전 + 직전 1개를 보존하고 나머지 게시 디렉토리를 삭제합니다. + * + * 직전 버전을 남기는 이유: 브라우저에 캐시된 직전 렌더 HTML 이 아직 구버전 + * 정적 URL 을 참조할 수 있다 (asset-url-recovery 파샬이 최후 방어). + * + * @return int 삭제된 디렉토리 수 + */ + public function cleanup(): int + { + $base = $this->baseDir(); + + if (! File::isDirectory($base)) { + return 0; + } + + $current = self::getExtensionCacheVersion(); + $versions = []; + $deleted = 0; + + foreach (File::directories($base) as $dir) { + $name = basename($dir); + + // 미완료 tmp 잔존물은 무조건 제거 대상 (rename 전 실패 흔적) + if (str_ends_with($name, '.tmp')) { + File::deleteDirectory($dir); + $deleted++; + + continue; + } + + if (ctype_digit($name)) { + $versions[(int) $name] = $dir; + } + } + + // 현재 버전과, 현재를 제외한 최신 1개(직전) 보존 + $keep = [$current]; + $others = array_keys($versions); + rsort($others); + foreach ($others as $v) { + if ($v !== $current) { + $keep[] = $v; + break; + } + } + + foreach ($versions as $v => $dir) { + if (! in_array($v, $keep, true)) { + File::deleteDirectory($dir); + $deleted++; + } + } + + return $deleted; + } + + /** + * 수명주기 이벤트에서 호출되는 terminating 게시 예약. + * + * `incrementExtensionCacheVersion()` 내부 단일 지점에서 호출된다. + * 프로세스당 1회만 등록하며, 게시는 예약 시점이 아니라 **실행 시점의 현재 + * 버전**으로 수행되어 연속 bump(일괄 업데이트)를 자연 병합한다. + * + * 프로덕션 전용 — 비프로덕션은 blade 가 정적 URL 을 방출하지 않으므로 + * 게시 자체가 무의미하고(§2-2), testing 환경의 파일 쓰기 부수효과도 차단한다. + */ + public static function schedulePublishOnTerminate(): void + { + if (self::$publishScheduled || ! app()->environment('production')) { + return; + } + + // root 프로세스(sudo 코어 업데이트 등)에서는 예약하지 않는다 — 게시가 만드는 + // 캐시 락 샤드 디렉토리(storage/framework/cache/data/xx)와 병합 번들 + // (storage/app/ext-bundles)이 root 소유로 남아, 이후 웹 프로세스의 캐시 + // 쓰기가 그 샤드에 해시되는 순간 Permission denied 로 죽는다 (실사례: + // sudo 업데이트 직후 전면 500). normalizeOwnership 은 게시 트리(build/ext)만 + // 다루므로 storage 측 부수 산출물은 회피가 정답이다. 게시는 다음 웹 렌더의 + // 자가 치유(웹 계정)가 수행하고, 명시적 `ext-static:publish` 커맨드는 이 + // 게이트를 거치지 않는다 (운영자 책임 — 규정 문서 §6). + if (self::isRootProcess()) { + return; + } + + self::$publishScheduled = true; + + app()->terminating(static function (): void { + // 실행 시점에 재무장 가능 상태로 복귀 — 요청마다 앱 인스턴스를 새로 쓰는 + // 장수 프로세스(Octane 류)에서는 static 플래그만 살아남으므로, 리셋 없이는 + // 2번째 이후 bump 가 새 앱에 콜백을 등록하지 못한 채 영구 미게시가 된다 + // (자가 치유도 같은 플래그 공유). FPM(요청=프로세스)에서는 무영향. + self::$publishScheduled = false; + + try { + app(self::class)->publishCurrent(); + } catch (\Throwable $e) { + Log::warning('정적 게시 terminating 실행 실패 — 다음 렌더의 자가 치유가 재시도합니다', [ + 'error' => $e->getMessage(), + ]); + } + }); + } + + /** + * 테스트 격리용 — terminating 예약 플래그를 초기화합니다. + */ + public static function resetPublishScheduleForTesting(): void + { + self::$publishScheduled = false; + self::$rootProcessForTesting = null; + } + + /** + * 테스트 전용 — root 프로세스 판정을 강제합니다 (null 로 실판정 복귀). + * + * @param bool|null $isRoot 강제할 판정값 + */ + public static function fakeRootProcessForTesting(?bool $isRoot): void + { + self::$rootProcessForTesting = $isRoot; + } + + /** + * 현재 프로세스가 root(euid 0)로 실행 중인지 판정합니다. + * + * posix 확장이 없는 환경(Windows, 함수 비활성 호스팅)은 root 아님으로 본다. + * + * @return bool root 실행 여부 + */ + private static function isRootProcess(): bool + { + if (self::$rootProcessForTesting !== null) { + return self::$rootProcessForTesting; + } + + return function_exists('posix_geteuid') && posix_geteuid() === 0; + } + + /** + * 게시 루트 디렉토리 절대 경로를 반환합니다. + * + * @return string `public/build/ext` 절대 경로 + */ + public function baseDir(): string + { + return public_path('build/ext'); + } + + /** + * 버전 디렉토리 절대 경로를 반환합니다. + * + * @param int $version 확장 캐시 버전 + * @return string 버전 디렉토리 절대 경로 + */ + public function versionDir(int $version): string + { + return $this->baseDir().DIRECTORY_SEPARATOR.$version; + } + + /** + * kill-switch 판정 (`core.static_cache.enabled`, .env `G7_STATIC_CACHE`). + * + * @return bool 정적 게시 활성 여부 + */ + public function isEnabled(): bool + { + return (bool) config('core.static_cache.enabled', true); + } + + /** + * 한 버전의 게시를 실제 수행합니다 (tmp 쓰기 → rename → manifest → GC). + * + * @param int $version 게시할 확장 캐시 버전 + * @return bool 성공 여부 + */ + private function publishVersion(int $version): bool + { + $base = $this->baseDir(); + $tmp = $base.DIRECTORY_SEPARATOR.$version.'.tmp'; + $final = $this->versionDir($version); + + try { + File::deleteDirectory($tmp); + File::ensureDirectoryExists($tmp, 0775); + + $files = []; + + $this->writeHtaccess($tmp, $files); + + $locales = $this->publishableLocales(); + + foreach ($this->templateRepository->getActive() as $template) { + $this->publishTemplate($tmp, $template, $locales, $files); + } + + $this->publishBundles($tmp, $version, $files); + + // 원자적 스왑 — force 재게시 시 기존 디렉토리를 비켜낸 뒤 rename + if (File::isDirectory($final)) { + File::deleteDirectory($final); + } + + if (! @rename($tmp, $final)) { + throw new StaticCachePublishException("Failed to rename publish directory: {$tmp} -> {$final}"); + } + + // manifest 는 rename 후 마지막 기록 — 존재 = 게시 완료 + $this->writeManifest($final, $version, $files); + unset($this->publishedMemo[$version]); + + // sudo/root CLI 게시 대응 — terminating 게시는 코어 업데이트의 + // restoreOwnership **이후**(프로세스 종료 시)에 실행되므로, root 소유로 + // 남으면 이후 php-fpm 의 재게시·GC 가 영구 실패한다. 부모(public/build) + // 소유권을 상속시킨다 (FilePermissionHelper::copyFile 의 sudo 대응 선례). + $this->normalizeOwnership(); + + // 인라인 GC (현재 + 직전 1개 보존) + $this->cleanup(); + + Log::info('부트스트랩 리소스 정적 게시 완료', [ + 'version' => $version, + 'files' => count($files), + ]); + + return true; + } catch (\Throwable $e) { + Log::warning('부트스트랩 리소스 정적 게시 실패 — API 폴백으로 동작합니다', [ + 'version' => $version, + 'error' => $e->getMessage(), + ]); + File::deleteDirectory($tmp); + + return false; + } + } + + /** + * root 로 실행된 CLI 게시의 산출물 소유권을 부모 디렉토리 기준으로 정상화합니다. + * + * root 가 아닌 프로세스는 chown 자체가 불가능하고 필요도 없다(자기 소유로 생성됨) + * — 그 경우 즉시 no-op. 실패는 chownRecursive 가 경고 로그로 누적한다. + * 게시 루트 전체(`build/ext`)를 대상으로 하므로 방금 게시된 버전 디렉토리와 + * 잔존 구버전이 함께 정상화된다. + */ + private function normalizeOwnership(): void + { + if (! function_exists('chown') || ! function_exists('posix_geteuid') || posix_geteuid() !== 0) { + return; + } + + $parent = dirname($this->baseDir()); + $owner = @fileowner($parent); + $group = @filegroup($parent); + + if ($owner === false || $owner === 0) { + return; + } + + FilePermissionHelper::chownRecursive($this->baseDir(), $owner, $group); + } + + /** + * 활성 템플릿 1개의 게시물(lang/components/routes/assets)을 기록합니다. + * + * @param string $tmp tmp 디렉토리 절대 경로 + * @param Template $template 활성 템플릿 + * @param array $locales 게시 대상 로케일 + * @param array $files 기록된 상대 경로 누적 (참조) + */ + private function publishTemplate(string $tmp, Template $template, array $locales, array &$files): void + { + $identifier = (string) $template->identifier; + + if (! preg_match(self::IDENTIFIER_PATTERN, $identifier)) { + Log::warning('정적 게시 제외 — 식별자 패턴 불일치', ['identifier' => $identifier]); + + return; + } + + $templateDir = $tmp.DIRECTORY_SEPARATOR.'templates'.DIRECTORY_SEPARATOR.$identifier; + + // 1. lang 병합 결과 (코어→템플릿→모듈→플러그인→언어팩 훅) — 현 lang API 와 동일 형상(raw) + foreach ($locales as $locale) { + $result = $this->templateService->getLanguageDataWithModules($identifier, $locale); + + if (! ($result['success'] ?? false) || ! is_array($result['data'] ?? null)) { + continue; + } + + $this->writeJson( + $templateDir.DIRECTORY_SEPARATOR.'lang'.DIRECTORY_SEPARATOR.$locale.'.json', + $result['data'], + $files, + $tmp + ); + } + + // 2. components.json 사본 (raw) + $componentsPath = base_path("templates/{$identifier}/components.json"); + if (is_file($componentsPath)) { + $this->copyFile($componentsPath, $templateDir.DIRECTORY_SEPARATOR.'components.json', $files, $tmp); + } + + // 3. routes 병합 결과 — 프론트 소비 코드 무변경을 위한 성공 봉투 포함. + // 열화 스냅샷(확장 업데이트 진행 중 등)은 게시하지 않는다 — 정적 파일은 + // 스스로 회복되지 않으므로 열화 상태가 다음 bump 까지 박제된다 (getRoutes 와 동일 규율) + $routesResult = $this->templateService->getRoutesDataWithModules($identifier); + + if (($routesResult['success'] ?? false) && ! $this->templateService->lastRouteMergeWasDegraded()) { + $this->writeJson( + $templateDir.DIRECTORY_SEPARATOR.'routes.json', + ['success' => true, 'message' => '', 'data' => $routesResult['data']], + $files, + $tmp + ); + } elseif ($routesResult['success'] ?? false) { + Log::warning('정적 게시에서 routes 제외 — 라우트 병합 열화 상태 (확장 업데이트 진행 중 추정)', [ + 'template' => $identifier, + ]); + } + + // 4. dist 에셋 사본 (확장자 화이트리스트, *.map 제외) + $this->publishDistAssets( + base_path("templates/{$identifier}/dist"), + $templateDir.DIRECTORY_SEPARATOR.'assets', + $files, + $tmp + ); + } + + /** + * 템플릿 dist 디렉토리를 재귀 복사합니다 (허용 확장자만, 소스맵 제외). + * + * @param string $sourceDir 원본 dist 절대 경로 + * @param string $targetDir 게시 대상 절대 경로 + * @param array $files 기록된 상대 경로 누적 (참조) + * @param string $tmp tmp 루트 (상대 경로 계산용) + */ + private function publishDistAssets(string $sourceDir, string $targetDir, array &$files, string $tmp): void + { + $realSource = realpath($sourceDir); + + if ($realSource === false || ! is_dir($realSource)) { + return; + } + + // 소스맵은 배포 금지 정책(`*.map` gitignore)과 동일하게 제외 + $allowed = array_diff(AllowedTemplateFileType::getAllowedExtensions(), ['map']); + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($realSource, \FilesystemIterator::SKIP_DOTS) + ); + + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if (! $file->isFile()) { + continue; + } + + $extension = strtolower($file->getExtension()); + if (! in_array($extension, $allowed, true)) { + continue; + } + + // 컨테인먼트 검증 — 심볼릭 링크 등으로 dist 밖을 가리키는 실경로 차단 + $realFile = $file->getRealPath(); + if ($realFile === false || ! str_starts_with($realFile, $realSource.DIRECTORY_SEPARATOR)) { + continue; + } + + $relative = substr($realFile, strlen($realSource) + 1); + $this->copyFile($realFile, $targetDir.DIRECTORY_SEPARATOR.$relative, $files, $tmp); + } + } + + /** + * 확장 병합 번들 4종(modules/plugins × js/css)을 게시합니다. + * + * @param string $tmp tmp 디렉토리 절대 경로 + * @param int $version 확장 캐시 버전 + * @param array $files 기록된 상대 경로 누적 (참조) + */ + private function publishBundles(string $tmp, int $version, array &$files): void + { + $map = ['module' => 'modules', 'plugin' => 'plugins']; + + foreach ($map as $type => $plural) { + foreach (['js', 'css'] as $kind) { + $path = $this->bundleService->getBundleFilePath($type, $kind, $version); + + if ($path === '' || ! is_file($path)) { + continue; + } + + $this->copyFile( + $path, + $tmp.DIRECTORY_SEPARATOR.'bundles'.DIRECTORY_SEPARATOR."{$plural}.{$kind}", + $files, + $tmp + ); + } + } + } + + /** + * 게시 대상 로케일을 열거합니다 — `/api/locales/active` 와 동일 소스 + * (언어팩이 추가한 로케일 포함). + * + * @return array 로케일 목록 (패턴 검증 통과분) + */ + private function publishableLocales(): array + { + $locales = $this->languagePackService->getActiveLocales(); + + return array_values(array_filter( + is_array($locales) ? $locales : [], + static fn ($locale) => is_string($locale) && preg_match(self::LOCALE_PATTERN, $locale) + )); + } + + /** + * JSON 파일을 API 와 동일 인코딩 옵션으로 기록합니다. + * + * @param string $absolutePath 기록 대상 절대 경로 + * @param mixed $data 직렬화할 데이터 + * @param array $files 기록된 상대 경로 누적 (참조) + * @param string $tmp tmp 루트 (상대 경로 계산용) + */ + private function writeJson(string $absolutePath, mixed $data, array &$files, string $tmp): void + { + File::ensureDirectoryExists(dirname($absolutePath), 0775); + + $json = json_encode($data, ResponseHelper::JSON_ENCODE_OPTIONS); + + if ($json === false) { + throw new StaticCachePublishException("Failed to encode JSON payload: {$absolutePath}"); + } + + if (File::put($absolutePath, $json) === false) { + throw new StaticCachePublishException("Failed to write file: {$absolutePath}"); + } + + $files[] = $this->relativePath($absolutePath, $tmp); + } + + /** + * 파일 1개를 복사합니다. + * + * @param string $source 원본 절대 경로 + * @param string $target 대상 절대 경로 + * @param array $files 기록된 상대 경로 누적 (참조) + * @param string $tmp tmp 루트 (상대 경로 계산용) + */ + private function copyFile(string $source, string $target, array &$files, string $tmp): void + { + File::ensureDirectoryExists(dirname($target), 0775); + + if (! File::copy($source, $target)) { + throw new StaticCachePublishException("Failed to copy file: {$source} -> {$target}"); + } + + $files[] = $this->relativePath($target, $tmp); + } + + /** + * Apache 용 불변 캐시 헤더 + 압축 .htaccess 를 기록합니다. + * + * 정적 서빙은 Laravel 압축 미들웨어(GzipEncodeResponse)를 우회하므로 압축을 + * 여기서 직접 선언한다 — 없으면 종전 API 대비 전송량 회귀다 (실측: lang/ko.json + * 524,915B 비압축). nginx 는 서버 기본(ETag/Last-Modified) 재검증으로 충분하며 + * 권장 gzip/expires 스니펫은 규정 문서(§8)에 안내한다. + * + * @param string $tmp tmp 디렉토리 절대 경로 + * @param array $files 기록된 상대 경로 누적 (참조) + */ + private function writeHtaccess(string $tmp, array &$files): void + { + $content = <<<'HTACCESS' + + Header set Cache-Control "public, max-age=31536000, immutable" + + + AddOutputFilterByType DEFLATE application/json application/javascript text/css image/svg+xml + + + HTACCESS; + + if (File::put($tmp.DIRECTORY_SEPARATOR.'.htaccess', $content) === false) { + throw new StaticCachePublishException('Failed to write .htaccess'); + } + + $files[] = '.htaccess'; + } + + /** + * 게시 완료 마커(manifest.json)를 기록합니다. + * + * @param string $finalDir 최종 버전 디렉토리 절대 경로 + * @param int $version 확장 캐시 버전 + * @param array $files 게시된 상대 경로 목록 + */ + private function writeManifest(string $finalDir, int $version, array $files): void + { + $manifest = [ + 'cache_version' => $version, + 'published_at' => now()->toIso8601String(), + 'files' => array_values($files), + ]; + + $json = json_encode($manifest, ResponseHelper::JSON_ENCODE_OPTIONS); + + if ($json === false || File::put($finalDir.DIRECTORY_SEPARATOR.self::MANIFEST_FILE, $json) === false) { + throw new StaticCachePublishException('Failed to write manifest'); + } + } + + /** + * tmp 루트 기준 상대 경로를 반환합니다. + * + * @param string $absolutePath 절대 경로 + * @param string $tmp tmp 루트 + * @return string 상대 경로 (구분자 `/` 정규화) + */ + private function relativePath(string $absolutePath, string $tmp): string + { + return str_replace('\\', '/', substr($absolutePath, strlen($tmp) + 1)); + } +} diff --git a/app/Support/AssetUrl.php b/app/Support/AssetUrl.php index ce3cb068..71f15af8 100644 --- a/app/Support/AssetUrl.php +++ b/app/Support/AssetUrl.php @@ -2,6 +2,7 @@ namespace App\Support; +use App\Services\ExtensionStaticCacheService; use App\Support\Routing\DualExtensionRoute; /** @@ -59,6 +60,23 @@ class AssetUrl */ private static ?string $modeOverride = null; + /** + * 정적 게시(bake) 베이스 경로 메모 (요청당 1회 판정). + */ + private static ?string $staticExtBaseMemo = null; + + /** + * 정적 게시 베이스 판정 완료 여부. + */ + private static bool $staticExtBaseResolved = false; + + /** + * 태그 계층 파일 단위 게이트 메모 (상대 경로 => 존재 여부). + * + * @var array + */ + private static array $staticFileMemo = []; + /** * 현재 자산 URL 모드를 반환합니다. * @@ -103,6 +121,105 @@ class AssetUrl self::$modeOverride = $mode; } + /** + * 정적 게시(bake) 베이스 경로를 반환합니다 (#122). + * + * 게이트 3조건 — ① 프로덕션 ② `core.static_cache.enabled` ③ 현재 버전 게시 + * 완료(manifest 존재) — 을 전부 통과할 때만 `/build/ext/{v}` 를 반환한다. + * 아니면 null (종전 API URL 방출). 요청당 1회 판정 후 메모이즈한다. + * + * 자가 치유 — 프로덕션인데 현재 버전이 미게시면 terminating 게시를 예약한다. + * 이번 응답은 종전 API URL 로 나가고(첫 방문자 1회만 종전 속도), 다음 + * 렌더부터 정적 fast path 가 적용된다. + * + * blade 렌더 경로에서 호출되므로 어떤 실패도 예외로 새 나가면 안 된다. + * + * @return string|null 정적 베이스 경로 또는 null + */ + public static function staticExtBase(): ?string + { + if (self::$staticExtBaseResolved) { + return self::$staticExtBaseMemo; + } + + self::$staticExtBaseResolved = true; + self::$staticExtBaseMemo = null; + + try { + if (! app()->environment('production')) { + return null; + } + + if (! (bool) config('core.static_cache.enabled', true)) { + return null; + } + + // 트레이트 정적 메서드 직접 호출(ClearsTemplateCaches::)은 PHP 8.1+ E_DEPRECATED + // — 트레이트를 사용하는 클래스 경유로 호출한다 (게이트·게시자가 같은 static + // 스토어 메모 슬롯을 공유하게 되는 부수 이점도 있다) + $version = ExtensionStaticCacheService::getExtensionCacheVersion(); + + if (! app(ExtensionStaticCacheService::class)->isPublished($version)) { + // 자가 치유 — 응답 종료 후 게시 시도 (실패해도 API 폴백으로 정상) + ExtensionStaticCacheService::schedulePublishOnTerminate(); + + return null; + } + + self::$staticExtBaseMemo = '/build/ext/'.$version; + } catch (\Throwable) { + return null; + } + + return self::$staticExtBaseMemo; + } + + /** + * 정적 게시 베이스 메모를 초기화합니다 (테스트 전용). + */ + public static function resetStaticExtBaseMemo(): void + { + self::$staticExtBaseResolved = false; + self::$staticExtBaseMemo = null; + self::$staticFileMemo = []; + } + + /** + * 정적 게시본 내 파일 존재 여부를 확인합니다 (태그 계층 파일 단위 게이트). + * + * ``/`', open); + expect(open, 'blade 파샬에서 스크립트 태그를 찾지 못했다').toBeGreaterThan(-1); + expect(close, 'blade 파샬에서 스크립트 종료 태그를 찾지 못했다').toBeGreaterThan(open); + + // blade echo(`{{ ... }}`)는 JS 가 아니므로 리터럴로 치환한다 + const js = source.slice(open + '