Files
Gnuboard7/app/Http/Requests/Settings/SaveSettingsRequest.php
T
HeuJung 15cd4452bf feat(core): 정적 최적화 서버 대응 자산 URL 이중 모드
nginx 의 정규식 location 은 프리픽스 location 보다 먼저 매칭되므로,
`location ~* \.(js|css|json)$` 블록이 있는 서버에서는 확장자 붙은 동적
엔드포인트가 `try_files ... /index.php` 폴백이 실행될 기회 없이 404 가 된다.
aaPanel/CyberPanel/Plesk 기본 템플릿에 들어있어 드물지 않으며, 관리자 화면조차
뜨지 않아 "서버 설정을 고치세요" 안내가 순환 참조가 된다.

관례를 깬 쪽이 G7 이므로 해소 책임도 G7 에 두고, 두 형태를 모두 서빙한 뒤
환경에 따라 택일한다. 확장자 형태는 영구 유지한다 — 제거하면 URL 을 하드코딩한
서드파티 확장이 깨진다.

- 라우트: dualSuffix / dualSuffixSegment / dualAsset 매크로로 23개 엔드포인트와
 프로브를 이중 등록. 확장자 형태를 먼저 등록한다 — 확장자 없는 쪽이 더 느슨한
 패턴이라 순서가 뒤집히면 `.json` 요청까지 삼킨다
- URL 생성: 서버 App\Support\AssetUrl, 프론트 core/support/assetUrl.ts 로 집약.
 기본 모드에서 생성 결과는 치환 이전과 문자열까지 동일하다 (쿼리 순서 포함 —
 순서가 바뀌면 의미는 같아도 HTTP 캐시 키가 갈린다)
- 자가 복구: 부트스트랩 자산 로드 실패 시 확장자 없는 형태로 1회 단방향 전환.
 역방향 금지 + 기존 재시도 예산 공유로 무한 왕복을 막는다
- 감지·전환: 인스톨러 프로브(설치 시 확정) / 관리자 환경설정 일반 탭 /
 g7:asset-url-mode. 판정은 상태코드가 아니라 매직 토큰 + Content-Type 으로 한다.
 상태코드만 보면 "404 대신 200 + 에러 HTML" 환경에서 영원히 오판한다
- 봇은 JavaScript 를 실행하지 않아 자가 복구가 닿지 않으므로, 모드 변경 시
 SEO 프리렌더 캐시를 비워 재생성시킨다

가드: audit 룰 2종(dynamic-route-static-extension / asset-url-builder-required),
Playwright 6건(정적 블록 가로채기 시뮬레이션), 루프 방지 불변식 L1~L9 전수 red 증명.

부수 정리: 양 Composer 에 중복돼 있던 확장 에셋 수집 123줄을 트레이트로 통합하고,
자산 확장자 화이트리스트 누락(.map)과 경로 정규화 우회 가능성을 함께 교정.

sir.kr 커뮤니티의 hang 님께서 제보해주셨습니다.
2026-07-23 11:34:32 +09:00

786 lines
45 KiB
PHP

