input('content'); if (is_string($content)) { $decoded = json_decode($content, true); if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { $this->merge(['content' => $decoded]); } } } /** * 요청에 적용할 검증 규칙 * * @return array|string> */ public function rules(): array { $rules = [ // 낙관적 잠금 — 클라이언트가 로드한 시점의 lock_version 필수 전달 'expected_lock_version' => ['required', 'integer', 'min:0'], 'content' => [ 'required', 'array', new ValidLayoutExtensionStructure, // 표현식 샌드박스 우회/원격 스크립트 저장측 차단 (KVE-2026-1915). // content 트리 전체(data_sources·scripts·표현식 문자열)를 재귀 순회한다. new SafeLayoutExpressions, // props·actions·init_actions 의 외부 URL 차단 (Store/UpdateLayoutRequest 와 동일 강도) new NoExternalUrls, ], // 우선순위 (선택 — content.priority 와 별개로 직접 지정 가능) 'priority' => ['nullable', 'integer', 'min:0', 'max:9999'], // content 내부 priority 'content.priority' => ['nullable', 'integer', 'min:0', 'max:9999'], // 데이터소스 검증 'content.data_sources' => ['nullable', 'array', new ValidDataSourceMerge], // 데이터소스 endpoint 검증 // SafeLayoutExpressions 는 content 배열 규칙이 트리 전체를 순회하며 data_sources[].endpoint // same-origin 까지 검사하므로 여기(문자열)에는 부착하지 않는다 (문자열 부착 시 no-op). 'content.data_sources.*.endpoint' => [ 'nullable', 'string', new WhitelistedEndpoint, new NoExternalUrls, ], ]; // 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공 return HookManager::applyFilters('core.layout_extension.update_content_validation_rules', $rules, $this); } /** * 검증 오류 메시지 커스터마이징 * * @return array */ public function messages(): array { return [ 'expected_lock_version.required' => __('validation.layout_extension.expected_lock_version.required'), 'expected_lock_version.integer' => __('validation.layout_extension.expected_lock_version.integer'), 'expected_lock_version.min' => __('validation.layout_extension.expected_lock_version.min'), 'content.required' => __('validation.layout_extension.content.required'), 'content.array' => __('validation.layout_extension.content.array'), 'priority.integer' => __('validation.layout_extension.priority.integer'), 'priority.min' => __('validation.layout_extension.priority.min'), 'priority.max' => __('validation.layout_extension.priority.max'), 'content.priority.integer' => __('validation.layout_extension.priority.integer'), 'content.priority.min' => __('validation.layout_extension.priority.min'), 'content.priority.max' => __('validation.layout_extension.priority.max'), 'content.data_sources.array' => __('validation.layout_extension.data_sources.array'), ]; } }