Files
Gnuboard7/app/Seo/TemplateRouteResolver.php
T
HeuJung 7f8fb6870f fix(core,board,ecommerce): 조건부 검증 결함 패밀리 일괄 수정 및 실패 안내 노출
여러 화면에서 조건부(FormRequest) 검증이 우회되거나 과도하게 제한되던 결함을 한 계열로 묶어 수정한다.

- 검증 규칙 확장 필터 이름 10개를 나머지 92개와 동일 규칙으로 통일
- 설명 길이를 바이트가 아닌 글자 수로 계산(한글 오검출 해소)
- 비활성 언어팩 번역 보존한 채 수정 허용
- 알림 템플릿 제목 nullable 존중, 미지원 발송 수단 저장 차단
- 일정/실행 이력 목록 조회 검증 정합
- 게시판 첨부 미사용 시 빈 확장자 통과, new_display_hours/최소 길이 0 허용, 일괄 적용 override_values 범위 검증(SSoT)
- 이커머스 클레임 사유 조회 검증, 용어 정정
- 원문 i18n 키 노출 다수 해소, 저장/삭제 실패 시 사유 화면 노출
- 아이콘 크기/다크모드 대비 회귀 수정

번들 확장(모듈3·플러그인6·템플릿2·언어팩5) 버전 new-bump 및 CHANGELOG 반영, ja 언어팩 동기.
2026-07-23 10:54:17 +09:00

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;
}
}