<?php
namespace App\Http\Requests\Settings;
use App\Extension\HookManager;
use App\Models\Attachment;
use App\Search\Engines\DatabaseFulltextEngine;
use App\Support\AssetUrl;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class SaveSettingsRequest extends FormRequest
{
/**
* 지원되는 메일러 목록
*/
private const SUPPORTED_MAILERS = ['smtp', 'mailgun', 'ses'];
/**
* 지원되는 암호화 방식 목록
*/
private const SUPPORTED_ENCRYPTIONS = ['tls', 'ssl', ''];
/**
* 지원되는 스토리지 드라이버 목록
*/
private const SUPPORTED_STORAGE_DRIVERS = ['local', 's3'];
/**
* 지원되는 S3 리전 목록
*/
private const SUPPORTED_S3_REGIONS = ['ap-northeast-2', 'ap-northeast-1', 'us-east-1', 'us-west-2', 'eu-west-1'];
/**
* 지원되는 캐시 드라이버 목록
*/
private const SUPPORTED_CACHE_DRIVERS = ['file', 'redis', 'memcached'];
/**
* 지원되는 세션 드라이버 목록
*/
private const SUPPORTED_SESSION_DRIVERS = ['file', 'database', 'redis'];
/**
* 지원되는 큐 드라이버 목록
*/
private const SUPPORTED_QUEUE_DRIVERS = ['sync', 'database', 'redis'];
/**
* 지원되는 웹소켓 프로토콜 목록
*/
private const SUPPORTED_WEBSOCKET_SCHEMES = ['http', 'https'];
/**
* 지원되는 검색엔진 드라이버 기본 목록
*/
private const DEFAULT_SEARCH_ENGINE_DRIVERS = ['mysql-fulltext'];
/**
* 지원되는 로그 드라이버 목록
*/
private const SUPPORTED_LOG_DRIVERS = ['single', 'daily'];
/**
* 지원되는 로그 레벨 목록
*/
private const SUPPORTED_LOG_LEVELS = ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'];
/**
* Determine if the user is authorized to make this request.
*
* @return bool 권한 체크는 permission 미들웨어 체인이 담당하므로 항상 true 반환
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 전 입력 데이터를 정규화합니다.
*
* site_logo 필드가 Attachment 객체 배열로 전송될 수 있으므로
* 정수 ID 배열로 변환합니다.
*/
protected function prepareForValidation(): void
{
$siteLogo = $this->input('general.site_logo');
if (is_array($siteLogo)) {
$this->merge([
'general' => array_merge($this->input('general', []), [
'site_logo' => array_map(
fn ($item) => is_array($item) ? ($item['id'] ?? $item) : $item,
$siteLogo
),
]),
]);
}
// GeoIP 토글 필드: 신규 필드라 초기값이 settings에 없을 수 있음.
// Toggle 컴포넌트가 undefined → true 전환 시 비표준 타입을 보낼 수 있으므로
// boolean으로 명시 캐스팅하여 Laravel boolean 검증 호환 보장.
$this->castAdvancedBooleanFields([
'advanced.geoip_enabled',
'advanced.geoip_auto_update_enabled',
]);
}
/**
* advanced 탭의 boolean 필드를 PHP boolean으로 캐스팅합니다.
*
* Toggle 컴포넌트가 초기값 미존재(undefined) 상태에서 전환 시
* 비표준 타입(문자열 "true", null 등)을 전송할 수 있으므로
* prepareForValidation 단계에서 명시 캐스팅합니다.
*
* @param array<int, string> $fields dot-notation 필드 경로 배열
*/
private function castAdvancedBooleanFields(array $fields): void
{
$advanced = $this->input('advanced', []);
$changed = false;
foreach ($fields as $dotPath) {
$key = str_replace('advanced.', '', $dotPath);
if (array_key_exists($key, $advanced)) {
$advanced[$key] = filter_var($advanced[$key], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
$changed = true;
}
}
if ($changed) {
$this->merge(['advanced' => $advanced]);
}
}
/**
* Get the validation rules that apply to the request.
*
* nested 구조로 요청됩니다:
* { _tab: 'general', general: { site_name: '...', ... } }
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$tab = $this->input('_tab', 'general');
$rules = [
// 탭 식별자
'_tab' => ['nullable', 'string', Rule::in(['general', 'mail', 'upload', 'seo', 'security', 'drivers', 'advanced', 'notifications', 'identity'])],
// 각 탭의 컨테이너
'general' => ['sometimes', 'array'],
'mail' => ['sometimes', 'array'],
'upload' => ['sometimes', 'array'],
'seo' => ['sometimes', 'array'],
'security' => ['sometimes', 'array'],
'drivers' => ['sometimes', 'array'],
'advanced' => ['sometimes', 'array'],
'notifications' => ['sometimes', 'array'],
'identity' => ['sometimes', 'array'],
'notifications.channels' => ['sometimes', 'array'],
'notifications.channels.*.id' => ['required_with:notifications.channels', 'string', 'max:50'],
'notifications.channels.*.is_active' => ['required_with:notifications.channels', 'boolean'],
'notifications.channels.*.sort_order' => ['nullable', 'integer', 'min:0'],
// 일반 설정
'general.site_name' => $this->getTabRules($tab, 'general', 'string|max:100'),
'general.site_url' => $this->getTabRules($tab, 'general', 'url|max:255'),
'general.site_description' => ['nullable', 'string', 'max:500'],
'general.admin_email' => $this->getTabRules($tab, 'general', 'email|max:255'),
'general.timezone' => $this->getTabRules($tab, 'general', ['timezone']),
'general.language' => $this->getTabRules($tab, 'general', [Rule::in(config('app.supported_locales', ['ko', 'en']))]),
'general.currency' => ['nullable', 'string', 'max:10'],
'general.maintenance_mode' => ['nullable', 'boolean'],
// 자산 URL 방식 — 정적 최적화 서버 대응 (이슈 #486).
// 임의 문자열이 들어오면 AssetUrl::mode() 가 기본값으로 폴백하지만,
// 저장 단계에서 막아 설정 파일에 뜻 모를 값이 남지 않게 한다.
'general.asset_url_mode' => ['nullable', 'string', Rule::in([
AssetUrl::MODE_EXTENSION,
AssetUrl::MODE_EXTENSIONLESS,
])],
'general.site_logo' => ['nullable', 'array'],
'general.site_logo.*' => ['integer', Rule::exists(Attachment::class, 'id')],
// 메일 설정
'mail.mailer' => $this->getTabRules($tab, 'mail', [Rule::in(self::SUPPORTED_MAILERS)]),
'mail.host' => $this->getMailerRules($tab, 'smtp', 'string|max:255'),
'mail.port' => $this->getMailerRules($tab, 'smtp', 'integer|min:1|max:65535'),
'mail.username' => ['nullable', 'string', 'max:255'],
'mail.password' => ['nullable', 'string', 'max:255'],
'mail.encryption' => ['nullable', Rule::in(self::SUPPORTED_ENCRYPTIONS)],
'mail.mailgun_domain' => $this->getMailerRules($tab, 'mailgun', 'string|max:255'),
'mail.mailgun_secret' => $this->getMailerRules($tab, 'mailgun', 'string|max:255'),
'mail.mailgun_endpoint' => ['nullable', 'string', 'max:255'],
'mail.ses_key' => $this->getMailerRules($tab, 'ses', 'string|max:255'),
'mail.ses_secret' => $this->getMailerRules($tab, 'ses', 'string|max:255'),
'mail.ses_region' => $this->getMailerRules($tab, 'ses', 'string|max:255'),
'mail.from_address' => $this->getTabRules($tab, 'mail', 'email|max:255'),
'mail.from_name' => $this->getTabRules($tab, 'mail', 'string|max:255'),
// 업로드 설정
'upload.max_file_size' => $this->getTabRules($tab, 'upload', 'integer|min:1|max:1024'),
'upload.allowed_extensions' => $this->getAllowedExtensionsRules($tab),
'upload.image_max_width' => ['nullable', 'integer', 'min:100', 'max:10000'],
'upload.image_max_height' => ['nullable', 'integer', 'min:100', 'max:10000'],
'upload.image_quality' => ['nullable', 'integer', 'min:1', 'max:100'],
// SEO 설정
'seo.meta_title_suffix' => ['nullable', 'string', 'max:100'],
'seo.meta_description' => ['nullable', 'string', 'max:160'],
'seo.meta_keywords' => ['nullable', 'string', 'max:255'],
'seo.google_analytics_id' => ['nullable', 'string', 'max:50'],
'seo.google_site_verification' => ['nullable', 'string', 'max:100'],
'seo.naver_site_verification' => ['nullable', 'string', 'max:100'],
'seo.bot_user_agents' => ['nullable', 'array'],
'seo.bot_user_agents.*' => ['string', 'max:100'],
'seo.bot_detection_enabled' => ['nullable', 'boolean'],
'seo.bot_detection_library_enabled' => ['nullable', 'boolean'],
'seo.og_default_site_name' => ['nullable', 'string', 'max:200'],
'seo.og_image_default_width' => ['nullable', 'integer', 'min:0', 'max:8000'],
'seo.og_image_default_height' => ['nullable', 'integer', 'min:0', 'max:8000'],
'seo.twitter_default_card' => ['nullable', 'string', Rule::in(['summary', 'summary_large_image', 'app', 'player', ''])],
'seo.twitter_default_site' => ['nullable', 'string', 'max:50'],
'seo.cache_enabled' => ['nullable', 'boolean'],
'seo.cache_ttl' => ['nullable', 'integer', 'min:60', 'max:86400'],
'seo.sitemap_enabled' => ['nullable', 'boolean'],
'seo.sitemap_cache_ttl' => ['nullable', 'integer', 'min:3600', 'max:604800'],
// 분할 기준. 상한 50000 은 sitemaps.org 프로토콜 제한이며 SitemapWriter 가 동일 값으로 클램프합니다.
'seo.sitemap_urls_per_file' => ['nullable', 'integer', 'min:1000', 'max:50000'],
'seo.sitemap_gzip' => ['nullable', 'boolean'],
'seo.sitemap_serve_stale_on_miss' => ['nullable', 'boolean'],
'seo.sitemap_max_urls_per_contributor' => ['nullable', 'integer', 'min:0'],
'seo.sitemap_hreflang_enabled' => ['nullable', 'boolean'],
'seo.sitemap_schedule' => ['nullable', 'string', Rule::in(['hourly', 'daily', 'weekly'])],
'seo.sitemap_schedule_time' => ['nullable', 'string', 'regex:/^\d{2}:\d{2}$/'],
'seo.generator_enabled' => ['nullable', 'boolean'],
'seo.generator_content' => ['nullable', 'string', 'max:200'],
// 보안 설정
'security.force_https' => $this->getTabRules($tab, 'security', 'boolean'),
'security.login_attempt_enabled' => $this->getTabRules($tab, 'security', 'boolean'),
'security.auth_token_lifetime' => $this->getSecurityTabAuthTokenRules($tab),
'security.max_login_attempts' => ['nullable', 'integer', 'min:0', 'max:100'],
'security.login_lockout_time' => ['nullable', 'integer', 'min:0', 'max:1440'],
// 신규 설정이므로 미전송(기존 클라이언트)을 허용한다 — 미전송 시 기본값 false 유지
'security.allow_internal_outbound_urls' => ['nullable', 'boolean'],
// 캐시 설정 (advanced 탭)
'advanced.cache_enabled' => $this->getTabRules($tab, 'advanced', 'boolean'),
'advanced.layout_cache_enabled' => $this->getTabRules($tab, 'advanced', 'boolean'),
'advanced.layout_cache_ttl' => $this->getAdvancedTabCacheTtlRules($tab),
'advanced.stats_cache_enabled' => $this->getTabRules($tab, 'advanced', 'boolean'),
'advanced.stats_cache_ttl' => $this->getAdvancedTabCacheTtlRules($tab),
'advanced.seo_cache_enabled' => $this->getTabRules($tab, 'advanced', 'boolean'),
'advanced.seo_cache_ttl' => $this->getAdvancedTabCacheTtlRules($tab),
// 신규 설정이므로 미전송(기존 클라이언트)을 허용한다 — 미전송/미지정 시 코드 기본값 유지.
// 범위는 SEO 탭의 오버라이드 칸(seo.sitemap_cache_ttl)과 동일하게 맞춘다.
'advanced.seo_sitemap_cache_ttl' => ['nullable', 'integer', 'min:3600', 'max:604800'],
// 디버그 설정 (advanced 탭)
'advanced.debug_mode' => $this->getTabRules($tab, 'advanced', 'boolean'),
'advanced.sql_query_log' => $this->getTabRules($tab, 'advanced', 'boolean'),
// 코어 업데이트 설정 (advanced 탭)
'advanced.core_update_github_url' => ['nullable', 'url', 'max:500'],
'advanced.core_update_github_token' => ['nullable', 'string', 'max:500'],
// GeoIP 설정 (advanced 탭)
// 마스터 토글 OFF 시 하위 필드(license_key, auto_update)는 collapse 되어 미전송됨
// → nullable 필수 (기존 cache/debug 필드와 달리 조건부 렌더링 내부에 있음)
'advanced.geoip_enabled' => ['nullable', 'boolean'],
'advanced.geoip_license_key' => ['nullable', 'string', 'max:200', 'regex:/^[A-Za-z0-9_]+$/'],
'advanced.geoip_auto_update_enabled' => ['nullable', 'boolean'],
// 드라이버 설정 (drivers 탭)
'drivers.storage_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_STORAGE_DRIVERS)]),
'drivers.s3_bucket' => ['nullable', 'string', 'max:255'],
'drivers.s3_region' => ['nullable', Rule::in(self::SUPPORTED_S3_REGIONS)],
'drivers.s3_access_key' => ['nullable', 'string', 'max:255'],
'drivers.s3_secret_key' => ['nullable', 'string', 'max:255'],
'drivers.s3_url' => ['nullable', 'url', 'max:500'],
'drivers.cache_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_CACHE_DRIVERS)]),
'drivers.redis_host' => ['nullable', 'string', 'max:255'],
'drivers.redis_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
'drivers.redis_password' => ['nullable', 'string', 'max:255'],
'drivers.redis_database' => ['nullable', 'integer', 'min:0', 'max:15'],
'drivers.memcached_host' => ['nullable', 'string', 'max:255'],
'drivers.memcached_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
'drivers.session_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_SESSION_DRIVERS)]),
'drivers.session_lifetime' => ['nullable', 'integer', 'min:1', 'max:43200'], // 1분 ~ 30일
'drivers.queue_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_QUEUE_DRIVERS)]),
'drivers.websocket_enabled' => ['nullable', 'boolean'],
'drivers.websocket_app_id' => [Rule::requiredIf(fn () => $this->boolean('drivers.websocket_enabled')), 'nullable', 'string', 'max:255'],
'drivers.websocket_app_key' => [Rule::requiredIf(fn () => $this->boolean('drivers.websocket_enabled')), 'nullable', 'string', 'max:255'],
'drivers.websocket_app_secret' => [Rule::requiredIf(fn () => $this->boolean('drivers.websocket_enabled')), 'nullable', 'string', 'max:255'],
'drivers.websocket_host' => ['nullable', 'string', 'max:255'],
'drivers.websocket_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
'drivers.websocket_scheme' => ['nullable', Rule::in(self::SUPPORTED_WEBSOCKET_SCHEMES)],
'drivers.websocket_verify_ssl' => ['nullable', 'boolean'],
'drivers.websocket_server_host' => ['nullable', 'string', 'max:255'],
'drivers.websocket_server_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
'drivers.websocket_server_scheme' => ['nullable', Rule::in(self::SUPPORTED_WEBSOCKET_SCHEMES)],
'drivers.search_engine_driver' => ['nullable', Rule::in($this->getSupportedSearchEngineDrivers())],
'drivers.log_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_LOG_DRIVERS)]),
'drivers.log_level' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_LOG_LEVELS)]),
'drivers.log_days' => ['nullable', 'integer', 'min:1', 'max:365'],
// 본인인증(IDV) provider 기술 파라미터 — 정책 분기는 IdentityPolicy 로 흡수됨
'identity.default_provider' => ['nullable', 'string', 'max:100'],
'identity.purpose_providers' => ['sometimes', 'array'],
'identity.challenge_ttl_minutes' => $this->getTabRules($tab, 'identity', 'integer|min:1|max:1440'),
'identity.max_attempts' => $this->getTabRules($tab, 'identity', 'integer|min:1|max:20'),
];
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
return HookManager::applyFilters('core.settings.save_validation_rules', $rules, $this);
}
/**
* 특정 탭의 필수 필드에 대한 규칙을 반환합니다.
*
* @param string $currentTab 현재 활성 탭
* @param string $targetTab 규칙이 적용될 타겟 탭
* @param string|array $additionalRules 추가 규칙
* @return array 검증 규칙 배열
*/
private function getTabRules(string $currentTab, string $targetTab, string|array $additionalRules): array
{
$baseRules = $currentTab === $targetTab ? ['required'] : ['nullable'];
if (is_string($additionalRules)) {
return array_merge($baseRules, explode('|', $additionalRules));
}
return array_merge($baseRules, $additionalRules);
}
/**
* 메일러 드라이버별 조건부 필드 규칙을 반환합니다.
*
* mail 탭이 활성 상태이고 현재 선택된 메일러가 대상 메일러와 일치하면 필수,
* 그렇지 않으면 nullable로 처리합니다.
*
* @param string $currentTab 현재 활성 탭
* @param string $targetMailer 규칙이 적용될 대상 메일러 (smtp, mailgun, ses)
* @param string|array $additionalRules 추가 규칙
* @return array 검증 규칙 배열
*/
private function getMailerRules(string $currentTab, string $targetMailer, string|array $additionalRules): array
{
$mailer = $this->input('mail.mailer', 'smtp');
$isRequired = $currentTab === 'mail' && $mailer === $targetMailer;
$baseRules = $isRequired ? ['required'] : ['nullable'];
if (is_string($additionalRules)) {
return array_merge($baseRules, explode('|', $additionalRules));
}
return array_merge($baseRules, $additionalRules);
}
/**
* 인증 토큰 유지시간에 대한 검증 규칙을 반환합니다.
* 0이면 무한대, 30~3600 분 사이 값만 허용
*
* @param string $currentTab 현재 탭
* @return array 검증 규칙 배열
*/
private function getSecurityTabAuthTokenRules(string $currentTab): array
{
return [
'nullable',
'integer',
'min:0',
'max:3600',
function ($attribute, $value, $fail) {
// 0은 무한대를 의미하므로 허용, 그 외에는 30 이상이어야 함
if ($value !== null && $value !== 0 && $value < 30) {
$fail(__('validation.settings.auth_token_lifetime_range'));
}
},
];
}
/**
* 허용 확장자에 대한 검증 규칙을 반환합니다.
* 문자열(콤마 구분) 또는 배열 형태 모두 허용합니다.
*
* @param string $currentTab 현재 탭
* @return array 검증 규칙 배열
*/
private function getAllowedExtensionsRules(string $currentTab): array
{
$baseRules = $currentTab === 'upload' ? ['required'] : ['nullable'];
return array_merge($baseRules, [
function ($attribute, $value, $fail) {
// 배열인 경우
if (is_array($value)) {
$joined = implode(',', $value);
if (strlen($joined) > 500) {
$fail(__('validation.settings.allowed_extensions_max'));
}
return;
}
// 문자열인 경우
if (is_string($value)) {
if (strlen($value) > 500) {
$fail(__('validation.settings.allowed_extensions_max'));
}
return;
}
// 그 외 타입은 에러
$fail(__('validation.settings.allowed_extensions_invalid_type'));
},
]);
}
/**
* 캐시 TTL에 대한 검증 규칙을 반환합니다.
* 0이면 무한대, 1~14400 초 사이 값만 허용
*
* @param string $currentTab 현재 탭
* @return array 검증 규칙 배열
*/
private function getAdvancedTabCacheTtlRules(string $currentTab): array
{
$baseRules = $currentTab === 'advanced' ? ['required'] : ['nullable'];
return array_merge($baseRules, [
'integer',
'min:0',
'max:14400',
]);
}
/**
* 추가 검증을 위해 validator after 콜백을 등록합니다.
*
* 목적별 프로바이더(purpose_providers) 매핑은 purpose id 에 점(.)이 포함될 수
* 있어 단일 깊이 와일드카드 룰로는 leaf 값을 검증할 수 없습니다.
* 임의 깊이의 leaf 값을 재귀 순회하며 string|max:100 으로 검증합니다.
*
* @param Validator $validator 검증기 인스턴스
*/
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$purposeProviders = $this->input('identity.purpose_providers');
if (is_array($purposeProviders)) {
$this->validatePurposeProviderLeaves(
$purposeProviders,
'identity.purpose_providers',
$validator
);
}
});
}
/**
* 목적별 프로바이더 매핑을 재귀 순회하며 leaf 값을 검증합니다.
*
* purpose id 에 점(.)이 포함될 수 있습니다 (예: KG이니시스 플러그인의
* `inicis.adult_verification`). 프론트엔드 폼 자동 바인딩이 dot-notation name
* (`identity.purpose_providers.inicis.adult_verification`) 을 중첩 객체로 풀어
* `purpose_providers.inicis = { adult_verification: '...' }` 형태로 전송하므로,
* 깊이에 무관하게 각 leaf 값을 검증하고 위반 시 전체 dot-path 키로 에러를 부착합니다.
* 백엔드 조회(IdentityVerificationManager::resolveForPurpose) 가 config dot-path
* 라 이 중첩 구조 자체는 정상입니다.
*
* @param array<string, mixed> $node 검증할 노드 (purpose_providers 또는 중첩 하위)
* @param string $path 현재 노드의 dot-path (에러 키 prefix)
* @param Validator $validator 검증기 인스턴스
*/
private function validatePurposeProviderLeaves(array $node, string $path, $validator): void
{
foreach ($node as $key => $value) {
$childPath = $path.'.'.$key;
if (is_array($value)) {
$this->validatePurposeProviderLeaves($value, $childPath, $validator);
continue;
}
// null / '' (기본 프로바이더 사용) 은 허용
if ($value === null || $value === '') {
continue;
}
if (! is_string($value)) {
$validator->errors()->add($childPath, __('validation.settings.identity_purpose_provider_string'));
continue;
}
if (mb_strlen($value) > 100) {
$validator->errors()->add($childPath, __('validation.settings.identity_purpose_provider_max'));
}
}
}
/**
* 지원되는 검색엔진 드라이버 목록을 반환합니다.
*
* 플러그인이 `core.search.engine_drivers` 필터 훅으로 등록한 드라이버를 포함합니다.
*
* @return array<string>
*/
private function getSupportedSearchEngineDrivers(): array
{
$drivers = HookManager::applyFilters(
'core.search.engine_drivers',
['mysql-fulltext' => DatabaseFulltextEngine::class]
);
return array_keys($drivers);
}
/**
* Get custom messages for validator errors.
*
* nested 구조에 맞게 'category.field.rule' 형식으로 정의합니다.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
// 일반 설정 - 필수 필드
'general.site_name.required' => __('validation.settings.site_name_required'),
'general.site_name.max' => __('validation.settings.site_name_max'),
'general.site_url.required' => __('validation.settings.site_url_required'),
'general.site_url.url' => __('validation.settings.site_url_invalid'),
'general.site_url.max' => __('validation.settings.site_url_max'),
'general.site_description.max' => __('validation.settings.site_description_max'),
'general.admin_email.required' => __('validation.settings.admin_email_required'),
'general.admin_email.email' => __('validation.settings.admin_email_invalid'),
'general.admin_email.max' => __('validation.settings.admin_email_max'),
'general.timezone.required' => __('validation.settings.timezone_required'),
'general.timezone.in' => __('validation.settings.timezone_invalid'),
'general.language.required' => __('validation.settings.language_required'),
'general.language.in' => __('validation.settings.language_invalid'),
'general.currency.max' => __('validation.settings.currency_max'),
'general.maintenance_mode.boolean' => __('validation.settings.maintenance_mode_boolean'),
// 메일 설정
'mail.mailer.required' => __('validation.settings.mailer_required'),
'mail.mailer.in' => __('validation.settings.mailer_invalid'),
'mail.host.required' => __('validation.settings.host_required'),
'mail.host.max' => __('validation.settings.host_max'),
'mail.port.required' => __('validation.settings.port_required'),
'mail.port.integer' => __('validation.settings.port_integer'),
'mail.port.min' => __('validation.settings.port_min'),
'mail.port.max' => __('validation.settings.port_max'),
'mail.username.max' => __('validation.settings.username_max'),
'mail.password.max' => __('validation.settings.password_max'),
'mail.encryption.in' => __('validation.settings.encryption_invalid'),
'mail.from_address.required' => __('validation.settings.from_address_required'),
'mail.from_address.email' => __('validation.settings.from_address_invalid'),
'mail.from_address.max' => __('validation.settings.from_address_max'),
'mail.from_name.required' => __('validation.settings.from_name_required'),
'mail.from_name.max' => __('validation.settings.from_name_max'),
// 업로드 설정
'upload.max_file_size.required' => __('validation.settings.max_file_size_required'),
'upload.max_file_size.integer' => __('validation.settings.max_file_size_integer'),
'upload.max_file_size.min' => __('validation.settings.max_file_size_min'),
'upload.max_file_size.max' => __('validation.settings.max_file_size_max'),
'upload.allowed_extensions.required' => __('validation.settings.allowed_extensions_required'),
'upload.allowed_extensions.max' => __('validation.settings.allowed_extensions_max'),
'upload.image_max_width.integer' => __('validation.settings.image_max_width_integer'),
'upload.image_max_width.min' => __('validation.settings.image_max_width_min'),
'upload.image_max_width.max' => __('validation.settings.image_max_width_max'),
'upload.image_max_height.integer' => __('validation.settings.image_max_height_integer'),
'upload.image_max_height.min' => __('validation.settings.image_max_height_min'),
'upload.image_max_height.max' => __('validation.settings.image_max_height_max'),
'upload.image_quality.integer' => __('validation.settings.image_quality_integer'),
'upload.image_quality.min' => __('validation.settings.image_quality_min'),
'upload.image_quality.max' => __('validation.settings.image_quality_max'),
// SEO 설정
'seo.meta_title_suffix.max' => __('validation.settings.meta_title_suffix_max'),
'seo.meta_description.max' => __('validation.settings.meta_description_max'),
'seo.meta_keywords.max' => __('validation.settings.meta_keywords_max'),
'seo.google_analytics_id.max' => __('validation.settings.google_analytics_id_max'),
'seo.google_site_verification.max' => __('validation.settings.google_site_verification_max'),
'seo.naver_site_verification.max' => __('validation.settings.naver_site_verification_max'),
'seo.generator_enabled.boolean' => __('validation.settings.generator_enabled_boolean'),
'seo.generator_content.string' => __('validation.settings.generator_content_string'),
'seo.generator_content.max' => __('validation.settings.generator_content_max'),
'seo.bot_user_agents.array' => __('validation.settings.bot_user_agents_array'),
'seo.bot_user_agents.*.string' => __('validation.settings.bot_user_agents_item_string'),
'seo.bot_user_agents.*.max' => __('validation.settings.bot_user_agents_item_max'),
'seo.bot_detection_enabled.boolean' => __('validation.settings.bot_detection_enabled_boolean'),
'seo.bot_detection_library_enabled.boolean' => __('validation.settings.bot_detection_library_enabled_boolean'),
'seo.og_default_site_name.string' => __('validation.settings.og_default_site_name_string'),
'seo.og_default_site_name.max' => __('validation.settings.og_default_site_name_max'),
'seo.og_image_default_width.integer' => __('validation.settings.og_image_default_width_integer'),
'seo.og_image_default_width.min' => __('validation.settings.og_image_default_width_min'),
'seo.og_image_default_width.max' => __('validation.settings.og_image_default_width_max'),
'seo.og_image_default_height.integer' => __('validation.settings.og_image_default_height_integer'),
'seo.og_image_default_height.min' => __('validation.settings.og_image_default_height_min'),
'seo.og_image_default_height.max' => __('validation.settings.og_image_default_height_max'),
'seo.twitter_default_card.string' => __('validation.settings.twitter_default_card_string'),
'seo.twitter_default_card.in' => __('validation.settings.twitter_default_card_in'),
'seo.twitter_default_site.string' => __('validation.settings.twitter_default_site_string'),
'seo.twitter_default_site.max' => __('validation.settings.twitter_default_site_max'),
'seo.cache_enabled.boolean' => __('validation.settings.seo_cache_enabled_boolean'),
'seo.cache_ttl.integer' => __('validation.settings.seo_cache_ttl_integer'),
'seo.cache_ttl.min' => __('validation.settings.seo_cache_ttl_min'),
'seo.cache_ttl.max' => __('validation.settings.seo_cache_ttl_max'),
'seo.sitemap_enabled.boolean' => __('validation.settings.sitemap_enabled_boolean'),
'seo.sitemap_cache_ttl.integer' => __('validation.settings.sitemap_cache_ttl_integer'),
'seo.sitemap_cache_ttl.min' => __('validation.settings.sitemap_cache_ttl_min'),
'seo.sitemap_cache_ttl.max' => __('validation.settings.sitemap_cache_ttl_max'),
'seo.sitemap_urls_per_file.integer' => __('validation.settings.sitemap_urls_per_file_integer'),
'seo.sitemap_urls_per_file.min' => __('validation.settings.sitemap_urls_per_file_min'),
'seo.sitemap_urls_per_file.max' => __('validation.settings.sitemap_urls_per_file_max'),
'seo.sitemap_gzip.boolean' => __('validation.settings.sitemap_gzip_boolean'),
'seo.sitemap_serve_stale_on_miss.boolean' => __('validation.settings.sitemap_serve_stale_on_miss_boolean'),
'seo.sitemap_max_urls_per_contributor.integer' => __('validation.settings.sitemap_max_urls_per_contributor_integer'),
'seo.sitemap_max_urls_per_contributor.min' => __('validation.settings.sitemap_max_urls_per_contributor_min'),
'seo.sitemap_hreflang_enabled.boolean' => __('validation.settings.sitemap_hreflang_enabled_boolean'),
'seo.sitemap_schedule.in' => __('validation.settings.sitemap_schedule_invalid'),
'seo.sitemap_schedule_time.regex' => __('validation.settings.sitemap_schedule_time_invalid'),
// 보안 설정
'security.force_https.required' => __('validation.settings.force_https_required'),
'security.force_https.boolean' => __('validation.settings.force_https_boolean'),
'security.login_attempt_enabled.required' => __('validation.settings.login_attempt_enabled_required'),
'security.login_attempt_enabled.boolean' => __('validation.settings.login_attempt_enabled_boolean'),
'security.auth_token_lifetime.integer' => __('validation.settings.auth_token_lifetime_integer'),
'security.auth_token_lifetime.min' => __('validation.settings.auth_token_lifetime_min'),
'security.auth_token_lifetime.max' => __('validation.settings.auth_token_lifetime_max'),
'security.allow_internal_outbound_urls.boolean' => __('validation.settings.allow_internal_outbound_urls_boolean'),
'security.max_login_attempts.integer' => __('validation.settings.max_login_attempts_integer'),
'security.max_login_attempts.min' => __('validation.settings.max_login_attempts_min'),
'security.max_login_attempts.max' => __('validation.settings.max_login_attempts_max'),
'security.login_lockout_time.integer' => __('validation.settings.login_lockout_time_integer'),
'security.login_lockout_time.min' => __('validation.settings.login_lockout_time_min'),
'security.login_lockout_time.max' => __('validation.settings.login_lockout_time_max'),
// 캐시 설정
'advanced.cache_enabled.required' => __('validation.settings.cache_enabled_required'),
'advanced.cache_enabled.boolean' => __('validation.settings.cache_enabled_boolean'),
'advanced.layout_cache_enabled.required' => __('validation.settings.layout_cache_enabled_required'),
'advanced.layout_cache_enabled.boolean' => __('validation.settings.layout_cache_enabled_boolean'),
'advanced.layout_cache_ttl.required' => __('validation.settings.layout_cache_ttl_required'),
'advanced.layout_cache_ttl.integer' => __('validation.settings.layout_cache_ttl_integer'),
'advanced.layout_cache_ttl.min' => __('validation.settings.layout_cache_ttl_min'),
'advanced.layout_cache_ttl.max' => __('validation.settings.layout_cache_ttl_max'),
'advanced.stats_cache_enabled.required' => __('validation.settings.stats_cache_enabled_required'),
'advanced.stats_cache_enabled.boolean' => __('validation.settings.stats_cache_enabled_boolean'),
'advanced.stats_cache_ttl.required' => __('validation.settings.stats_cache_ttl_required'),
'advanced.stats_cache_ttl.integer' => __('validation.settings.stats_cache_ttl_integer'),
'advanced.stats_cache_ttl.min' => __('validation.settings.stats_cache_ttl_min'),
'advanced.stats_cache_ttl.max' => __('validation.settings.stats_cache_ttl_max'),
'advanced.seo_cache_enabled.required' => __('validation.settings.advanced_seo_cache_enabled_required'),
'advanced.seo_cache_enabled.boolean' => __('validation.settings.advanced_seo_cache_enabled_boolean'),
'advanced.seo_cache_ttl.required' => __('validation.settings.advanced_seo_cache_ttl_required'),
'advanced.seo_cache_ttl.integer' => __('validation.settings.advanced_seo_cache_ttl_integer'),
'advanced.seo_cache_ttl.min' => __('validation.settings.advanced_seo_cache_ttl_min'),
'advanced.seo_cache_ttl.max' => __('validation.settings.advanced_seo_cache_ttl_max'),
'advanced.seo_sitemap_cache_ttl.integer' => __('validation.settings.seo_sitemap_cache_ttl_integer'),
'advanced.seo_sitemap_cache_ttl.min' => __('validation.settings.seo_sitemap_cache_ttl_min'),
'advanced.seo_sitemap_cache_ttl.max' => __('validation.settings.seo_sitemap_cache_ttl_max'),
// 디버그 설정
'advanced.debug_mode.required' => __('validation.settings.debug_mode_required'),
'advanced.debug_mode.boolean' => __('validation.settings.debug_mode_boolean'),
'advanced.sql_query_log.required' => __('validation.settings.sql_query_log_required'),
'advanced.sql_query_log.boolean' => __('validation.settings.sql_query_log_boolean'),
// 코어 업데이트 설정
'advanced.core_update_github_url.url' => __('validation.settings.core_update_github_url_invalid'),
'advanced.core_update_github_url.max' => __('validation.settings.core_update_github_url_max'),
'advanced.core_update_github_token.max' => __('validation.settings.core_update_github_token_max'),
// 드라이버 설정
'drivers.storage_driver.required' => __('validation.settings.storage_driver_required'),
'drivers.storage_driver.in' => __('validation.settings.storage_driver_invalid'),
'drivers.s3_bucket.max' => __('validation.settings.s3_bucket_max'),
'drivers.s3_region.in' => __('validation.settings.s3_region_invalid'),
'drivers.s3_access_key.max' => __('validation.settings.s3_access_key_max'),
'drivers.s3_secret_key.max' => __('validation.settings.s3_secret_key_max'),
'drivers.s3_url.url' => __('validation.settings.s3_url_invalid'),
'drivers.s3_url.max' => __('validation.settings.s3_url_max'),
'drivers.cache_driver.required' => __('validation.settings.cache_driver_required'),
'drivers.cache_driver.in' => __('validation.settings.cache_driver_invalid'),
'drivers.redis_host.max' => __('validation.settings.redis_host_max'),
'drivers.redis_port.integer' => __('validation.settings.redis_port_integer'),
'drivers.redis_port.min' => __('validation.settings.redis_port_min'),
'drivers.redis_port.max' => __('validation.settings.redis_port_max'),
'drivers.redis_password.max' => __('validation.settings.redis_password_max'),
'drivers.redis_database.integer' => __('validation.settings.redis_database_integer'),
'drivers.redis_database.min' => __('validation.settings.redis_database_min'),
'drivers.redis_database.max' => __('validation.settings.redis_database_max'),
'drivers.memcached_host.max' => __('validation.settings.memcached_host_max'),
'drivers.memcached_port.integer' => __('validation.settings.memcached_port_integer'),
'drivers.memcached_port.min' => __('validation.settings.memcached_port_min'),
'drivers.memcached_port.max' => __('validation.settings.memcached_port_max'),
'drivers.session_driver.required' => __('validation.settings.session_driver_required'),
'drivers.session_driver.in' => __('validation.settings.session_driver_invalid'),
'drivers.session_lifetime.integer' => __('validation.settings.session_lifetime_integer'),
'drivers.session_lifetime.min' => __('validation.settings.session_lifetime_min'),
'drivers.session_lifetime.max' => __('validation.settings.session_lifetime_max'),
'drivers.queue_driver.required' => __('validation.settings.queue_driver_required'),
'drivers.queue_driver.in' => __('validation.settings.queue_driver_invalid'),
'drivers.websocket_enabled.boolean' => __('validation.settings.websocket_enabled_boolean'),
'drivers.websocket_app_id.required' => __('validation.settings.websocket_app_id_required'),
'drivers.websocket_app_id.max' => __('validation.settings.websocket_app_id_max'),
'drivers.websocket_app_key.required' => __('validation.settings.websocket_app_key_required'),
'drivers.websocket_app_key.max' => __('validation.settings.websocket_app_key_max'),
'drivers.websocket_app_secret.required' => __('validation.settings.websocket_app_secret_required'),
'drivers.websocket_app_secret.max' => __('validation.settings.websocket_app_secret_max'),
'drivers.websocket_host.max' => __('validation.settings.websocket_host_max'),
'drivers.websocket_port.integer' => __('validation.settings.websocket_port_integer'),
'drivers.websocket_port.min' => __('validation.settings.websocket_port_min'),
'drivers.websocket_port.max' => __('validation.settings.websocket_port_max'),
'drivers.websocket_scheme.in' => __('validation.settings.websocket_scheme_invalid'),
'drivers.websocket_verify_ssl.boolean' => __('validation.settings.websocket_verify_ssl_boolean'),
'drivers.websocket_server_host.max' => __('validation.settings.websocket_server_host_max'),
'drivers.websocket_server_port.integer' => __('validation.settings.websocket_server_port_integer'),
'drivers.websocket_server_port.min' => __('validation.settings.websocket_server_port_min'),
'drivers.websocket_server_port.max' => __('validation.settings.websocket_server_port_max'),
'drivers.websocket_server_scheme.in' => __('validation.settings.websocket_server_scheme_invalid'),
'drivers.search_engine_driver.in' => __('validation.settings.search_engine_driver_invalid'),
'drivers.log_driver.required' => __('validation.settings.log_driver_required'),
'drivers.log_driver.in' => __('validation.settings.log_driver_invalid'),
'drivers.log_level.required' => __('validation.settings.log_level_required'),
'drivers.log_level.in' => __('validation.settings.log_level_invalid'),
'drivers.log_days.integer' => __('validation.settings.log_days_integer'),
'drivers.log_days.min' => __('validation.settings.log_days_min'),
'drivers.log_days.max' => __('validation.settings.log_days_max'),
// 본인인증(IDV) 설정
'identity.default_provider.string' => __('validation.settings.identity_default_provider_string'),
'identity.default_provider.max' => __('validation.settings.identity_default_provider_max'),
'identity.purpose_providers.array' => __('validation.settings.identity_purpose_providers_array'),
// 목적별 프로바이더 leaf 값(점 포함 purpose id 대응)은 withValidator() 에서
// validation.settings.identity_purpose_provider_string / _max 를 직접 부착합니다.
'identity.challenge_ttl_minutes.required' => __('validation.settings.identity_challenge_ttl_required'),
'identity.challenge_ttl_minutes.integer' => __('validation.settings.identity_challenge_ttl_integer'),
'identity.challenge_ttl_minutes.min' => __('validation.settings.identity_challenge_ttl_min'),
'identity.challenge_ttl_minutes.max' => __('validation.settings.identity_challenge_ttl_max'),
'identity.max_attempts.required' => __('validation.settings.identity_max_attempts_required'),
'identity.max_attempts.integer' => __('validation.settings.identity_max_attempts_integer'),
'identity.max_attempts.min' => __('validation.settings.identity_max_attempts_min'),
'identity.max_attempts.max' => __('validation.settings.identity_max_attempts_max'),
];
}
/**
* Get custom attributes for validator errors.
*
* nested 구조에 맞게 'category.field' 형식으로 정의합니다.
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'general.site_name' => __('validation.attributes.site_name'),
'general.site_url' => __('validation.attributes.site_url'),
'general.site_description' => __('validation.attributes.site_description'),
'general.admin_email' => __('validation.attributes.admin_email'),
'general.timezone' => __('validation.attributes.timezone'),
'general.language' => __('validation.attributes.language'),
// 본인인증(IDV) 필드
'identity.default_provider' => __('validation.attributes.identity_default_provider'),
'identity.purpose_providers' => __('validation.attributes.identity_purpose_providers'),
'identity.challenge_ttl_minutes' => __('validation.attributes.identity_challenge_ttl_minutes'),
'identity.max_attempts' => __('validation.attributes.identity_max_attempts'),
];
}
}