공개 제보 https://github.com/gnuboard/g7/issues/122 대응 — 초기 부트스트랩 리소스(다국어 병합·컴포넌트 정의·라우트·확장 번들·템플릿 dist)를 캐시 버전 디렉토리(public/build/ext/{v}/)에 실파일로 게시해 웹서버가 rewrite 전에 직접 서빙한다. 부트 임계 경로의 PHP 왕복을 제거하고(실측 TTFB 131~144ms → 1~7ms), 미게시·부분게시·GC 직후에는 fetch·태그·번들 3계층이 종전 API 로 즉시 폴백한다. - 게시: 원자적 tmp→rename→manifest(존재=완료), 캐시 락 단일 실행, 인라인 GC (현재+직전 1개), incrementExtensionCacheVersion 단일 지점 terminating 트리거 + blade 자가 치유 + 설치기 태스크(best_effort) + 일일 cleanup 스케줄, sudo 업데이트 대비 소유권 정상화(normalizeOwnership)·prune/백업 제외 - 프론트(engine-v1.61.0): blade 주입 cache_version 1급 시드(이중 부트 로드 제거), fetchStaticFirst 즉시 폴백, ComponentRegistry 버전 키드 매니페스트, ModuleAssetLoader 번들 정적→레거시 폴백, asset-url-recovery staticToLegacy 역변환 - 폴백 API 품질: lang/components/routes ETag+304 + 환경 분기 Cache-Control, 열화 라우트 스냅샷 공개 캐시 금지(서버측 캐시 회피와 대칭), 게시 .htaccess mod_deflate + nginx gzip 스니펫(압축 전송량 회귀 방지) - SEO 정합: 봇 HTML 은 GC 대상 정적 URL 미사용(allowStatic:false), props $switch 봇측 해석 구현(engine-v1.56.0 패리티), 패리티 룰 expression-dialect 그룹 신설, 상주 allow 헤더 제거로 잠금 복원, _comment* 접두 주석 키 분류 - 검증: 전 수정 red→green 4단계, Playwright 라이브 21건, Chrome MCP 24축+M1~M3, 봇 curl 3축, 캐시 저장소(file/redis/database) 축 판정, 히스토리·공개이슈·커밋 이력 전수 재조사 반영 - 코어 7.0.10, engine-v1.61.0. kill-switch: G7_STATIC_CACHE=false
604 lines
22 KiB
PHP
604 lines
22 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Contracts\Repositories\TemplateRepositoryInterface;
|
||
use App\Exceptions\StaticCachePublishException;
|
||
use App\Extension\Helpers\FilePermissionHelper;
|
||
use App\Extension\Traits\ClearsTemplateCaches;
|
||
use App\Helpers\ResponseHelper;
|
||
use App\Models\Template;
|
||
use App\Rules\AllowedTemplateFileType;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\File;
|
||
use Illuminate\Support\Facades\Log;
|
||
|
||
/**
|
||
* 부트스트랩 리소스 정적 게시(bake) 서비스.
|
||
*
|
||
* 병합 결과물(다국어·컴포넌트 정의·라우트·확장 번들·템플릿 dist 에셋)을
|
||
* 캐시 버전 디렉토리(`public/build/ext/{v}/`)에 실파일로 게시해 웹서버가
|
||
* rewrite 전에 직접 서빙하게 한다 (#122). 병합 로직은 새로 만들지 않고
|
||
* 전부 기존 SSoT(TemplateService/ExtensionBundleService)를 호출한다.
|
||
*
|
||
* 원자성: `{v}.tmp/` 에 전부 쓴 뒤 디렉토리 rename → `{v}/`, manifest.json 은
|
||
* rename 후 마지막에 기록한다. manifest 존재 = 게시 완료(부분 게시 참조 방지).
|
||
*
|
||
* 실패 정책: 쓰기 실패는 예외를 삼키고 Log::warning + tmp 정리 — 사이트는
|
||
* API 폴백으로 정상 유지된다(fail-open 이 아니라 "정적 fast path 미적용" 상태).
|
||
*/
|
||
class ExtensionStaticCacheService
|
||
{
|
||
use ClearsTemplateCaches;
|
||
|
||
/** 게시 완료 마커 파일명 */
|
||
private const MANIFEST_FILE = 'manifest.json';
|
||
|
||
/** 게시 락 이름 접두사 */
|
||
private const LOCK_PREFIX = 'ext-static.publish.';
|
||
|
||
/** 확장 식별자 패턴 (vendor-name) — 경로 세그먼트 화이트리스트 */
|
||
private const IDENTIFIER_PATTERN = '/^[a-z0-9]+-[a-z0-9_]+$/';
|
||
|
||
/** 로케일 패턴 — 경로 세그먼트 화이트리스트 */
|
||
private const LOCALE_PATTERN = '/^[a-z]{2}(?:[-_][A-Za-z0-9]{2,8})?$/';
|
||
|
||
/** terminating 게시 예약 플래그 (프로세스당 1회) */
|
||
private static bool $publishScheduled = false;
|
||
|
||
/** isPublished 요청당 메모이즈 (version => 존재 여부) */
|
||
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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 게시 루트 디렉토리 절대 경로를 반환합니다.
|
||
*
|
||
* @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<string> $locales 게시 대상 로케일
|
||
* @param array<string> $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<string> $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<string> $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<string> 로케일 목록 (패턴 검증 통과분)
|
||
*/
|
||
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<string> $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<string> $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<string> $files 기록된 상대 경로 누적 (참조)
|
||
*/
|
||
private function writeHtaccess(string $tmp, array &$files): void
|
||
{
|
||
$content = <<<'HTACCESS'
|
||
<IfModule mod_headers.c>
|
||
Header set Cache-Control "public, max-age=31536000, immutable"
|
||
</IfModule>
|
||
<IfModule mod_deflate.c>
|
||
AddOutputFilterByType DEFLATE application/json application/javascript text/css image/svg+xml
|
||
</IfModule>
|
||
|
||
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<string> $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));
|
||
}
|
||
}
|