여러 화면에서 조건부(FormRequest) 검증이 우회되거나 과도하게 제한되던 결함을 한 계열로 묶어 수정한다. - 검증 규칙 확장 필터 이름 10개를 나머지 92개와 동일 규칙으로 통일 - 설명 길이를 바이트가 아닌 글자 수로 계산(한글 오검출 해소) - 비활성 언어팩 번역 보존한 채 수정 허용 - 알림 템플릿 제목 nullable 존중, 미지원 발송 수단 저장 차단 - 일정/실행 이력 목록 조회 검증 정합 - 게시판 첨부 미사용 시 빈 확장자 통과, new_display_hours/최소 길이 0 허용, 일괄 적용 override_values 범위 검증(SSoT) - 이커머스 클레임 사유 조회 검증, 용어 정정 - 원문 i18n 키 노출 다수 해소, 저장/삭제 실패 시 사유 화면 노출 - 아이콘 크기/다크모드 대비 회귀 수정 번들 확장(모듈3·플러그인6·템플릿2·언어팩5) 버전 new-bump 및 CHANGELOG 반영, ja 언어팩 동기.
283 lines
10 KiB
PHP
283 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Seo;
|
|
|
|
use App\Contracts\Extension\ModuleManagerInterface;
|
|
use App\Contracts\Extension\PluginManagerInterface;
|
|
use App\Contracts\Extension\TemplateManagerInterface;
|
|
use App\Services\TemplateService;
|
|
|
|
class TemplateRouteResolver
|
|
{
|
|
/**
|
|
* 템플릿 routes.json 이 아닌 **코어가 직접 소유하는** admin 경로 접두사.
|
|
*
|
|
* 레이아웃 편집기는 코어 기능이라 어느 템플릿의 routes.json 에도 선언되지 않는다.
|
|
* 템플릿 라우트만 보고 판정하면 실제로 동작하는 화면이 HTTP 404 로 서빙되므로
|
|
* (본문은 SPA 셸이라 화면은 뜨지만 상태 코드만 404), 존재하는 경로로 인정한다.
|
|
*
|
|
* @var array<string>
|
|
*/
|
|
private const CORE_ADMIN_ROUTE_PREFIXES = [
|
|
'/admin/layout-editor',
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly TemplateService $templateService,
|
|
private readonly TemplateManagerInterface $templateManager,
|
|
private readonly ModuleManagerInterface $moduleManager,
|
|
private readonly PluginManagerInterface $pluginManager,
|
|
) {}
|
|
|
|
/**
|
|
* URL을 템플릿 레이아웃으로 매핑합니다.
|
|
*
|
|
* @param string $url 요청 URL 경로
|
|
* @return array|null { templateIdentifier, layoutName, routeParams, moduleIdentifier? } 또는 null
|
|
*/
|
|
public function resolve(string $url): ?array
|
|
{
|
|
$loaded = $this->loadActiveRoutes('user');
|
|
if ($loaded === null) {
|
|
return null;
|
|
}
|
|
|
|
[$templateIdentifier, $routes] = $loaded;
|
|
|
|
foreach ($routes as $route) {
|
|
// auth_required 라우트 제외
|
|
if ($route['auth_required'] ?? false) {
|
|
continue;
|
|
}
|
|
|
|
// guest_only 라우트 제외
|
|
if ($route['guest_only'] ?? false) {
|
|
continue;
|
|
}
|
|
|
|
$routePath = $route['path'] ?? '';
|
|
$routePath = $this->resolveRoutePath($routePath);
|
|
|
|
$params = $this->matchRoute($routePath, $url);
|
|
if ($params !== null) {
|
|
$layoutName = $route['layout'] ?? '';
|
|
|
|
// 확장 식별자 추출 (레이아웃명에 '.' 포함 시)
|
|
$moduleIdentifier = null;
|
|
$pluginIdentifier = null;
|
|
if (str_contains($layoutName, '.')) {
|
|
[$extensionId, $layoutName] = explode('.', $layoutName, 2);
|
|
|
|
// 모듈/플러그인 판별
|
|
if ($this->moduleManager->getModule($extensionId) !== null) {
|
|
$moduleIdentifier = $extensionId;
|
|
} elseif ($this->pluginManager->getPlugin($extensionId) !== null) {
|
|
$pluginIdentifier = $extensionId;
|
|
} else {
|
|
// fallback: 기존 동작 유지 (모듈로 간주)
|
|
$moduleIdentifier = $extensionId;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'templateIdentifier' => $templateIdentifier,
|
|
'layoutName' => $layoutName,
|
|
'routeParams' => $params,
|
|
'moduleIdentifier' => $moduleIdentifier,
|
|
'pluginIdentifier' => $pluginIdentifier,
|
|
'routeMeta' => $route['meta'] ?? [],
|
|
];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* URL이 활성 템플릿(+모듈/플러그인 병합)의 등록된 라우트에 존재하는지 판별합니다.
|
|
*
|
|
* resolve()와 달리 auth_required/guest_only 라우트도 "존재"로 인정합니다.
|
|
* 미등록 경로 catch-all 의 404 판별에 사용합니다 (공개#47).
|
|
*
|
|
* @param string $url 요청 URL 경로 (선행 '/' 포함)
|
|
* @param string $type 템플릿 타입 ('user' | 'admin')
|
|
* @return bool 등록 라우트면 true, 미등록이면 false. 템플릿 미설치/로드 실패 시 true(404 억제, 안전측).
|
|
*/
|
|
public function routeExists(string $url, string $type = 'user'): bool
|
|
{
|
|
// 코어 소유 admin 경로는 템플릿 routes.json 에 없어도 존재하는 경로다.
|
|
if ($type === 'admin' && $this->isCoreAdminRoute($url)) {
|
|
return true;
|
|
}
|
|
|
|
$loaded = $this->loadActiveRoutes($type);
|
|
// 템플릿 미설치/로드 실패 시 전 경로 404 차단 — 안전측 폴백 (D-47-3).
|
|
if ($loaded === null) {
|
|
return true;
|
|
}
|
|
|
|
[, $routes] = $loaded;
|
|
|
|
foreach ($routes as $route) {
|
|
// auth_required/guest_only 검사 없이 path 만 매칭 (resolve()와 차이 — 핵심).
|
|
$routePath = $this->resolveRoutePath($route['path'] ?? '');
|
|
|
|
if ($this->matchRoute($routePath, $url) !== null) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* URL 이 코어가 직접 소유하는 admin 경로인지 판정합니다.
|
|
*
|
|
* 접두사 뒤에는 경로 구분자(`/`) 또는 문자열 끝만 허용한다 — `/admin/layout-editorX`
|
|
* 같은 유사 경로가 통과하지 않도록 한다.
|
|
*
|
|
* @param string $url 요청 URL 경로
|
|
* @return bool 코어 소유 경로면 true
|
|
*/
|
|
private function isCoreAdminRoute(string $url): bool
|
|
{
|
|
$path = '/'.trim(parse_url($url, PHP_URL_PATH) ?? $url, '/');
|
|
|
|
foreach (self::CORE_ADMIN_ROUTE_PREFIXES as $prefix) {
|
|
if ($path === $prefix || str_starts_with($path, $prefix.'/')) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 활성 템플릿($type)의 병합 routes 배열을 로드합니다.
|
|
*
|
|
* @param string $type 템플릿 타입 ('user' | 'admin')
|
|
* @return array{0: string, 1: array}|null [templateIdentifier, routes] 또는 실패 시 null
|
|
*/
|
|
private function loadActiveRoutes(string $type): ?array
|
|
{
|
|
$activeTemplate = $this->templateManager->getActiveTemplate($type);
|
|
if (! $activeTemplate) {
|
|
return null;
|
|
}
|
|
|
|
$templateIdentifier = $activeTemplate['identifier'] ?? null;
|
|
if (! $templateIdentifier) {
|
|
return null;
|
|
}
|
|
|
|
$routesResult = $this->templateService->getRoutesDataWithModules($templateIdentifier);
|
|
if (! ($routesResult['success'] ?? false) || empty($routesResult['data']['routes'])) {
|
|
return null;
|
|
}
|
|
|
|
return [$templateIdentifier, $routesResult['data']['routes']];
|
|
}
|
|
|
|
/**
|
|
* 라우트 경로의 동적 표현식을 해석합니다.
|
|
*
|
|
* @param string $routePath 라우트 경로
|
|
* @return string 해석된 경로
|
|
*/
|
|
private function resolveRoutePath(string $routePath): string
|
|
{
|
|
// 와일드카드 '*' 제거 (prefix 표시용)
|
|
$routePath = ltrim($routePath, '*/');
|
|
if (! str_starts_with($routePath, '/')) {
|
|
$routePath = '/'.$routePath;
|
|
}
|
|
|
|
// 템플릿 표현식 해석: {{_global.modules?.['sirsoft-ecommerce']?.basic_info?.route_path ?? 'shop'}}
|
|
$routePath = (string) preg_replace_callback('/\{\{(.+?)\}\}/', function ($matches) {
|
|
$expr = trim($matches[1]);
|
|
|
|
return $this->resolveRouteExpression($expr);
|
|
}, $routePath);
|
|
|
|
// 이중 슬래시 정규화 (빈 표현식 결과 시 발생)
|
|
$routePath = preg_replace('#/+#', '/', $routePath);
|
|
|
|
return $routePath;
|
|
}
|
|
|
|
/**
|
|
* 라우트 경로 내 표현식을 해석합니다.
|
|
*
|
|
* @param string $expr 표현식
|
|
* @return string 해석된 값
|
|
*/
|
|
private function resolveRouteExpression(string $expr): string
|
|
{
|
|
// 삼항 연산자 패턴: condition ? '' : (modules?.['id']?.key ?? 'default')
|
|
// no_route 플래그가 true면 빈 문자열, 아니면 route_path 또는 기본값 반환
|
|
if (preg_match("/\.no_route\s*\?/", $expr)) {
|
|
// 삼항 else 절(: 이후)에서 모듈 설정 추출
|
|
$elseClause = preg_match("/:\s*(.+)$/", $expr, $elseMatch) ? $elseMatch[1] : '';
|
|
if ($elseClause !== '' && preg_match("/modules\?\.\['([^']+)'\]\?\.([\\w?.]+)\s*\?\?\s*'(.+?)'/", $elseClause, $matches)) {
|
|
$moduleId = $matches[1];
|
|
// optional chaining 제거: basic_info?.route_path → basic_info.route_path
|
|
$settingKey = str_replace('?.', '.', $matches[2]);
|
|
$default = $matches[3];
|
|
|
|
// no_route 설정 확인
|
|
$noRoute = g7_module_settings($moduleId, 'basic_info.no_route');
|
|
if ($noRoute) {
|
|
return '';
|
|
}
|
|
|
|
return g7_module_settings($moduleId, $settingKey) ?? $default;
|
|
}
|
|
}
|
|
|
|
// _global.modules?.['module-id']?.key ?? 'default' 패턴 처리 (optional chaining 포함)
|
|
if (preg_match("/modules\?\.\['([^']+)'\]\?\.([\\w?.]+)\s*\?\?\s*'(.+?)'/", $expr, $matches)) {
|
|
$moduleId = $matches[1];
|
|
$settingKey = str_replace('?.', '.', $matches[2]);
|
|
$default = $matches[3];
|
|
|
|
return g7_module_settings($moduleId, $settingKey) ?? $default;
|
|
}
|
|
|
|
// 단순 null coalescing
|
|
if (str_contains($expr, '??')) {
|
|
$parts = array_map('trim', explode('??', $expr, 2));
|
|
|
|
return trim($parts[1], " '\"()");
|
|
}
|
|
|
|
return $expr;
|
|
}
|
|
|
|
/**
|
|
* URL이 라우트 패턴에 매칭되는지 확인합니다.
|
|
*
|
|
* @param string $routePath 라우트 패턴 (예: /shop/products/:id)
|
|
* @param string $url 실제 URL (예: /shop/products/123)
|
|
* @return array|null 매칭된 파라미터 또는 null
|
|
*/
|
|
private function matchRoute(string $routePath, string $url): ?array
|
|
{
|
|
// 정규식으로 변환
|
|
$pattern = preg_replace('/:(\w+)/', '(?P<$1>[^/]+)', $routePath);
|
|
$pattern = '#^'.$pattern.'$#';
|
|
|
|
if (preg_match($pattern, $url, $matches)) {
|
|
$params = [];
|
|
foreach ($matches as $key => $value) {
|
|
if (is_string($key)) {
|
|
$params[$key] = $value;
|
|
}
|
|
}
|
|
|
|
return $params;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|