> */ 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', 'shop', 'forum', ]; } 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', 'shop' => 'aksrt_settings', 'forum' => '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; } 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; } $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 ($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'], true) && !empty($field['options'])) { $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)) { $errors[] = sprintf('%s has an invalid value', $field['label'] ?? $key); break; } } } elseif (!in_array($value, $valid_values, true)) { $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; } } } } } 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 !== '') { return $this->write_settings($domain, $this->get_defaults($domain, $section), $section); } return $this->write_settings($domain, $this->get_defaults($domain)); } 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, 'shop' => Schema\SchemaShop::class, 'forum' => Schema\SchemaForum::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) { if (array_key_exists($field['key'], $stored)) { $data[$field['key']] = $stored[$field['key']]; } } // Captcha: always surface normalized enabled_scenarios (legacy disabled list inverted). if ($option_key === 'aksrt_captcha_config' && function_exists('aksrt_get_captcha_config')) { $normalized = aksrt_get_captcha_config(); foreach (['enabled', 'active_provider', 'enabled_scenarios', 'disabled_scenarios', 'tencent', 'aliyun', 'cloudflare', 'geetest', 'mode'] as $key) { if (array_key_exists($key, $normalized)) { $data[$key] = $normalized[$key]; } } } 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 = []; } $allowed = $this->collect_allowed_fields($schema); foreach ($allowed as $key => $type) { if (!array_key_exists($key, $data)) { continue; } $value = $this->sanitize_value($data[$key], $type); $stored[$key] = $value; } // Captcha: keep enabled_scenarios / disabled_scenarios mirrors in sync. if ($option_key === 'aksrt_captcha_config' && function_exists('aksrt_captcha_scenes')) { $all = array_keys(aksrt_captcha_scenes()); if (array_key_exists('enabled_scenarios', $stored)) { $enabled = function_exists('aksrt_captcha_sanitize_scene_list') ? aksrt_captcha_sanitize_scene_list($stored['enabled_scenarios']) : (array) $stored['enabled_scenarios']; $stored['enabled_scenarios'] = $enabled; $stored['disabled_scenarios'] = array_values(array_diff($all, $enabled)); } elseif (array_key_exists('disabled_scenarios', $stored)) { $disabled = function_exists('aksrt_captcha_sanitize_scene_list') ? aksrt_captcha_sanitize_scene_list($stored['disabled_scenarios']) : (array) $stored['disabled_scenarios']; $stored['disabled_scenarios'] = $disabled; $stored['enabled_scenarios'] = array_values(array_diff($all, $disabled)); } } $updated = update_option($option_key, $stored); \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 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) { if (!is_array($field) || empty($field['key'])) { continue; } // Notice/help-only rows are UI chrome, not stored settings keys. if (($field['type'] ?? '') === 'notice') { continue; } $fields[] = $field; } } return $fields; } private function sanitize_value($value, string $type) { switch ($type) { case 'html': return 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 '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; default: return sanitize_text_field((string) $value); } } private function infer_sanitize_type(string $field_type): string { $map = [ 'switch' => 'bool', 'input' => 'string', 'text' => 'string', 'textarea' => 'textarea', 'number' => 'int', 'slider' => 'int', 'select' => 'string', 'radio' => 'string', 'checkbox' => 'array', 'repeater' => 'array', 'json' => 'array', // 自定义面板(验证码服务商等)按对象存储 'custom' => 'array', 'color' => 'string', 'icon' => 'string', 'media' => 'url', 'code' => 'code', ]; return $map[$field_type] ?? 'string'; } }