$filters 필터 조건 (scope/locale/status/search/vendor/page) * @param int $perPage 페이지당 건수 * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 병합 페이지네이션 결과 */ public function list(array $filters = [], int $perPage = 20): \Illuminate\Contracts\Pagination\LengthAwarePaginator { $statusFilter = $filters['status'] ?? null; $dbCollection = $this->repository->getFilteredCollection($filters); $occupiedSlots = $this->collectSlotKeys($dbCollection); // 가상 보호 행 (코어/번들 확장의 lang/{ko,en}/) — DB 슬롯에 없는 것만 $builtInVirtual = $this->getVirtualBuiltInPacks($filters) ->reject(fn (LanguagePack $pack) => isset($occupiedSlots[$this->slotKey($pack)])) ->each(fn (LanguagePack $pack) => $occupiedSlots[$this->slotKey($pack)] = true); // 미설치 가상 행 (lang-packs/_bundled/) — DB 슬롯 + built-in 슬롯에 없는 것만 $uninstalledVirtual = $this->shouldIncludeUninstalledBundled($statusFilter) ? $this->getUninstalledBundledPacks($filters) ->reject(fn (LanguagePack $pack) => isset($occupiedSlots[$this->slotKey($pack)])) : collect(); // DB + built-in + uninstalled 가상 행을 동일한 안정 키로 정렬해야 // 설치/활성화로 행 종류가 바뀌어도 페이지 위치가 흔들리지 않는다. $merged = $dbCollection ->concat($builtInVirtual) ->concat($uninstalledVirtual) ->sortBy([ ['scope', 'asc'], ['target_identifier', 'asc'], ['locale', 'asc'], ]) ->values(); $page = max(1, (int) ($filters['page'] ?? 1)); $items = $this->annotateFilesMissing($merged->forPage($page, $perPage)->values()); return new LengthAwarePaginator( $items, $merged->count(), $perPage, $page, ['path' => Paginator::resolveCurrentPath()] ); } /** * 한 확장(또는 코어) 에 귀속된 모든 로케일 언어팩을 머지하여 반환합니다. * * 모듈/플러그인/템플릿 detail API 의 `language_packs` 필드 데이터 소스로 사용되며, * `list()` 와 동일한 머지 전략(DB > built_in 가상 > 미설치 번들 가상) 을 단일 * 슬롯 그룹(scope + target_identifier) 으로 한정해 적용합니다. * * @param LanguagePackScope $scope 대상 스코프 (module/plugin/template/core) * @param string|null $targetIdentifier 대상 확장 식별자 (코어는 null) * @return Collection 로케일별 정렬 된 머지 컬렉션 */ public function getPacksForExtension(LanguagePackScope $scope, ?string $targetIdentifier): Collection { $filters = [ 'scope' => $scope->value, 'target_identifier' => $targetIdentifier, ]; $dbCollection = $this->repository->getFilteredCollection($filters); $occupiedSlots = $this->collectSlotKeys($dbCollection); $builtInVirtual = $this->getVirtualBuiltInPacks($filters) ->reject(fn (LanguagePack $pack) => isset($occupiedSlots[$this->slotKey($pack)])) ->each(fn (LanguagePack $pack) => $occupiedSlots[$this->slotKey($pack)] = true); $uninstalledVirtual = $this->getUninstalledBundledPacks($filters) ->reject(fn (LanguagePack $pack) => isset($occupiedSlots[$this->slotKey($pack)])); return $this->annotateFilesMissing( $dbCollection ->concat($builtInVirtual) ->concat($uninstalledVirtual) ->sortBy('locale') ->values() ); } /** * 컬렉션에서 슬롯 키 (scope|target|locale) 맵을 생성합니다. * * @param Collection $collection 컬렉션 * @return array */ private function collectSlotKeys(Collection $collection): array { return $collection ->mapWithKeys(fn (LanguagePack $pack) => [$this->slotKey($pack) => true]) ->all(); } /** * 슬롯 키를 생성합니다. * * @param LanguagePack $pack 팩 * @return string 슬롯 키 (scope|target_identifier|locale) */ private function slotKey(LanguagePack $pack): string { return $pack->scope.'|'.($pack->target_identifier ?? '').'|'.$pack->locale; } /** * 각 팩에 드리프트 파생 플래그 `files_missing` 와 복구 가능 여부 `bundled_source_available` 를 부여합니다. * * `files_missing` 은 DB 에 active 로 기록됐으나 설치본 디렉토리가 실재하지 않는 행을 표면화하기 위한 것으로, * 이 상태의 팩은 런타임에 오류 없이 base locale 로 조용히 폴백한다(이슈 #496 의 드리프트 증상). * * `bundled_source_available` 은 그 팩을 `lang-packs/_bundled/` 소스로 재설치해 복구할 수 있는지를 나타낸다. * 설치 경로(source_type)가 아니라 **번들 소스의 실재 여부**로 판정하므로, github/url/zip 으로 설치된 팩이라도 * 동일 식별자의 번들 소스가 있으면 복구 대상이 된다 — 판정을 source_type 에 걸면 그 팩들은 드리프트가 * 보이기만 하고 복구 수단이 없는 상태로 남는다. * * @param Collection $packs 대상 컬렉션 * @return Collection 동일 컬렉션(플래그 부여됨) */ private function annotateFilesMissing(Collection $packs): Collection { foreach ($packs as $pack) { $pack->setAttribute('files_missing', $this->isInstalledButFilesMissing($pack)); $pack->setAttribute('bundled_source_available', $this->hasBundledSource($pack)); } return $packs; } /** * 팩과 동일한 식별자의 번들 소스(`lang-packs/_bundled/{identifier}`)가 실재하는지 확인합니다. * * @param LanguagePack $pack 판정 대상 팩 * @return bool 번들 소스가 있으면 true */ private function hasBundledSource(LanguagePack $pack): bool { $identifier = (string) ($pack->getAttribute('bundled_identifier') ?? $pack->identifier); if ($identifier === '') { return false; } return File::isDirectory(base_path('lang-packs/_bundled/'.$identifier)); } /** * 설치본 파일이 부재(드리프트)해 번들 소스로 복구 가능한 설치 행을 반환합니다. * * `getUninstalledBundledPacks()` 는 슬롯이 점유된 팩을 후보에서 제외하므로 드리프트 행을 잡지 못합니다. * 프로비저닝이 "수동 복구" 경로까지 겸하려면 이 집합이 함께 대상이 되어야 합니다. * * @param array $filters 필터 (scope/locale/target_identifier/search/vendor) * @return Collection 드리프트 설치 행 컬렉션 */ public function getDriftedInstalledPacks(array $filters = []): Collection { return $this->annotateFilesMissing( $this->repository ->getFilteredCollection($filters) ->filter(fn (LanguagePack $pack) => $this->isInstalledButFilesMissing($pack)) ->filter(fn (LanguagePack $pack) => $this->hasBundledSource($pack)) ->values() ); } /** * 팩이 "설치(active) 로 기록됐으나 설치본 파일이 부재" 한 드리프트 상태인지 판정합니다. * * 가상 행(built_in / uninstalled)은 실제 설치본이 아니므로 대상에서 제외합니다. * * @param LanguagePack $pack 판정 대상 팩 * @return bool 드리프트면 true */ private function isInstalledButFilesMissing(LanguagePack $pack): bool { // 가상 행(exists=false: built_in/uninstalled)은 설치본 개념이 없으므로 제외 if (! $pack->exists) { return false; } if ($pack->status !== LanguagePackStatus::Active->value) { return false; } try { return ! File::isDirectory($pack->resolveDirectory()); } catch (Throwable $e) { return false; } } /** * 코어/번들 확장의 `lang/{ko,en}/` 디렉토리를 자동 스캔하여 가상 보호 LanguagePack * 인스턴스 컬렉션을 반환합니다 (요구사항 #1, #2). * * 스캔 대상 — **런타임이 실제로 로드하는 경로**와 같아야 한다. 어긋나면 관리자 화면이 * "내장 번역" 으로 보여주는 것과 화면에 나오는 문구의 출처가 달라진다. * - 코어: lang/{ko,en}/ * - 번들 모듈: modules/_bundled/{id}/src/lang/{ko,en}/ (TranslationServiceProvider 기준) * - 번들 플러그인: plugins/_bundled/{id}/lang/{ko,en}/ * - 번들 템플릿: templates/_bundled/{id}/lang/{ko,en}.json * * 발견된 각 슬롯에 대해 Repository::buildVirtualBuiltInPack() 으로 가상 행 합성. * 모든 가상 행은 status=active, is_protected=true, source_type=built_in. * * @param array $filters 필터 (scope/locale/target_identifier/search/vendor) * @return Collection 가상 보호 LanguagePack 컬렉션 */ public function getVirtualBuiltInPacks(array $filters = []): Collection { $packs = collect(); foreach (LanguagePackBaseLocales::all() as $locale) { // 코어 if (File::isDirectory(base_path('lang/'.$locale))) { $pack = $this->repository->buildVirtualBuiltInPack( scope: LanguagePackScope::Core->value, targetIdentifier: null, locale: $locale, vendor: 'g7', version: (string) config('app.version', '0.0.0'), langPathRelative: 'lang/'.$locale, ); if ($this->matchesBuiltInFilters($pack, $filters)) { $packs->push($pack); } } // 번들 모듈 (런타임 등록 경로 = src/lang) $packs = $packs->concat($this->scanBundledExtensionLang( bundledRoot: 'modules/_bundled', scope: LanguagePackScope::Module->value, langSubpath: 'src/lang/'.$locale, locale: $locale, filters: $filters, )); // 번들 플러그인 $packs = $packs->concat($this->scanBundledExtensionLang( bundledRoot: 'plugins/_bundled', scope: LanguagePackScope::Plugin->value, langSubpath: 'lang/'.$locale, locale: $locale, filters: $filters, )); // 번들 템플릿 (lang/{locale}.json 또는 lang/partial/{locale}/) $packs = $packs->concat($this->scanBundledTemplateLang($locale, $filters)); } return $packs->values(); } /** * 번들 확장 디렉토리(modules/_bundled, plugins/_bundled) 의 lang/{locale}/ 자원을 스캔합니다. * * @param string $bundledRoot base_path 기준 상대 경로 (예: 'modules/_bundled') * @param string $scope 스코프 enum 값 * @param string $langSubpath 확장 디렉토리 하위의 lang 경로 (예: 'resources/lang/ko') * @param string $locale 로케일 * @param array $filters 필터 * @return Collection */ private function scanBundledExtensionLang( string $bundledRoot, string $scope, string $langSubpath, string $locale, array $filters, ): Collection { $absRoot = base_path($bundledRoot); if (! File::isDirectory($absRoot)) { return collect(); } $packs = collect(); foreach (File::directories($absRoot) as $extDir) { $extId = basename($extDir); $langDir = $extDir.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $langSubpath); if (! File::isDirectory($langDir)) { continue; } $manifestPath = $extDir.DIRECTORY_SEPARATOR.$this->guessManifestFilename($scope); $manifest = is_file($manifestPath) ? json_decode((string) @file_get_contents($manifestPath), true) : null; $vendor = is_array($manifest) ? (string) ($manifest['vendor'] ?? explode('-', $extId)[0] ?? 'g7') : 'g7'; $version = is_array($manifest) ? (string) ($manifest['version'] ?? '0.0.0') : '0.0.0'; $pack = $this->repository->buildVirtualBuiltInPack( scope: $scope, targetIdentifier: $extId, locale: $locale, vendor: $vendor, version: $version, langPathRelative: $bundledRoot.'/'.$extId.'/'.$langSubpath, ); if ($this->matchesBuiltInFilters($pack, $filters)) { $packs->push($pack); } } return $packs; } /** * 번들 템플릿의 lang/{locale}.json 또는 lang/partial/{locale}/ 자원을 스캔합니다. * * @param string $locale 로케일 * @param array $filters 필터 * @return Collection */ private function scanBundledTemplateLang(string $locale, array $filters): Collection { $absRoot = base_path('templates/_bundled'); if (! File::isDirectory($absRoot)) { return collect(); } $packs = collect(); foreach (File::directories($absRoot) as $tplDir) { $tplId = basename($tplDir); $hasJson = is_file($tplDir.DIRECTORY_SEPARATOR.'lang'.DIRECTORY_SEPARATOR.$locale.'.json'); $hasPartial = File::isDirectory($tplDir.DIRECTORY_SEPARATOR.'lang'.DIRECTORY_SEPARATOR.'partial'.DIRECTORY_SEPARATOR.$locale); if (! $hasJson && ! $hasPartial) { continue; } $manifestPath = $tplDir.DIRECTORY_SEPARATOR.'template.json'; $manifest = is_file($manifestPath) ? json_decode((string) @file_get_contents($manifestPath), true) : null; $vendor = is_array($manifest) ? (string) ($manifest['vendor'] ?? explode('-', $tplId)[0] ?? 'g7') : 'g7'; $version = is_array($manifest) ? (string) ($manifest['version'] ?? '0.0.0') : '0.0.0'; $pack = $this->repository->buildVirtualBuiltInPack( scope: LanguagePackScope::Template->value, targetIdentifier: $tplId, locale: $locale, vendor: $vendor, version: $version, langPathRelative: 'templates/_bundled/'.$tplId.'/lang', ); if ($this->matchesBuiltInFilters($pack, $filters)) { $packs->push($pack); } } return $packs; } /** * 스코프별 manifest 파일명을 추측합니다. * * @param string $scope 스코프 * @return string manifest 파일명 */ private function guessManifestFilename(string $scope): string { return match ($scope) { 'module' => 'module.json', 'plugin' => 'plugin.json', 'template' => 'template.json', default => 'manifest.json', }; } /** * 가상 보호 행이 list 필터에 맞는지 검사합니다. * * @param LanguagePack $pack 가상 행 * @param array $filters 필터 */ private function matchesBuiltInFilters(LanguagePack $pack, array $filters): bool { if (! empty($filters['scope']) && $pack->scope !== $filters['scope']) { return false; } if (! empty($filters['locale']) && $pack->locale !== $filters['locale']) { return false; } if (array_key_exists('target_identifier', $filters) && $filters['target_identifier'] !== null) { // 개별 확장 언어팩 관리 화면 — 그 확장에 해당되는 행만 노출 (정정 사항). // 코어 보호 팩은 환경설정 > 언어팩 관리(필터 없음) 화면에서만 노출됨. if ($pack->target_identifier !== $filters['target_identifier']) { return false; } } if (! empty($filters['vendor']) && $pack->vendor !== $filters['vendor']) { return false; } if (! empty($filters['exclude_protected']) && $pack->is_protected) { return false; } if (! empty($filters['status']) && $filters['status'] !== LanguagePackStatus::Active->value) { return false; } if (! empty($filters['search'])) { $needle = mb_strtolower((string) $filters['search']); $haystack = mb_strtolower($pack->identifier.' '.$pack->vendor.' '.$pack->locale.' '.$pack->locale_native_name); if (! str_contains($haystack, $needle)) { return false; } } return true; } /** * `lang-packs/_bundled/{identifier}` 디렉토리를 스캔하여, DB 에 동일 슬롯 * (scope/target_identifier/locale) 레코드가 없는 미설치 번들을 가상 LanguagePack * 인스턴스로 반환합니다. 모듈/플러그인의 `getUninstalledModules()` 와 동일 패턴. * * 가상 인스턴스의 특성: * - `id` 는 null (DB 미존재 → exists=false) * - `status` = `LanguagePackStatus::Uninstalled` 가상값 (DB 컬럼에는 없음) * - `source_type` = `bundled` (`installFromBundled` 가 받는 식별자와 동일) * - 추가 가상 속성 `bundled_identifier` 로 행 액션이 사용할 식별자 노출 * * 필터(filters)는 Repository 와 동일 의미로 처리하여 일관성을 유지합니다. * * @param array $filters 필터 (scope/locale/target_identifier/search/vendor) * @return Collection 미설치 번들 가상 LanguagePack 컬렉션 */ public function getUninstalledBundledPacks(array $filters = []): Collection { $bundledRoot = base_path('lang-packs/_bundled'); if (! File::isDirectory($bundledRoot)) { return collect(); } $installedSlots = $this->repository ->getFilteredCollection([]) ->mapWithKeys(fn (LanguagePack $pack) => [ $pack->scope.'|'.($pack->target_identifier ?? '').'|'.$pack->locale => true, ]) ->all(); $packs = collect(); foreach (File::directories($bundledRoot) as $dir) { $manifestPath = $dir.DIRECTORY_SEPARATOR.'language-pack.json'; if (! is_file($manifestPath)) { continue; } $raw = @file_get_contents($manifestPath); $manifest = $raw ? json_decode($raw, true) : null; if (! is_array($manifest) || empty($manifest['identifier'])) { continue; } $scope = $manifest['scope'] ?? null; $locale = $manifest['locale'] ?? null; if (! $scope || ! $locale) { continue; } $targetIdentifier = $manifest['target_identifier'] ?? null; $slotKey = $scope.'|'.($targetIdentifier ?? '').'|'.$locale; if (isset($installedSlots[$slotKey])) { continue; } if (! $this->matchesBundledFilters($manifest, $filters)) { continue; } $virtual = $this->repository->buildVirtualFromManifest($manifest, basename($dir)); $virtual->setAttribute('install_blocked_reason', $this->resolveInstallBlockedReason($manifest)); $packs->push($virtual); } return $packs->values(); } /** * `status` 필터가 미설치 번들 포함을 허용하는지 판단합니다. * * @param string|null $statusFilter status 필터 값 (null/공백/uninstalled 만 허용) * @return bool 포함 시 true */ private function shouldIncludeUninstalledBundled(?string $statusFilter): bool { if ($statusFilter === null || $statusFilter === '') { return true; } return $statusFilter === LanguagePackStatus::Uninstalled->value; } /** * 번들 manifest 가 필터 조건을 충족하는지 검사합니다 (Repository 와 동일 의미). * * @param array $manifest 번들 manifest * @param array $filters 필터 조건 * @return bool 통과 시 true */ private function matchesBundledFilters(array $manifest, array $filters): bool { if (! empty($filters['scope']) && ($manifest['scope'] ?? null) !== $filters['scope']) { return false; } if (! empty($filters['locale']) && ($manifest['locale'] ?? null) !== $filters['locale']) { return false; } if (array_key_exists('target_identifier', $filters) && $filters['target_identifier'] !== null) { if (($manifest['target_identifier'] ?? null) !== $filters['target_identifier']) { return false; } } if (! empty($filters['vendor']) && ($manifest['vendor'] ?? null) !== $filters['vendor']) { return false; } if (! empty($filters['search'])) { $needle = mb_strtolower((string) $filters['search']); $haystack = mb_strtolower(implode(' ', array_filter([ (string) ($manifest['identifier'] ?? ''), (string) ($manifest['vendor'] ?? ''), (string) ($manifest['locale_name'] ?? ''), (string) ($manifest['locale_native_name'] ?? ''), ]))); if (! str_contains($haystack, $needle)) { return false; } } // exclude_protected 는 DB 행 단계에서 LanguagePack::is_protected 컬럼으로 처리. // 번들 manifest 는 보호 행을 표현하지 않으므로 (Repository::buildVirtualFromManifest 가 컨텍스트로 결정) // 여기서는 항상 통과시킨다. return true; } /** * ID 로 언어팩을 조회합니다. * * @param int $id 언어팩 ID * @return LanguagePack|null 언어팩 또는 null */ public function find(int $id): ?LanguagePack { return $this->repository->findById($id); } /** * 식별자로 언어팩을 조회합니다. * * Artisan 커맨드(language-pack:update / language-pack:uninstall) 의 단일 진입점으로 사용됩니다. * * @param string $identifier 언어팩 식별자 * @return LanguagePack|null 언어팩 또는 null */ public function findByIdentifier(string $identifier): ?LanguagePack { return $this->repository->findByIdentifier($identifier); } /** * 언어 셀렉터에 즉시 노출 가능한 활성 로케일 목록을 반환합니다. * * config('app.supported_locales') 의 허용 집합과 활성 코어 * 언어팩의 합집합을 반환하여 코어 기본 로케일이 누락되지 않도록 * 합니다. 언어팩 설치/활성화 직후 사용자 UI 갱신용으로 사용됩니다. * * @return array 로케일 문자열 배열 */ public function getActiveLocales(): array { $configLocales = (array) config('app.supported_locales', ['ko', 'en']); $activeLocales = $this->repository->getActiveCoreLocales(); return array_values(array_unique(array_merge($configLocales, $activeLocales))); } /** * 정수 ID 또는 번들 식별자(문자열) 로 언어팩을 조회합니다. * * 정수면 `find()` 로 DB 레코드를, 식별자면 `lang-packs/_bundled/{id}` manifest 로부터 * `LanguagePackRepository::buildVirtualFromManifest()` 가상 모델을 반환합니다 (모듈 관리의 미설치 가상 행과 동일 패턴). * 입력 형태별 dispatch 만 담당하며 검증/모델 합성은 모두 위임합니다. * * @param int|string $id ID(정수) 또는 번들 식별자 * @return LanguagePack|null 언어팩 또는 null */ public function findOrBundled(int|string $id): ?LanguagePack { if (is_numeric($id)) { return $this->find((int) $id); } // 1) lang-packs/_bundled/{id}/ 의 ja 등 별도 패키지 가상 행 $manifestPath = base_path('lang-packs/_bundled/'.$id.'/language-pack.json'); if (File::isFile($manifestPath)) { $manifest = json_decode(File::get($manifestPath), true); if (is_array($manifest) && ($manifest['identifier'] ?? null) === $id) { return $this->repository->buildVirtualFromManifest($manifest, (string) $id); } } // 2) 코어/번들 확장의 lang/{ko,en}/ 가상 보호 행 foreach ($this->getVirtualBuiltInPacks() as $pack) { if ($pack->identifier === $id) { return $pack; } } return null; } /** * 업로드된 ZIP 파일에서 언어팩을 설치합니다. * * @param UploadedFile $file 업로드된 ZIP 파일 * @param bool $autoActivate 설치 후 자동 활성화 여부 * @param int|null $installedBy 설치자 사용자 ID * @return LanguagePack 설치된 언어팩 * * @throws RuntimeException 설치 실패 시 */ public function installFromFile(UploadedFile $file, bool $autoActivate = false, ?int $installedBy = null): LanguagePack { $this->assertInstallDirectoriesWritable(); $tempId = (string) Str::uuid(); $extractPath = base_path('lang-packs/_pending/'.$tempId); try { $zipPath = $file->getRealPath(); ZipInstallHelper::extractZip($zipPath, $extractPath); return $this->finalizeInstall($extractPath, 'zip', $file->getClientOriginalName(), $autoActivate, $installedBy); } catch (Throwable $e) { $this->cleanupPending($extractPath); throw $e; } } /** * GitHub URL 에서 언어팩을 설치합니다. * * @param string $githubUrl GitHub repository URL * @param bool $autoActivate 설치 후 자동 활성화 여부 * @param int|null $installedBy 설치자 사용자 ID * @param bool $force 다운그레이드 차단 우회 여부 (true 면 신버전 < 기존버전 도 허용) * @return LanguagePack 설치된 언어팩 * * @throws RuntimeException 설치 실패 시 */ public function installFromGithub(string $githubUrl, bool $autoActivate = false, ?int $installedBy = null, bool $force = false): LanguagePack { $this->assertInstallDirectoriesWritable(); $tempId = (string) Str::uuid(); $tempPath = storage_path('app/temp/'.$tempId); $extractPath = base_path('lang-packs/_pending/'.$tempId); try { File::ensureDirectoryExists($tempPath); ['owner' => $owner, 'repo' => $repo] = GithubHelper::parseUrl($githubUrl); $zipPath = GithubHelper::downloadZip($owner, $repo, $tempPath); ZipInstallHelper::extractZip($zipPath, $extractPath); return $this->finalizeInstall($extractPath, 'github', $githubUrl, $autoActivate, $installedBy, $force); } catch (Throwable $e) { $this->cleanupPending($extractPath); throw $e; } finally { // 다운로드 임시 디렉토리는 성공/실패와 무관하게 소비가 끝난 시점에 정리한다. // catch 에만 두면 설치가 성공할 때마다 ZIP 사본이 storage/app/temp 에 잔존한다. File::deleteDirectory($tempPath); } } /** * 임의 URL 에서 언어팩을 설치합니다. * * @param string $url ZIP 다운로드 URL * @param string|null $checksum SHA-256 체크섬 (옵션) * @param bool $autoActivate 설치 후 자동 활성화 여부 * @param int|null $installedBy 설치자 사용자 ID * @param bool $force 다운그레이드 차단 우회 여부 (true 면 신버전 < 기존버전 도 허용) * @return LanguagePack 설치된 언어팩 * * @throws RuntimeException 설치 실패 시 */ public function installFromUrl(string $url, ?string $checksum, bool $autoActivate = false, ?int $installedBy = null, bool $force = false): LanguagePack { // 서버가 이 URL 을 대신 내려받으므로, 내부 네트워크 주소가 목적지가 되지 않도록 차단한다. if (! OutboundUrlValidator::isPublicHttpUrl($url)) { throw new LanguagePackOperationException('language_packs.errors.download_url_not_public'); } $this->assertInstallDirectoriesWritable(); $tempId = (string) Str::uuid(); $tempPath = storage_path('app/temp/'.$tempId); $extractPath = base_path('lang-packs/_pending/'.$tempId); try { File::ensureDirectoryExists($tempPath); $zipPath = $tempPath.DIRECTORY_SEPARATOR.'pack.zip'; $response = Http::timeout(120)->get($url); if (! $response->successful()) { throw new LanguagePackOperationException('language_packs.errors.download_failed', ['url' => $url]); } File::put($zipPath, $response->body()); if ($checksum) { $actual = hash_file('sha256', $zipPath); if (! hash_equals($checksum, $actual)) { throw new LanguagePackOperationException('language_packs.errors.checksum_mismatch'); } } ZipInstallHelper::extractZip($zipPath, $extractPath); return $this->finalizeInstall($extractPath, 'url', $url, $autoActivate, $installedBy, $force); } catch (Throwable $e) { $this->cleanupPending($extractPath); throw $e; } finally { // 다운로드 임시 디렉토리는 성공/실패와 무관하게 소비가 끝난 시점에 정리한다. // catch 에만 두면 설치가 성공할 때마다 ZIP 사본이 storage/app/temp 에 잔존한다. File::deleteDirectory($tempPath); } } /** * `lang-packs/_bundled/{identifier}` 의 번들 소스에서 언어팩을 설치(또는 재설치)합니다. * * 모듈/플러그인/템플릿의 `_bundled` 설치 패턴과 동일하게, 코어/공식 언어팩을 * 외부 다운로드 없이 로컬 번들 디렉토리에서 가져와 활성 디렉토리로 복사합니다. * 시더만으로는 손상된 활성 디렉토리를 복구할 수 없는 운영상의 갭을 메웁니다. * * @param string $identifier 번들 디렉토리 식별자 (lang-packs/_bundled/{identifier}) * @param bool $autoActivate 설치 후 자동 활성화 여부 * @param int|null $installedBy 설치자 사용자 ID * @param bool $force 다운그레이드 차단 우회 여부 (true 면 신버전 < 기존버전 도 허용) * @return LanguagePack 설치/갱신된 언어팩 * * @throws RuntimeException 번들 디렉토리/manifest 부재 또는 검증 실패 시 */ public function installFromBundled(string $identifier, bool $autoActivate = false, ?int $installedBy = null, bool $force = false): LanguagePack { $this->assertInstallDirectoriesWritable(); $bundledPath = base_path('lang-packs/_bundled/'.$identifier); if (! File::isDirectory($bundledPath)) { throw new LanguagePackOperationException('language_packs.errors.bundled_not_found', ['identifier' => $identifier]); } $tempId = (string) Str::uuid(); $extractPath = base_path('lang-packs/_pending/'.$tempId); try { File::ensureDirectoryExists(dirname($extractPath)); File::copyDirectory($bundledPath, $extractPath); return $this->finalizeInstall($extractPath, 'bundled', $identifier, $autoActivate, $installedBy, $force); } catch (Throwable $e) { $this->cleanupPending($extractPath); throw $e; } } /** * _pending 디렉토리에서 manifest 검증, 디렉토리 이동, DB 등록까지 수행합니다. * * @param string $extractPath 추출된 디렉토리 경로 * @param string $sourceType 설치 소스 유형 (zip/github/url) * @param string $sourceUrl 설치 소스 URL/파일명 * @param bool $autoActivate 설치 후 자동 활성화 여부 * @param int|null $installedBy 설치자 사용자 ID * @return LanguagePack 설치/갱신된 언어팩 * * @throws RuntimeException 검증/의존성/충돌 실패 시 */ private function finalizeInstall( string $extractPath, string $sourceType, string $sourceUrl, bool $autoActivate, ?int $installedBy, bool $force = false ): LanguagePack { $manifestPath = ZipInstallHelper::findManifest($extractPath, 'language-pack.json'); if (! $manifestPath) { throw new LanguagePackOperationException('language_packs.errors.manifest_not_found'); } $manifestRaw = File::get($manifestPath); $manifest = json_decode($manifestRaw, true); if (! is_array($manifest)) { throw new LanguagePackOperationException('language_packs.errors.manifest_invalid_json'); } $packageRoot = dirname($manifestPath); $this->validator->validate($manifest, $packageRoot); $this->assertSecurityRules($packageRoot, $manifest); $this->assertDependencies($manifest); $this->assertTargetExtensionExists($manifest); $identifier = $manifest['identifier']; $existing = $this->repository->findByIdentifier($identifier); if ($existing && ! $force) { // force=true 는 모듈/플러그인/템플릿 update --force 와 동일 의미 — 다운그레이드 우회 허용. $this->assertNotDowngrade($existing, $manifest); } $finalPath = base_path('lang-packs/'.$identifier); if (File::isDirectory($finalPath)) { File::deleteDirectory($finalPath); } File::ensureDirectoryExists(dirname($finalPath)); File::moveDirectory($packageRoot, $finalPath); if ($extractPath !== $packageRoot) { File::deleteDirectory($extractPath); } // 재설치(update path) 시 자기 자신을 슬롯 충돌로 오인하지 않도록 $existing->id 를 제외. // 이전엔 재설치 시 자기가 active 였더라도 $shouldActivate 가 false 가 되어 // status 가 active → installed 로 강등 → 의존하는 확장 언어팩이 'core_locale_missing' // 으로 차단되는 회귀가 발생. $shouldActivate = $autoActivate && ! $this->repository->findActiveForSlot( $manifest['scope'], $manifest['target_identifier'] ?? null, $manifest['locale'], $existing?->id ); $pack = DB::transaction(function () use ($existing, $manifest, $sourceType, $sourceUrl, $installedBy) { $data = $this->buildPackData($manifest, $sourceType, $sourceUrl, $installedBy); // 설치 트랜잭션은 항상 installed 로만 기록한다. 활성화는 아래에서 activate() 를 // 경유해야 의존성 검사·대상 확장 존재 확인·슬롯 충돌 검사가 반드시 수행되고, // activated 훅도 한 곳에서만 발화한다. // // 이 규칙의 부수 효과로, 이미 active 인 팩을 auto_activate 없이 덮어쓰면 // active → installed 로 강등된다. 겉보기엔 버그지만 의도된 안전장치다 — // 활성 팩만 require 경로에 배선되므로, 방금 외부에서 반입된 내용이 // 활성화 권한자의 동의 없이 그 자리를 이어받는 일이 없어야 한다. // (설치 권한만 가진 사용자가 활성 팩을 덮어쓰는 방식으로 활성화 권한 게이트를 // 우회하는 경로를 닫는다. 정상 운영자는 auto_activate 로 활성 상태를 유지하며, // 그 플래그는 RequiresActivationPermission 이 지킨다.) $data['status'] = LanguagePackStatus::Installed->value; return $existing ? $this->repository->update($existing, $data) : $this->repository->create($data); }); // 캐시 전 계층 무효화 (registry + translator + template-lang) $this->refreshCache(); HookManager::doAction( $existing ? 'core.language_packs.updated' : 'core.language_packs.installed', $pack ); if ($shouldActivate) { // activate() 내부에서 상태 기록·캐시 무효화·activated 훅 발화를 모두 담당한다. $pack = $this->activate($pack, false); } return $pack; } /** * 언어팩을 활성화합니다 (슬롯 스위칭). * * 동일 슬롯(scope + target_identifier + locale)에 이미 다른 active 팩이 있고 * `$force=false` 인 경우 `LanguagePackSlotConflictException` 을 던져 사용자에게 * 교체 확인을 요청한다. `$force=true` 인 경우 기존 팩을 inactive 로 강등하고 * 대상 팩을 active 로 승격한다. * * @param LanguagePack $pack 대상 언어팩 * @param bool $force 기존 활성 팩이 있어도 강제로 교체 * @return LanguagePack 활성화된 언어팩 * * @throws LanguagePackSlotConflictException 슬롯에 다른 활성 팩이 있고 force=false 인 경우 */ public function activate(LanguagePack $pack, bool $force = false): LanguagePack { $this->assertNotInProgress($pack); if ($pack->isActive()) { return $pack; } // 요구사항 #5: activate 시점에도 의존성/버전 호환성 검사 (이전: install 시점만) $this->assertDependencies($pack->manifest ?? []); $this->assertTargetExtensionExists($pack->manifest ?? []); // 슬롯 충돌 사전 감지 — force=false 면 사용자 확인 모달을 위해 예외 $current = $this->repository->findActiveForSlot( $pack->scope, $pack->target_identifier, $pack->locale ); if ($current && $current->id !== $pack->id && ! $force) { throw new LanguagePackSlotConflictException($current, $pack); } DB::transaction(function () use ($pack, $current) { if ($current && $current->id !== $pack->id) { $this->repository->update($current, [ 'status' => LanguagePackStatus::Inactive->value, ]); HookManager::doAction('core.language_packs.deactivated', $current); } $this->repository->update($pack, [ 'status' => LanguagePackStatus::Active->value, 'activated_at' => now(), ]); }); $this->refreshCache(); $fresh = $this->repository->findById($pack->id) ?? $pack; HookManager::doAction('core.language_packs.activated', $fresh); return $fresh; } /** * 여러 언어팩을 일괄 활성화합니다 (요구사항 #7 reactivate 모달 → "활성화" 버튼). * * 각 ID 에 대해 activate() 를 호출하며, 실패한 항목은 reason 과 함께 응답에 포함합니다. * 의존성 검사는 activate() 내부에서 자동 수행됩니다 (§4). * * @param array $ids 언어팩 ID 배열 * @return array{succeeded: array, failed: array>} */ public function bulkActivate(array $ids): array { $succeeded = []; $failed = []; foreach ($ids as $id) { $pack = $this->repository->findById((int) $id); if (! $pack) { $failed[] = ['id' => $id, 'reason' => 'not_found']; continue; } try { $this->activate($pack); $succeeded[] = $pack->id; } catch (Throwable $e) { $failed[] = ['id' => $id, 'reason' => $e->getMessage()]; } } return ['succeeded' => $succeeded, 'failed' => $failed]; } /** * 언어팩을 비활성화합니다. * * 슬롯에 다른 후보가 존재하면 가장 최근 inactive/installed 팩이 자동 active 로 승격됩니다. * * @param LanguagePack $pack 대상 언어팩 * @param bool $cascadeFromHost 호스트 확장 비활성화로 인한 cascade 호출 여부 (true 면 보호 행/protected 검증 우회) * @return LanguagePack 비활성화된 언어팩 */ public function deactivate(LanguagePack $pack, bool $cascadeFromHost = false): LanguagePack { $this->assertNotInProgress($pack); if (! $pack->isActive()) { return $pack; } // 요구사항 #6: 호스트 확장 cascade 컨텍스트에서는 protected 가드 우회 if ($pack->isProtected() && ! $cascadeFromHost) { throw new LanguagePackOperationException('language_packs.errors.protected_pack'); } DB::transaction(function () use ($pack) { $this->repository->update($pack, [ 'status' => LanguagePackStatus::Inactive->value, ]); $this->promoteSlotSuccessor($pack); }); $this->refreshCache(); HookManager::doAction('core.language_packs.deactivated', $pack); return $this->repository->findById($pack->id) ?? $pack; } /** * 언어팩을 제거합니다. * * @param LanguagePack $pack 대상 언어팩 * @param bool $cascade 코어 제거 시 하위(module/plugin/template) 동일 locale 팩도 함께 제거 */ public function uninstall(LanguagePack $pack, bool $cascade = false): void { if ($pack->isProtected()) { throw new LanguagePackOperationException('language_packs.errors.protected_pack'); } $this->assertNotInProgress($pack); DB::transaction(function () use ($pack, $cascade) { if ($pack->isActive()) { $this->promoteSlotSuccessor($pack); } if ($pack->source_type !== 'bundled_with_extension') { $directory = $pack->resolveDirectory(); if (File::isDirectory($directory)) { File::deleteDirectory($directory); } } $this->repository->delete($pack); if ($cascade && $pack->scope === LanguagePackScope::Core->value) { $relatedPacks = $this->repository ->getPacksForLocale($pack->locale) ->filter(fn (LanguagePack $p) => $p->scope !== LanguagePackScope::Core->value); foreach ($relatedPacks as $related) { $this->repository->update($related, [ 'status' => LanguagePackStatus::Inactive->value, ]); } } }); $this->refreshCache(); HookManager::doAction('core.language_packs.uninstalled', $pack); } /** * 슬롯 비활성화 후 다음 후보를 active 로 승격합니다. * * @param LanguagePack $pack 방금 비활성화된 언어팩 */ private function promoteSlotSuccessor(LanguagePack $pack): void { $candidates = $this->repository ->getPacksForSlot($pack->scope, $pack->target_identifier, $pack->locale) ->filter(fn (LanguagePack $p) => $p->id !== $pack->id && $p->status !== LanguagePackStatus::Error->value); $next = $candidates->first(); if ($next) { $this->repository->update($next, [ 'status' => LanguagePackStatus::Active->value, 'activated_at' => now(), ]); } } /** * manifest → DB 컬럼 매핑 데이터를 빌드합니다. * * @param array $manifest manifest 데이터 * @param string $sourceType 설치 소스 유형 * @param string $sourceUrl 설치 소스 URL * @param int|null $installedBy 설치자 사용자 ID * @return array Repository::create/update 입력 데이터 */ private function buildPackData(array $manifest, string $sourceType, string $sourceUrl, ?int $installedBy): array { return [ 'identifier' => $manifest['identifier'], 'vendor' => $manifest['vendor'], 'scope' => $manifest['scope'], 'target_identifier' => $manifest['target_identifier'] ?? null, 'locale' => $manifest['locale'], 'locale_name' => $manifest['locale_name'] ?? $manifest['locale'], 'locale_native_name' => $manifest['locale_native_name'], 'text_direction' => $manifest['text_direction'] ?? 'ltr', 'version' => $manifest['version'], 'target_version_constraint' => $manifest['requires']['target_version'] ?? null, 'target_version_mismatch' => $this->checkTargetVersionMismatch($manifest), 'license' => $manifest['license'] ?? null, 'description' => is_array($manifest['description'] ?? null) ? $manifest['description'] : null, 'is_protected' => false, 'manifest' => $manifest, 'source_type' => $sourceType, 'source_url' => $sourceUrl, 'installed_by' => $installedBy, 'installed_at' => now(), ]; } /** * 보안 규칙 검사 — 허용된 파일 형식·위치·내용만 통과시킵니다. * * 언어팩의 PHP 파일은 활성화되면 `LanguagePackTranslator` 가 `require` 하므로 그 내용이 * 그대로 실행된다. 과거에는 위험 함수 11개를 정규식으로 나열해 막았으나 목록에 없는 * 함수(`file_put_contents` `fopen` `call_user_func` `assert` 백틱 등)로 전부 우회됐다. * 따라서 판정을 뒤집어, 아래 네 가지를 모두 만족하는 것만 통과시킨다. * * 1. 확장자 화이트리스트 — `.json` / `.php` / `.md` (대소문자 무관) * 2. PHP 파일 위치 강제 — `backend/{manifest locale}/{group}.php` 또는 `backend/{group}.php` * 3. 심볼릭 링크 거부 — 패키지 밖 파일을 끌어들이는 통로 차단 * 4. PHP 내용은 "번역 배열만" (LanguagePackPhpArrayValidator 문법 검사) * * 번들 여부와 무관하게 같은 경로로 검사한다 — 예외를 두면 "동봉된 것으로 위장하면 통과" * 라는 틈이 생기고, 앞으로 언어팩을 만들 때 기준이 둘로 갈린다. * * @param string $packageRoot 패키지 루트 디렉토리 * @param array $manifest manifest 데이터 * * @throws RuntimeException 보안 위반 발견 시 */ private function assertSecurityRules(string $packageRoot, array $manifest): void { $locale = (string) ($manifest['locale'] ?? ''); // `backend/{locale}/{group}.php` 와 평면형 `backend/{group}.php` 만 허용. $phpLocationPattern = '#^backend/(?:'.preg_quote($locale, '#').'/)?[A-Za-z0-9_-]+\.php$#'; foreach ($this->collectPackageEntries($packageRoot) as $relative => $absolute) { if (is_link($absolute)) { throw new LanguagePackOperationException('language_packs.errors.symlink_not_allowed', [ 'file' => $relative, ]); } if (is_dir($absolute)) { continue; } $extension = strtolower(pathinfo($relative, PATHINFO_EXTENSION)); if ($extension === 'php') { if ($locale === '' || preg_match($phpLocationPattern, $relative) !== 1) { throw new LanguagePackOperationException('language_packs.errors.php_outside_backend', [ 'file' => $relative, ]); } $violation = LanguagePackPhpArrayValidator::inspectFile($absolute); if ($violation !== null) { throw new LanguagePackOperationException('language_packs.errors.php_file_not_literal_array', [ 'file' => $relative, 'line' => $violation['line'], ]); } continue; } // 압축 도구가 끼워 넣는 OS 메타데이터는 형식 검사에서 제외한다. // PHP 는 위 분기에서 이미 위치 규칙에 걸리므로 이 예외로 새는 실행 경로가 없다. if ($this->isOsMetadataEntry($relative)) { continue; } if (! in_array($extension, ['json', 'md'], true)) { throw new LanguagePackOperationException('language_packs.errors.unsupported_file_type', [ 'file' => $relative, 'extension' => $extension === '' ? '(none)' : $extension, ]); } } } /** * 패키지 안의 모든 엔트리(디렉토리 포함)를 상대경로 ⇒ 절대경로로 수집합니다. * * 심볼릭 링크를 따라 들어가지 않는다 — 링크는 호출부가 거부 대상으로 판정하며, * 따라 들어가면 순환 링크로 무한 재귀에 빠질 수 있다. * * @param string $packageRoot 패키지 루트 디렉토리 * @param string $prefix 현재 상대경로 접두사 * @return array 상대경로(슬래시 구분) ⇒ 절대경로 */ private function collectPackageEntries(string $packageRoot, string $prefix = ''): array { $base = $prefix === '' ? $packageRoot : $packageRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $prefix); if (! is_dir($base)) { return []; } $entries = []; foreach (scandir($base) ?: [] as $name) { if ($name === '.' || $name === '..') { continue; } $relative = $prefix === '' ? $name : $prefix.'/'.$name; $absolute = $base.DIRECTORY_SEPARATOR.$name; $entries[$relative] = $absolute; if (is_dir($absolute) && ! is_link($absolute)) { $entries += $this->collectPackageEntries($packageRoot, $relative); } } return $entries; } /** * 압축 도구가 생성하는 OS 메타데이터 엔트리인지 판정합니다. * * @param string $relative 패키지 루트 기준 상대경로 * @return bool 메타데이터면 true */ private function isOsMetadataEntry(string $relative): bool { if (str_starts_with($relative, '__MACOSX/') || $relative === '__MACOSX') { return true; } return in_array(basename($relative), ['.DS_Store', 'Thumbs.db', '.gitkeep', '.gitignore'], true); } /** * 언어팩 설치 디렉토리(`lang-packs/`, `lang-packs/_pending/`) 의 쓰기 권한을 사전 검증합니다. * * 모듈/플러그인/템플릿 install 과 동일 수준의 가드 — 권한 부족 시 chmod 안내 메시지가 * 포함된 `RuntimeException` 을 던집니다. 컨트롤러는 본 예외를 422 응답으로 변환합니다. * * * @throws RuntimeException 디렉토리 미존재/쓰기 불가 시 */ private function assertInstallDirectoriesWritable(): void { foreach (['lang-packs', 'lang-packs/_pending'] as $relative) { $absolute = base_path($relative); if (! File::isDirectory($absolute)) { File::ensureDirectoryExists($absolute); } if (! is_writable($absolute)) { throw new LanguagePackOperationException('language_packs.errors.directory_not_writable', [ 'path' => $relative, ]); } } } /** * 의존성 검증 — depends_on_core_locale 이 true 면 코어 언어팩 active 여부 확인. * * @param array $manifest manifest 데이터 * * @throws RuntimeException 의존성 미충족 시 */ private function assertDependencies(array $manifest): void { $reason = $this->resolveCoreLocaleBlockedReason($manifest); if ($reason !== null) { throw new LanguagePackOperationException('language_packs.errors.core_locale_missing', [ 'locale' => $manifest['locale'], ]); } } /** * 대상 확장 존재 + 활성 여부를 확인합니다. * * 모듈/플러그인 시스템과 동일한 강도로 — 비활성 확장에도 언어팩이 들러붙지 않게 차단합니다. * * @param array $manifest manifest 데이터 * * @throws RuntimeException 대상 확장 미설치/비활성 시 */ private function assertTargetExtensionExists(array $manifest): void { $reason = $this->resolveTargetExtensionBlockedReason($manifest); if ($reason === 'target_not_installed') { throw new LanguagePackOperationException('language_packs.errors.target_not_installed', [ 'scope' => $manifest['scope'], 'target' => $manifest['target_identifier'] ?? '', ]); } if ($reason === 'target_inactive') { throw new LanguagePackOperationException('language_packs.errors.target_inactive', [ 'scope' => $manifest['scope'], 'target' => $manifest['target_identifier'] ?? '', ]); } if ($reason === 'target_version_too_old') { $constraint = $manifest['requires']['target_version'] ?? ''; throw new LanguagePackOperationException('language_packs.errors.target_version_too_old', [ 'scope' => $manifest['scope'], 'target' => $manifest['target_identifier'] ?? '', 'constraint' => (string) $constraint, ]); } } /** * 설치 차단 사유를 단일 키워드로 반환합니다 (UI 표시 + 차단 검증의 SSoT). * * 우선순위: 코어 locale 미활성 → 대상 미설치 → 대상 비활성 → 대상 버전 미달. * 어느 것도 해당하지 않으면 null 을 반환합니다. * * @param array $manifest manifest 데이터 * @return string|null 차단 사유 키 또는 null */ public function resolveInstallBlockedReason(array $manifest): ?string { return $this->resolveCoreLocaleBlockedReason($manifest) ?? $this->resolveTargetExtensionBlockedReason($manifest); } /** * 코어 locale 의존성 차단 사유를 반환합니다. * * @param array $manifest manifest 데이터 * @return string|null 'core_locale_missing' 또는 null */ private function resolveCoreLocaleBlockedReason(array $manifest): ?string { $scope = $manifest['scope'] ?? null; if ($scope === LanguagePackScope::Core->value) { return null; } $dependsOn = $manifest['requires']['depends_on_core_locale'] ?? true; if (! $dependsOn) { return null; } $locale = $manifest['locale'] ?? ''; if ($locale === '') { return null; } return $this->registry->hasActiveCoreLocale($locale) ? null : 'core_locale_missing'; } /** * 대상 확장 존재/활성/버전 차단 사유를 반환합니다. * * @param array $manifest manifest 데이터 * @return string|null 'target_not_installed' / 'target_inactive' / 'target_version_too_old' 또는 null */ private function resolveTargetExtensionBlockedReason(array $manifest): ?string { $scope = $manifest['scope'] ?? null; $target = $manifest['target_identifier'] ?? null; if ($scope === LanguagePackScope::Core->value || ! $target) { return null; } $row = $this->repository->findHostExtensionRow($scope, $target); if (! $row) { return 'target_not_installed'; } if (($row->status ?? null) !== 'active') { return 'target_inactive'; } $constraint = $manifest['requires']['target_version'] ?? null; if ($constraint && $row->version) { $cleaned = ltrim((string) $constraint, '^~>=version, $cleaned, '<')) { return 'target_version_too_old'; } } return null; } /** * 다운그레이드 차단 검사. * * @param LanguagePack $existing 기존 언어팩 * @param array $manifest 새 manifest * * @throws RuntimeException 다운그레이드 시도 시 */ private function assertNotDowngrade(LanguagePack $existing, array $manifest): void { if (version_compare($manifest['version'], $existing->version, '<')) { throw new LanguagePackOperationException('language_packs.errors.downgrade_blocked', [ 'from' => $existing->version, 'to' => $manifest['version'], ]); } } /** * target_version 불일치 여부를 계산합니다 (경고 플래그용, 차단하지 않음). * * @param array $manifest manifest 데이터 * @return bool 불일치 시 true */ private function checkTargetVersionMismatch(array $manifest): bool { $scope = $manifest['scope']; $target = $manifest['target_identifier'] ?? null; $constraint = $manifest['requires']['target_version'] ?? null; if ($scope === LanguagePackScope::Core->value || ! $target || ! $constraint) { return false; } $row = $this->repository->findHostExtensionRow($scope, $target); if (! $row || ! $row->version) { return false; } // 단순 정확 일치 여부만 본다 (Composer 제약 평가는 별도 비용 — v1 범위 밖) $cleaned = ltrim($constraint, '^~>=version, $cleaned, '<'); } /** * _pending 임시 디렉토리를 정리합니다. * * @param string $path 정리 대상 경로 */ private function cleanupPending(string $path): void { try { if (File::isDirectory($path)) { File::deleteDirectory($path); } } catch (Throwable $e) { Log::warning('lang-pack pending cleanup failed', ['path' => $path, 'error' => $e->getMessage()]); } } /** * GitHub 소스 언어팩의 latest_version 을 갱신합니다. * * @param string|null $identifier 특정 언어팩만 확인 (옵션) * @return array{checked:int, updates:int, details:array>} */ public function checkUpdates(?string $identifier = null): array { // 요구사항 #4: 모듈 패턴 — 모든 source_type 점검 (GitHub 1순위, 실패 시 bundled 폴백) // 전량 순회는 allForUpdateCheck() — paginate 는 HTTP page 파라미터를 암묵 해석해 // `?page=2` 요청에서 순회가 0건이 된다 (공개 이슈 #102 동형) $packs = $identifier ? collect([$this->repository->findByIdentifier($identifier)])->filter() : $this->repository->allForUpdateCheck(); $checked = 0; $updates = 0; $details = []; foreach ($packs as $pack) { // GitHub URL 해석 가능 (manifest.github_url 또는 source_url) 또는 // bundled 폴백 가능한 팩만 점검 대상. if ($this->resolveGithubUrl($pack) === null && ! $this->hasBundledManifest($pack->identifier)) { continue; } $checked++; $entry = $this->checkSinglePackUpdate($pack); $details[] = $entry; if ($entry['has_update']) { $updates++; } } return ['checked' => $checked, 'updates' => $updates, 'details' => $details]; } /** * 단일 GitHub 언어팩의 최신 버전을 조회하고 latest_version 을 갱신합니다. * * @param LanguagePack $pack 대상 언어팩 * @return array{identifier:string, current:string, latest:string|null, has_update:bool, error:string|null} */ private function checkSinglePackUpdate(LanguagePack $pack): array { $latest = null; $error = null; // 1순위: GitHub (manifest.github_url SSoT — 모듈/플러그인/템플릿과 동일 패턴. // 매니페스트에 없으면 source_url 이 GitHub URL 인 경우 폴백) $githubUrl = $this->resolveGithubUrl($pack); if ($githubUrl !== null) { try { ['owner' => $owner, 'repo' => $repo] = GithubHelper::parseUrl($githubUrl); $release = GithubHelper::fetchLatestRelease($owner, $repo); $latest = ltrim((string) ($release['tag_name'] ?? ''), 'v') ?: null; } catch (Throwable $e) { $error = $e->getMessage(); Log::warning('language-pack github update check failed', [ 'identifier' => $pack->identifier, 'error' => $error, ]); } } // 2순위: bundled 폴백 (lang-packs/_bundled/{identifier}/language-pack.json) if ($latest === null) { $latest = $this->resolveBundledLatestVersion($pack); } if ($latest === null) { return [ 'identifier' => $pack->identifier, 'current' => $pack->version, 'latest' => null, 'has_update' => false, 'error' => $error, ]; } $this->repository->update($pack, ['latest_version' => $latest]); return [ 'identifier' => $pack->identifier, 'current' => $pack->version, 'latest' => $latest, 'has_update' => version_compare($latest, $pack->version, '>'), 'error' => null, ]; } /** * 언어팩의 GitHub repository URL 을 해석합니다. * * 우선순위 (모듈/플러그인/템플릿의 `github_url` SSoT 패턴과 일관): * 1. `manifest.github_url` (DB `manifest` 컬럼에 스냅샷된 매니페스트 필드) * 2. `source_url` 이 `https://github.com/` 로 시작 (GitHub 설치 경로 호환) * * 둘 다 없거나 GitHub URL 이 아니면 null. * * @param LanguagePack $pack 대상 언어팩 * @return string|null GitHub URL 또는 null */ private function resolveGithubUrl(LanguagePack $pack): ?string { $manifest = $pack->manifest; if (is_array($manifest)) { $manifestUrl = $manifest['github_url'] ?? null; if (is_string($manifestUrl) && self::isGithubUrl($manifestUrl)) { return $manifestUrl; } } $sourceUrl = (string) $pack->source_url; if (self::isGithubUrl($sourceUrl)) { return $sourceUrl; } return null; } /** * URL 이 실제 GitHub host 를 가리키는지 판정합니다. * * 접두사 매칭은 `https://github.com@evil.example/` 같은 userinfo 위장을 통과시키므로 * host 를 완전 일치로 검증합니다. * * @param string $url 판정 대상 URL * @return bool GitHub URL 이면 true */ private static function isGithubUrl(string $url): bool { return OutboundUrlValidator::isHostAllowed($url, ['github.com', 'www.github.com']); } /** * 번들 manifest 로부터 최신 버전을 조회합니다 (요구사항 #4 폴백 경로). * * @param LanguagePack $pack 대상 언어팩 * @return string|null 최신 버전 문자열 또는 null */ private function resolveBundledLatestVersion(LanguagePack $pack): ?string { $manifestPath = base_path('lang-packs/_bundled/'.$pack->identifier.'/language-pack.json'); if (! is_file($manifestPath)) { return null; } $raw = @file_get_contents($manifestPath); $manifest = $raw ? json_decode($raw, true) : null; if (! is_array($manifest) || empty($manifest['version'])) { return null; } return (string) $manifest['version']; } /** * 번들 manifest 가 존재하는지 확인합니다. * * @param string $identifier 언어팩 식별자 * @return bool 번들 디렉토리가 있고 manifest 가 유효하면 true */ private function hasBundledManifest(string $identifier): bool { return is_file(base_path('lang-packs/_bundled/'.$identifier.'/language-pack.json')); } /** * `lang-packs/_bundled/` 의 신버전 manifest 와 DB 의 설치된 언어팩 버전을 직접 비교하여 * 업데이트 가능한 언어팩 목록을 수집합니다. * * `core:update` 의 `runBundledExtensionUpdatePrompt` 가 사용. GitHub 검사를 우회하여 * `_bundled` 만으로 업데이트 후보를 빠르게 판정 (모듈/플러그인/템플릿의 * `CoreUpdateService::collectBundledExtensionUpdates()` 와 동일 패턴). * * @return array */ public function collectBundledLangPackUpdates(): array { $updates = []; $bundledRoot = base_path('lang-packs/_bundled'); if (! is_dir($bundledRoot)) { return $updates; } // DB 에 설치된 모든 언어팩 (보호 가상 행 제외 — protected 로 필터됨). // 전량 순회는 allForUpdateCheck() — paginate 의 암묵 page 해석 회피 (#102 동형) $packs = $this->repository->allForUpdateCheck(); foreach ($packs as $pack) { if (! $this->hasBundledManifest($pack->identifier)) { continue; } $manifestPath = $bundledRoot.'/'.$pack->identifier.'/language-pack.json'; try { $manifest = json_decode((string) file_get_contents($manifestPath), true, 512, JSON_THROW_ON_ERROR); } catch (Throwable) { continue; } $bundledVersion = (string) ($manifest['version'] ?? ''); $currentVersion = (string) $pack->version; if ($bundledVersion === '' || $currentVersion === '') { continue; } // 시맨틱 버전 비교: 번들이 더 크면 업데이트 후보 if (version_compare($bundledVersion, $currentVersion, '>')) { $updates[] = [ 'identifier' => $pack->identifier, 'current_version' => $currentVersion, 'latest_version' => $bundledVersion, ]; } } return $updates; } /** * GitHub 소스 언어팩의 신버전을 다운로드하여 적용합니다. * * 모듈/플러그인/템플릿 update 플로우와 동일한 깊이의 안전 장치 적용: * - 동시성 가드 (status=Updating 상태에서 재진입 차단) * - 백업 + 실패 시 자동 롤백 (ExtensionBackupHelper) * - 이전 상태 복원 보장 (Active/Inactive) * - before/after hooks: core.language_packs.updated (성공 시) * * @param LanguagePack $pack 대상 언어팩 * @param bool $force 버전 변동 가드 우회 여부 (true 면 같은 버전으로도 _bundled 강제 재적용) * @return LanguagePack 갱신된 언어팩 * * @throws LanguagePackOperationException source_type 이 github 이 아니거나 source_url 이 없는 경우, 또는 진행 중일 때 */ public function performUpdate(LanguagePack $pack, bool $force = false): LanguagePack { // 요구사항 #4: bundled 도 update 가능. force=true 시 bundled 1순위, 그 외 GitHub 1순위 + bundled 폴백 $githubUrl = $this->resolveGithubUrl($pack); if (! $githubUrl && ! $this->hasBundledManifest($pack->identifier)) { throw new LanguagePackOperationException('language_packs.errors.update_no_source'); } $this->assertNotInProgress($pack); // force=false 일 때만 버전 변동 여부를 가드한다. force=true 는 모듈/플러그인/템플릿의 // {type}:update --force 와 동일하게 버전이 같아도 _bundled 에서 강제 재적용을 허용한다. if (! $force) { $check = $this->checkSinglePackUpdate($pack); if (! $check['has_update']) { throw new LanguagePackOperationException('language_packs.errors.update_already_latest'); } } $identifier = $pack->identifier; $previousStatus = $pack->status; $autoActivate = $pack->isActive(); $backupPath = null; // 우선순위 결정: force=true + bundled 존재 → bundled, 그 외 → GitHub (manifest.github_url 우선) $useBundled = $force && $this->hasBundledManifest($identifier); if (! $useBundled && $githubUrl === null) { // GitHub URL 해석 실패 시 bundled 폴백 자동 사용 $useBundled = $this->hasBundledManifest($identifier); } // 1. 상태 → Updating (가드용) $this->repository->update($pack, [ 'status' => LanguagePackStatus::Updating->value, ]); try { // 2. 활성 디렉토리 백업 (롤백 대비) $activeDir = base_path('lang-packs/'.$identifier); if (File::isDirectory($activeDir)) { $backupPath = ExtensionBackupHelper::createBackup('lang-packs', $identifier); } // 3. 신버전 다운로드 + 적용 (force 전파 — 다운그레이드 우회 허용) if ($useBundled) { $updated = $this->installFromBundled($identifier, $autoActivate, $pack->installed_by, $force); } else { $updated = $this->installFromGithub((string) $githubUrl, $autoActivate, $pack->installed_by, $force); } // 4. 백업 정리 if ($backupPath) { ExtensionBackupHelper::deleteBackup($backupPath); } return $updated; } catch (Throwable $e) { Log::error('language pack update failed', [ 'identifier' => $identifier, 'error' => $e->getMessage(), ]); // 백업 복원 if ($backupPath) { try { ExtensionBackupHelper::restoreFromBackup('lang-packs', $identifier, $backupPath); ExtensionBackupHelper::deleteBackup($backupPath); } catch (Throwable $restoreError) { Log::error('language pack backup restore failed', [ 'identifier' => $identifier, 'error' => $restoreError->getMessage(), ]); } } // 상태 복원 (DB 행이 finalizeInstall 에서 status 를 다시 쓰지 않은 경우만) try { $current = $this->repository->findByIdentifier($identifier); if ($current && $current->status === LanguagePackStatus::Updating->value) { $this->repository->update($current, ['status' => $previousStatus]); } } catch (Throwable $statusError) { Log::error('language pack status restore failed', [ 'identifier' => $identifier, 'error' => $statusError->getMessage(), ]); } // 백업 복원으로 디스크 상태가 바뀌었으므로 전 계층 캐시 갱신 + 프론트 cache busting $this->refreshCache(); throw $e; } } /** * 진행 중인 작업이 없는지 확인합니다. * * 모듈/플러그인/템플릿의 ExtensionStatusGuard 와 동일한 의미의 가드. * status=Updating 인 팩에 대해 활성/제거/재업데이트 진입을 차단합니다. * * @param LanguagePack $pack 대상 언어팩 * * @throws RuntimeException 진행 중일 때 */ public function assertNotInProgress(LanguagePack $pack): void { if ($pack->status === LanguagePackStatus::Updating->value) { throw new LanguagePackOperationException('language_packs.errors.operation_in_progress', [ 'identifier' => $pack->identifier, ]); } } /** * 번역/레지스트리/템플릿 언어 캐시를 무효화합니다. * * 추가로 `ext.cache_version` 을 bump 하여 프론트엔드의 lang/routes/layout fetch URL * 에 부착되는 `?v=` 쿼리 파라미터가 새 값으로 갱신되도록 한다 — 모듈/플러그인/템플릿 * 활성화와 동일한 cache busting 메커니즘. * * @return array{registry:bool, translator:bool, template:bool, version:bool} */ public function refreshCache(): array { $result = ['registry' => false, 'translator' => false, 'template' => false, 'version' => false]; try { $this->registry->invalidate(); $result['registry'] = true; } catch (Throwable $e) { Log::warning('language-pack registry invalidate failed', ['error' => $e->getMessage()]); } try { $translator = app('translator'); if (method_exists($translator, 'setLoaded')) { $translator->setLoaded([]); } $result['translator'] = true; } catch (Throwable $e) { Log::warning('language-pack translator reset failed', ['error' => $e->getMessage()]); } try { if ($this->cache->supportsTags()) { $this->cache->flushTags(['template-lang']); } else { $this->cache->forget('template-lang'); } $result['template'] = true; } catch (Throwable $e) { Log::warning('language-pack template cache flush failed', ['error' => $e->getMessage()]); } try { // 프론트엔드 cache busting — `?v=` 쿼리 파라미터가 새 타임스탬프로 갱신됨 $this->incrementExtensionCacheVersion(); $result['version'] = true; } catch (Throwable $e) { Log::warning('language-pack extension cache version bump failed', ['error' => $e->getMessage()]); } return $result; } /** * 업로드된 ZIP 의 manifest 와 검증 결과만 추출합니다 (실제 설치 X). * * @param UploadedFile $file 업로드된 ZIP * @return array{manifest:array, validation:array} * * @throws RuntimeException manifest 추출/검증 실패 시 */ public function previewManifest(UploadedFile $file): array { $tempId = (string) Str::uuid(); $extractPath = base_path('lang-packs/_pending/preview-'.$tempId); try { $zipPath = $file->getRealPath(); ZipInstallHelper::extractZip($zipPath, $extractPath); $manifestPath = ZipInstallHelper::findManifest($extractPath, 'language-pack.json'); if (! $manifestPath) { throw new LanguagePackOperationException('language_packs.errors.manifest_not_found'); } $manifestRaw = File::get($manifestPath); $manifest = json_decode($manifestRaw, true); if (! is_array($manifest)) { throw new LanguagePackOperationException('language_packs.errors.manifest_invalid_json'); } $packageRoot = dirname($manifestPath); $validationErrors = []; try { $this->validator->validate($manifest, $packageRoot); } catch (Throwable $e) { $validationErrors[] = $e->getMessage(); } $existing = $this->repository->findByIdentifier($manifest['identifier'] ?? ''); return [ 'manifest' => $manifest, 'validation' => [ 'errors' => $validationErrors, 'is_valid' => $validationErrors === [], 'already_installed' => $existing !== null, 'existing_version' => $existing?->version, 'target_version_mismatch' => $this->checkTargetVersionMismatch($manifest), ], ]; } finally { $this->cleanupPending($extractPath); } } }