The widget FormRenderer icon case and the nav-menu item icon field now emit the shared picker (preview + choose/clear + editable text input, delegated events so lazy forms and repeater rows work). Icon values are sanitized through aksrt_sanitize_icon_value on both save paths so inline customsvg- markup survives; the widget admin screen enqueues the picker.
1010 lines
38 KiB
PHP
1010 lines
38 KiB
PHP
<?php
|
||
/**
|
||
* @package AKSRT
|
||
*/
|
||
|
||
namespace AKSRT\Admin;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
class SchemaProvider {
|
||
|
||
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') {
|
||
try {
|
||
if (!is_string($value)) {
|
||
throw new \InvalidArgumentException('money_decimal_must_be_string');
|
||
}
|
||
\AKSRT\Ledger\AssetAmount::balance(trim($value));
|
||
} catch (\InvalidArgumentException $e) {
|
||
$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'], 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)) {
|
||
$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;
|
||
}
|
||
}
|
||
}
|
||
|
||
$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) {
|
||
if (array_key_exists($field['key'], $stored)) {
|
||
$stored_value = $stored[$field['key']];
|
||
$is_mail_template = $option_key === 'aksrt_settings'
|
||
&& preg_match('/^email_.+_(subject|content)$/', (string) $field['key']);
|
||
if (!$is_mail_template || trim((string) $stored_value) !== '') {
|
||
$data[$field['key']] = $stored_value;
|
||
}
|
||
}
|
||
|
||
$stored_value = array_key_exists($field['key'], $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) {
|
||
if (!is_array($field) || empty($field['key'])) {
|
||
continue;
|
||
}
|
||
// UI-only controls never become persisted settings keys.
|
||
if (!empty($field['ui_only'])) {
|
||
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 '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 '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':
|
||
if (!is_string($value)) {
|
||
return '';
|
||
}
|
||
try {
|
||
return \AKSRT\Ledger\AssetAmount::balance(trim($value))->decimal();
|
||
} catch (\InvalidArgumentException $e) {
|
||
return '';
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
|
||
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 === 'business_prefix') {
|
||
return \AKSRT\Finance\BusinessNumber::normalize_prefix($value, (string) ($field['default'] ?? 'AK'));
|
||
}
|
||
if ($sanitize_type !== '') {
|
||
return $this->sanitize_value($value, $sanitize_type);
|
||
}
|
||
|
||
if ($type === 'gallery') {
|
||
return $this->sanitize_value($value, 'attachment_ids');
|
||
}
|
||
|
||
if (in_array($type, ['fieldset', 'accordion'], true)) {
|
||
return $this->sanitize_object_fields($value, (array) ($field['fields'] ?? []));
|
||
}
|
||
|
||
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 [];
|
||
}
|
||
$items = [];
|
||
foreach ((array) ($field['items'] ?? []) as $item_schema) {
|
||
$item_key = sanitize_key((string) ($item_schema['key'] ?? ''));
|
||
if ($item_key !== '' && array_key_exists($item_key, $value) && is_array($value[$item_key])) {
|
||
$items[$item_key] = $this->sanitize_object_fields($value[$item_key], (array) ($item_schema['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',
|
||
'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';
|
||
}
|
||
}
|