> */ 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'); } } if ($domain === 'permissions' && $section === 'download') { $alias = trim((string) ($data['url_alias'] ?? '')); if ($alias === '') { $errors[] = __('下载链接 URL 别名不能为空', 'aksrt'); } elseif (strlen($alias) > 48) { $errors[] = __('下载链接 URL 别名长度不能超过 48 个字符', 'aksrt'); } elseif (!preg_match('/^[A-Za-z0-9_-]+$/', $alias)) { $errors[] = __('下载链接 URL 别名只能包含英文字母、数字、连字符和下划线,禁止中文、空格和 / \\ ? # & 等特殊符号', 'aksrt'); } elseif (in_array(strtolower($alias), $this->reserved_download_aliases(), true)) { $errors[] = __('该别名与系统保留路径冲突,请更换其他别名以避免路由 404', 'aksrt'); } } return ['valid' => empty($errors), 'errors' => $errors]; } private function validate_oauth_settings(array $settings, array $stored = []): array { if (empty($settings['oauth_s'])) { return []; } $errors = []; $mode = sanitize_key((string) ($settings['oauth_proxy_mode'] ?? 'close')); $secret_configured = $this->oauth_secret_is_configured($settings['oauth_proxy_secret'] ?? '', $stored['oauth_proxy_secret'] ?? ''); if ($mode === 'client') { $url = trim((string) ($settings['oauth_proxy_server_url'] ?? '')); if (!$this->is_secure_oauth_url($url)) { $errors[] = __('客户端代理模式需要有效的 HTTPS 服务端地址', 'aksrt'); } if (!$secret_configured) { $errors[] = __('客户端代理模式需要代理 HMAC 密钥', 'aksrt'); } if (empty($settings['oauth_proxy_client_providers']) || !is_array($settings['oauth_proxy_client_providers'])) { $errors[] = __('客户端代理模式至少需要启用一个登录平台', 'aksrt'); } return $errors; } if ($mode === 'server') { if (!$secret_configured) { $errors[] = __('服务端代理模式需要代理 HMAC 密钥', 'aksrt'); } $selected = is_array($settings['oauth_proxy_server_providers'] ?? null) ? $settings['oauth_proxy_server_providers'] : []; if ($selected === []) { $errors[] = __('服务端代理模式至少需要允许一个登录平台', 'aksrt'); } $provider_fields = [ 'github' => ['GitHub', 'github_appid', 'github_appkey'], 'gitee' => ['Gitee', 'gitee_appid', 'gitee_appkey'], 'qq' => ['QQ', 'qq_appid', 'qq_appkey'], 'google' => ['Google', 'google_client_id', 'google_client_secret'], 'weibo' => ['微博', 'weibo_appid', 'weibo_appkey'], 'weixin' => ['微信开放平台', 'weixin_appid', 'weixin_appsecret'], ]; foreach ($selected as $provider) { if (!isset($provider_fields[$provider])) { continue; } [$label, $id_key, $secret_key] = $provider_fields[$provider]; if (trim((string) ($settings[$id_key] ?? '')) === '' || !$this->oauth_secret_is_configured($settings[$secret_key] ?? '', $stored[$secret_key] ?? '')) { $errors[] = sprintf(__('代理服务端的 %s 平台缺少完整凭据', 'aksrt'), $label); } } return $errors; } $providers = [ 'github' => ['GitHub', 'github_appid', 'github_appkey'], 'gitee' => ['Gitee', 'gitee_appid', 'gitee_appkey'], 'qq' => ['QQ', 'qq_appid', 'qq_appkey'], 'google' => ['Google', 'google_client_id', 'google_client_secret'], 'weibo' => ['微博', 'weibo_appid', 'weibo_appkey'], 'weixin' => ['微信开放平台', 'weixin_appid', 'weixin_appsecret'], ]; $enabled = 0; foreach ($providers as $provider => [$label, $id_key, $secret_key]) { if (empty($settings['oauth_' . $provider . '_s'])) { continue; } $enabled++; if (trim((string) ($settings[$id_key] ?? '')) === '' || !$this->oauth_secret_is_configured($settings[$secret_key] ?? '', $stored[$secret_key] ?? '')) { $errors[] = sprintf(__('%s 登录已启用,请填写完整凭据', 'aksrt'), $label); } } if (!empty($settings['oauth_weixingzh_s'])) { $enabled++; $gzh_type = sanitize_key((string) ($settings['weixingzh_gzh_type'] ?? 'service')); $base_ready = $this->oauth_secret_is_configured($settings['weixingzh_token'] ?? '', $stored['weixingzh_token'] ?? '') && trim((string) ($settings['weixingzh_appid'] ?? '')) !== ''; $mode_ready = $gzh_type === 'subscribe' ? trim((string) ($settings['weixingzh_code_qrcode'] ?? '')) !== '' && trim((string) ($settings['weixingzh_code_keyword'] ?? '')) !== '' : $this->oauth_secret_is_configured($settings['weixingzh_appsecret'] ?? '', $stored['weixingzh_appsecret'] ?? ''); if (!$base_ready || !$mode_ready) { $errors[] = $gzh_type === 'subscribe' ? __('微信公众号订阅号登录已启用,请填写 Token、AppID、二维码和回复关键词', 'aksrt') : __('微信公众号服务号登录已启用,请填写 Token、AppID 和 AppSecret', 'aksrt'); } } if (!empty($settings['clogin_s'])) { $enabled++; if (!$this->is_secure_oauth_url((string) ($settings['clogin_url'] ?? '')) || trim((string) ($settings['clogin_id'] ?? '')) === '' || !$this->oauth_secret_is_configured($settings['clogin_key'] ?? '', $stored['clogin_key'] ?? '') || empty($settings['clogin_oauth_s']) ) { $errors[] = __('彩虹聚合登录已启用,请填写 HTTPS 接口地址、AppID、AppKey 并选择平台', 'aksrt'); } } if ($enabled === 0) { $errors[] = __('启用第三方登录后,至少需要启用一个本地登录提供商', 'aksrt'); } return $errors; } private function oauth_secret_is_configured($value, $stored_value = ''): bool { $value = trim((string) $value); if ($value === '***') { return trim((string) $stored_value) !== ''; } return $value !== ''; } private function is_secure_oauth_url(string $url): bool { $url = trim($url); if ($url === '' || filter_var($url, FILTER_VALIDATE_URL) === false) { return false; } $parts = wp_parse_url($url); return is_array($parts) && strtolower((string) ($parts['scheme'] ?? '')) === 'https' && !empty($parts['host']) && empty($parts['user']) && empty($parts['pass']) && empty($parts['fragment']); } /** * Top-level slugs that must never be shadowed by the download route. */ private function reserved_download_aliases(): array { return [ 'wp-json', 'wp-admin', 'wp-content', 'wp-includes', 'admin', 'login', 'logout', 'register', 'lostpassword', 'feed', 'search', 'author', 'page', 'comments', 'sitemap', 'sign', 'cgi-bin', ]; } 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) { $allowed[$field['key']] = $field['sanitize_type'] ?? $this->infer_sanitize_type($field['type'] ?? 'input'); } 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> */ 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); default: return sanitize_text_field((string) $value); } } /** * 对大小写不敏感字段(case_insensitive),把值归一化为选项中的规范写法; * 匹配不到返回 null。 * * @param mixed $value * @param array $options */ private function normalize_case_insensitive_option($value, array $options): ?string { if (!is_string($value) || $value === '') { return null; } $lower = strtolower(trim($value)); foreach ($options as $opt) { $opt_value = is_array($opt) ? ($opt['value'] ?? null) : $opt; if (is_string($opt_value) && strtolower($opt_value) === $lower) { return $opt_value; } } return null; } 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-" 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 (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'] ?? ''); } 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; } $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' => 'string', 'icon' => 'icon', 'media' => 'url', 'upload' => 'url', 'gallery' => 'attachment_ids', 'code' => 'code', ]; return $map[$field_type] ?? 'string'; } }