- pay_nhnkcp 1.0.3 분리: 관리자 API 권한 게이트 변경분이 이미 배포된 1.0.2 섹션에 소급 누적돼 있어 배포본과 어긋나고 업데이트 감지도 불가하던 것을 manifest/package/composer/lock 동기와 함께 정정 - 공개 시나리오 매니페스트의 내부 경로 참조 제거 — 릴리즈 파일 누출 검사가 error 로 발행을 차단하던 상태 해소 (내부 정적 검사 연계는 도구 비귀속 서술로 이관) - 모듈/플러그인/템플릿 수동 설치에서 깨진 zip·manifest 누락 같은 사용자 입력 오류가 500 으로 위장되던 422 계약 회귀 복원 — 서비스 경계에서 도메인 예외로 승격해 상태코드와 실패 사유를 함께 보존 (회귀 테스트 동반) - 본인인증 정책 검색 버튼이 입력 중인 검색어를 반영하지 않고, 초기화 후에도 입력창에 이전 검색어가 남던 문제 수정 — 폼 자동바인딩 옵트아웃 + 명시 상태 전환, E2E 회귀 스펙과 트러블슈팅 사례 동반 - 프록시 URL 검증 비문자열 분기의 :schemes 치환 누락 보정 (회귀 테스트 동반) - 7.0.8 사이클 출시일자를 2026-08-22 로 정렬 (코어·엔진·확장 7종·언어팩 3종, 버전·콘텐츠 무변경) 및 설치본 언어팩 동기
2359 lines
93 KiB
PHP
2359 lines
93 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Contracts\Extension\ModuleManagerInterface;
|
|
use App\Contracts\Extension\PluginManagerInterface;
|
|
use App\Contracts\Extension\TemplateManagerInterface;
|
|
use App\Contracts\Repositories\LayoutVersionRepositoryInterface;
|
|
use App\Contracts\Repositories\TemplateRepositoryInterface;
|
|
use App\Enums\DeactivationReason;
|
|
use App\Enums\ExtensionStatus;
|
|
use App\Exceptions\TemplateNotFoundException;
|
|
use App\Exceptions\TemplateOperationException;
|
|
use App\Extension\Helpers\ChangelogParser;
|
|
use App\Extension\Helpers\GithubHelper;
|
|
use App\Extension\Helpers\ZipInstallHelper;
|
|
use App\Extension\HookManager;
|
|
use App\Extension\Traits\ResolvesLanguageFragments;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class TemplateService
|
|
{
|
|
use ResolvesLanguageFragments;
|
|
|
|
/**
|
|
* 직전 라우트 병합에서 활성 모듈 디렉토리가 비어 라우트가 누락됐는지 여부
|
|
*/
|
|
private bool $routeMergeDegraded = false;
|
|
|
|
public function __construct(
|
|
private TemplateRepositoryInterface $templateRepository,
|
|
private TemplateManagerInterface $templateManager,
|
|
private ModuleManagerInterface $moduleManager,
|
|
private PluginManagerInterface $pluginManager,
|
|
private LayoutVersionRepositoryInterface $layoutVersionRepository
|
|
) {
|
|
// TemplateManager 초기화 — 아직 로드되지 않았을 때만 스캔한다.
|
|
// 무조건 loadTemplates() 를 부르면 공유 싱글톤의 템플릿 맵을 리셋한 뒤 디렉토리를
|
|
// 통째로 재스캔하므로, 이 서비스가 주입될 때마다 풀스캔과 상태 변형이 반복된다.
|
|
// (웹/serve/test 는 CoreServiceProvider::boot 가 로드를 보장하지만, 그 외 콘솔 경로는
|
|
// 로딩을 건너뛰므로 이 초기화 자체를 없앨 수는 없다)
|
|
$this->templateManager->ensureLoaded();
|
|
}
|
|
|
|
/**
|
|
* 검색 가능한 필드 목록
|
|
*/
|
|
private const SEARCHABLE_FIELDS = ['name', 'identifier', 'description', 'vendor'];
|
|
|
|
/**
|
|
* 모든 템플릿 목록을 조회합니다 (설치된 템플릿과 미설치 템플릿 포함).
|
|
*
|
|
* @param string|null $type 템플릿 타입 (admin 또는 user)
|
|
* @return array 모든 템플릿 목록
|
|
*/
|
|
public function getAllTemplates(?string $type = null): array
|
|
{
|
|
HookManager::doAction('core.templates.before_list', $type);
|
|
|
|
// 설치된 템플릿과 미설치 템플릿을 분리하여 반환
|
|
$installedTemplates = $this->templateManager->getInstalledTemplatesWithDetails();
|
|
$uninstalledTemplates = $this->templateManager->getUninstalledTemplates();
|
|
|
|
// 타입 필터링
|
|
if ($type) {
|
|
$installedTemplates = array_filter($installedTemplates, fn ($t) => $t['type'] === $type);
|
|
$uninstalledTemplates = array_filter($uninstalledTemplates, fn ($t) => $t['type'] === $type);
|
|
}
|
|
|
|
$result = [
|
|
'installed' => array_values($installedTemplates),
|
|
'uninstalled' => array_values($uninstalledTemplates),
|
|
'total' => count($installedTemplates) + count($uninstalledTemplates),
|
|
];
|
|
|
|
HookManager::doAction('core.templates.after_list', $result, $type);
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 페이지네이션 및 검색 필터가 적용된 템플릿 목록을 조회합니다.
|
|
*
|
|
* @param array $filters 검색 필터 (search, filters, status, type)
|
|
* @param int $perPage 페이지당 항목 수
|
|
* @param int $page 현재 페이지
|
|
* @return array 페이지네이션된 템플릿 목록
|
|
*/
|
|
public function getPaginatedTemplates(array $filters, int $perPage = 12, int $page = 1): array
|
|
{
|
|
HookManager::doAction('core.templates.before_list', $filters);
|
|
|
|
// 템플릿 매니저 초기화
|
|
$this->templateManager->loadTemplates();
|
|
|
|
// 모든 템플릿 가져오기
|
|
$installedTemplates = $this->templateManager->getInstalledTemplatesWithDetails();
|
|
$uninstalledTemplates = $this->templateManager->getUninstalledTemplates();
|
|
|
|
// 모든 템플릿 합치기
|
|
$allTemplates = array_merge(
|
|
array_values($installedTemplates),
|
|
array_values($uninstalledTemplates)
|
|
);
|
|
|
|
// 타입 필터 적용
|
|
if (! empty($filters['type'])) {
|
|
$allTemplates = array_filter($allTemplates, fn ($t) => $t['type'] === $filters['type']);
|
|
$allTemplates = array_values($allTemplates);
|
|
}
|
|
|
|
// 숨김 필터 적용 (기본: 숨김 항목 제외)
|
|
$allTemplates = $this->applyHiddenFilter($allTemplates, (bool) ($filters['include_hidden'] ?? false));
|
|
|
|
// 상태 필터 적용
|
|
if (! empty($filters['status'])) {
|
|
$allTemplates = $this->applyStatusFilter($allTemplates, $filters['status']);
|
|
}
|
|
|
|
// 다중 검색 필터 적용 (우선)
|
|
if (! empty($filters['filters']) && is_array($filters['filters'])) {
|
|
$allTemplates = $this->applyMultipleSearchFilters($allTemplates, $filters['filters']);
|
|
}
|
|
// 단일 검색어 필터 (하위 호환성)
|
|
elseif (! empty($filters['search'])) {
|
|
$allTemplates = $this->applyOrSearchAcrossFields($allTemplates, $filters['search']);
|
|
}
|
|
|
|
// 총 개수
|
|
$total = count($allTemplates);
|
|
|
|
// 페이지네이션 적용
|
|
$offset = ($page - 1) * $perPage;
|
|
$paginatedTemplates = array_slice($allTemplates, $offset, $perPage);
|
|
|
|
$result = [
|
|
'data' => array_values($paginatedTemplates),
|
|
'total' => $total,
|
|
'current_page' => $page,
|
|
'last_page' => (int) ceil($total / $perPage),
|
|
'per_page' => $perPage,
|
|
];
|
|
|
|
HookManager::doAction('core.templates.after_list', $result, $filters);
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 숨김 필터를 적용합니다.
|
|
*
|
|
* manifest 의 hidden=true 로 마킹된 템플릿은 기본 제외되며,
|
|
* $includeHidden=true 인 경우 포함됩니다.
|
|
*
|
|
* @param array $templates 템플릿 목록
|
|
* @param bool $includeHidden 숨김 항목 포함 여부
|
|
* @return array 필터링된 템플릿 목록
|
|
*/
|
|
private function applyHiddenFilter(array $templates, bool $includeHidden): array
|
|
{
|
|
if ($includeHidden) {
|
|
return $templates;
|
|
}
|
|
|
|
return array_filter($templates, function ($template) {
|
|
return empty($template['hidden']);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 상태 필터를 적용합니다.
|
|
*
|
|
* @param array $templates 템플릿 목록
|
|
* @param string $status 상태 (installed, not_installed, active, inactive)
|
|
* @return array 필터링된 템플릿 목록
|
|
*/
|
|
private function applyStatusFilter(array $templates, string $status): array
|
|
{
|
|
return array_filter($templates, function ($template) use ($status) {
|
|
return match ($status) {
|
|
'installed' => $template['status'] !== 'not_installed',
|
|
'not_installed' => $template['status'] === 'not_installed',
|
|
'active' => $template['status'] === 'active',
|
|
'inactive' => $template['status'] === 'inactive',
|
|
default => true,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 다중 검색 조건을 적용합니다 (AND 조건).
|
|
*
|
|
* @param array $templates 템플릿 목록
|
|
* @param array $searchFilters 검색 필터 배열
|
|
* @return array 필터링된 템플릿 목록
|
|
*/
|
|
private function applyMultipleSearchFilters(array $templates, array $searchFilters): array
|
|
{
|
|
if (empty($searchFilters)) {
|
|
return $templates;
|
|
}
|
|
|
|
return array_filter($templates, function ($template) use ($searchFilters) {
|
|
foreach ($searchFilters as $filter) {
|
|
if (! $this->matchesFilter($template, $filter)) {
|
|
return false; // AND 조건: 하나라도 실패하면 제외
|
|
}
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 단일 필터 조건 매칭 여부를 확인합니다.
|
|
*
|
|
* @param array $template 템플릿 정보
|
|
* @param array $filter 필터 조건
|
|
* @return bool 매칭 여부
|
|
*/
|
|
private function matchesFilter(array $template, array $filter): bool
|
|
{
|
|
$field = $filter['field'] ?? null;
|
|
$value = $filter['value'] ?? null;
|
|
$operator = $filter['operator'] ?? 'like';
|
|
|
|
if (! $field || ! $value || ! in_array($field, self::SEARCHABLE_FIELDS)) {
|
|
return true; // 유효하지 않은 필터는 통과
|
|
}
|
|
|
|
// 필드 값 가져오기 (다국어 필드 처리)
|
|
$fieldValue = $this->getFieldValue($template, $field);
|
|
|
|
if ($fieldValue === null) {
|
|
return false;
|
|
}
|
|
|
|
return match ($operator) {
|
|
'eq' => mb_strtolower($fieldValue) === mb_strtolower($value),
|
|
'starts_with' => str_starts_with(mb_strtolower($fieldValue), mb_strtolower($value)),
|
|
'ends_with' => str_ends_with(mb_strtolower($fieldValue), mb_strtolower($value)),
|
|
default => str_contains(mb_strtolower($fieldValue), mb_strtolower($value)), // like
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 단일 검색어로 여러 필드를 OR 조건으로 검색합니다.
|
|
*
|
|
* @param array $templates 템플릿 목록
|
|
* @param string $searchTerm 검색어
|
|
* @return array 필터링된 템플릿 목록
|
|
*/
|
|
private function applyOrSearchAcrossFields(array $templates, string $searchTerm): array
|
|
{
|
|
$searchTerm = mb_strtolower($searchTerm);
|
|
|
|
return array_filter($templates, function ($template) use ($searchTerm) {
|
|
foreach (self::SEARCHABLE_FIELDS as $field) {
|
|
$fieldValue = $this->getFieldValue($template, $field);
|
|
if ($fieldValue !== null && str_contains(mb_strtolower($fieldValue), $searchTerm)) {
|
|
return true; // OR 조건: 하나라도 매칭되면 포함
|
|
}
|
|
}
|
|
|
|
return false;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 템플릿에서 필드 값을 가져옵니다 (다국어 필드 처리 포함).
|
|
*
|
|
* @param array $template 템플릿 정보
|
|
* @param string $field 필드명
|
|
* @return string|null 필드 값
|
|
*/
|
|
private function getFieldValue(array $template, string $field): ?string
|
|
{
|
|
$value = $template[$field] ?? null;
|
|
|
|
if ($value === null) {
|
|
return null;
|
|
}
|
|
|
|
// 다국어 필드인 경우 (name, description)
|
|
if (is_array($value)) {
|
|
// 현재 로케일 우선, 없으면 ko, 그 다음 en
|
|
$locale = app()->getLocale();
|
|
|
|
return $value[$locale] ?? $value[config('app.fallback_locale', 'ko')] ?? reset($value) ?: null;
|
|
}
|
|
|
|
return (string) $value;
|
|
}
|
|
|
|
/**
|
|
* 설치된 템플릿만 조회합니다.
|
|
*
|
|
* @param string|null $type 템플릿 타입 (admin 또는 user)
|
|
* @return array 설치된 템플릿 목록
|
|
*/
|
|
public function getInstalledTemplatesOnly(?string $type = null): array
|
|
{
|
|
$templates = array_values($this->templateManager->getInstalledTemplatesWithDetails());
|
|
|
|
if ($type) {
|
|
$templates = array_filter($templates, fn ($t) => $t['type'] === $type);
|
|
$templates = array_values($templates);
|
|
}
|
|
|
|
return $templates;
|
|
}
|
|
|
|
/**
|
|
* 미설치 템플릿만 조회합니다.
|
|
*
|
|
* @param string|null $type 템플릿 타입 (admin 또는 user)
|
|
* @return array 미설치 템플릿 목록
|
|
*/
|
|
public function getUninstalledTemplatesOnly(?string $type = null): array
|
|
{
|
|
$templates = array_values($this->templateManager->getUninstalledTemplates());
|
|
|
|
if ($type) {
|
|
$templates = array_filter($templates, fn ($t) => $t['type'] === $type);
|
|
$templates = array_values($templates);
|
|
}
|
|
|
|
return $templates;
|
|
}
|
|
|
|
/**
|
|
* 특정 템플릿의 상세 정보를 조회합니다.
|
|
*
|
|
* @param string $identifier 템플릿 식별자 (vendor-name 형식)
|
|
* @return array|null 템플릿 정보 또는 null
|
|
*/
|
|
public function getTemplateInfo(string $identifier): ?array
|
|
{
|
|
return $this->templateManager->getTemplateInfo($identifier);
|
|
}
|
|
|
|
/**
|
|
* ID로 템플릿 조회
|
|
*
|
|
* @param int $id 템플릿 ID
|
|
* @return object|null 템플릿 모델 또는 null
|
|
*/
|
|
public function getTemplateById(int $id): ?object
|
|
{
|
|
HookManager::doAction('core.templates.before_show', $id);
|
|
|
|
$template = $this->templateRepository->findById($id);
|
|
|
|
HookManager::doAction('core.templates.after_show', $template, $id);
|
|
|
|
return $template;
|
|
}
|
|
|
|
/**
|
|
* 식별자로 템플릿 조회
|
|
*
|
|
* @param string $identifier 템플릿 식별자 (vendor-name 형식)
|
|
* @return object|null 템플릿 모델 또는 null
|
|
*/
|
|
public function findByIdentifier(string $identifier): ?object
|
|
{
|
|
HookManager::doAction('core.templates.before_find_by_identifier', $identifier);
|
|
|
|
$template = $this->templateRepository->findByIdentifier($identifier);
|
|
|
|
HookManager::doAction('core.templates.after_find_by_identifier', $template, $identifier);
|
|
|
|
return $template;
|
|
}
|
|
|
|
/**
|
|
* 활성화된 템플릿 identifier 조회.
|
|
*
|
|
* @param string $type 템플릿 타입 ('admin' 또는 'user')
|
|
* @return string 활성 템플릿 identifier
|
|
*
|
|
* @throws TemplateNotFoundException 활성화된 템플릿이 없을 때
|
|
*/
|
|
public function getActiveTemplateIdentifier(string $type): string
|
|
{
|
|
$template = $this->templateRepository->findActiveByType($type);
|
|
|
|
if (! $template) {
|
|
throw new TemplateNotFoundException($type);
|
|
}
|
|
|
|
return $template->identifier;
|
|
}
|
|
|
|
/**
|
|
* 템플릿을 설치합니다.
|
|
*
|
|
* @param string $identifier 설치할 템플릿 식별자
|
|
* @param bool $force 활성 디렉토리가 있어도 원본으로 덮어쓰고 재설치
|
|
* @return array|null 설치된 템플릿 정보 또는 null
|
|
*
|
|
* @throws ValidationException 설치 실패 시
|
|
*/
|
|
public function installTemplate(string $identifier, bool $force = false): ?array
|
|
{
|
|
HookManager::doAction('core.templates.before_install', $identifier);
|
|
|
|
try {
|
|
$result = $this->templateManager->installTemplate($identifier, null, $force);
|
|
|
|
if ($result) {
|
|
$templateInfo = $this->templateManager->getTemplateInfo($identifier);
|
|
|
|
HookManager::doAction('core.templates.after_install', $identifier, $templateInfo);
|
|
|
|
return $templateInfo;
|
|
}
|
|
|
|
return null;
|
|
} catch (\Exception $e) {
|
|
throw ValidationException::withMessages([
|
|
'identifier' => [__('templates.errors.installation_failed').': '.$e->getMessage()],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 템플릿을 제거합니다.
|
|
*
|
|
* @param string $identifier 제거할 템플릿 식별자
|
|
* @param bool $deleteData 템플릿 관련 데이터 삭제 여부
|
|
* @return array|null 제거된 템플릿 정보 또는 null
|
|
*
|
|
* @throws ValidationException 제거 실패 시
|
|
*/
|
|
public function uninstallTemplate(string $identifier, bool $deleteData = false): ?array
|
|
{
|
|
HookManager::doAction('core.templates.before_uninstall', $identifier, $deleteData);
|
|
|
|
try {
|
|
// 제거 전 템플릿 정보 보존
|
|
$templateInfo = $this->templateManager->getTemplateInfo($identifier);
|
|
|
|
$result = $this->templateManager->uninstallTemplate($identifier);
|
|
|
|
if ($result) {
|
|
HookManager::doAction('core.templates.after_uninstall', $identifier, $templateInfo, $deleteData);
|
|
|
|
return $templateInfo;
|
|
}
|
|
|
|
return null;
|
|
} catch (\Exception $e) {
|
|
throw ValidationException::withMessages([
|
|
'identifier' => [__('templates.errors.uninstallation_failed').': '.$e->getMessage()],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 템플릿 삭제 시 삭제될 데이터 정보를 조회합니다.
|
|
*
|
|
* @param string $templateName 템플릿명
|
|
* @return array|null 삭제 정보 배열 또는 null (템플릿 없음)
|
|
*/
|
|
public function getTemplateUninstallInfo(string $templateName): ?array
|
|
{
|
|
$this->templateManager->loadTemplates();
|
|
|
|
return $this->templateManager->getTemplateUninstallInfo($templateName);
|
|
}
|
|
|
|
/**
|
|
* 템플릿을 비활성화합니다.
|
|
*
|
|
* @param int|string $idOrIdentifier 템플릿 ID 또는 식별자
|
|
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
|
|
* @return array|null 비활성화된 템플릿 정보 또는 null
|
|
*
|
|
* @throws ValidationException 비활성화 실패 시
|
|
*/
|
|
public function deactivateTemplate(int|string $idOrIdentifier, ?string &$failureReason = null): ?array
|
|
{
|
|
$failureReason = null;
|
|
|
|
// ID 또는 identifier로 템플릿 조회
|
|
$template = is_int($idOrIdentifier)
|
|
? $this->templateRepository->findById($idOrIdentifier)
|
|
: $this->templateRepository->findByIdentifier($idOrIdentifier);
|
|
|
|
if (! $template) {
|
|
throw ValidationException::withMessages([
|
|
'template' => [__('templates.errors.template_not_found', ['identifier' => $idOrIdentifier])],
|
|
]);
|
|
}
|
|
|
|
HookManager::doAction('core.templates.before_deactivate', $template->identifier);
|
|
|
|
try {
|
|
// 위치 인자로 넘긴다 — 이 의존성은 인터페이스 타입이고 테스트가 그 인터페이스를
|
|
// mock 하므로, 이름 붙인 인자는 mock 의 __call 에 닿아 "Unknown named parameter" 가 된다.
|
|
$result = $this->templateManager->deactivateTemplate(
|
|
$template->identifier,
|
|
DeactivationReason::Manual->value,
|
|
null,
|
|
$failureReason
|
|
);
|
|
|
|
if ($result) {
|
|
// 템플릿 매니저에서 업데이트된 정보 조회
|
|
// (after_deactivate 훅은 TemplateManager 가 string identifier 시그니처로 이미 발화)
|
|
$this->templateManager->loadTemplates();
|
|
$templateInfo = $this->templateManager->getTemplateInfo($template->identifier);
|
|
|
|
return $templateInfo;
|
|
}
|
|
|
|
return null;
|
|
} catch (\Exception $e) {
|
|
throw ValidationException::withMessages([
|
|
'identifier' => [__('templates.errors.deactivation_failed').': '.$e->getMessage()],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 템플릿을 활성화합니다.
|
|
*
|
|
* force 파라미터가 없고 필요한 의존성이 충족되지 않은 경우 경고를 반환합니다.
|
|
*
|
|
* @param int|string $idOrIdentifier 템플릿 ID 또는 식별자
|
|
* @param bool $force 의존성 미충족 시에도 강제 활성화
|
|
* @return array 활성화 결과 (성공 시 템플릿 정보, 경고 시 warning 배열)
|
|
*
|
|
* @throws ValidationException 활성화 실패 시
|
|
*/
|
|
public function activateTemplate(int|string $idOrIdentifier, bool $force = false): array
|
|
{
|
|
// ID 또는 identifier로 템플릿 조회
|
|
$template = is_int($idOrIdentifier)
|
|
? $this->templateRepository->findById($idOrIdentifier)
|
|
: $this->templateRepository->findByIdentifier($idOrIdentifier);
|
|
|
|
if (! $template) {
|
|
throw ValidationException::withMessages([
|
|
'template' => [__('templates.errors.template_not_found', ['identifier' => $idOrIdentifier])],
|
|
]);
|
|
}
|
|
|
|
HookManager::doAction('core.templates.before_activate', $template->identifier);
|
|
|
|
try {
|
|
// 필터 훅 - 활성화 데이터 변형
|
|
$data = ['status' => ExtensionStatus::Active->value];
|
|
$data = HookManager::applyFilters('core.templates.filter_activate_data', $data, $template);
|
|
|
|
// TemplateManager에 활성화 로직 위임 (force 파라미터 전달)
|
|
$result = $this->templateManager->activateTemplate($template->identifier, $force);
|
|
|
|
// 경고 응답인 경우 그대로 반환
|
|
if (isset($result['warning']) && $result['warning'] === true) {
|
|
return $result;
|
|
}
|
|
|
|
// 템플릿 매니저에서 업데이트된 정보 조회
|
|
$this->templateManager->loadTemplates();
|
|
$templateInfo = $this->templateManager->getTemplateInfo($template->identifier);
|
|
|
|
HookManager::doAction('core.templates.after_activate', $templateInfo);
|
|
|
|
return [
|
|
'success' => true,
|
|
'template_info' => $templateInfo,
|
|
];
|
|
} catch (\Exception $e) {
|
|
throw ValidationException::withMessages([
|
|
'identifier' => [__('templates.errors.activation_failed').': '.$e->getMessage()],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 템플릿 DB 레코드를 업데이트합니다 (before/after_update 훅 + filter_update_data 발화).
|
|
*
|
|
* @param int $id 템플릿 DB 레코드 ID
|
|
* @param array<string, mixed> $data 업데이트할 필드 (display_name/description/settings 등)
|
|
* @return object 갱신된 템플릿 레코드
|
|
*
|
|
* @throws TemplateOperationException 템플릿을 찾을 수 없을 때
|
|
*/
|
|
public function updateTemplate(int $id, array $data): object
|
|
{
|
|
HookManager::doAction('core.templates.before_update', $id, $data);
|
|
|
|
$template = $this->templateRepository->findById($id);
|
|
|
|
if (! $template) {
|
|
throw new TemplateOperationException('templates.not_found');
|
|
}
|
|
|
|
$data = HookManager::applyFilters('core.templates.filter_update_data', $data, $template);
|
|
|
|
$updatedTemplate = $this->templateRepository->update($id, $data);
|
|
|
|
HookManager::doAction('core.templates.after_update', $updatedTemplate, $data);
|
|
|
|
return $updatedTemplate;
|
|
}
|
|
|
|
/**
|
|
* 템플릿 DB 레코드를 삭제합니다 (before/after_delete 훅 발화).
|
|
*
|
|
* @param int $id 템플릿 DB 레코드 ID
|
|
* @return bool 삭제 성공 여부
|
|
*
|
|
* @throws TemplateOperationException 템플릿을 찾을 수 없을 때
|
|
*/
|
|
public function deleteTemplate(int $id): bool
|
|
{
|
|
HookManager::doAction('core.templates.before_delete', $id);
|
|
|
|
$template = $this->templateRepository->findById($id);
|
|
|
|
if (! $template) {
|
|
throw new TemplateOperationException('templates.not_found');
|
|
}
|
|
|
|
$result = $this->templateRepository->delete($id);
|
|
|
|
HookManager::doAction('core.templates.after_delete', $template, $result);
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 템플릿 정적 파일 경로 조회 및 검증
|
|
*
|
|
* @param string $identifier 템플릿 식별자
|
|
* @param string $path 파일 경로
|
|
* @return array{success: bool, filePath: string|null, mimeType: string|null, error: string|null}
|
|
*/
|
|
public function getAssetFilePath(string $identifier, string $path): array
|
|
{
|
|
// 1. 활성화된 템플릿 확인
|
|
$template = $this->templateRepository->findByIdentifier($identifier);
|
|
|
|
if (! $template || $template->status !== ExtensionStatus::Active->value) {
|
|
return [
|
|
'success' => false,
|
|
'filePath' => null,
|
|
'mimeType' => null,
|
|
'error' => 'template_not_found',
|
|
];
|
|
}
|
|
|
|
// 2. Path Traversal 방지
|
|
$safePath = $this->sanitizePath($path);
|
|
|
|
// 3. 파일 경로 구성
|
|
$filePath = base_path("templates/{$identifier}/dist/{$safePath}");
|
|
|
|
// 4. 파일 존재 확인
|
|
if (! file_exists($filePath) || ! is_file($filePath)) {
|
|
return [
|
|
'success' => false,
|
|
'filePath' => null,
|
|
'mimeType' => null,
|
|
'error' => 'file_not_found',
|
|
];
|
|
}
|
|
|
|
// 5. 보안 검증 (허용된 확장자만)
|
|
if (! $this->isAllowedExtension($filePath)) {
|
|
return [
|
|
'success' => false,
|
|
'filePath' => null,
|
|
'mimeType' => null,
|
|
'error' => 'file_type_not_allowed',
|
|
];
|
|
}
|
|
|
|
// 6. MIME 타입 감지
|
|
$mimeType = $this->getMimeType($filePath);
|
|
|
|
return [
|
|
'success' => true,
|
|
'filePath' => $filePath,
|
|
'mimeType' => $mimeType,
|
|
'error' => null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 컴포넌트 정의 파일 경로 조회 및 검증
|
|
*
|
|
* @param string $identifier 템플릿 식별자
|
|
* @return array{success: bool, componentsPath: string|null, error: string|null}
|
|
*/
|
|
public function getComponentsFilePath(string $identifier): array
|
|
{
|
|
// 1. 활성화된 템플릿 확인
|
|
$template = $this->templateRepository->findByIdentifier($identifier);
|
|
|
|
if (! $template || $template->status !== ExtensionStatus::Active->value) {
|
|
return [
|
|
'success' => false,
|
|
'componentsPath' => null,
|
|
'error' => 'template_not_found',
|
|
];
|
|
}
|
|
|
|
// 2. components.json 경로
|
|
$componentsPath = base_path("templates/{$identifier}/components.json");
|
|
|
|
// 3. 파일 존재 확인
|
|
if (! file_exists($componentsPath)) {
|
|
return [
|
|
'success' => false,
|
|
'componentsPath' => null,
|
|
'error' => 'components_not_found',
|
|
];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'componentsPath' => $componentsPath,
|
|
'error' => null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 템플릿의 다국어 파일 경로를 조회하고 검증합니다.
|
|
*
|
|
* @param string $identifier 템플릿 식별자
|
|
* @param string $locale 로케일 (ko, en 등)
|
|
* @return array{success: bool, langPath: string|null, error: string|null}
|
|
*/
|
|
public function getLanguageFilePath(string $identifier, string $locale): array
|
|
{
|
|
// 1. 로케일 형식 검증 (ISO 639-1: 2자리 소문자)
|
|
if (! preg_match('/^[a-z]{2}(-[A-Z]{2})?$/', $locale)) {
|
|
return [
|
|
'success' => false,
|
|
'langPath' => null,
|
|
'error' => 'invalid_locale',
|
|
];
|
|
}
|
|
|
|
// 2. 템플릿 DB 조회 및 활성화 여부 확인
|
|
$template = $this->templateRepository->findByIdentifier($identifier);
|
|
if (! $template || $template->status !== ExtensionStatus::Active->value) {
|
|
return [
|
|
'success' => false,
|
|
'langPath' => null,
|
|
'error' => 'template_not_found',
|
|
];
|
|
}
|
|
|
|
// 3. template.json에서 locales 목록 확인
|
|
$templateInfo = $this->getTemplateInfo($identifier);
|
|
if (! $templateInfo) {
|
|
return [
|
|
'success' => false,
|
|
'langPath' => null,
|
|
'error' => 'template_not_found',
|
|
];
|
|
}
|
|
|
|
// 4. 요청된 로케일 검증 — 시스템 활성 로케일(언어팩 반영) 기준
|
|
// 템플릿 자체 번역(`template.json` `locales`)에 없더라도, 활성 언어팩이
|
|
// 번역을 제공할 수 있으므로 시스템 supported_locales 로 통과시킨다.
|
|
$systemLocales = config('app.supported_locales', ['ko', 'en']);
|
|
if (! in_array($locale, $systemLocales, true)) {
|
|
return [
|
|
'success' => false,
|
|
'langPath' => null,
|
|
'error' => 'locale_not_supported',
|
|
];
|
|
}
|
|
|
|
// 5. lang/{locale}.json 파일 존재 여부 확인
|
|
$langPath = base_path("templates/{$identifier}/lang/{$locale}.json");
|
|
|
|
// 6. Path Traversal 공격 방지
|
|
$basePath = realpath(base_path("templates/{$identifier}/lang"));
|
|
$realPath = realpath($langPath);
|
|
|
|
if ($realPath !== false && ($basePath === false || ! str_starts_with($realPath, $basePath))) {
|
|
return [
|
|
'success' => false,
|
|
'langPath' => null,
|
|
'error' => 'file_not_found',
|
|
];
|
|
}
|
|
|
|
// 7. 템플릿이 자체 번역하지 않는 로케일(언어팩 전담)도 허용 — null 반환 시
|
|
// 호출자가 빈 베이스에 언어팩 데이터를 병합하도록 한다.
|
|
if (! file_exists($langPath)) {
|
|
$templateLocales = $templateInfo['locales'] ?? [];
|
|
if (! in_array($locale, $templateLocales, true)) {
|
|
return [
|
|
'success' => true,
|
|
'langPath' => null,
|
|
'error' => null,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'success' => false,
|
|
'langPath' => null,
|
|
'error' => 'file_not_found',
|
|
];
|
|
}
|
|
|
|
// 7. 성공 시 파일 경로 반환
|
|
return [
|
|
'success' => true,
|
|
'langPath' => $langPath,
|
|
'error' => null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 템플릿 다국어 데이터를 활성화된 모듈의 다국어와 병합하여 반환합니다.
|
|
*
|
|
* $partial 디렉티브를 사용하여 분할된 다국어 파일들을 자동으로 병합합니다.
|
|
*
|
|
* @param string $identifier 템플릿 식별자
|
|
* @param string $locale 로케일 (ko, en 등)
|
|
* @return array{success: bool, data: array|null, error: string|null}
|
|
*/
|
|
public function getLanguageDataWithModules(string $identifier, string $locale): array
|
|
{
|
|
// 1. 템플릿 다국어 파일 경로 조회
|
|
$result = $this->getLanguageFilePath($identifier, $locale);
|
|
|
|
if (! $result['success']) {
|
|
return [
|
|
'success' => false,
|
|
'data' => null,
|
|
'error' => $result['error'],
|
|
];
|
|
}
|
|
|
|
// 2. 템플릿 다국어 데이터 로드 (fragment 해석 포함)
|
|
// langPath 가 null 인 경우 템플릿이 해당 로케일을 자체 번역하지 않음 →
|
|
// 빈 베이스에서 시작해 활성 언어팩 데이터로만 채운다.
|
|
if ($result['langPath'] === null) {
|
|
$templateLangData = [];
|
|
} else {
|
|
$templateLangData = $this->loadLanguageFileWithFragments($result['langPath']);
|
|
|
|
if ($templateLangData === null) {
|
|
return [
|
|
'success' => false,
|
|
'data' => null,
|
|
'error' => 'invalid_json',
|
|
];
|
|
}
|
|
}
|
|
|
|
// 3. 활성화된 모듈들의 다국어 데이터 병합 (fragment 해석 포함)
|
|
$moduleLangData = $this->loadActiveModulesLanguageData($locale);
|
|
|
|
// 4. 활성화된 플러그인들의 다국어 데이터 병합 (fragment 해석 포함)
|
|
$pluginLangData = $this->loadActivePluginsLanguageData($locale);
|
|
|
|
// 5. 코어 자체의 프론트엔드 다국어 자원 로드 (베이스 레이어)
|
|
// 어떤 템플릿이 부팅되든 코어 키(`core.*`) 가 자동 노출되도록
|
|
// 가장 낮은 우선순위로 합류시킨다.
|
|
$coreLangData = $this->loadCoreFrontendLanguageData($locale);
|
|
|
|
// 6. 병합 순서: 코어 → 템플릿 → 모듈 → 플러그인 (코어가 가장 낮은 우선순위)
|
|
// 모듈/플러그인은 식별자 wrap 으로 충돌 없음. 코어 키는 `core.*` prefix 컨벤션.
|
|
//
|
|
// deep merge 사용: 동일 top-level 키(예: `layout_editor`) 의
|
|
// 하위 트리를 재귀 병합한다. 과거 array_merge 는 shallow 라서 템플릿이
|
|
// `layout_editor.palette` 만 정의해도 코어의 `layout_editor.chrome` 등
|
|
// 전체 트리가 통째로 교체되어 chrome/device/zoom 키가 미해석되었다.
|
|
// leaf 충돌 시 우선순위는 기존과 동일(코어 < 템플릿 < 모듈 < 플러그인).
|
|
$mergedData = $this->deepMergeLanguageData(
|
|
$coreLangData,
|
|
$templateLangData,
|
|
$moduleLangData,
|
|
$pluginLangData
|
|
);
|
|
|
|
// 7. 활성 언어팩의 frontend/*.json 병합 (가장 높은 우선순위 — 코어/모듈/플러그인 모두 덮어쓸 수 있음)
|
|
$mergedData = HookManager::applyFilters(
|
|
'template.language.merge',
|
|
$mergedData,
|
|
$identifier,
|
|
$locale
|
|
);
|
|
|
|
return [
|
|
'success' => true,
|
|
'data' => $mergedData,
|
|
'error' => null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 코어/템플릿/모듈/플러그인 다국어 데이터를 재귀 병합합니다.
|
|
*
|
|
* PHP `array_merge` 는 shallow merge 라서 동일 top-level 키(예: `layout_editor`) 가
|
|
* 양쪽에 있으면 뒤에 오는 쪽이 트리 전체를 통째로 교체한다. 본 helper 는 연관
|
|
* 배열(assoc) 끼리는 재귀적으로 leaf 까지 내려가며 병합하고, 시퀀셜 배열(list)
|
|
* 이나 scalar leaf 는 뒤에 오는 값으로 덮어쓴다.
|
|
*
|
|
* 우선순위(낮음 → 높음): 코어 < 템플릿 < 모듈 < 플러그인. 같은 키에 leaf 충돌
|
|
* 시 뒤에 오는 입력 우선. 이 정책은 기존 `array_merge` 호출 순서와 동일.
|
|
*
|
|
* 시퀀셜 배열 판정: 키가 `0, 1, 2, ...` 연속 정수 → list. 그 외 → assoc.
|
|
* lang JSON 은 본질적으로 assoc 트리(`{ "layout_editor": { "chrome": { ... } } }`)
|
|
* 이고 leaf 가 string 인 게 일반적이라 list 케이스는 드물지만, 어떤 키가 검증
|
|
* 룰 배열(`["required", "string"]`) 같은 list 를 leaf 로 둘 수 있어 명시적으로
|
|
* 분기한다.
|
|
*
|
|
* @param array ...$layers 병합할 lang 배열들 (낮은 우선순위 → 높은 우선순위)
|
|
* @return array 병합된 lang 데이터
|
|
*/
|
|
private function deepMergeLanguageData(array ...$layers): array
|
|
{
|
|
$result = [];
|
|
foreach ($layers as $layer) {
|
|
$result = $this->deepMergeTwo($result, $layer);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 두 배열을 재귀 병합합니다 (deepMergeLanguageData 의 내부 helper).
|
|
*
|
|
* @param array $base 기존 누적 결과 (낮은 우선순위)
|
|
* @param array $override 새로 합칠 입력 (높은 우선순위)
|
|
* @return array 병합 결과
|
|
*/
|
|
private function deepMergeTwo(array $base, array $override): array
|
|
{
|
|
foreach ($override as $key => $value) {
|
|
if (
|
|
array_key_exists($key, $base)
|
|
&& is_array($base[$key])
|
|
&& is_array($value)
|
|
&& $this->isAssocArray($base[$key])
|
|
&& $this->isAssocArray($value)
|
|
) {
|
|
// 양쪽이 assoc 트리 → 재귀
|
|
$base[$key] = $this->deepMergeTwo($base[$key], $value);
|
|
} else {
|
|
// list, scalar, 일방만 array → 덮어쓰기
|
|
$base[$key] = $value;
|
|
}
|
|
}
|
|
|
|
return $base;
|
|
}
|
|
|
|
/**
|
|
* 배열이 연관 배열(assoc) 인지 판정합니다.
|
|
*
|
|
* 빈 배열은 assoc 으로 취급해 양쪽이 빈 경우에도 재귀 호출이 안전하게 종료
|
|
* 되도록 한다. 키가 `0, 1, 2, ...` 연속 정수 sequence 면 list 로 판정.
|
|
*
|
|
* @param array $arr 검사할 배열
|
|
* @return bool assoc 여부
|
|
*/
|
|
private function isAssocArray(array $arr): bool
|
|
{
|
|
if ($arr === []) {
|
|
return true;
|
|
}
|
|
|
|
return ! array_is_list($arr);
|
|
}
|
|
|
|
/**
|
|
* 코어 자체의 프론트엔드 다국어 자원을 로드합니다.
|
|
*
|
|
* 어떤 템플릿이 부팅되든 코어 키(`core.*`) 가 자동 노출되도록
|
|
* 1단계(템플릿 lang) 앞에서 베이스 데이터로 로드됩니다.
|
|
*
|
|
* 모듈/플러그인/템플릿과 동일한 디렉토리 구조(`lang/{locale}.json` +
|
|
* `lang/partial/`) 를 사용하며, $partial 디렉티브 해석도 동일하게 처리됩니다.
|
|
*
|
|
* @param string $locale 로케일 (ko, en 등)
|
|
* @return array 코어 프론트엔드 다국어 데이터
|
|
*/
|
|
private function loadCoreFrontendLanguageData(string $locale): array
|
|
{
|
|
$coreLangPath = base_path("lang/{$locale}.json");
|
|
|
|
if (! file_exists($coreLangPath)) {
|
|
return [];
|
|
}
|
|
|
|
$data = $this->loadLanguageFileWithFragments($coreLangPath);
|
|
|
|
return is_array($data) ? $data : [];
|
|
}
|
|
|
|
/**
|
|
* 다국어 파일을 로드하고 $partial 디렉티브를 해석합니다.
|
|
*
|
|
* @param string $langPath 다국어 파일 경로
|
|
* @return array|null 해석된 다국어 데이터, 실패 시 null
|
|
*/
|
|
private function loadLanguageFileWithFragments(string $langPath): ?array
|
|
{
|
|
if (! file_exists($langPath)) {
|
|
return [];
|
|
}
|
|
|
|
$content = file_get_contents($langPath);
|
|
$data = json_decode($content, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
return null;
|
|
}
|
|
|
|
// Fragment 해석 (basePath는 lang 디렉토리 루트, $partial 값에 fragments/ko/... 전체 경로 포함)
|
|
$this->resetFragmentStack();
|
|
$basePath = dirname($langPath);
|
|
|
|
return $this->resolveLanguageFragments($data, $basePath);
|
|
}
|
|
|
|
/**
|
|
* 활성화된 모든 모듈의 다국어 데이터를 로드합니다.
|
|
*
|
|
* $partial 디렉티브를 사용하여 분할된 다국어 파일들을 자동으로 병합합니다.
|
|
*
|
|
* @param string $locale 로케일 (ko, en 등)
|
|
* @return array 모듈별로 식별자가 키인 다국어 데이터
|
|
*/
|
|
private function loadActiveModulesLanguageData(string $locale): array
|
|
{
|
|
$langData = [];
|
|
$activeModules = $this->moduleManager->getActiveModules();
|
|
|
|
foreach ($activeModules as $module) {
|
|
$moduleIdentifier = $module->getIdentifier();
|
|
$langFilePath = base_path("modules/{$moduleIdentifier}/resources/lang/{$locale}.json");
|
|
|
|
// 다국어 파일이 존재하는 경우에만 로드 (fragment 해석 포함)
|
|
$data = $this->loadLanguageFileWithFragments($langFilePath);
|
|
|
|
if ($data !== null && is_array($data) && ! empty($data)) {
|
|
$langData[$moduleIdentifier] = $data;
|
|
}
|
|
}
|
|
|
|
return $langData;
|
|
}
|
|
|
|
/**
|
|
* 활성화된 모든 플러그인의 다국어 데이터를 로드합니다.
|
|
*
|
|
* $partial 디렉티브를 사용하여 분할된 다국어 파일들을 자동으로 병합합니다.
|
|
*
|
|
* @param string $locale 로케일 (ko, en 등)
|
|
* @return array 플러그인별로 식별자가 키인 다국어 데이터
|
|
*/
|
|
private function loadActivePluginsLanguageData(string $locale): array
|
|
{
|
|
$langData = [];
|
|
$activePlugins = $this->pluginManager->getActivePlugins();
|
|
|
|
foreach ($activePlugins as $plugin) {
|
|
$pluginIdentifier = $plugin->getIdentifier();
|
|
$langFilePath = base_path("plugins/{$pluginIdentifier}/resources/lang/{$locale}.json");
|
|
|
|
// 다국어 파일이 존재하는 경우에만 로드 (fragment 해석 포함)
|
|
$data = $this->loadLanguageFileWithFragments($langFilePath);
|
|
|
|
if ($data !== null && is_array($data) && ! empty($data)) {
|
|
$langData[$pluginIdentifier] = $data;
|
|
}
|
|
}
|
|
|
|
return $langData;
|
|
}
|
|
|
|
/**
|
|
* 템플릿 routes.json 데이터를 활성화된 모듈의 routes와 병합하여 반환합니다.
|
|
*
|
|
* @param string $identifier 템플릿 식별자
|
|
* @return array{success: bool, data: array|null, error: string|null}
|
|
*/
|
|
public function getRoutesDataWithModules(string $identifier): array
|
|
{
|
|
$this->routeMergeDegraded = false;
|
|
|
|
// 1. 템플릿 DB 조회 및 활성화 여부 확인
|
|
$template = $this->templateRepository->findByIdentifier($identifier);
|
|
if (! $template || $template->status !== ExtensionStatus::Active->value) {
|
|
return [
|
|
'success' => false,
|
|
'data' => null,
|
|
'error' => 'template_not_found',
|
|
];
|
|
}
|
|
|
|
// 2. 템플릿 정보 조회
|
|
$templateInfo = $this->getTemplateInfo($identifier);
|
|
if (! $templateInfo) {
|
|
return [
|
|
'success' => false,
|
|
'data' => null,
|
|
'error' => 'template_not_found',
|
|
];
|
|
}
|
|
|
|
// 3. 템플릿 routes.json 파일 경로
|
|
$routesFilePath = base_path("templates/{$identifier}/routes.json");
|
|
|
|
// 4. routes.json 파일이 없는 경우
|
|
if (! file_exists($routesFilePath)) {
|
|
return [
|
|
'success' => false,
|
|
'data' => null,
|
|
'error' => 'routes_not_found',
|
|
];
|
|
}
|
|
|
|
// 5. 템플릿 routes.json 데이터 로드
|
|
$templateRoutesContent = file_get_contents($routesFilePath);
|
|
$templateRoutesData = json_decode($templateRoutesContent, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
return [
|
|
'success' => false,
|
|
'data' => null,
|
|
'error' => 'invalid_json',
|
|
];
|
|
}
|
|
|
|
// 6. 템플릿 타입 추출 (admin 또는 user)
|
|
$templateType = $template->type;
|
|
|
|
// 7. 템플릿 타입에 맞는 모듈 routes 데이터만 병합
|
|
$moduleRoutes = $this->loadActiveModulesRoutesData($templateType);
|
|
|
|
// 8. 플러그인 routes는 admin 템플릿에만 포함 (플러그인은 설정 페이지 등 admin 전용)
|
|
$pluginRoutes = ($templateType === 'admin')
|
|
? $this->loadActivePluginsRoutesData()
|
|
: [];
|
|
|
|
// 9. 템플릿 자체 라우트에 source 태깅
|
|
// 모듈/플러그인 라우트는 각 로더(loadActive*RoutesData)에서 이미 source 부여됨
|
|
$templateRoutes = array_map(function ($route) {
|
|
$route['source'] = ['kind' => 'template', 'identifier' => null];
|
|
|
|
return $route;
|
|
}, $templateRoutesData['routes'] ?? []);
|
|
|
|
$mergedRoutes = array_merge(
|
|
$templateRoutes,
|
|
$moduleRoutes,
|
|
$pluginRoutes
|
|
);
|
|
|
|
// 10. 시스템 라우트 주입 필터 — 코어/모듈/플러그인이 전역 라우트를 주입할 수 있는 확장점
|
|
$mergedRoutes = HookManager::applyFilters(
|
|
'core.routes.filter_merged',
|
|
$mergedRoutes,
|
|
$templateType,
|
|
$identifier
|
|
);
|
|
|
|
// 11. 필터 단계에서 주입된 시스템 라우트(preview 등)에 source 메타 fallback 부여
|
|
// (라우트 트리 그룹핑은 template/module/plugin 3종만 표시,
|
|
// source.kind='core' 인 라우트는 트리에서 제외)
|
|
$mergedRoutes = array_map(function ($route) {
|
|
if (! isset($route['source'])) {
|
|
$route['source'] = ['kind' => 'core', 'identifier' => null];
|
|
}
|
|
|
|
return $route;
|
|
}, $mergedRoutes);
|
|
|
|
// 12. 최종 데이터 구성
|
|
$resultData = [
|
|
'version' => $templateRoutesData['version'] ?? '1.0.0',
|
|
'routes' => $mergedRoutes,
|
|
];
|
|
|
|
return [
|
|
'success' => true,
|
|
'data' => $resultData,
|
|
'error' => null,
|
|
'degraded' => $this->routeMergeDegraded,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 직전 라우트 병합이 열화 상태였는지 여부를 반환합니다.
|
|
*
|
|
* 활성 모듈의 디렉토리가 없어 그 모듈 라우트가 통째로 빠진 경우 true.
|
|
*
|
|
* @return bool 열화 여부
|
|
*/
|
|
public function lastRouteMergeWasDegraded(): bool
|
|
{
|
|
return $this->routeMergeDegraded;
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 편집기용 routes 데이터를 source 태깅과 함께 반환합니다.
|
|
*
|
|
* `getRoutesDataWithModules` 와 동일하게 템플릿/모듈/플러그인 라우트에 `source`
|
|
* (`{kind, identifier}`)를 태깅하지만, 편집기 요구사항에 맞춰 두 가지가 다르다:
|
|
* - 활성/비활성 무관: 설치돼 있으나 비활성인 템플릿도 편집 가능해야 하므로
|
|
* 활성 상태 가드를 적용하지 않는다.
|
|
* - `_bundled` 폴백: 활성 디렉토리에 routes.json 이 없으면 `_bundled` 원본을 읽는다.
|
|
*
|
|
* source 태깅이 없으면 편집기 `buildRouteTree`(useRouteTree)가 `route.source.kind`
|
|
* 접근에서 throw 하여 라우트 트리 전체가 network 에러로 무너진다 — 본 메서드가
|
|
* 편집기 진입 routes 응답의 SSoT 다.
|
|
*
|
|
* @param string $identifier 템플릿 식별자
|
|
* @return array{success: bool, data: array|null, error: string|null}
|
|
*/
|
|
public function getEditorRoutesDataWithModules(string $identifier): array
|
|
{
|
|
// 열화 판정은 이 호출의 병합 결과만 가리켜야 한다. 이 서비스는 공유 인스턴스라
|
|
// 리셋하지 않으면 직전 호출(업데이트 스왑 창)의 판정이 인스턴스에 눌어붙어,
|
|
// 모듈 디렉토리가 복구된 뒤의 병합까지 열화로 보고된다.
|
|
$this->routeMergeDegraded = false;
|
|
|
|
// 1. routes.json 경로 — 활성 디렉토리 우선, _bundled 폴백 (활성/비활성 무관).
|
|
$candidates = [
|
|
base_path("templates/{$identifier}/routes.json"),
|
|
base_path("templates/_bundled/{$identifier}/routes.json"),
|
|
];
|
|
$routesFilePath = null;
|
|
foreach ($candidates as $candidate) {
|
|
if (file_exists($candidate)) {
|
|
$routesFilePath = $candidate;
|
|
break;
|
|
}
|
|
}
|
|
if ($routesFilePath === null) {
|
|
return ['success' => false, 'data' => null, 'error' => 'routes_not_found'];
|
|
}
|
|
|
|
// 2. routes.json 디코드
|
|
$templateRoutesData = json_decode((string) file_get_contents($routesFilePath), true);
|
|
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($templateRoutesData)) {
|
|
return ['success' => false, 'data' => null, 'error' => 'invalid_json'];
|
|
}
|
|
|
|
// 3. 템플릿 타입(admin/user) — 모듈/플러그인 라우트 병합 분기에 사용.
|
|
// DB 모델(비활성 포함) → 매니페스트 info → 'user' 순으로 폴백.
|
|
$template = $this->templateRepository->findByIdentifier($identifier);
|
|
$templateType = $template->type
|
|
?? ($this->getTemplateInfo($identifier)['type'] ?? 'user');
|
|
|
|
// 4. 템플릿 라우트에 source 태깅 (모듈/플러그인 로더는 자체 source 부여).
|
|
$templateRoutes = array_map(function ($route) {
|
|
$route['source'] = ['kind' => 'template', 'identifier' => null];
|
|
|
|
return $route;
|
|
}, $templateRoutesData['routes'] ?? []);
|
|
|
|
$moduleRoutes = $this->loadActiveModulesRoutesData($templateType);
|
|
$pluginRoutes = ($templateType === 'admin')
|
|
? $this->loadActivePluginsRoutesData()
|
|
: [];
|
|
|
|
$mergedRoutes = array_merge($templateRoutes, $moduleRoutes, $pluginRoutes);
|
|
|
|
// 5. 시스템 라우트 주입 필터 (getRoutesDataWithModules 와 동일 확장점).
|
|
$mergedRoutes = HookManager::applyFilters(
|
|
'core.routes.filter_merged',
|
|
$mergedRoutes,
|
|
$templateType,
|
|
$identifier
|
|
);
|
|
|
|
// 6. source 미부여 라우트(필터 주입 시스템 라우트 등)에 core fallback.
|
|
$mergedRoutes = array_map(function ($route) {
|
|
if (! isset($route['source'])) {
|
|
$route['source'] = ['kind' => 'core', 'identifier' => null];
|
|
}
|
|
|
|
return $route;
|
|
}, $mergedRoutes);
|
|
|
|
// 7. base 레이아웃 + 인라인 모달 수집 — 위지윅
|
|
// 편집기 라우트 트리의 `[공통 레이아웃]` · `[모달]` 그룹 SSoT. routes.json 에는
|
|
// base/modal 정보가 없으므로 레이아웃 파일에서 직접 수집한다. 모듈/플러그인
|
|
// 수집은 라우트 로더와 동일하게 템플릿 타입(admin/user)으로 필터링한다.
|
|
$baseAndModals = $this->collectEditorBaseAndModals($identifier, $templateType);
|
|
|
|
// 8. 레이아웃별 현재(최신) 저장 버전 맵 — 라우트 트리 버전 배지.
|
|
// 버전 이력이 있는 레이아웃만 포함(미저장 = 원본 → 배지 미표시). 템플릿이
|
|
// DB 에 없으면(파일만 존재) 버전 이력도 없으므로 빈 맵.
|
|
$layoutVersions = $template
|
|
? $this->layoutVersionRepository->getCurrentVersionsByTemplateId($template->id)
|
|
: [];
|
|
|
|
return [
|
|
'success' => true,
|
|
'data' => [
|
|
'version' => $templateRoutesData['version'] ?? '1.0.0',
|
|
'routes' => $mergedRoutes,
|
|
'base_layouts' => $baseAndModals['base_layouts'],
|
|
'modals' => $baseAndModals['modals'],
|
|
'layout_versions' => $layoutVersions,
|
|
],
|
|
'error' => null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 편집기 라우트 트리의 `[공통 레이아웃]` · `[모달]` 그룹용 base 레이아웃 + 모달
|
|
* 목록을 템플릿 + 활성 모듈/플러그인 레이아웃 파일에서 수집합니다.
|
|
*
|
|
* 다음 세 출처의 `layouts/` 를 재귀 순회해 수집한다(계획서 8.4.5 — "대상 템플릿의
|
|
* 모든 레이아웃의 modals 섹션을 스캔"):
|
|
* - 템플릿: `templates/{id}/layouts/` (활성 → `_bundled` 폴백). host_layout 접두사 없음.
|
|
* - 활성 모듈: `modules/{id}/resources/layouts/`. host_layout 에 `{moduleId}.` 접두사.
|
|
* - 활성 플러그인: `plugins/{id}/resources/layouts/`. host_layout 에 `{pluginId}.` 접두사.
|
|
*
|
|
* 각 레이아웃에서:
|
|
* - `meta.is_base === true` → base 레이아웃 항목(`{layout_name, label}`).
|
|
* - `modals[]` 의 모달(인라인 정의 + `{"partial": "..."}` 참조 모두) → 모달 항목
|
|
* (`{modal_id, host_layout, label}`). partial 참조는 참조 파일을 읽어 id/title 추출.
|
|
*
|
|
* host_layout 접두사 규약은 라우트 트리 layout 규약(`loadActiveModulesRoutesData`
|
|
* 의 `{moduleId}.{layout}`)과 일치시켜 트리에서 호스트 화면 노드와 매칭되게 한다.
|
|
*
|
|
* 모듈/플러그인 레이아웃은 라우트 로더(`routes/{admin|user}.json` 분기)와 동일하게
|
|
* 템플릿 타입(admin/user)으로 필터링한다 — `layouts/{admin|user}/` 서브디렉토리만
|
|
* 순회한다. 이를 누락하면 user 템플릿 편집기에 admin 레이아웃의 모달이(또는 그 반대로)
|
|
* 새어 들어와 트리에 노출된다. 타입 서브디렉토리가 없는 출처는 라우트 로더와 동형으로
|
|
* 건너뛴다(타입 미일치 레이아웃 비노출). 템플릿 자체 레이아웃은 그 템플릿 전용이므로
|
|
* 타입 필터 없이 전부 수집한다.
|
|
*
|
|
* 라벨은 base 는 `meta.editor_label`/`layout_name`, 모달은 `modal.meta.editor_label`/
|
|
* `modal.title`/`modal.id` 우선순위로 해석한다(편집기 클라이언트 라벨 규칙과 동형).
|
|
* 활성/비활성 무관(편집은 비활성 템플릿도 허용), 파일 부재/파싱 실패는
|
|
* 조용히 건너뛴다(라우트 그룹은 정상 표시).
|
|
*
|
|
* @param string $identifier 템플릿 식별자
|
|
* @param string $templateType 템플릿 타입(`admin`/`user`) — 모듈/플러그인 레이아웃 타입 필터
|
|
* @return array{base_layouts: array<int, array{layout_name: string, label: string|null}>, modals: array<int, array{modal_id: string, host_layout: string, label: string|null}>}
|
|
*/
|
|
private function collectEditorBaseAndModals(string $identifier, string $templateType): array
|
|
{
|
|
$baseLayouts = [];
|
|
$modals = [];
|
|
|
|
// 1. 템플릿 레이아웃 (활성 → _bundled 폴백). host_layout 접두사 없음.
|
|
// 템플릿 레이아웃은 해당 템플릿 전용이므로 타입 필터 없이 전부 수집한다.
|
|
$templateDir = null;
|
|
foreach ([
|
|
base_path("templates/{$identifier}/layouts"),
|
|
base_path("templates/_bundled/{$identifier}/layouts"),
|
|
] as $candidate) {
|
|
if (File::isDirectory($candidate)) {
|
|
$templateDir = $candidate;
|
|
break;
|
|
}
|
|
}
|
|
if ($templateDir !== null) {
|
|
$collected = $this->collectBaseAndModalsFromLayoutDir($templateDir, '');
|
|
$baseLayouts = array_merge($baseLayouts, $collected['base_layouts']);
|
|
$modals = array_merge($modals, $collected['modals']);
|
|
}
|
|
|
|
// 2. 활성 모듈 레이아웃. host_layout 에 `{moduleId}.` 접두사(라우트 규약 동형).
|
|
// 라우트 로더와 동일하게 `layouts/{templateType}/` 서브디렉토리만 순회한다.
|
|
foreach ($this->moduleManager->getActiveModules() as $module) {
|
|
$moduleId = $module->getIdentifier();
|
|
$moduleDir = base_path("modules/{$moduleId}/resources/layouts/{$templateType}");
|
|
if (! File::isDirectory($moduleDir)) {
|
|
continue;
|
|
}
|
|
$collected = $this->collectBaseAndModalsFromLayoutDir($moduleDir, $moduleId.'.');
|
|
$baseLayouts = array_merge($baseLayouts, $collected['base_layouts']);
|
|
$modals = array_merge($modals, $collected['modals']);
|
|
}
|
|
|
|
// 3. 활성 플러그인 레이아웃. host_layout 에 `{pluginId}.` 접두사.
|
|
// 플러그인 라우트는 admin 템플릿에만 포함되므로(라우트 로더 규약), 모달도
|
|
// admin 템플릿에서만 `layouts/{templateType}/` 서브디렉토리를 순회한다.
|
|
if ($templateType === 'admin') {
|
|
foreach ($this->pluginManager->getActivePlugins() as $plugin) {
|
|
$pluginId = $plugin->getIdentifier();
|
|
$pluginDir = base_path("plugins/{$pluginId}/resources/layouts/{$templateType}");
|
|
if (! File::isDirectory($pluginDir)) {
|
|
continue;
|
|
}
|
|
$collected = $this->collectBaseAndModalsFromLayoutDir($pluginDir, $pluginId.'.');
|
|
$baseLayouts = array_merge($baseLayouts, $collected['base_layouts']);
|
|
$modals = array_merge($modals, $collected['modals']);
|
|
}
|
|
}
|
|
|
|
return ['base_layouts' => $baseLayouts, 'modals' => $modals];
|
|
}
|
|
|
|
/**
|
|
* 한 `layouts/` 디렉토리를 재귀 순회해 base 레이아웃 + 모달을 수집합니다.
|
|
*
|
|
* `collectEditorBaseAndModals` 의 출처별(템플릿/모듈/플러그인) 공통 수집 본체.
|
|
* `partials/` 디렉토리는 모달 본체(partial)일 뿐 호스트 레이아웃이 아니므로 제외한다
|
|
* — partial 모달은 각 호스트의 `modals[]` 참조로 수집된다.
|
|
*
|
|
* @param string $layoutDir 스캔할 layouts 디렉토리 절대 경로
|
|
* @param string $hostPrefix host_layout/layout_name 에 붙일 접두사(예 `sirsoft-ecommerce.`). 템플릿은 빈 문자열.
|
|
* @return array{base_layouts: array<int, array{layout_name: string, label: string|null}>, modals: array<int, array{modal_id: string, host_layout: string, label: string|null}>}
|
|
*/
|
|
private function collectBaseAndModalsFromLayoutDir(string $layoutDir, string $hostPrefix): array
|
|
{
|
|
$baseLayouts = [];
|
|
$modals = [];
|
|
|
|
// 라우트 레이아웃은 하위 디렉토리(`auth/`, `shop/`, `admin/` 등)에 있으므로 재귀 순회한다
|
|
// (File::files 는 최상위만 읽어 하위 레이아웃의 모달을 누락한다).
|
|
$partialsDirPrefix = $layoutDir.DIRECTORY_SEPARATOR.'partials';
|
|
foreach (File::allFiles($layoutDir) as $file) {
|
|
if ($file->getExtension() !== 'json') {
|
|
continue;
|
|
}
|
|
if (str_starts_with($file->getPathname(), $partialsDirPrefix)) {
|
|
continue;
|
|
}
|
|
|
|
$decoded = json_decode((string) File::get($file->getPathname()), true);
|
|
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($decoded)) {
|
|
continue;
|
|
}
|
|
|
|
// layout_name 미선언 시 layouts 디렉토리 기준 상대 경로(확장자 제외)로 폴백한다
|
|
// — 라우트 layoutName 규약(`auth/register`)과 일치시켜 host_layout 매칭을 보장.
|
|
$relativeName = str_replace(
|
|
['\\', '.json'],
|
|
['/', ''],
|
|
$file->getRelativePathname()
|
|
);
|
|
$layoutBaseName = is_string($decoded['layout_name'] ?? null) && $decoded['layout_name'] !== ''
|
|
? $decoded['layout_name']
|
|
: $relativeName;
|
|
// 모듈/플러그인은 라우트 트리 layout 규약(`{id}.{layout}`)과 동일하게 접두사를 붙인다.
|
|
$layoutName = $hostPrefix.$layoutBaseName;
|
|
|
|
// base 레이아웃
|
|
if (($decoded['meta']['is_base'] ?? false) === true) {
|
|
$baseLayouts[] = [
|
|
'layout_name' => $layoutName,
|
|
'label' => $this->resolveEditorTreeLabel($decoded['meta'] ?? [], null),
|
|
];
|
|
}
|
|
|
|
// 모달 수집 — 인라인 정의 + partial 참조 모두.
|
|
//
|
|
// 호스트 레이아웃의 `modals[]` 에 선언된 모달은 인라인/partial 여부와 무관하게
|
|
// 동일한 모달이다(런타임/DB 에서는 partial 이 펼쳐져 호스트 content 의 완전한
|
|
// 모달 노드로 저장된다). 편집기 트리에 노출돼야 호스트 하위 "이 화면의 모달"
|
|
// 그룹·전체 `[모달]` 그룹에서 편집 가능하다. partial 참조는 그 파일을 읽어
|
|
// id/title 을 추출한다.
|
|
$layoutModals = $decoded['modals'] ?? null;
|
|
if (is_array($layoutModals)) {
|
|
foreach ($layoutModals as $modal) {
|
|
if (! is_array($modal)) {
|
|
continue;
|
|
}
|
|
|
|
// partial 참조 모달 → 참조 파일을 읽어 모달 노드를 끌어온다.
|
|
// partial 경로는 호스트 파일 디렉토리 기준(1순위) → layouts 루트(폴백)로 해석한다.
|
|
if (isset($modal['partial']) && is_string($modal['partial'])) {
|
|
$resolved = $this->resolveModalPartial($modal['partial'], $layoutDir, $file->getPath());
|
|
if ($resolved === null) {
|
|
continue;
|
|
}
|
|
$modal = $resolved;
|
|
}
|
|
|
|
$modalId = $modal['id'] ?? $modal['modal_id'] ?? null;
|
|
if (! is_string($modalId) || $modalId === '') {
|
|
continue;
|
|
}
|
|
// 라벨 fallback: 모달 노드 최상위 `title` → Modal 컴포넌트 `props.title`.
|
|
// G7 모달은 제목을 Modal 컴포넌트의 `props.title` 로 두는 것이 일반적이므로
|
|
// (최상위 `title` 키는 거의 쓰이지 않음), props.title 까지 fallback 으로 읽어야
|
|
// 편집기 트리에 modal_id 원문 대신 친화 제목(대개 `$t:` 키)이 표시된다.
|
|
$modalTitle = $modal['title']
|
|
?? (is_array($modal['props'] ?? null) ? ($modal['props']['title'] ?? null) : null);
|
|
$modals[] = [
|
|
'modal_id' => $modalId,
|
|
'host_layout' => $layoutName,
|
|
'label' => $this->resolveEditorTreeLabel($modal['meta'] ?? [], is_string($modalTitle) ? $modalTitle : null),
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
return ['base_layouts' => $baseLayouts, 'modals' => $modals];
|
|
}
|
|
|
|
/**
|
|
* 호스트 레이아웃 `modals[]` 의 partial 참조를 실제 모달 노드로 해석합니다.
|
|
*
|
|
* 편집기 트리 모달 수집은 레이아웃 파일 raw 를 읽으므로, partial 참조 모달은 그 자체엔
|
|
* `id`/`title` 이 없다(`{"partial": "..."}` 뿐). 참조 파일을 읽어 모달 노드를 끌어와
|
|
* 인라인 모달과 동일하게 다룬다. 경로 규약은 레이아웃 partial 해석과 동형: 호스트 파일
|
|
* 디렉토리 기준 상대 경로, `partials/` 시작 시 layouts 루트 폴백. layouts 디렉토리 밖
|
|
* 참조는 거부(경로 traversal 방지). 파일 부재/파싱 실패는 null(조용히 건너뜀).
|
|
*
|
|
* @param string $partialPath modals[].partial 값 (예: `partials/auth/_modal_terms.json`)
|
|
* @param string $layoutDir layouts 디렉토리 절대 경로(보안 경계 + 루트 폴백 기준)
|
|
* @param string $hostFileDir 호스트 레이아웃 파일이 위치한 디렉토리 절대 경로(1순위 기준)
|
|
* @return array<string, mixed>|null 해석된 모달 노드 배열 또는 null
|
|
*/
|
|
private function resolveModalPartial(string $partialPath, string $layoutDir, string $hostFileDir): ?array
|
|
{
|
|
$layoutDirReal = realpath($layoutDir);
|
|
if ($layoutDirReal === false) {
|
|
return null;
|
|
}
|
|
$normalize = static fn (string $p): string => str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $p);
|
|
|
|
// 경로 해석 규약(레이아웃 partial 과 동형): ① 호스트 파일 디렉토리 기준 →
|
|
// ② layouts 루트 기준 폴백. 두 결합을 순서대로 시도해 먼저 존재하는 파일을 채택한다.
|
|
// - 호스트 기준: 예) layouts/admin/admin_ecommerce_order_detail.json 의
|
|
// `partials/.../x.json` → layouts/admin/partials/.../x.json
|
|
// - 루트 폴백: 예) layouts/auth/register.json 의 `partials/auth/x.json`
|
|
// → layouts/partials/auth/x.json
|
|
$resolved = false;
|
|
foreach ([$hostFileDir, $layoutDirReal] as $baseDir) {
|
|
$candidate = realpath($baseDir.DIRECTORY_SEPARATOR.$partialPath);
|
|
if ($candidate === false) {
|
|
continue;
|
|
}
|
|
// 보안 — layouts 디렉토리 밖 참조 거부(경로 traversal 방지).
|
|
if (! str_starts_with($normalize($candidate), $normalize($layoutDirReal))) {
|
|
continue;
|
|
}
|
|
$resolved = $candidate;
|
|
break;
|
|
}
|
|
|
|
if ($resolved === false) {
|
|
return null;
|
|
}
|
|
|
|
$decoded = json_decode((string) File::get($resolved), true);
|
|
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($decoded)) {
|
|
return null;
|
|
}
|
|
|
|
return $decoded;
|
|
}
|
|
|
|
/**
|
|
* 편집기 트리 노드 라벨을 해석합니다 (base/modal 공용).
|
|
*
|
|
* `meta.editor_label`(다국어 `$t:` 키 허용) → 전달된 fallback(모달 title 등) →
|
|
* null 순으로 해석한다. 클라이언트(useRouteTree.resolveLabel)와 동일하게 `$t:` 키는
|
|
* 그대로 전달해 프론트가 해석하도록 한다.
|
|
*
|
|
* @param array $meta 레이아웃/모달 meta 배열
|
|
* @param string|null $fallback meta.editor_label 부재 시 사용할 라벨(모달 title 등)
|
|
* @return string|null 해석된 라벨 또는 null
|
|
*/
|
|
private function resolveEditorTreeLabel(array $meta, ?string $fallback): ?string
|
|
{
|
|
$editorLabel = $meta['editor_label'] ?? null;
|
|
if (is_string($editorLabel) && $editorLabel !== '') {
|
|
return $editorLabel;
|
|
}
|
|
|
|
return is_string($fallback) && $fallback !== '' ? $fallback : null;
|
|
}
|
|
|
|
/**
|
|
* 템플릿의 레이아웃 이름 → 라우트 path 매핑을 반환합니다.
|
|
*
|
|
* 코드 편집기가 파일 선택 시 `?route=` URL 동기화 / 위지윅 편집기에서 넘어온
|
|
* `?route=` 로 해당 레이아웃을 복원하는 데 사용한다. path 내 `{{...}}` 표현식은
|
|
* 위지윅 편집기(클라이언트 useEditorRoutes)와 동일하게 모듈 설정 기반으로 해석해
|
|
* 양쪽 path 가 일치하도록 한다. redirect / layout 미지정 / 해석 불가 라우트는
|
|
* 매핑에서 제외하며, 동일 레이아웃을 여러 라우트가 공유하면 먼저 선언된 path 를 채택한다.
|
|
*
|
|
* @param string $identifier 템플릿 식별자
|
|
* @return array<string, string> 레이아웃 이름 → 라우트 path
|
|
*/
|
|
public function getLayoutRoutePathMap(string $identifier): array
|
|
{
|
|
$routesData = $this->getRoutesDataWithModules($identifier);
|
|
$routes = $routesData['data']['routes'] ?? [];
|
|
|
|
$map = [];
|
|
foreach ($routes as $route) {
|
|
$layout = $route['layout'] ?? null;
|
|
$path = $route['path'] ?? null;
|
|
if (! is_string($layout) || $layout === '' || ! is_string($path) || $path === '') {
|
|
continue;
|
|
}
|
|
$resolvedPath = $this->resolveRoutePathExpressions($path);
|
|
if ($resolvedPath === '') {
|
|
continue;
|
|
}
|
|
if (! isset($map[$layout])) {
|
|
$map[$layout] = $resolvedPath;
|
|
}
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
/**
|
|
* 라우트 path 내 `{{...}}` 표현식을 위지윅 편집기와 동일하게 해석합니다.
|
|
*
|
|
* 위지윅 편집기는 클라이언트에서 `_global.modules` 컨텍스트로 path 표현식을 평가해
|
|
* selectedRoute.path 를 만든다(useEditorRoutes.resolveEditorRouteExpressions).
|
|
* 코드 편집기의 `?route=` 가 그 값과 매칭되려면 서버 맵도 동일하게 해석해야 한다.
|
|
* 모듈 route_path 토글 패턴을 g7_module_settings 로 치환하고, 평가 불가한 표현식은
|
|
* 클라이언트 fallback 과 동일하게 비운다.
|
|
*
|
|
* @param string $path 원본 라우트 path
|
|
* @return string 해석된 path (선행 `*` 제거, 중복 슬래시 정규화)
|
|
*/
|
|
private function resolveRoutePathExpressions(string $path): string
|
|
{
|
|
$path = ltrim($path, '*');
|
|
|
|
$resolved = (string) preg_replace_callback('/\{\{(.+?)\}\}/', function (array $m): string {
|
|
$expr = trim($m[1]);
|
|
if (preg_match("/modules\?\.\['([^']+)'\]\?\.([\\w?.]+)\s*\?\?\s*'([^']+)'/", $expr, $mm)) {
|
|
$moduleId = $mm[1];
|
|
$settingKey = str_replace('?.', '.', $mm[2]);
|
|
$default = $mm[3];
|
|
if (preg_match('/\.no_route\s*\?/', $expr) && g7_module_settings($moduleId, 'basic_info.no_route')) {
|
|
return '';
|
|
}
|
|
|
|
return (string) (g7_module_settings($moduleId, $settingKey) ?? $default);
|
|
}
|
|
|
|
return '';
|
|
}, $path);
|
|
|
|
$resolved = (string) preg_replace('#/+#', '/', $resolved);
|
|
if ($resolved !== '/') {
|
|
$resolved = rtrim($resolved, '/');
|
|
}
|
|
|
|
return $resolved;
|
|
}
|
|
|
|
/**
|
|
* 활성화된 모든 모듈의 routes 데이터를 템플릿 타입에 맞게 로드합니다.
|
|
*
|
|
* 새 구조(routes/admin.json, routes/user.json)를 우선 탐색하고,
|
|
* 레거시 구조(routes.json)는 admin 타입에만 폴백으로 적용합니다.
|
|
*
|
|
* @param string $templateType 템플릿 타입 ('admin' 또는 'user')
|
|
* @return array 모든 모듈의 routes 배열
|
|
*/
|
|
private function loadActiveModulesRoutesData(string $templateType = 'admin'): array
|
|
{
|
|
$routes = [];
|
|
$activeModules = $this->moduleManager->getActiveModules();
|
|
|
|
foreach ($activeModules as $module) {
|
|
$moduleIdentifier = $module->getIdentifier();
|
|
|
|
// 새 구조: routes/{type}.json 우선
|
|
$typedRoutesPath = base_path("modules/{$moduleIdentifier}/resources/routes/{$templateType}.json");
|
|
|
|
// 레거시 구조: routes.json 폴백 (admin 타입에만 적용)
|
|
$legacyRoutesPath = base_path("modules/{$moduleIdentifier}/resources/routes.json");
|
|
|
|
$routesFilePath = null;
|
|
|
|
if (file_exists($typedRoutesPath)) {
|
|
$routesFilePath = $typedRoutesPath;
|
|
} elseif ($templateType === 'admin' && file_exists($legacyRoutesPath)) {
|
|
$routesFilePath = $legacyRoutesPath;
|
|
Log::warning('모듈 routes.json이 레거시 위치에 있습니다. routes/admin.json으로 이동하세요.', [
|
|
'module' => $moduleIdentifier,
|
|
'path' => $legacyRoutesPath,
|
|
]);
|
|
}
|
|
|
|
if ($routesFilePath === null) {
|
|
// 활성 모듈의 디렉토리 자체가 없다면 업데이트 중 활성 디렉토리가 잠시 비운
|
|
// 상태다(정상적으로 admin 라우트가 없는 모듈과 구분된다). 이때의 병합 결과는
|
|
// 그 모듈의 화면이 통째로 빠진 열화 스냅샷이므로 호출자가 캐시하지 않도록
|
|
// 표시한다. 표시 없이 캐시되면 업데이트가 끝난 뒤에도 캐시가 만료될 때까지
|
|
// 해당 모듈의 모든 화면이 404 로 남는다.
|
|
if (! is_dir(base_path("modules/{$moduleIdentifier}"))) {
|
|
$this->routeMergeDegraded = true;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
$content = file_get_contents($routesFilePath);
|
|
$data = json_decode($content, true);
|
|
|
|
// JSON 파싱 성공 시 routes 배열 병합
|
|
if (json_last_error() === JSON_ERROR_NONE && isset($data['routes']) && is_array($data['routes'])) {
|
|
// 모듈 routes의 layout 필드에 moduleIdentifier 접두사 추가 + source 태깅
|
|
$moduleRoutes = array_map(function ($route) use ($moduleIdentifier) {
|
|
if (isset($route['layout'])) {
|
|
$route['layout'] = $moduleIdentifier.'.'.$route['layout'];
|
|
}
|
|
$route['source'] = ['kind' => 'module', 'identifier' => $moduleIdentifier];
|
|
|
|
return $route;
|
|
}, $data['routes']);
|
|
|
|
$routes = array_merge($routes, $moduleRoutes);
|
|
}
|
|
}
|
|
|
|
return $routes;
|
|
}
|
|
|
|
/**
|
|
* 활성화된 모든 플러그인의 routes 데이터를 로드합니다.
|
|
*
|
|
* @return array 모든 플러그인의 routes 배열
|
|
*/
|
|
private function loadActivePluginsRoutesData(): array
|
|
{
|
|
$routes = [];
|
|
$activePlugins = $this->pluginManager->getActivePlugins();
|
|
|
|
foreach ($activePlugins as $plugin) {
|
|
$pluginIdentifier = $plugin->getIdentifier();
|
|
$routesFilePath = base_path("plugins/{$pluginIdentifier}/resources/routes.json");
|
|
|
|
// routes.json 파일이 존재하는 경우에만 로드
|
|
if (file_exists($routesFilePath)) {
|
|
$content = file_get_contents($routesFilePath);
|
|
$data = json_decode($content, true);
|
|
|
|
// JSON 파싱 성공 시 routes 배열 병합
|
|
if (json_last_error() === JSON_ERROR_NONE && isset($data['routes']) && is_array($data['routes'])) {
|
|
// 플러그인 routes의 layout 필드에 pluginIdentifier 접두사 추가 + source 태깅
|
|
$pluginRoutes = array_map(function ($route) use ($pluginIdentifier) {
|
|
if (isset($route['layout'])) {
|
|
$route['layout'] = $pluginIdentifier.'.'.$route['layout'];
|
|
}
|
|
$route['source'] = ['kind' => 'plugin', 'identifier' => $pluginIdentifier];
|
|
|
|
return $route;
|
|
}, $data['routes']);
|
|
|
|
$routes = array_merge($routes, $pluginRoutes);
|
|
}
|
|
}
|
|
|
|
// 설정 페이지가 있는 플러그인은 자동으로 설정 라우트 생성
|
|
if ($plugin->hasSettings()) {
|
|
$routes[] = [
|
|
'path' => '*/admin/plugins/'.$pluginIdentifier.'/settings',
|
|
'layout' => $pluginIdentifier.'.plugin_settings',
|
|
'auth_required' => true,
|
|
'params' => [
|
|
'identifier' => $pluginIdentifier,
|
|
],
|
|
'meta' => [
|
|
'title' => '$t:'.$pluginIdentifier.'.settings.title',
|
|
'permission' => 'core.plugins.read',
|
|
],
|
|
'source' => ['kind' => 'plugin', 'identifier' => $pluginIdentifier],
|
|
];
|
|
}
|
|
}
|
|
|
|
return $routes;
|
|
}
|
|
|
|
/**
|
|
* Path Traversal 방지를 위한 경로 정제
|
|
*/
|
|
private function sanitizePath(string $path): string
|
|
{
|
|
// ../ 및 ..\ 패턴 제거 — 결과가 안정될 때까지 반복한다.
|
|
//
|
|
// 1회성 치환이면 제거 자체가 새 패턴을 만들어낸다:
|
|
// '....//' → 가운데 '../' 제거 → '../' (탈출 시퀀스 복원)
|
|
// 현재는 FormRequest 의 realpath 검사가 앞단에서 막고 있으나,
|
|
// 방어 계층이 하나 무력한 상태로 두지 않는다.
|
|
do {
|
|
$previous = $path;
|
|
$path = str_replace(['../', '..\\'], '', $path);
|
|
} while ($path !== $previous);
|
|
|
|
// 절대 경로 방지
|
|
return ltrim($path, '/\\');
|
|
}
|
|
|
|
/**
|
|
* 허용된 파일 확장자 확인
|
|
*/
|
|
private function isAllowedExtension(string $filePath): bool
|
|
{
|
|
$allowedExtensions = [
|
|
'js', 'mjs', 'css', 'json',
|
|
'png', 'jpg', 'jpeg', 'svg', 'webp', 'gif',
|
|
'woff', 'woff2', 'ttf', 'otf', 'eot',
|
|
];
|
|
|
|
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
|
|
|
return in_array($extension, $allowedExtensions);
|
|
}
|
|
|
|
/**
|
|
* MIME 타입 감지
|
|
*/
|
|
private function getMimeType(string $filePath): string
|
|
{
|
|
$mimeTypes = [
|
|
'js' => 'application/javascript',
|
|
'mjs' => 'application/javascript',
|
|
'css' => 'text/css',
|
|
'json' => 'application/json',
|
|
'png' => 'image/png',
|
|
'jpg' => 'image/jpeg',
|
|
'jpeg' => 'image/jpeg',
|
|
'svg' => 'image/svg+xml',
|
|
'webp' => 'image/webp',
|
|
'gif' => 'image/gif',
|
|
'woff' => 'font/woff',
|
|
'woff2' => 'font/woff2',
|
|
'ttf' => 'font/ttf',
|
|
'otf' => 'font/otf',
|
|
'eot' => 'application/vnd.ms-fontobject',
|
|
];
|
|
|
|
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
|
|
|
return $mimeTypes[$extension] ?? 'application/octet-stream';
|
|
}
|
|
|
|
/**
|
|
* ZIP 파일에서 템플릿을 설치합니다.
|
|
*
|
|
* @param UploadedFile $file 업로드된 ZIP 파일
|
|
* @return array 설치된 템플릿 정보
|
|
*
|
|
* @throws \RuntimeException 설치 실패 시
|
|
*/
|
|
/**
|
|
* 업로드된 ZIP 의 manifest 와 검증 결과만 추출합니다 (실제 설치 X).
|
|
*
|
|
* 사용자가 템플릿 설치 전 template.json 검증 실패 사유를 미리 확인할 수 있게 합니다.
|
|
*
|
|
* @param UploadedFile $file 업로드된 ZIP 파일
|
|
* @return array{manifest: ?array<string, mixed>, validation: array<string, mixed>} 미리보기 결과
|
|
*/
|
|
public function previewManifest(UploadedFile $file): array
|
|
{
|
|
$tempPath = storage_path('app/temp/templates');
|
|
$extractPath = $tempPath.'/preview-'.uniqid('template_');
|
|
$manifest = null;
|
|
$errors = [];
|
|
|
|
try {
|
|
File::ensureDirectoryExists($tempPath);
|
|
|
|
$result = ZipInstallHelper::extractAndValidate(
|
|
$file->getRealPath(), $extractPath, 'template.json', 'templates'
|
|
);
|
|
|
|
$manifest = $result['config'];
|
|
} catch (\Throwable $e) {
|
|
$errors[] = $e->getMessage();
|
|
} finally {
|
|
if (File::exists($extractPath)) {
|
|
File::deleteDirectory($extractPath);
|
|
}
|
|
}
|
|
|
|
$existing = $manifest && ! empty($manifest['identifier'])
|
|
? $this->templateRepository->findByIdentifier($manifest['identifier'])
|
|
: null;
|
|
|
|
return [
|
|
'manifest' => $manifest,
|
|
'validation' => [
|
|
'errors' => $errors,
|
|
'is_valid' => $errors === [] && $manifest !== null,
|
|
'already_installed' => $existing !== null,
|
|
'existing_version' => $existing?->version,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 업로드된 ZIP 파일로부터 템플릿을 추출/검증하고 _pending 으로 이동 후 설치합니다.
|
|
*
|
|
* `template.json` 검증 → identifier 충돌 검사 → _pending 이동 → 설치 파이프라인 진입.
|
|
*
|
|
* @param UploadedFile $file 사용자가 업로드한 템플릿 ZIP 파일
|
|
* @return array 설치 결과 (identifier/version/installed_at 포함)
|
|
*/
|
|
public function installFromZipFile(UploadedFile $file): array
|
|
{
|
|
$tempPath = storage_path('app/temp/templates');
|
|
$extractPath = $tempPath.'/'.uniqid('template_');
|
|
|
|
try {
|
|
File::ensureDirectoryExists($tempPath);
|
|
|
|
$result = ZipInstallHelper::extractAndValidate(
|
|
$file->getRealPath(), $extractPath, 'template.json', 'templates'
|
|
);
|
|
|
|
$this->ensureTemplateNotInstalled($result['identifier']);
|
|
|
|
ZipInstallHelper::moveToPending(
|
|
$result['sourcePath'], base_path('templates/_pending'), $result['identifier']
|
|
);
|
|
|
|
try {
|
|
return $this->executeTemplateInstall($result['identifier']);
|
|
} catch (\Throwable $e) {
|
|
$pendingPath = base_path('templates/_pending/'.$result['identifier']);
|
|
if (File::exists($pendingPath)) {
|
|
File::deleteDirectory($pendingPath);
|
|
}
|
|
throw $e;
|
|
}
|
|
} catch (TemplateOperationException $e) {
|
|
throw $e;
|
|
} catch (\RuntimeException $e) {
|
|
// ZipInstallHelper 등 설치 원본 처리의 raw RuntimeException(깨진 zip·manifest
|
|
// 누락 같은 사용자 입력 오류)을 도메인 예외로 승격한다 — 컨트롤러의 좁혀진
|
|
// catch 가 인프라 예외와 구분해 종전 422 계약을 유지하고, 사유는 :error 로 보존.
|
|
throw new TemplateOperationException('templates.errors.install_failed', ['error' => $e->getMessage()], $e);
|
|
} finally {
|
|
if (File::exists($extractPath)) {
|
|
File::deleteDirectory($extractPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GitHub 저장소에서 템플릿을 설치합니다.
|
|
*
|
|
* @param string $githubUrl GitHub 저장소 URL
|
|
* @return array 설치된 템플릿 정보
|
|
*
|
|
* @throws \RuntimeException 설치 실패 시
|
|
*/
|
|
public function installFromGithub(string $githubUrl): array
|
|
{
|
|
$tempPath = storage_path('app/temp/templates');
|
|
$extractPath = $tempPath.'/'.uniqid('template_');
|
|
$zipPath = null;
|
|
|
|
try {
|
|
File::ensureDirectoryExists($tempPath);
|
|
|
|
[$owner, $repo] = GithubHelper::parseUrl($githubUrl);
|
|
|
|
$token = config('app.update.github_token') ?? '';
|
|
|
|
if (! GithubHelper::checkRepoExists($owner, $repo, $token)) {
|
|
throw new TemplateOperationException('templates.errors.github_repo_not_found');
|
|
}
|
|
|
|
$zipPath = GithubHelper::downloadZip($owner, $repo, $tempPath, $token);
|
|
|
|
$result = ZipInstallHelper::extractAndValidate(
|
|
$zipPath, $extractPath, 'template.json', 'templates'
|
|
);
|
|
|
|
$this->ensureTemplateNotInstalled($result['identifier']);
|
|
|
|
ZipInstallHelper::moveToPending(
|
|
$result['sourcePath'], base_path('templates/_pending'), $result['identifier']
|
|
);
|
|
|
|
try {
|
|
return $this->executeTemplateInstall($result['identifier']);
|
|
} catch (\Throwable $e) {
|
|
$pendingPath = base_path('templates/_pending/'.$result['identifier']);
|
|
if (File::exists($pendingPath)) {
|
|
File::deleteDirectory($pendingPath);
|
|
}
|
|
throw $e;
|
|
}
|
|
} catch (TemplateOperationException $e) {
|
|
throw $e;
|
|
} catch (\RuntimeException $e) {
|
|
// GithubHelper·ZipInstallHelper 의 raw RuntimeException(잘못된 URL·다운로드
|
|
// 실패·manifest 오류)을 도메인 예외로 승격한다 — 종전 422 계약 유지, 사유 보존.
|
|
throw new TemplateOperationException('templates.errors.install_failed', ['error' => $e->getMessage()], $e);
|
|
} finally {
|
|
if (File::exists($extractPath)) {
|
|
File::deleteDirectory($extractPath);
|
|
}
|
|
if ($zipPath && File::exists($zipPath)) {
|
|
File::delete($zipPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 템플릿이 이미 설치되어 있는지 확인합니다.
|
|
*
|
|
* _bundled/_pending에만 존재하는 경우(is_installed=false)는 설치 허용합니다.
|
|
*
|
|
* @param string $identifier 템플릿 식별자
|
|
*
|
|
* @throws \RuntimeException 이미 설치된 경우
|
|
*/
|
|
private function ensureTemplateNotInstalled(string $identifier): void
|
|
{
|
|
$this->templateManager->loadTemplates();
|
|
$existingTemplate = $this->templateManager->getTemplateInfo($identifier);
|
|
|
|
if ($existingTemplate && $existingTemplate['is_installed']) {
|
|
throw new TemplateOperationException('templates.errors.already_installed');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* _pending에서 템플릿을 설치합니다.
|
|
*
|
|
* @param string $identifier 템플릿 식별자
|
|
* @return array 설치된 템플릿 정보
|
|
*
|
|
* @throws \RuntimeException 설치 실패 시
|
|
*/
|
|
private function executeTemplateInstall(string $identifier): array
|
|
{
|
|
$this->templateManager->loadTemplates();
|
|
$result = $this->templateManager->installTemplate($identifier);
|
|
|
|
if (! $result) {
|
|
// installTemplate 은 사유 out 파라미터를 갖지 않으므로 일반 문구로 채운다.
|
|
// 비워 두면 치환 자리가 남아 관리자 화면에 리터럴 ':error' 가 노출된다.
|
|
throw new TemplateOperationException('templates.errors.install_failed', [
|
|
'error' => __('templates.errors.unknown_error'),
|
|
]);
|
|
}
|
|
|
|
return $this->templateManager->getTemplateInfo($identifier);
|
|
}
|
|
|
|
/**
|
|
* 템플릿의 컴포넌트 목록을 조회합니다.
|
|
*
|
|
* @param string $identifier 템플릿 식별자
|
|
* @return array{basic: array, composite: array} 컴포넌트 목록
|
|
*/
|
|
public function getTemplateComponents(string $identifier): array
|
|
{
|
|
$componentsPath = base_path("templates/{$identifier}/components.json");
|
|
|
|
if (! File::exists($componentsPath)) {
|
|
return ['basic' => [], 'composite' => []];
|
|
}
|
|
|
|
$content = File::get($componentsPath);
|
|
$components = json_decode($content, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
return ['basic' => [], 'composite' => []];
|
|
}
|
|
|
|
$basic = [];
|
|
$composite = [];
|
|
|
|
foreach ($components['components'] ?? [] as $component) {
|
|
$name = $component['name'] ?? '';
|
|
$type = $component['type'] ?? 'basic';
|
|
|
|
if ($type === 'basic') {
|
|
$basic[] = $name;
|
|
} elseif ($type === 'composite') {
|
|
$composite[] = $name;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'basic' => $basic,
|
|
'composite' => $composite,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 템플릿의 레이아웃을 파일에서 다시 읽어 DB에 갱신합니다.
|
|
*
|
|
* @param string $identifier 템플릿 식별자
|
|
* @return array|null 갱신 결과 또는 null
|
|
*
|
|
* @throws ValidationException 레이아웃 갱신 실패 시
|
|
*/
|
|
public function refreshTemplateLayouts(string $identifier): ?array
|
|
{
|
|
HookManager::doAction('core.templates.before_refresh_layouts', $identifier);
|
|
|
|
try {
|
|
$this->templateManager->loadTemplates();
|
|
$result = $this->templateManager->refreshTemplateLayouts($identifier);
|
|
|
|
if ($result['success']) {
|
|
$templateInfo = $this->templateManager->getTemplateInfo($identifier);
|
|
|
|
HookManager::doAction('core.templates.after_refresh_layouts', $identifier, $result);
|
|
|
|
return $templateInfo;
|
|
}
|
|
|
|
return null;
|
|
} catch (\Exception $e) {
|
|
throw ValidationException::withMessages([
|
|
'identifier' => [__('templates.errors.refresh_layouts_failed').': '.$e->getMessage()],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 특정 모듈에 의존하는 템플릿 목록을 조회합니다.
|
|
*
|
|
* @param string $moduleIdentifier 모듈 식별자
|
|
* @return array 의존하는 템플릿 목록
|
|
*/
|
|
public function getTemplatesDependingOnModule(string $moduleIdentifier): array
|
|
{
|
|
$this->templateManager->loadTemplates();
|
|
|
|
$dependentTemplates = [];
|
|
|
|
// 설치된 모든 템플릿 조회
|
|
$installedTemplates = $this->templateManager->getInstalledTemplatesWithDetails();
|
|
|
|
foreach ($installedTemplates as $template) {
|
|
$dependencies = $template['dependencies'] ?? [];
|
|
|
|
// 모듈 의존성 확인
|
|
if (isset($dependencies['modules']) && is_array($dependencies['modules'])) {
|
|
if (array_key_exists($moduleIdentifier, $dependencies['modules'])) {
|
|
$dependentTemplates[] = [
|
|
'identifier' => $template['identifier'],
|
|
'name' => $template['name'],
|
|
'version' => $template['version'],
|
|
'type' => $template['type'],
|
|
'status' => $template['status'],
|
|
'required_version' => $dependencies['modules'][$moduleIdentifier],
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
return $dependentTemplates;
|
|
}
|
|
|
|
/**
|
|
* 특정 플러그인에 의존하는 템플릿 목록을 조회합니다.
|
|
*
|
|
* @param string $pluginIdentifier 플러그인 식별자
|
|
* @return array 의존하는 템플릿 목록
|
|
*/
|
|
public function getTemplatesDependingOnPlugin(string $pluginIdentifier): array
|
|
{
|
|
$this->templateManager->loadTemplates();
|
|
|
|
$dependentTemplates = [];
|
|
|
|
// 설치된 모든 템플릿 조회
|
|
$installedTemplates = $this->templateManager->getInstalledTemplatesWithDetails();
|
|
|
|
foreach ($installedTemplates as $template) {
|
|
$dependencies = $template['dependencies'] ?? [];
|
|
|
|
// 플러그인 의존성 확인
|
|
if (isset($dependencies['plugins']) && is_array($dependencies['plugins'])) {
|
|
if (array_key_exists($pluginIdentifier, $dependencies['plugins'])) {
|
|
$dependentTemplates[] = [
|
|
'identifier' => $template['identifier'],
|
|
'name' => $template['name'],
|
|
'version' => $template['version'],
|
|
'type' => $template['type'],
|
|
'status' => $template['status'],
|
|
'required_version' => $dependencies['plugins'][$pluginIdentifier],
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
return $dependentTemplates;
|
|
}
|
|
|
|
/**
|
|
* 모든 설치된 템플릿의 업데이트를 확인합니다.
|
|
*
|
|
* @return array 업데이트 확인 결과 (updated_count, details)
|
|
*
|
|
* @throws ValidationException 확인 실패 시
|
|
*/
|
|
public function checkForUpdates(): array
|
|
{
|
|
HookManager::doAction('core.templates.before_check_updates');
|
|
|
|
try {
|
|
$this->templateManager->loadTemplates();
|
|
$result = $this->templateManager->checkAllTemplatesForUpdates();
|
|
|
|
HookManager::doAction('core.templates.after_check_updates', $result);
|
|
|
|
return $result;
|
|
} catch (\Exception $e) {
|
|
throw ValidationException::withMessages([
|
|
'templates' => [__('templates.check_updates_failed', ['error' => $e->getMessage()])],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 지정된 템플릿의 버전을 업데이트합니다.
|
|
*
|
|
* @param string $templateName 업데이트할 템플릿 identifier
|
|
* @param string $layoutStrategy 레이아웃 전략 ('overwrite' 또는 'keep')
|
|
* @param bool $force 코어 버전 비호환 강제 우회 (위험 — 사용자 명시 필요)
|
|
* @return array 업데이트 결과 (identifier, from_version, to_version 등)
|
|
*
|
|
* @throws ValidationException 업데이트 실패 시
|
|
*/
|
|
public function performVersionUpdate(string $templateName, string $layoutStrategy = 'overwrite', bool $force = false): array
|
|
{
|
|
HookManager::doAction('core.templates.before_version_update', $templateName);
|
|
|
|
try {
|
|
$this->templateManager->loadTemplates();
|
|
$result = $this->templateManager->updateTemplate($templateName, $force, null, $layoutStrategy);
|
|
|
|
$templateInfo = $this->templateManager->getTemplateInfo($templateName);
|
|
|
|
HookManager::doAction('core.templates.after_version_update', $templateName, $result, $templateInfo);
|
|
|
|
return array_merge($result, [
|
|
'template_info' => $templateInfo,
|
|
]);
|
|
} catch (\Exception $e) {
|
|
// Manager의 RuntimeException은 이미 번역된 메시지를 포함하므로
|
|
// getPrevious()로 원본 에러를 추출하여 이중 래핑 방지
|
|
$rawError = $e->getPrevious() ? $e->getPrevious()->getMessage() : $e->getMessage();
|
|
|
|
throw ValidationException::withMessages([
|
|
'template_name' => [__('templates.errors.update_failed', ['template' => $templateName, 'error' => $rawError])],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 지정된 템플릿의 수정된 레이아웃을 확인합니다.
|
|
*
|
|
* @param string $templateName 확인할 템플릿 identifier
|
|
* @return array{has_modified_layouts: bool, modified_count: int, modified_layouts: array}
|
|
*
|
|
* @throws ValidationException 확인 실패 시
|
|
*/
|
|
public function checkModifiedLayouts(string $templateName): array
|
|
{
|
|
try {
|
|
$this->templateManager->loadTemplates();
|
|
|
|
return $this->templateManager->hasModifiedLayouts($templateName);
|
|
} catch (\Exception $e) {
|
|
throw ValidationException::withMessages([
|
|
'template_name' => [__('templates.check_modified_layouts_failed', ['error' => $e->getMessage()])],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 템플릿의 변경 내역(changelog)을 조회합니다.
|
|
*
|
|
* source가 'github'이면 GitHub에서 원격 CHANGELOG.md를 가져와 파싱합니다.
|
|
*
|
|
* @param string $identifier 템플릿 식별자
|
|
* @param string|null $source 소스 ('active', 'bundled', 'github')
|
|
* @param string|null $fromVersion 시작 버전 (초과)
|
|
* @param string|null $toVersion 끝 버전 (이하)
|
|
* @return array 변경 내역 배열
|
|
*/
|
|
public function getTemplateChangelog(string $identifier, ?string $source = null, ?string $fromVersion = null, ?string $toVersion = null): array
|
|
{
|
|
// GitHub 소스: 원격에서 CHANGELOG.md를 가져옴
|
|
if ($source === 'github') {
|
|
return $this->fetchRemoteChangelog($identifier, $fromVersion, $toVersion);
|
|
}
|
|
|
|
$basePath = base_path('templates');
|
|
$filePath = ChangelogParser::resolveChangelogPath($basePath, $identifier, $source);
|
|
|
|
if ($filePath === null) {
|
|
return [];
|
|
}
|
|
|
|
if ($fromVersion !== null && $toVersion !== null) {
|
|
return ChangelogParser::getVersionRange($filePath, $fromVersion, $toVersion);
|
|
}
|
|
|
|
return ChangelogParser::parse($filePath);
|
|
}
|
|
|
|
/**
|
|
* GitHub에서 원격 CHANGELOG.md를 가져와 파싱합니다.
|
|
*
|
|
* @param string $identifier 템플릿 식별자
|
|
* @param string|null $fromVersion 시작 버전 (초과)
|
|
* @param string|null $toVersion 끝 버전 (이하)
|
|
* @return array 변경 내역 배열
|
|
*/
|
|
private function fetchRemoteChangelog(string $identifier, ?string $fromVersion = null, ?string $toVersion = null): array
|
|
{
|
|
$template = $this->templateManager->getTemplate($identifier);
|
|
|
|
if (! $template) {
|
|
return [];
|
|
}
|
|
|
|
$githubUrl = $template['github_url'] ?? null;
|
|
|
|
if (empty($githubUrl)) {
|
|
return $this->getTemplateChangelog($identifier, 'bundled', $fromVersion, $toVersion);
|
|
}
|
|
|
|
try {
|
|
[$owner, $repo] = GithubHelper::parseUrl($githubUrl);
|
|
} catch (\RuntimeException $e) {
|
|
return $this->getTemplateChangelog($identifier, 'bundled', $fromVersion, $toVersion);
|
|
}
|
|
|
|
$ref = $toVersion ?? 'main';
|
|
$content = GithubHelper::fetchRawFile($owner, $repo, $ref, 'CHANGELOG.md');
|
|
|
|
if ($content === null) {
|
|
return $this->getTemplateChangelog($identifier, 'bundled', $fromVersion, $toVersion);
|
|
}
|
|
|
|
if ($fromVersion !== null && $toVersion !== null) {
|
|
return ChangelogParser::getVersionRangeFromString($content, $fromVersion, $toVersion);
|
|
}
|
|
|
|
return ChangelogParser::parseFromString($content);
|
|
}
|
|
}
|