- Admin: 登录权限 → 人机验证; 停用场景 → 启用场景 (opt-in) - Normalize enabled_scenarios with legacy disabled_scenarios BC - Sign-in: never soft-bypass captcha; cancel unlocks button - Captcha JS: cancel path finishes pending callback as false
442 lines
16 KiB
PHP
442 lines
16 KiB
PHP
<?php
|
||
/**
|
||
* AKSRT Admin Schema Provider
|
||
*
|
||
* Defines the canonical PHP-side settings contract:
|
||
* domain -> section -> group -> field.
|
||
*
|
||
* @package AKSRT
|
||
*/
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
class AKSRT_Admin_Schema_Provider {
|
||
|
||
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',
|
||
'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');
|
||
if (!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');
|
||
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 {
|
||
return [
|
||
'global' => 'AKSRT_Admin_Schema_Global',
|
||
'page_display' => 'AKSRT_Admin_Schema_Page_Display',
|
||
'post_list' => 'AKSRT_Admin_Schema_Post_List',
|
||
'permissions' => 'AKSRT_Admin_Schema_Permissions',
|
||
'users' => 'AKSRT_Admin_Schema_Users',
|
||
'payment' => 'AKSRT_Admin_Schema_Payment',
|
||
'extensions' => 'AKSRT_Admin_Schema_Extensions',
|
||
'tools' => 'AKSRT_Admin_Schema_Tools',
|
||
'shop' => 'AKSRT_Admin_Schema_Shop',
|
||
'forum' => 'AKSRT_Admin_Schema_Forum',
|
||
];
|
||
}
|
||
|
||
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::flush_settings_cache();
|
||
|
||
if (isset($stored['site_favicon'])) {
|
||
AKSRT_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 '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' => 'string',
|
||
'number' => 'int',
|
||
'slider' => 'int',
|
||
'select' => 'string',
|
||
'radio' => 'string',
|
||
'checkbox' => 'array',
|
||
'repeater' => 'array',
|
||
// 自定义面板(验证码服务商等)按对象存储
|
||
'custom' => 'array',
|
||
'color' => 'string',
|
||
'icon' => 'string',
|
||
'media' => 'url',
|
||
'code' => 'code',
|
||
];
|
||
|
||
return $map[$field_type] ?? 'string';
|
||
}
|
||
}
|