Files
wordpress-theme-aksrt/src/Admin/SchemaProvider.php
T
kate522andClaude Fable 5 57ff405691 refactor(color): unify color sanitizing and picking behind shared components
- Add AKSRT\Core\Color::sanitize($value, $allow_alpha) as the single PHP
  color sanitizer; replace sanitize_hex_color calls and per-class regexes
  (FloatButtons, FieldSchema, ThemeSearch, icon cards, TicketStatusService)
- SchemaProvider / ControllerBase sanitize `color` fields as colors;
  alpha kept only when the schema sets showAlpha
- Split SchemaProvider OAuth validation and option matching into sibling
  traits to stay under the 1000-line limit
- Add AksColorPicker.vue + utils/color.js mirroring the PHP rules; AksField,
  FloatBtnCommonFields and TicketStatusPanel use it
- Widget background color input now uses wpColorPicker
- Remove dead AksCompositeField dimensions/spacing/background branches and
  the unused Gutenberg ColorPicker var
- Add color-unification contract test; update harness requires and tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 20:47:41 +08:00

990 lines
37 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Admin;
defined('ABSPATH') || exit;
class SchemaProvider {
use SchemaProviderOAuthValidation;
use SchemaProviderOptionMatch;
private static $instance = null;
/**
* @var array<string, array<string, mixed>>
*/
private $domain_schemas = [];
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function normalize_domain(string $domain): string {
$domain = trim(str_replace('-', '_', $domain));
return $domain !== '' ? $domain : 'global';
}
public function normalize_section(string $section): string {
return trim($section);
}
public function get_domain_keys(): array {
return [
'global',
'page_display',
'post_list',
'permissions',
'users',
'payment',
'extensions',
'tools',
];
}
public function get_schema(string $domain): ?array {
$domain = $this->normalize_domain($domain);
if (isset($this->domain_schemas[$domain])) {
return $this->domain_schemas[$domain];
}
$builder_map = $this->get_schema_builder_map();
if (!isset($builder_map[$domain])) {
return null;
}
$builder = $builder_map[$domain];
$this->domain_schemas[$domain] = $builder::build();
return $this->domain_schemas[$domain];
}
public function get_sections(string $domain): array {
$schema = $this->get_schema($domain);
return is_array($schema['sections'] ?? null) ? $schema['sections'] : [];
}
public function get_section_schema(string $domain, string $section): ?array {
$sections = $this->get_sections($domain);
$section = $this->normalize_section($section);
return $sections[$section] ?? null;
}
public function get_option_key(string $domain, string $section = ''): string {
if ($section !== '') {
$schema = $this->get_section_schema($domain, $section);
return (string) ($schema['option_key'] ?? 'aksrt_settings');
}
$primary = [
'global' => 'aksrt_settings',
'page_display' => 'aksrt_settings',
'post_list' => 'aksrt_settings',
'permissions' => 'aksrt_settings',
'users' => 'aksrt_settings',
'payment' => 'aksrt_settings',
'extensions' => 'aksrt_settings',
'tools' => 'aksrt_settings',
];
return $primary[$this->normalize_domain($domain)] ?? 'aksrt_settings';
}
public function get_defaults(string $domain, string $section = ''): array {
if ($section !== '') {
$schema = $this->get_section_schema($domain, $section);
return $schema ? $this->collect_defaults($schema) : [];
}
$defaults = [];
foreach ($this->get_sections($domain) as $section_schema) {
$defaults = array_merge($defaults, $this->collect_defaults($section_schema));
}
return $defaults;
}
public function get_allowed_fields(string $domain, string $section = ''): array {
if ($section !== '') {
$schema = $this->get_section_schema($domain, $section);
return $schema ? $this->collect_allowed_fields($schema) : [];
}
$allowed = [];
foreach ($this->get_sections($domain) as $section_schema) {
$allowed = array_merge($allowed, $this->collect_allowed_fields($section_schema));
}
return $allowed;
}
/**
* Sanitize a schema payload with the full nested field contract.
*/
public function sanitize_payload(string $domain, array $data, string $section = ''): array {
$schema = $section !== '' ? $this->get_section_schema($domain, $section) : $this->get_schema($domain);
if (!$schema) {
return [];
}
$clean = [];
foreach ($this->get_all_fields($schema) as $field) {
$key = (string) $field['key'];
if (array_key_exists($key, $data)) {
$clean[$key] = $this->sanitize_field_value($data[$key], $field);
}
}
if (!empty($data['__clear_secrets']) && is_array($data['__clear_secrets'])) {
$requested = array_values(array_filter(array_map([$this, 'normalize_secret_path'], $data['__clear_secrets'])));
$clean['__clear_secrets'] = array_values(array_intersect($requested, $this->secret_field_paths($schema)));
}
return $clean;
}
public function validate(string $domain, array $data, string $section = ''): array {
$schemas = [];
if ($section !== '') {
$schema = $this->get_section_schema($domain, $section);
if (!$schema) {
return ['valid' => false, 'errors' => ['Unknown section']];
}
$schemas[] = $schema;
} else {
$schemas = array_values($this->get_sections($domain));
}
$errors = [];
foreach ($schemas as $schema) {
foreach ($this->get_all_fields($schema) as $field) {
$key = $field['key'];
if (!array_key_exists($key, $data)) {
continue;
}
$value = $data[$key];
if (!empty($field['required']) && ($value === '' || $value === null)) {
$errors[] = sprintf('%s is required', $field['label'] ?? $key);
continue;
}
if (($field['sanitize_type'] ?? '') === 'money_decimal') {
if ($this->normalize_money_decimal($value) === '') {
$errors[] = sprintf('%s must be a decimal string with at most two fractional digits', $field['label'] ?? $key);
continue;
}
}
$type = $field['type'] ?? 'input';
if (($type === 'number' || $type === 'slider') && !is_numeric($value)) {
$errors[] = sprintf('%s must be a number', $field['label'] ?? $key);
continue;
}
if ($type === 'checkbox' && !is_array($value)) {
$errors[] = sprintf('%s must be an array', $field['label'] ?? $key);
continue;
}
if (in_array($type, ['fieldset', 'accordion', 'sortable', 'link', 'between_number'], true) && !is_array($value)) {
$errors[] = sprintf('%s must be an object', $field['label'] ?? $key);
continue;
}
if ($type === 'group' && !is_array($value)) {
$errors[] = sprintf('%s must be an array of items', $field['label'] ?? $key);
continue;
}
if (in_array($type, ['group', 'repeater'], true) && is_array($value)) {
if (isset($field['min']) && count($value) < (int) $field['min']) {
$errors[] = sprintf('%s must contain at least %s items', $field['label'] ?? $key, $field['min']);
}
if (isset($field['max']) && count($value) > (int) $field['max']) {
$errors[] = sprintf('%s must contain at most %s items', $field['label'] ?? $key, $field['max']);
}
}
if ($type === 'repeater' && !is_array($value)) {
$errors[] = sprintf('%s must be an array of items', $field['label'] ?? $key);
continue;
}
if (isset($field['min']) && is_numeric($value) && $value < $field['min']) {
$errors[] = sprintf('%s must be at least %s', $field['label'] ?? $key, $field['min']);
}
if (isset($field['max']) && is_numeric($value) && $value > $field['max']) {
$errors[] = sprintf('%s must be at most %s', $field['label'] ?? $key, $field['max']);
}
if (in_array($type, ['select', 'radio', 'palette', 'image_select'], true) && !empty($field['options'])) {
// Optional enum fields use an empty string to mean "unset".
// This is required for clearable palettes such as the footer
// background preset, whose default is intentionally empty.
if ($value === '' && empty($field['required'])) {
continue;
}
$valid_values = array_column($field['options'], 'value');
// Multi-select: accept array values when schema.multiple is set
// (legacy / SPA may still use type=select multiple).
if (!empty($field['multiple']) && is_array($value)) {
foreach ($value as $item) {
if (!in_array($item, $valid_values, true)
&& (empty($field['case_insensitive']) || $this->normalize_case_insensitive_option($item, (array) $field['options']) === null)) {
$errors[] = sprintf('%s has an invalid value', $field['label'] ?? $key);
break;
}
}
} elseif (!in_array($value, $valid_values, true)
&& (empty($field['case_insensitive']) || $this->normalize_case_insensitive_option($value, (array) $field['options']) === null)) {
$errors[] = sprintf('%s has an invalid value', $field['label'] ?? $key);
}
}
if ($type === 'checkbox' && !empty($field['options'])) {
$valid_values = array_column($field['options'], 'value');
if (!is_array($value)) {
$errors[] = sprintf('%s must be an array', $field['label'] ?? $key);
continue;
}
foreach ((array) $value as $item) {
if (!in_array($item, $valid_values, true)) {
$errors[] = sprintf('%s has an invalid value', $field['label'] ?? $key);
break;
}
}
}
$errors = array_merge($errors, $this->validate_nested_field($field, $value, (string) ($field['label'] ?? $key)));
}
}
if ($domain === 'users' && ($section === 'oauth' || $section === '')) {
$stored = get_option('aksrt_settings', []);
$merged = is_array($stored) ? array_merge($stored, $data) : $data;
foreach ((array) ($data['__clear_secrets'] ?? []) as $secret_key) {
$merged[sanitize_key((string) $secret_key)] = '';
}
$errors = array_merge($errors, $this->validate_oauth_settings($merged, is_array($stored) ? $stored : []));
}
if ($domain === 'payment' && $section === 'withdrawal') {
$stored = get_option('aksrt_settings', []);
$merged = is_array($stored) ? array_merge($stored, $data) : $data;
$count = $merged['affiliate_daily_withdrawal_count'] ?? 3;
$min_raw = $merged['affiliate_min_withdrawal'] ?? '50.00';
$limit_raw = $merged['affiliate_daily_withdrawal_limit'] ?? '500.00';
$time = (string) ($merged['affiliate_withdrawal_reset_time'] ?? '00:00');
if (filter_var($count, FILTER_VALIDATE_INT) === false || (int) $count < 0) {
$errors[] = __('每日可提现次数必须是非负整数', 'aksrt');
}
try {
$min_minor = \AKSRT\Ledger\AssetAmount::balance(is_string($min_raw) ? $min_raw : '')->minor();
$limit_minor = \AKSRT\Ledger\AssetAmount::balance(is_string($limit_raw) ? $limit_raw : '')->minor();
} catch (\InvalidArgumentException $e) {
$min_minor = 0;
$limit_minor = 0;
}
if ($limit_minor < $min_minor) {
$errors[] = __('每日提现上限不得低于最低提现金额', 'aksrt');
}
if (preg_match('/^(?:[01]\\d|2[0-3]):[0-5]\\d$/', $time) !== 1) {
$errors[] = __('重置时间必须为 HH:MM', 'aksrt');
}
}
return ['valid' => empty($errors), 'errors' => $errors];
}
public function read_settings(string $domain, string $section = ''): array {
if ($section !== '') {
$schema = $this->get_section_schema($domain, $section);
if (!$schema) {
return [];
}
return $this->read_section_settings($schema);
}
$settings = [];
foreach ($this->get_sections($domain) as $section_schema) {
$settings = array_merge($settings, $this->read_section_settings($section_schema));
}
return $settings;
}
public function write_settings(string $domain, array $data, string $section = ''): bool {
if ($section !== '') {
$schema = $this->get_section_schema($domain, $section);
if (!$schema) {
return false;
}
return $this->write_section_settings($schema, $data);
}
$result = true;
foreach ($this->get_sections($domain) as $section_schema) {
$allowed = array_keys($this->collect_allowed_fields($section_schema));
$payload = array_intersect_key($data, array_flip($allowed));
if ($payload === []) {
continue;
}
$result = $this->write_section_settings($section_schema, $payload) && $result;
}
return $result;
}
public function reset_to_defaults(string $domain, string $section = ''): bool {
if ($section !== '') {
$defaults = $this->get_defaults($domain, $section);
$schema = $this->get_section_schema($domain, $section);
$defaults['__clear_secrets'] = $schema ? $this->secret_field_paths($schema) : [];
return $this->write_settings($domain, $defaults, $section);
}
$result = true;
foreach ($this->get_sections($domain) as $section_schema) {
$defaults = $this->collect_defaults($section_schema);
$defaults['__clear_secrets'] = $this->secret_field_paths($section_schema);
$result = $this->write_section_settings($section_schema, $defaults) && $result;
}
return $result;
}
private function get_schema_builder_map(): array {
// Builders live under AKSRT\Admin\Schema (Composer PSR-4). Relative
// Schema\*::class resolves correctly from this AKSRT\Admin namespace.
return [
'global' => Schema\GlobalSchema::class,
'page_display' => Schema\SchemaPageDisplay::class,
'post_list' => Schema\SchemaPostList::class,
'permissions' => Schema\SchemaPermissions::class,
'users' => Schema\SchemaUsers::class,
'payment' => Schema\SchemaPayment::class,
'extensions' => Schema\SchemaExtensions::class,
'tools' => Schema\SchemaTools::class,
];
}
private function read_section_settings(array $schema): array {
$option_key = (string) ($schema['option_key'] ?? 'aksrt_settings');
$stored = get_option($option_key, []);
if (!is_array($stored)) {
$stored = [];
}
$data = $this->collect_defaults($schema);
foreach ($this->get_all_fields($schema) as $field) {
$field_type = (string) ($field['type'] ?? 'text');
$has_stored = array_key_exists($field['key'], $stored);
// group/repeater 只接受数组;旧标量值(如 textarea 时代的字符串)
// 直接忽略并回退默认,避免前端把字符串按字符渲染成大量假条目。
if ($has_stored && in_array($field_type, ['group', 'repeater'], true) && !is_array($stored[$field['key']])) {
$has_stored = false;
}
if ($has_stored) {
$data[$field['key']] = $stored[$field['key']];
}
// 大小写不敏感字段:存储值归一化为选项规范写法;匹配不到回退默认,
// 避免旧自由文本值(如小写货币代码)导致枚举校验失败。
if (!empty($field['case_insensitive']) && in_array($field_type, ['select', 'radio'], true) && !empty($field['options'])) {
$normalized = $this->normalize_case_insensitive_option($data[$field['key']] ?? '', (array) $field['options']);
$data[$field['key']] = $normalized !== null ? $normalized : ($field['default'] ?? ($data[$field['key']] ?? ''));
}
if (($field['sanitize_type'] ?? '') === 'money_decimal' && array_key_exists($field['key'], $data)) {
$normalized = $this->normalize_money_decimal($data[$field['key']]);
if ($normalized !== '') {
$data[$field['key']] = $normalized;
}
}
$stored_value = $has_stored ? $stored[$field['key']] : ($data[$field['key']] ?? '');
if (!empty($field['secret'])) {
$data[$field['key']] = trim((string) $stored_value) !== '' ? '***' : '';
}
if (!empty($field['captcha_provider'])) {
$data[$field['key']] = $this->mask_captcha_provider_secrets(
(string) $field['captcha_provider'],
is_array($stored_value) ? $stored_value : []
);
}
}
// Captcha: always surface the normalized positive scene list.
if ($option_key === 'aksrt_captcha_config' && function_exists('aksrt_get_captcha_config')) {
$normalized = aksrt_get_captcha_config();
foreach (['enabled', 'active_provider', 'enabled_scenarios'] as $key) {
if (array_key_exists($key, $normalized)) {
$data[$key] = $normalized[$key];
}
}
foreach (function_exists('aksrt_captcha_providers') ? aksrt_captcha_providers() : [] as $provider) {
$data[$provider] = $this->mask_captcha_provider_secrets($provider, (array) ($normalized[$provider] ?? []));
}
}
return $data;
}
private function write_section_settings(array $schema, array $data): bool {
$option_key = (string) ($schema['option_key'] ?? 'aksrt_settings');
$stored = get_option($option_key, []);
if (!is_array($stored)) {
$stored = [];
}
$fields = [];
foreach ($this->get_all_fields($schema) as $field) {
$fields[(string) $field['key']] = $field;
}
$clear_secrets = isset($data['__clear_secrets']) && is_array($data['__clear_secrets'])
? array_values(array_filter(array_map([$this, 'normalize_secret_path'], $data['__clear_secrets'])))
: [];
foreach ($fields as $key => $field) {
if (!empty($field['secret']) && in_array($key, $clear_secrets, true)) {
unset($stored[$key]);
continue;
}
if (!empty($field['captcha_provider'])) {
$provider = (string) $field['captcha_provider'];
$current = isset($stored[$key]) && is_array($stored[$key]) ? $stored[$key] : [];
$incoming = array_key_exists($key, $data) && is_array($data[$key]) ? $data[$key] : [];
$stored[$key] = $this->merge_captcha_provider_config($provider, $current, $incoming, $clear_secrets);
continue;
}
if (!array_key_exists($key, $data)) {
continue;
}
if (!empty($field['secret'])) {
$value = is_scalar($data[$key]) ? trim((string) $data[$key]) : '';
if ($value === '' || $value === '***') {
continue;
}
}
$stored[$key] = $this->sanitize_field_value($data[$key], $field);
}
// Captcha stores a single positive scene list.
if ($option_key === 'aksrt_captcha_config' && function_exists('aksrt_captcha_scenes')) {
$stored['enabled'] = !empty($stored['enabled']);
$active_provider = sanitize_key((string) ($stored['active_provider'] ?? ''));
$stored['active_provider'] = in_array($active_provider, aksrt_captcha_providers(), true) ? $active_provider : '';
$stored['enabled_scenarios'] = function_exists('aksrt_captcha_sanitize_scene_list')
? aksrt_captcha_sanitize_scene_list($stored['enabled_scenarios'] ?? [])
: (array) ($stored['enabled_scenarios'] ?? []);
foreach (aksrt_captcha_providers() as $provider) {
$stored[$provider] = function_exists('aksrt_captcha_sanitize_provider_config')
? aksrt_captcha_sanitize_provider_config($provider, $stored[$provider] ?? [])
: (array) ($stored[$provider] ?? []);
}
}
$updated = update_option($option_key, $stored);
if ($option_key === 'aksrt_captcha_config' && function_exists('aksrt_captcha_config_reset')) {
aksrt_captcha_config_reset();
}
\AKSRT\Settings\Settings::flush_settings_cache();
if (isset($stored['site_favicon'])) {
\AKSRT\Settings\Settings::sync_native_site_icon((string) $stored['site_favicon']);
}
return $updated || true;
}
private function normalize_secret_path($path): string {
$parts = array_values(array_filter(explode('.', strtolower(trim((string) $path))), static fn(string $part): bool => $part !== ''));
$parts = array_map('sanitize_key', $parts);
return in_array('', $parts, true) ? '' : implode('.', $parts);
}
private function secret_field_paths(array $schema): array {
$paths = [];
foreach ($this->get_all_fields($schema) as $field) {
$key = sanitize_key((string) ($field['key'] ?? ''));
if ($key === '') {
continue;
}
if (!empty($field['secret'])) {
$paths[] = $key;
}
if (!empty($field['captcha_provider']) && function_exists('aksrt_captcha_provider_secret_fields')) {
foreach (aksrt_captcha_provider_secret_fields((string) $field['captcha_provider']) as $secret) {
$paths[] = $key . '.' . sanitize_key((string) $secret);
}
}
}
return array_values(array_unique($paths));
}
private function mask_captcha_provider_secrets(string $provider, array $config): array {
$clean = function_exists('aksrt_captcha_sanitize_provider_config')
? aksrt_captcha_sanitize_provider_config($provider, $config)
: $config;
foreach (function_exists('aksrt_captcha_provider_secret_fields') ? aksrt_captcha_provider_secret_fields($provider) : [] as $secret) {
$clean[$secret] = trim((string) ($config[$secret] ?? '')) !== '' ? '***' : '';
}
return $clean;
}
private function merge_captcha_provider_config(string $provider, array $stored, array $incoming, array $clear_paths): array {
$current = function_exists('aksrt_captcha_sanitize_provider_config')
? aksrt_captcha_sanitize_provider_config($provider, $stored)
: $stored;
$defaults = function_exists('aksrt_captcha_provider_defaults') ? aksrt_captcha_provider_defaults($provider) : [];
$secret_fields = function_exists('aksrt_captcha_provider_secret_fields') ? aksrt_captcha_provider_secret_fields($provider) : [];
foreach (array_keys($defaults) as $key) {
$path = $provider . '.' . $key;
if (in_array($path, $clear_paths, true)) {
$current[$key] = '';
continue;
}
if (!array_key_exists($key, $incoming) || is_array($incoming[$key]) || is_object($incoming[$key])) {
continue;
}
$value = trim((string) $incoming[$key]);
if (in_array($key, $secret_fields, true) && ($value === '' || $value === '***')) {
continue;
}
$current[$key] = $value;
}
return function_exists('aksrt_captcha_sanitize_provider_config')
? aksrt_captcha_sanitize_provider_config($provider, $current)
: $current;
}
private function secret_field_keys(array $schema): array {
return array_values(array_filter($this->secret_field_paths($schema), static fn(string $path): bool => !str_contains($path, '.')));
}
private function collect_defaults(array $schema): array {
$defaults = [];
foreach ($this->get_all_fields($schema) as $field) {
if (array_key_exists('default', $field)) {
$defaults[$field['key']] = $field['default'];
}
}
return $defaults;
}
private function collect_allowed_fields(array $schema): array {
$allowed = [];
foreach ($this->get_all_fields($schema) as $field) {
$type = (string) ($field['type'] ?? 'input');
$allowed[$field['key']] = $field['sanitize_type']
?? ($type === 'color' ? $this->color_sanitize_type($field) : $this->infer_sanitize_type($type));
}
return $allowed;
}
private function get_all_fields(array $schema): array {
$fields = [];
foreach ($schema['groups'] ?? [] as $group) {
if (!is_array($group)) {
continue;
}
foreach ($group['fields'] ?? [] as $field) {
$fields = array_merge($fields, $this->collect_persisted_fields(is_array($field) ? $field : []));
}
}
return $fields;
}
/**
* Flatten one field definition into persisted keys. Tabbed is a UI-only
* container: its sub-fields are flat top-level option keys, so they are
* expanded into the persisted field list (notice / ui_only rows skipped).
*
* @return array<int,array<string,mixed>>
*/
private function collect_persisted_fields(array $field): array {
if (($field['type'] ?? '') === 'tabbed') {
$fields = [];
foreach ((array) ($field['tabs'] ?? []) as $tab) {
if (!is_array($tab)) {
continue;
}
foreach ((array) ($tab['fields'] ?? []) as $sub) {
$fields = array_merge($fields, $this->collect_persisted_fields(is_array($sub) ? $sub : []));
}
}
return $fields;
}
if (empty($field['key'])) {
return [];
}
// UI-only controls never become persisted settings keys.
if (!empty($field['ui_only'])) {
return [];
}
// Notice/help-only rows are UI chrome, not stored settings keys.
if (($field['type'] ?? '') === 'notice') {
return [];
}
return [$field];
}
/**
* Normalize legacy numeric money settings without accepting lossy precision.
*
* @param mixed $value
*/
private function normalize_money_decimal($value): string {
if (is_int($value)) {
$value = (string) $value;
} elseif (is_float($value)) {
if (!is_finite($value)) {
return '';
}
$value = (string) $value;
} elseif (!is_string($value)) {
return '';
}
try {
return \AKSRT\Ledger\AssetAmount::balance(trim($value))->decimal();
} catch (\InvalidArgumentException $e) {
return '';
}
}
private function sanitize_value($value, string $type) {
switch ($type) {
case 'attachment_ids':
if (!is_array($value)) {
return [];
}
$ids = array_values(array_filter(array_map('absint', $value)));
return array_values(array_unique($ids));
case 'html':
return wp_kses_post((string) $value);
case 'rich_html':
// Inline whitelist for card title/desc: `badge` is a custom
// element that wp_kses_post() would strip, but legacy (Zibll)
// renders such markup verbatim.
return function_exists('aksrt_icon_card_kses')
? aksrt_icon_card_kses((string) $value)
: wp_kses_post((string) $value);
case 'textarea':
return sanitize_textarea_field((string) $value);
case 'code':
return is_string($value) ? wp_unslash($value) : '';
case 'url':
return esc_url_raw((string) $value);
case 'int':
return (int) $value;
case 'bool':
return !empty($value);
case 'float':
return (float) $value;
case 'money_decimal':
return $this->normalize_money_decimal($value);
case 'array':
// 深层字符串净化,保留嵌套服务商配置(如 geetest.captcha_id)
if (!is_array($value)) {
return [];
}
$out = [];
foreach ($value as $k => $v) {
$key = is_string($k) ? sanitize_key($k) : $k;
if (is_array($v)) {
$out[$key] = $this->sanitize_value($v, 'array');
} elseif (is_bool($v)) {
$out[$key] = $v;
} elseif (is_int($v) || is_float($v)) {
$out[$key] = $v;
} else {
$out[$key] = sanitize_text_field((string) $v);
}
}
return $out;
case 'icon':
return function_exists('aksrt_sanitize_icon_value')
? aksrt_sanitize_icon_value((string) $value)
: sanitize_text_field((string) $value);
case 'color':
return \AKSRT\Core\Color::sanitize($value);
case 'color_alpha':
return \AKSRT\Core\Color::sanitize($value, true);
default:
return sanitize_text_field((string) $value);
}
}
private function sanitize_field_value($value, array $field) {
if (!empty($field['captcha_provider'])) {
return function_exists('aksrt_captcha_sanitize_provider_config')
? aksrt_captcha_sanitize_provider_config((string) $field['captcha_provider'], $value)
: [];
}
$type = (string) ($field['type'] ?? 'text');
$sanitize_type = (string) ($field['sanitize_type'] ?? '');
if ($sanitize_type !== '') {
return $this->sanitize_value($value, $sanitize_type);
}
if ($type === 'gallery') {
return $this->sanitize_value($value, 'attachment_ids');
}
// Icon values carry inline "customsvg-<svg>" payloads that plain
// sanitize_text_field would strip down to a bare prefix. Top-level icon
// fields already route through sanitize_value('icon'); group/repeater
// sub-fields (e.g. header_slider_card_option.cards[].icon) must too.
if ($type === 'icon') {
return function_exists('aksrt_sanitize_icon_value')
? aksrt_sanitize_icon_value((string) $value)
: $this->sanitize_value($value, 'icon');
}
if ($type === 'color') {
return $this->sanitize_value($value, $this->color_sanitize_type($field));
}
if (in_array($type, ['select', 'radio'], true) && !empty($field['case_insensitive']) && !empty($field['options'])) {
// 旧自由文本值(如小写货币代码)归一化为选项规范写法。
$normalized = $this->normalize_case_insensitive_option($value, (array) $field['options']);
return $normalized !== null ? $normalized : (string) ($field['default'] ?? '');
}
// Enum fields keep the option's declared value so the stored type always
// matches what validate() compares against with in_array(..., true).
if (in_array($type, ['select', 'radio', 'palette', 'image_select'], true) && empty($field['multiple']) && !empty($field['options'])) {
$matched = $this->match_option_value($value, (array) $field['options']);
if ($matched !== null) {
return $matched;
}
}
if (in_array($type, ['fieldset', 'accordion'], true)) {
return $this->sanitize_object_fields($value, (array) ($field['fields'] ?? []));
}
if ($type === 'between_number') {
$defaults = is_array($field['default'] ?? null) ? $field['default'] : ['min' => 0, 'max' => 0];
if (!is_array($value)) {
return ['min' => (int) ($defaults['min'] ?? 0), 'max' => (int) ($defaults['max'] ?? 0)];
}
return [
'min' => is_numeric($value['min'] ?? null) ? (int) $value['min'] : (int) ($defaults['min'] ?? 0),
'max' => is_numeric($value['max'] ?? null) ? (int) $value['max'] : (int) ($defaults['max'] ?? 0),
];
}
if (in_array($type, ['group', 'repeater'], true)) {
if (!is_array($value)) {
return [];
}
$items = [];
$limit = isset($field['max']) ? max(0, (int) $field['max']) : 100;
foreach (array_slice(array_values($value), 0, $limit ?: 100) as $item) {
if (is_array($item)) {
$items[] = $this->sanitize_object_fields($item, (array) ($field['fields'] ?? []));
}
}
return $items;
}
if ($type === 'sortable') {
if (!is_array($value)) {
return [];
}
$definitions = [];
foreach ((array) ($field['items'] ?? []) as $item_schema) {
if (!is_array($item_schema)) {
continue;
}
$item_key = sanitize_key((string) ($item_schema['key'] ?? ''));
if ($item_key !== '') {
$definitions[$item_key] = $item_schema;
}
}
// 键顺序即配置值本身:必须按提交顺序重建。若改为遍历 items
// 定义,用户排序会被静默改写回 schema 静态顺序(等于排序失效)。
$items = [];
foreach ($value as $item_key => $item_value) {
$item_key = sanitize_key((string) $item_key);
if ($item_key === '' || !isset($definitions[$item_key]) || !is_array($item_value)) {
continue;
}
$items[$item_key] = $this->sanitize_object_fields($item_value, (array) ($definitions[$item_key]['fields'] ?? []));
}
return $items;
}
if ($type === 'link') {
$value = is_array($value) ? $value : [];
return [
'url' => esc_url_raw((string) ($value['url'] ?? '')),
'target' => in_array(($value['target'] ?? ''), ['', '_self', '_blank'], true) ? (string) ($value['target'] ?? '') : '',
'text' => sanitize_text_field((string) ($value['text'] ?? '')),
];
}
if ($type === 'checkbox' || !empty($field['multiple'])) {
if (!is_array($value)) {
return [];
}
return array_values(array_map(static function ($item) {
return is_numeric($item) ? (int) $item : sanitize_key((string) $item);
}, $value));
}
if (in_array($type, ['switch', 'switcher'], true)) {
return !empty($value);
}
if (in_array($type, ['number', 'slider', 'spinner'], true)) {
return is_float($value) ? (float) $value : (int) $value;
}
if (in_array($type, ['media', 'upload', 'url'], true)) {
return esc_url_raw((string) $value);
}
return sanitize_text_field((string) $value);
}
private function sanitize_object_fields($value, array $fields): array {
if (!is_array($value)) {
return [];
}
$clean = [];
foreach ($fields as $field) {
if (!is_array($field) || empty($field['key'])) {
continue;
}
// UI-only chrome (notice / alert rows) is presentation only and must
// never become a persisted settings key, matching the top-level
// behaviour of collect_persisted_fields().
if (!empty($field['ui_only']) || ($field['type'] ?? '') === 'notice') {
continue;
}
$key = (string) $field['key'];
if (array_key_exists($key, $value)) {
$clean[$key] = $this->sanitize_field_value($value[$key], $field);
}
}
return $clean;
}
private function validate_nested_field(array $field, $value, string $path): array {
$type = (string) ($field['type'] ?? 'text');
$errors = [];
if (in_array($type, ['fieldset', 'accordion'], true) && is_array($value)) {
$errors = $this->validate_object_fields((array) ($field['fields'] ?? []), $value, $path);
} elseif (in_array($type, ['group', 'repeater'], true) && is_array($value)) {
foreach (array_values($value) as $index => $item) {
if (is_array($item)) {
$errors = array_merge($errors, $this->validate_object_fields((array) ($field['fields'] ?? []), $item, $path . '[' . $index . ']'));
}
}
} elseif ($type === 'sortable' && is_array($value)) {
$items = [];
foreach ((array) ($field['items'] ?? []) as $item_schema) {
if (is_array($item_schema) && !empty($item_schema['key'])) {
$items[(string) $item_schema['key']] = $item_schema;
}
}
foreach (array_keys($value) as $item_key) {
if (!isset($items[$item_key])) {
$errors[] = sprintf('%s has an invalid item', $path);
} elseif (is_array($value[$item_key])) {
$errors = array_merge($errors, $this->validate_object_fields((array) ($items[$item_key]['fields'] ?? []), $value[$item_key], $path . '.' . $item_key));
}
}
}
return $errors;
}
private function validate_object_fields(array $fields, array $value, string $path): array {
$errors = [];
foreach ($fields as $field) {
$key = (string) ($field['key'] ?? '');
if ($key === '' || !array_key_exists($key, $value)) {
continue;
}
$type = (string) ($field['type'] ?? 'text');
if (in_array($type, ['select', 'radio', 'palette', 'image_select'], true) && !empty($field['options'])) {
$allowed = array_column($field['options'], 'value');
if (!empty($field['multiple']) && is_array($value[$key])) {
foreach ($value[$key] as $item) {
if (!in_array($item, $allowed, true)) {
$errors[] = sprintf('%s.%s has an invalid value', $path, $key);
break;
}
}
} elseif (!in_array($value[$key], $allowed, true)) {
$errors[] = sprintf('%s.%s has an invalid value', $path, $key);
}
}
if (in_array($type, ['number', 'slider', 'spinner'], true)) {
if (!is_numeric($value[$key])) {
$errors[] = sprintf('%s.%s must be a number', $path, $key);
} elseif (isset($field['min']) && $value[$key] < $field['min']) {
$errors[] = sprintf('%s.%s must be at least %s', $path, $key, $field['min']);
} elseif (isset($field['max']) && $value[$key] > $field['max']) {
$errors[] = sprintf('%s.%s must be at most %s', $path, $key, $field['max']);
}
}
$errors = array_merge($errors, $this->validate_nested_field($field, $value[$key], $path . '.' . $key));
}
return $errors;
}
private function infer_sanitize_type(string $field_type): string {
$map = [
'switch' => 'bool',
'switcher' => 'bool',
'input' => 'string',
'text' => 'string',
'textarea' => 'textarea',
'number' => 'int',
'slider' => 'int',
'select' => 'string',
'radio' => 'string',
'checkbox' => 'array',
'repeater' => 'array',
'group' => 'array',
'fieldset' => 'array',
'accordion'=> 'array',
'sortable' => 'array',
'link' => 'array',
'between_number' => 'array',
'page_select' => 'int',
'async_select' => 'array',
'json' => 'array',
// 自定义面板(验证码服务商等)按对象存储
'custom' => 'array',
'color' => 'color',
'icon' => 'icon',
'media' => 'url',
'upload' => 'url',
'gallery' => 'attachment_ids',
'code' => 'code',
];
return $map[$field_type] ?? 'string';
}
/** Color fields keep alpha only when the schema opts in via showAlpha. */
private function color_sanitize_type(array $field): string {
return !empty($field['showAlpha']) ? 'color_alpha' : 'color';
}
}