diff --git a/assets/admin/src/components/floating/FloatBtnCommonFields.vue b/assets/admin/src/components/floating/FloatBtnCommonFields.vue index 8beeba28..ea2dd802 100644 --- a/assets/admin/src/components/floating/FloatBtnCommonFields.vue +++ b/assets/admin/src/components/floating/FloatBtnCommonFields.vue @@ -10,22 +10,20 @@
自定义图标颜色 -
自定义背景颜色 -
@@ -35,6 +33,8 @@ diff --git a/assets/admin/src/components/form/AksCompositeField.vue b/assets/admin/src/components/form/AksCompositeField.vue index d67fff7d..b704bf18 100644 --- a/assets/admin/src/components/form/AksCompositeField.vue +++ b/assets/admin/src/components/form/AksCompositeField.vue @@ -1,35 +1,6 @@ diff --git a/assets/admin/src/components/form/AksField.vue b/assets/admin/src/components/form/AksField.vue index ce98de0f..b6827d0f 100644 --- a/assets/admin/src/components/form/AksField.vue +++ b/assets/admin/src/components/form/AksField.vue @@ -199,13 +199,13 @@ -
@@ -315,9 +315,9 @@ @update:value="emit('update:modelValue', $event)" /> - + - - +
toHex(form.value.color)) const previewColor = computed(() => currentHex.value || DEFAULT_COLOR) const isCustomColor = computed(() => currentHex.value !== '' && !SWATCHES.includes(currentHex.value)) -/** - * 取色器已关闭 alpha(默认开启时回传 #rrggbbaa,会被后端回落成灰色), - * 这里仍只接受合法十六进制,其余忽略。 - */ +/** AksColorPicker 已归一化为 hex;清空(空串)时保留当前色。 */ function setColor(value) { const hex = toHex(value) if (hex) form.value.color = hex diff --git a/assets/admin/src/utils/color.js b/assets/admin/src/utils/color.js new file mode 100644 index 00000000..196ba21a --- /dev/null +++ b/assets/admin/src/utils/color.js @@ -0,0 +1,35 @@ +/** + * 颜色值归一化:与 PHP `AKSRT\Core\Color::sanitize()` 规则一致。 + * + * - 不带 alpha:仅 #rgb / #rrggbb; + * - 带 alpha:另允许 #rgba / #rrggbbaa 与 rgb()/rgba(); + * - 十六进制统一小写、补 #;非法值返回空串。 + */ + +export const HEX_RE = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i +const HEX_ALPHA_RE = /^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i +const RGB_RE = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*(0(?:\.\d+)?|1(?:\.0+)?|\.\d+)\s*)?\)$/i + +/** 小写 #rgb / #rrggbb;非法返回空串。 */ +export function toHex(value) { + const match = String(value ?? '').trim().match(HEX_RE) + return match ? `#${match[1].toLowerCase()}` : '' +} + +export function normalizeColor(value, alpha = false) { + if (typeof value !== 'string') return '' + const raw = value.trim() + if (raw === '') return '' + + const hex = raw.match(alpha ? HEX_ALPHA_RE : HEX_RE) + if (hex) return `#${hex[1].toLowerCase()}` + if (!alpha) return '' + + const rgb = raw.match(RGB_RE) + if (!rgb) return '' + const channels = rgb.slice(1, 4).map(Number) + if (channels.some((c) => c > 255)) return '' + if (rgb[4] === undefined) return `rgb(${channels.join(', ')})` + const a = String(Math.round(parseFloat(rgb[4]) * 1000) / 1000) + return `rgba(${channels.join(', ')}, ${a})` +} diff --git a/assets/js/gutenberg-blocks.js b/assets/js/gutenberg-blocks.js index fd0ef6ca..01ba8de3 100644 --- a/assets/js/gutenberg-blocks.js +++ b/assets/js/gutenberg-blocks.js @@ -23,7 +23,6 @@ var InnerBlocks = blockEditor.InnerBlocks || null; var RichText = blockEditor.RichText || null; var MediaUpload = blockEditor.MediaUpload || null; - var ColorPicker = wp.components.ColorPicker; var PanelBody = wp.components.PanelBody; var SelectControl = wp.components.SelectControl; var TextControl = wp.components.TextControl; diff --git a/inc/helpers/aksrt-icon-cards.php b/inc/helpers/aksrt-icon-cards.php index c562f296..7dede772 100644 --- a/inc/helpers/aksrt-icon-cards.php +++ b/inc/helpers/aksrt-icon-cards.php @@ -167,11 +167,8 @@ if (!function_exists('aksrt_icon_card')) : } $style = ''; - $custom_color = sanitize_hex_color((string) $args['icon_custom_color']); - if (!$custom_color && preg_match('/^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/', (string) $args['icon_custom_color'])) { - $custom_color = (string) $args['icon_custom_color']; - } - if (!$radius && $custom_color) { + $custom_color = \AKSRT\Core\Color::sanitize($args['icon_custom_color'], true); + if (!$radius && $custom_color !== '') { $style = ' style="color:' . esc_attr($custom_color) . ';"'; } @@ -327,8 +324,8 @@ if (!function_exists('aksrt_icon_cover_card')) : $icon_size = max(14, min(64, (int) $args['icon_size'])); // legacy always sets font-size on the icon badge. $style = 'font-size: ' . $icon_size . 'px;'; - $custom_color = sanitize_hex_color((string) $args['icon_custom_color']); - if ($custom_color) { + $custom_color = \AKSRT\Core\Color::sanitize($args['icon_custom_color'], true); + if ($custom_color !== '') { $style .= 'color:' . $custom_color . ';'; } diff --git a/inc/helpers/aksrt-slider.php b/inc/helpers/aksrt-slider.php index 8a070c28..8ffad759 100644 --- a/inc/helpers/aksrt-slider.php +++ b/inc/helpers/aksrt-slider.php @@ -132,7 +132,7 @@ function aksrt_render_slider(array $args = []): string { $title = sanitize_text_field($slide['title'] ?? ''); $desc = sanitize_text_field($slide['desc'] ?? ''); $alt = sanitize_text_field($slide['alt'] ?? $title); - $bg_color = sanitize_hex_color($slide['bg_color'] ?? ''); + $bg_color = \AKSRT\Core\Color::sanitize($slide['bg_color'] ?? ''); $target = ($slide['target'] ?? '') === '_blank' ? '_blank' : '_self'; $slide_style = $bg_color ? 'background-color:' . esc_attr($bg_color) . ';' : ''; diff --git a/inc/helpers/homepage/header-components.php b/inc/helpers/homepage/header-components.php index f212a4db..c80057b4 100644 --- a/inc/helpers/homepage/header-components.php +++ b/inc/helpers/homepage/header-components.php @@ -443,7 +443,7 @@ if (!function_exists('aksrt_header_slide')) { } } - $nav_color = sanitize_hex_color((string) ($args['header_slider_nav_color'] ?? '')); + $nav_color = \AKSRT\Core\Color::sanitize((string) ($args['header_slider_nav_color'] ?? '')); if ($nav_color && $nav_color !== '#ffffff') { echo ''; } diff --git a/src/Admin/ControllerBase.php b/src/Admin/ControllerBase.php index 5b4fd4f1..06672ff1 100644 --- a/src/Admin/ControllerBase.php +++ b/src/Admin/ControllerBase.php @@ -108,6 +108,12 @@ abstract class ControllerBase { $ids = is_array($val) ? array_filter(array_map('absint', $val)) : []; $out[$key] = array_values(array_unique($ids)); break; + case 'color': + $out[$key] = \AKSRT\Core\Color::sanitize($val); + break; + case 'color_alpha': + $out[$key] = \AKSRT\Core\Color::sanitize($val, true); + break; default: $out[$key] = sanitize_text_field($val); } diff --git a/src/Admin/GlobalController.php b/src/Admin/GlobalController.php index df7c7505..51cf10e7 100644 --- a/src/Admin/GlobalController.php +++ b/src/Admin/GlobalController.php @@ -365,8 +365,8 @@ class GlobalController extends ControllerBase { 'icon_size' => max(16, min(32, (int) ($params['icon_size'] ?? 22))), 'text_size' => max(8, min(14, (int) ($params['text_size'] ?? 10))), 'show_text' => !empty($params['show_text']), - 'active_color' => sanitize_hex_color($params['active_color'] ?? '') ?: '#f04494', - 'bg_color' => sanitize_hex_color($params['bg_color'] ?? '') ?: '', + 'active_color' => \AKSRT\Core\Color::sanitize($params['active_color'] ?? '') ?: '#f04494', + 'bg_color' => \AKSRT\Core\Color::sanitize($params['bg_color'] ?? '') ?: '', 'border' => !empty($params['border']), 'shadow' => !empty($params['shadow']), 'animation' => !empty($params['animation']), diff --git a/src/Admin/PageConfigMetaBox.php b/src/Admin/PageConfigMetaBox.php index b14ff517..ad7a0a3a 100644 --- a/src/Admin/PageConfigMetaBox.php +++ b/src/Admin/PageConfigMetaBox.php @@ -122,7 +122,7 @@ final class PageConfigMetaBox { $config = [ 'header_slider_show' => !empty($raw['header_slider_show']), 'header_slider_show_type' => self::enum((string) ($raw['header_slider_show_type'] ?? ''), ['', 'only_pc', 'only_sm']), - 'header_slider_nav_color' => sanitize_hex_color((string) ($raw['header_slider_nav_color'] ?? '')) ?: '#ffffff', + 'header_slider_nav_color' => \AKSRT\Core\Color::sanitize((string) ($raw['header_slider_nav_color'] ?? '')) ?: '#ffffff', 'header_slider' => self::sanitize_slides(self::decode_json($raw['header_slider'] ?? '')), 'header_slider_option' => self::sanitize_slider_options(self::decode_json($raw['header_slider_option'] ?? '')), 'header_slider_search_s' => self::sanitize_devices($raw['header_slider_search_s'] ?? []), diff --git a/src/Admin/SchemaProvider.php b/src/Admin/SchemaProvider.php index 503ae9e6..7f209c4f 100644 --- a/src/Admin/SchemaProvider.php +++ b/src/Admin/SchemaProvider.php @@ -9,6 +9,9 @@ defined('ABSPATH') || exit; class SchemaProvider { + use SchemaProviderOAuthValidation; + use SchemaProviderOptionMatch; + private static $instance = null; /** @@ -304,132 +307,6 @@ class SchemaProvider { 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']); - } - public function read_settings(string $domain, string $section = ''): array { if ($section !== '') { $schema = $this->get_section_schema($domain, $section); @@ -710,7 +587,9 @@ class SchemaProvider { 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'); + $type = (string) ($field['type'] ?? 'input'); + $allowed[$field['key']] = $field['sanitize_type'] + ?? ($type === 'color' ? $this->color_sanitize_type($field) : $this->infer_sanitize_type($type)); } return $allowed; } @@ -840,73 +719,15 @@ class SchemaProvider { return function_exists('aksrt_sanitize_icon_value') ? aksrt_sanitize_icon_value((string) $value) : sanitize_text_field((string) $value); + case 'color': + return \AKSRT\Core\Color::sanitize($value); + case 'color_alpha': + return \AKSRT\Core\Color::sanitize($value, true); default: return sanitize_text_field((string) $value); } } - /** - * 对大小写不敏感字段(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; - } - - /** - * Resolve a submitted enum value to the value declared by its option. - * - * Payloads reach the sanitizer as strings (or numbers), so a strict match - * against the schema would reject a valid choice whenever the option value - * is not itself a string -- e.g. 积分兑换明细.level declares 1 / 2 while the - * SPA submits them as numbers and sanitize_text_field() then turns them into - * "1" / "2". Returning the declared value keeps the stored type aligned with - * the schema, which the radio/select controls compare with === when they - * re-render a saved setting. - * - * Returns null when nothing matches, so validation still reports the - * invalid value instead of silently rewriting the operator's input. - * - * @param mixed $value - * @param array $options - * @return mixed - */ - private function match_option_value($value, array $options) { - foreach ($options as $opt) { - $opt_value = is_array($opt) ? ($opt['value'] ?? null) : $opt; - if ($opt_value === $value) { - return $opt_value; - } - } - if (is_bool($value) || !is_scalar($value)) { - return null; - } - $needle = (string) $value; - foreach ($options as $opt) { - $opt_value = is_array($opt) ? ($opt['value'] ?? null) : $opt; - if (is_bool($opt_value) || !is_scalar($opt_value)) { - continue; - } - if ((string) $opt_value === $needle) { - 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') @@ -933,6 +754,10 @@ class SchemaProvider { : $this->sanitize_value($value, 'icon'); } + if ($type === 'color') { + return $this->sanitize_value($value, $this->color_sanitize_type($field)); + } + if (in_array($type, ['select', 'radio'], true) && !empty($field['case_insensitive']) && !empty($field['options'])) { // 旧自由文本值(如小写货币代码)归一化为选项规范写法。 $normalized = $this->normalize_case_insensitive_option($value, (array) $field['options']); @@ -1146,7 +971,7 @@ class SchemaProvider { 'json' => 'array', // 自定义面板(验证码服务商等)按对象存储 'custom' => 'array', - 'color' => 'string', + 'color' => 'color', 'icon' => 'icon', 'media' => 'url', 'upload' => 'url', @@ -1156,4 +981,9 @@ class SchemaProvider { return $map[$field_type] ?? 'string'; } + + /** Color fields keep alpha only when the schema opts in via showAlpha. */ + private function color_sanitize_type(array $field): string { + return !empty($field['showAlpha']) ? 'color_alpha' : 'color'; + } } diff --git a/src/Admin/SchemaProviderOAuthValidation.php b/src/Admin/SchemaProviderOAuthValidation.php new file mode 100644 index 00000000..3a8a04bd --- /dev/null +++ b/src/Admin/SchemaProviderOAuthValidation.php @@ -0,0 +1,139 @@ +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']); + } +} diff --git a/src/Admin/SchemaProviderOptionMatch.php b/src/Admin/SchemaProviderOptionMatch.php new file mode 100644 index 00000000..cf263151 --- /dev/null +++ b/src/Admin/SchemaProviderOptionMatch.php @@ -0,0 +1,75 @@ + $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; + } + + /** + * Resolve a submitted enum value to the value declared by its option. + * + * Payloads reach the sanitizer as strings (or numbers), so a strict match + * against the schema would reject a valid choice whenever the option value + * is not itself a string -- e.g. 积分兑换明细.level declares 1 / 2 while the + * SPA submits them as numbers and sanitize_text_field() then turns them into + * "1" / "2". Returning the declared value keeps the stored type aligned with + * the schema, which the radio/select controls compare with === when they + * re-render a saved setting. + * + * Returns null when nothing matches, so validation still reports the + * invalid value instead of silently rewriting the operator's input. + * + * @param mixed $value + * @param array $options + * @return mixed + */ + private function match_option_value($value, array $options) { + foreach ($options as $opt) { + $opt_value = is_array($opt) ? ($opt['value'] ?? null) : $opt; + if ($opt_value === $value) { + return $opt_value; + } + } + if (is_bool($value) || !is_scalar($value)) { + return null; + } + $needle = (string) $value; + foreach ($options as $opt) { + $opt_value = is_array($opt) ? ($opt['value'] ?? null) : $opt; + if (is_bool($opt_value) || !is_scalar($opt_value)) { + continue; + } + if ((string) $opt_value === $needle) { + return $opt_value; + } + } + return null; + } +} diff --git a/src/Core/Color.php b/src/Core/Color.php new file mode 100644 index 00000000..a35fe071 --- /dev/null +++ b/src/Core/Color.php @@ -0,0 +1,55 @@ + 255) { + return ''; + } + } + if (!isset($m[4]) || $m[4] === '') { + return 'rgb(' . implode(', ', $channels) . ')'; + } + $alpha = rtrim(rtrim(number_format((float) $m[4], 3, '.', ''), '0'), '.'); + return 'rgba(' . implode(', ', $channels) . ', ' . ($alpha === '' ? '0' : $alpha) . ')'; + } +} diff --git a/src/Settings/DisplayPageGroups.php b/src/Settings/DisplayPageGroups.php index 1f7ce5df..bf8695b1 100644 --- a/src/Settings/DisplayPageGroups.php +++ b/src/Settings/DisplayPageGroups.php @@ -77,7 +77,7 @@ trait DisplayPageGroups { 'slides' => is_array($s['header_slider'] ?? null) ? $s['header_slider'] : self::header_slider_default_slides(), 'show_on_pages' => $show_on, 'show_type' => $show_type, - 'nav_color' => sanitize_hex_color((string) ($s['header_slider_nav_color'] ?? '')) ?: '', + 'nav_color' => \AKSRT\Core\Color::sanitize((string) ($s['header_slider_nav_color'] ?? '')) ?: '', 'slider_config' => $slider_option, 'search_show' => self::sanitize_device_visibility($s['header_slider_search_s'] ?? ['pc_s', 'm_s']), 'search_config' => $search_option, diff --git a/src/Settings/FacadeBulk.php b/src/Settings/FacadeBulk.php index c8e1942a..e419fefc 100644 --- a/src/Settings/FacadeBulk.php +++ b/src/Settings/FacadeBulk.php @@ -18,9 +18,9 @@ trait FacadeBulk { return [ 'custom' => !empty($s['header_theme_custom']), - 'bg_custom' => sanitize_hex_color((string) ($s['header_theme_bg_custom'] ?? '')) ?: '', + 'bg_custom' => \AKSRT\Core\Color::sanitize((string) ($s['header_theme_bg_custom'] ?? '')) ?: '', 'bg' => self::normalize_palette_hex((string) ($s['header_theme_bg'] ?? '')), - 'color_custom' => sanitize_hex_color((string) ($s['header_theme_color_custom'] ?? '')) ?: '', + 'color_custom' => \AKSRT\Core\Color::sanitize((string) ($s['header_theme_color_custom'] ?? '')) ?: '', ]; } diff --git a/src/Settings/FloatButtons.php b/src/Settings/FloatButtons.php index bfa96eb9..ffc1e89b 100644 --- a/src/Settings/FloatButtons.php +++ b/src/Settings/FloatButtons.php @@ -218,25 +218,8 @@ class FloatButtons { private static function sanitize_color_group($group): array { $group = is_array($group) ? $group : []; return [ - 'color' => self::sanitize_color((string) ($group['color'] ?? '')), - 'bg' => self::sanitize_color((string) ($group['bg'] ?? '')), + 'color' => \AKSRT\Core\Color::sanitize($group['color'] ?? '', true), + 'bg' => \AKSRT\Core\Color::sanitize($group['bg'] ?? '', true), ]; } - - /** - * 允许 #hex(3/4/6/8 位)与 rgb()/rgba()。 - */ - public static function sanitize_color(string $value): string { - $value = trim($value); - if ($value === '') { - return ''; - } - if (preg_match('/^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $value)) { - return $value; - } - if (preg_match('/^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:0|1|0?\.\d+)\s*)?\)$/', $value)) { - return $value; - } - return ''; - } } diff --git a/src/Settings/HomeCms.php b/src/Settings/HomeCms.php index edc8414d..b14293e9 100644 --- a/src/Settings/HomeCms.php +++ b/src/Settings/HomeCms.php @@ -61,9 +61,9 @@ class HomeCms { $s = self::get_footer_settings(); return [ 'custom' => !empty($s['footer_theme_custom']), - 'bg_custom' => sanitize_hex_color((string) ($s['footer_theme_bg_custom'] ?? '')) ?: '', + 'bg_custom' => \AKSRT\Core\Color::sanitize((string) ($s['footer_theme_bg_custom'] ?? '')) ?: '', 'bg' => self::normalize_palette_hex((string) ($s['footer_theme_bg'] ?? '')), - 'color_custom' => sanitize_hex_color((string) ($s['footer_theme_color_custom'] ?? '')) ?: '', + 'color_custom' => \AKSRT\Core\Color::sanitize((string) ($s['footer_theme_color_custom'] ?? '')) ?: '', ]; } diff --git a/src/Settings/ThemeSearch.php b/src/Settings/ThemeSearch.php index d0c18dbc..6cdb1343 100644 --- a/src/Settings/ThemeSearch.php +++ b/src/Settings/ThemeSearch.php @@ -388,12 +388,10 @@ class ThemeSearch { return ''; } - private const HEX_PATTERN = '/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/'; - /** 全局主题色:theme_skin_custom 优先,清空时使用 theme_skin 预置色(对齐参考主题 zib_head_css)。 */ public static function theme_color(): string { - $custom = trim((string) Settings::get_value('theme_skin_custom', '')); - if (preg_match(self::HEX_PATTERN, $custom)) { + $custom = \AKSRT\Core\Color::sanitize(Settings::get_value('theme_skin_custom', '')); + if ($custom !== '') { return $custom; } $skin = ltrim(trim((string) Settings::get_value('theme_skin', 'f04494')), '#'); @@ -402,8 +400,7 @@ class ThemeSearch { /** 夜间模式主题色;未设置返回空串,夜间沿用全局主题色。 */ public static function dark_theme_color(): string { - $color = trim((string) Settings::get_value('dark_theme_skin_custom', '')); - return preg_match(self::HEX_PATTERN, $color) ? $color : ''; + return \AKSRT\Core\Color::sanitize(Settings::get_value('dark_theme_skin_custom', '')); } public static function get_theme_mode_button_positions(): array { @@ -429,8 +426,7 @@ class ThemeSearch { if (!is_array($bg)) { return ''; } - $color = trim((string) ($bg['color'] ?? '')); - $color = preg_match(self::HEX_PATTERN, $color) ? $color : ''; + $color = \AKSRT\Core\Color::sanitize($bg['color'] ?? ''); $image = esc_url_raw(trim((string) ($bg['image'] ?? ''))); if ($color === '' && $image === '') { return ''; diff --git a/src/Ticket/TicketStatusService.php b/src/Ticket/TicketStatusService.php index 731a8c72..d47a70e5 100644 --- a/src/Ticket/TicketStatusService.php +++ b/src/Ticket/TicketStatusService.php @@ -134,11 +134,8 @@ final class TicketStatusService { /** 颜色只接受 #rgb / #rrggbb,其余回落默认灰。 */ public static function sanitize_color(string $color): string { - $color = strtolower(trim($color)); - if (preg_match('/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/', $color) !== 1) { - return '#6b7280'; - } - return $color; + $color = \AKSRT\Core\Color::sanitize($color); + return $color !== '' ? $color : '#6b7280'; } /** diff --git a/src/WidgetSystem/FieldSchema.php b/src/WidgetSystem/FieldSchema.php index 3965df25..a112e89f 100644 --- a/src/WidgetSystem/FieldSchema.php +++ b/src/WidgetSystem/FieldSchema.php @@ -79,7 +79,7 @@ final class FieldSchema { case 'upload': return esc_url_raw((string) $value); case 'color': - return self::color((string) $value); + return \AKSRT\Core\Color::sanitize($value, true); case 'icon': return function_exists('aksrt_sanitize_icon_value') ? aksrt_sanitize_icon_value((string) $value) @@ -192,7 +192,7 @@ final class FieldSchema { private static function background($value): array { $value = is_array($value) ? $value : []; return [ - 'color' => self::color((string) ($value['color'] ?? '')), + 'color' => \AKSRT\Core\Color::sanitize($value['color'] ?? '', true), 'image' => esc_url_raw((string) ($value['image'] ?? '')), 'position' => self::allow((string) ($value['position'] ?? 'center center'), ['center center', 'top center', 'bottom center', 'center left', 'center right'], 'center center'), 'repeat' => self::allow((string) ($value['repeat'] ?? 'no-repeat'), ['no-repeat', 'repeat', 'repeat-x', 'repeat-y'], 'no-repeat'), @@ -213,18 +213,6 @@ final class FieldSchema { return self::normalize($children, is_array($value) ? $value : []); } - private static function color(string $value): string { - $value = trim($value); - if ($value === '') { - return ''; - } - $hex = sanitize_hex_color($value); - if ($hex) { - return $hex; - } - return preg_match('/^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/', $value) ? $value : ''; - } - private static function allow(string $value, array $allowed, string $fallback): string { return in_array($value, $allowed, true) ? $value : $fallback; } diff --git a/src/WidgetSystem/FormRenderer.php b/src/WidgetSystem/FormRenderer.php index 719a397e..f58fd9df 100644 --- a/src/WidgetSystem/FormRenderer.php +++ b/src/WidgetSystem/FormRenderer.php @@ -221,7 +221,7 @@ final class FormRenderer { private static function background(SchemaWidget $widget, string $key, array $value): void { echo '
'; - self::plain_input($widget, $key . '[color]', (string) ($value['color'] ?? ''), 'text', '背景颜色'); + self::plain_input($widget, $key . '[color]', (string) ($value['color'] ?? ''), 'text', '背景颜色', 'widefat aksrt-widget-color'); self::plain_input($widget, $key . '[image]', (string) ($value['image'] ?? ''), 'url', '背景图片 URL'); foreach ([ 'position' => ['center center' => '居中', 'top center' => '顶部', 'bottom center' => '底部', 'center left' => '左侧', 'center right' => '右侧'], @@ -273,9 +273,9 @@ final class FormRenderer { echo '
'; } - private static function plain_input(SchemaWidget $widget, string $key, string $value, string $type, string $placeholder): void { + private static function plain_input(SchemaWidget $widget, string $key, string $value, string $type, string $placeholder, string $class = 'widefat'): void { $name = $widget->get_field_name($key); $id = $widget->get_field_id(str_replace(['[', ']'], ['-', ''], $key)); - echo ''; + echo ''; } } diff --git a/tests/widget-system/captcha-security-harness.php b/tests/widget-system/captcha-security-harness.php index 8b67db1d..fa04c072 100644 --- a/tests/widget-system/captcha-security-harness.php +++ b/tests/widget-system/captcha-security-harness.php @@ -88,6 +88,9 @@ namespace { require dirname(__DIR__, 2) . '/inc/modules/captcha.php'; require dirname(__DIR__, 2) . '/src/Attachment/UploadLimits.php'; require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaPermissions.php'; + require dirname(__DIR__, 2) . '/src/Core/Color.php'; + require dirname(__DIR__, 2) . '/src/Admin/SchemaProviderOAuthValidation.php'; + require dirname(__DIR__, 2) . '/src/Admin/SchemaProviderOptionMatch.php'; require dirname(__DIR__, 2) . '/src/Admin/SchemaProvider.php'; function check(bool $condition, string $message): void { diff --git a/tests/widget-system/color-unification-contract.test.cjs b/tests/widget-system/color-unification-contract.test.cjs new file mode 100644 index 00000000..3dae3e30 --- /dev/null +++ b/tests/widget-system/color-unification-contract.test.cjs @@ -0,0 +1,77 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const root = path.resolve(__dirname, '../..'); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); + +function walk(dir, exts, out = []) { + for (const entry of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) { + const rel = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (['node_modules', 'build', 'vendor'].includes(entry.name)) continue; + walk(rel, exts, out); + } else if (exts.some((ext) => entry.name.endsWith(ext))) { + out.push(rel); + } + } + return out; +} + +test('n-color-picker is used only inside the shared AksColorPicker', () => { + const offenders = walk('assets/admin/src', ['.vue', '.js']) + .filter((file) => /n-color-picker|NColorPicker/.test(read(file))) + .map((file) => file.replace(/\\/g, '/')); + assert.deepEqual(offenders, ['assets/admin/src/components/form/AksColorPicker.vue']); +}); + +test('PHP color sanitizing goes through AKSRT\\Core\\Color only', () => { + const offenders = [...walk('src', ['.php']), ...walk('inc', ['.php'])] + .filter((file) => /sanitize_hex_color\(|rgba\?\\\(/.test(read(file))) + .map((file) => file.replace(/\\/g, '/')) + .filter((file) => file !== 'src/Core/Color.php'); + assert.deepEqual(offenders, []); +}); + +test('schema color fields sanitize via Color with showAlpha opt-in', () => { + const provider = read('src/Admin/SchemaProvider.php'); + assert.match(provider, /'color'\s*=>\s*'color'/); + assert.match(provider, /case 'color_alpha':\s*return \\AKSRT\\Core\\Color::sanitize\(\$value, true\)/); + assert.match(provider, /showAlpha/); + const field = read('assets/admin/src/components/form/AksField.vue'); + assert.match(field, / { + const composite = read('assets/admin/src/components/form/AksCompositeField.vue'); + assert.doesNotMatch(composite, /'dimensions'|'spacing'|'background'|AksMediaField/); + assert.doesNotMatch(read('assets/js/gutenberg-blocks.js'), /ColorPicker/); +}); + +test('JS normalizeColor mirrors PHP Color::sanitize', async () => { + const { normalizeColor } = await import( + 'file://' + path.join(root, 'assets/admin/src/utils/color.js').replace(/\\/g, '/') + ); + const cases = [ + ['#ABC', false], ['abcdef', false], ['#abcd', false], ['#11223344', false], ['rgb(1,2,3)', false], + ['#abcd', true], ['#11223344', true], ['rgb(1,2,3)', true], ['RGBA( 10 , 20 , 30 , .5 )', true], + ['rgba(1,2,3,0.12345)', true], ['rgba(1,2,3,1.0)', true], ['rgb(256,0,0)', true], + ['red', true], ['', true], [' #FFF ', false], + ]; + const php = `require 'src/Core/Color.php';` + + `foreach (json_decode($argv[1], true) as [$v, $a]) { echo AKSRT\\Core\\Color::sanitize($v, $a), "\\n"; }`; + const out = execFileSync('php', ['-d', 'display_errors=1', '-r', `define('ABSPATH', '/');${php}`, JSON.stringify(cases)], { + cwd: root, + encoding: 'utf8', + }).split('\n').slice(0, cases.length); + assert.deepEqual(cases.map(([v, a]) => normalizeColor(v, a)), out); +}); + +test('widget background color input is wpColorPicker-enhanced like top-level color fields', () => { + const renderer = read('src/WidgetSystem/FormRenderer.php'); + assert.match(renderer, /\$key \. '\[color\]'[^\n]+'widefat aksrt-widget-color'\)/); + assert.match(renderer, /class="' \. esc_attr\(\$class\) \. '"/); + assert.match(read('assets/widgets/admin.js'), /\.aksrt-widget-color'\)\.wpColorPicker\(\)/); +}); diff --git a/tests/widget-system/oauth-security-harness.php b/tests/widget-system/oauth-security-harness.php index 87cc3370..8abc9945 100644 --- a/tests/widget-system/oauth-security-harness.php +++ b/tests/widget-system/oauth-security-harness.php @@ -90,6 +90,9 @@ namespace { require dirname(__DIR__, 2) . '/src/Auth/OAuth.php'; require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaUsers.php'; require dirname(__DIR__, 2) . '/src/Admin/Schema/Users/SchemaUsersTicket.php'; + require dirname(__DIR__, 2) . '/src/Core/Color.php'; + require dirname(__DIR__, 2) . '/src/Admin/SchemaProviderOAuthValidation.php'; + require dirname(__DIR__, 2) . '/src/Admin/SchemaProviderOptionMatch.php'; require dirname(__DIR__, 2) . '/src/Admin/SchemaProvider.php'; function check(bool $condition, string $message): void { diff --git a/tests/widget-system/page-display-save-harness.php b/tests/widget-system/page-display-save-harness.php index 6ec6ce2f..265570df 100644 --- a/tests/widget-system/page-display-save-harness.php +++ b/tests/widget-system/page-display-save-harness.php @@ -147,6 +147,9 @@ namespace { require_once dirname(__DIR__, 2) . '/src/Ledger/AssetAmount.php'; require_once dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaPageDisplay.php'; require_once dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaPayment.php'; + require_once dirname(__DIR__, 2) . '/src/Core/Color.php'; + require_once dirname(__DIR__, 2) . '/src/Admin/SchemaProviderOAuthValidation.php'; + require_once dirname(__DIR__, 2) . '/src/Admin/SchemaProviderOptionMatch.php'; require_once dirname(__DIR__, 2) . '/src/Admin/SchemaProvider.php'; require_once dirname(__DIR__, 2) . '/src/Admin/ControllerBase.php'; require_once dirname(__DIR__, 2) . '/inc/helpers/homepage/header-components.php'; diff --git a/tests/widget-system/ticket-schema-version.test.cjs b/tests/widget-system/ticket-schema-version.test.cjs index 4e42d84d..cc60b409 100644 --- a/tests/widget-system/ticket-schema-version.test.cjs +++ b/tests/widget-system/ticket-schema-version.test.cjs @@ -120,5 +120,6 @@ test('TicketStatusService seeds mirror the migrator and guard the default row', assert.match(service, /if \(self::is_closed\(\$from\)\) \{\s*\n\s*return false;/); // 颜色只接受 #rgb / #rrggbb。 assert.match(service, /'#6b7280'/); - assert.match(service, /preg_match\('\/\^#\(\?:\[0-9a-f\]\{3\}\|\[0-9a-f\]\{6\}\)\$\/'/); + assert.match(service, /\\AKSRT\\Core\\Color::sanitize\(\$color\)/); + assert.doesNotMatch(service, /preg_match\('\/\^#/); }); diff --git a/tests/widget-system/ticket-spa-config-contract.test.cjs b/tests/widget-system/ticket-spa-config-contract.test.cjs index f99d9aa7..862d1579 100644 --- a/tests/widget-system/ticket-spa-config-contract.test.cjs +++ b/tests/widget-system/ticket-spa-config-contract.test.cjs @@ -80,8 +80,12 @@ test('panels enforce the backend guards instead of trusting the client', () => { // 默认状态的编辑/删除入口在前端禁用,但后端 TicketConfigRest 仍有 409 兜底。 assert.match(status, /disabled: row\.is_default/); // 颜色必须落在 TicketStatusService::sanitize_color() 接受的范围内。 - assert.match(status, /\^#\(\?:\[0-9a-f\]\{3\}\|\[0-9a-f\]\{6\}\)\$/i); + assert.match(status, /import \{ toHex \} from '@\/utils\/color\.js'/); + assert.match(status, /