refactor(color): unify color sanitizing and picking behind shared components

- Add AKSRT\Core\Color::sanitize($value, $allow_alpha) as the single PHP
  color sanitizer; replace sanitize_hex_color calls and per-class regexes
  (FloatButtons, FieldSchema, ThemeSearch, icon cards, TicketStatusService)
- SchemaProvider / ControllerBase sanitize `color` fields as colors;
  alpha kept only when the schema sets showAlpha
- Split SchemaProvider OAuth validation and option matching into sibling
  traits to stay under the 1000-line limit
- Add AksColorPicker.vue + utils/color.js mirroring the PHP rules; AksField,
  FloatBtnCommonFields and TicketStatusPanel use it
- Widget background color input now uses wpColorPicker
- Remove dead AksCompositeField dimensions/spacing/background branches and
  the unused Gutenberg ColorPicker var
- Add color-unification contract test; update harness requires and tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-10-28 20:47:41 +08:00
1 parent 74021d26bb
commit 57ff405691
32 files changed
+522 -456

No files matched your search

@@ -10,22 +10,20 @@
<div class="float-common-fields__colors">
<div class="float-common-fields__color">
<span>自定义图标颜色</span>
<n-color-picker
:value="modelValue.color?.color || null"
:show-alpha="true"
:modes="['hex', 'rgb']"
<AksColorPicker
:model-value="modelValue.color?.color || ''"
alpha
clearable
@update:value="updateColor('color', $event)"
@update:model-value="updateColor('color', $event)"
/>
</div>
<div class="float-common-fields__color">
<span>自定义背景颜色</span>
<n-color-picker
:value="modelValue.color?.bg || null"
:show-alpha="true"
:modes="['hex', 'rgb']"
<AksColorPicker
:model-value="modelValue.color?.bg || ''"
alpha
clearable
@update:value="updateColor('bg', $event)"
@update:model-value="updateColor('bg', $event)"
/>
</div>
</div>
@@ -35,6 +33,8 @@
</template>
<script setup>
import AksColorPicker from '@/components/form/AksColorPicker.vue'
const props = defineProps({
modelValue: { type: Object, required: true },
})
@@ -0,0 +1,41 @@
<template>
<n-color-picker
:value="pickerValue"
:modes="modes"
:show-alpha="alpha"
:swatches="swatches"
:actions="clearable ? ['clear'] : undefined"
:disabled="disabled"
@update:value="onUpdate"
>
<template v-if="$slots.trigger" #trigger="slotProps">
<slot name="trigger" v-bind="slotProps" />
</template>
</n-color-picker>
</template>
<script setup>
/**
* 后台唯一取色器:所有颜色字段都经由此组件,输出与
* PHP `AKSRT\Core\Color::sanitize($value, $allow_alpha)` 同一规则的规范值。
* 清空 / 非法值统一回传空串。
*/
import { computed } from 'vue'
import { normalizeColor } from '@/utils/color.js'
const props = defineProps({
modelValue: { type: String, default: '' },
alpha: { type: Boolean, default: false },
clearable: { type: Boolean, default: false },
swatches: { type: Array, default: undefined },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue'])
const modes = computed(() => (props.alpha ? ['hex', 'rgb'] : ['hex']))
const pickerValue = computed(() => normalizeColor(props.modelValue, props.alpha) || null)
function onUpdate(value) {
emit('update:modelValue', normalizeColor(value, props.alpha))
}
</script>
@@ -1,35 +1,6 @@
<template>
<!-- Dimensions (width x height) -->
<div v-if="schema.type === 'dimensions'" class="aksrt-composite aksrt-composite--dimensions">
<n-input-number
:value="current.width"
:min="0"
:placeholder="schema.widthPlaceholder || '宽度'"
:disabled="schema.disabled"
style="flex: 1"
@update:value="update('width', $event)"
/>
<span class="aksrt-composite__sep">×</span>
<n-input-number
:value="current.height"
:min="0"
:placeholder="schema.heightPlaceholder || '高度'"
:disabled="schema.disabled"
style="flex: 1"
@update:value="update('height', $event)"
/>
<n-select
v-if="schema.unit"
:value="current.unit"
:options="dimensionUnitOptions"
:disabled="schema.disabled"
style="width: 80px"
@update:value="update('unit', $event)"
/>
</div>
<!-- Between number (min/max pair) -->
<div v-else-if="schema.type === 'between_number'" class="aksrt-composite aksrt-composite--between">
<div class="aksrt-composite aksrt-composite--between">
<n-input-number
:value="betweenValue('min')"
:min="schema.min"
@@ -53,56 +24,11 @@
/>
<span v-if="schema.unit" class="aksrt-composite__unit">{{ schema.unit }}</span>
</div>
<!-- Spacing (top right bottom left) -->
<div v-else-if="schema.type === 'spacing'" class="aksrt-composite--spacing">
<template v-for="(side, i) in spacingSides" :key="side">
<div class="aksrt-composite__label">{{ schema.spacingLabels?.[i] || spacingDefaults[i] }}</div>
<n-input-number
:value="current[side]"
:min="0"
:disabled="schema.disabled"
@update:value="update(side, $event)"
/>
</template>
</div>
<!-- Background (color + image + size + position + repeat) -->
<div v-else-if="schema.type === 'background'" class="aksrt-composite--background">
<div class="aksrt-composite__row">
<label class="aksrt-composite__label">背景颜色</label>
<n-color-picker
:value="current.color"
:show-alpha="true"
:disabled="schema.disabled"
@update:value="update('color', $event)"
/>
</div>
<div class="aksrt-composite__row">
<label class="aksrt-composite__label">背景图片</label>
<AksMediaField
:model-value="current.image"
:accept="'image/*'"
:disabled="schema.disabled"
@update:model-value="update('image', $event)"
/>
</div>
<div v-for="row in backgroundSelects" :key="row.key" class="aksrt-composite__row">
<label class="aksrt-composite__label">{{ row.label }}</label>
<n-select
:value="current[row.key]"
:options="row.options"
:disabled="schema.disabled"
@update:value="update(row.key, $event)"
/>
</div>
</div>
</template>
<script setup>
/** 对象型复合字段:dimensions / between_number / spacing / background。 */
/** 对象型复合字段:between_number(min / max 数值区间)。 */
import { computed } from 'vue'
import AksMediaField from './AksMediaField.vue'
const props = defineProps({
schema: { type: Object, required: true },
@@ -123,43 +49,6 @@ function update(part, value) {
function betweenValue(part) {
return current.value[part] !== undefined ? current.value[part] : (props.schema.default?.[part] ?? 0)
}
const dimensionUnitOptions = ['px', '%', 'em', 'rem', 'vw'].map(unit => ({ label: unit, value: unit }))
const spacingSides = ['top', 'right', 'bottom', 'left']
const spacingDefaults = ['上', '右', '下', '左']
const backgroundSelects = [
{
key: 'size',
label: '图片尺寸',
options: [
{ label: '覆盖', value: 'cover' },
{ label: '包含', value: 'contain' },
{ label: '原始大小', value: 'auto' },
],
},
{
key: 'position',
label: '图片位置',
options: [
{ label: '居中', value: 'center center' },
{ label: '顶部居中', value: 'top center' },
{ label: '底部居中', value: 'bottom center' },
{ label: '左侧居中', value: 'center left' },
{ label: '右侧居中', value: 'center right' },
],
},
{
key: 'repeat',
label: '图片重复',
options: [
{ label: '不重复', value: 'no-repeat' },
{ label: '平铺', value: 'repeat' },
{ label: '水平平铺', value: 'repeat-x' },
{ label: '垂直平铺', value: 'repeat-y' },
],
},
]
</script>
<style scoped>
@@ -183,49 +72,4 @@ const backgroundSelects = [
font-size: 13px;
white-space: nowrap;
}
.aksrt-composite--spacing {
display: grid;
grid-template-columns: auto 1fr auto 1fr auto 1fr auto 1fr;
align-items: center;
gap: 8px;
}
.aksrt-composite--background {
display: flex;
flex-direction: column;
gap: 12px;
}
.aksrt-composite__row {
display: flex;
align-items: center;
gap: 12px;
}
.aksrt-composite__label {
min-width: 70px;
color: var(--aksrt-color-muted);
font-size: 12px;
}
.aksrt-composite--spacing .aksrt-composite__label {
min-width: 0;
text-align: center;
}
@media (max-width: 768px) {
.aksrt-composite--dimensions {
flex-wrap: wrap;
}
.aksrt-composite--spacing {
grid-template-columns: auto 1fr;
}
.aksrt-composite__row {
flex-direction: column;
align-items: flex-start;
}
}
</style>
@@ -199,13 +199,13 @@
</n-checkbox-group>
<!-- Color picker -->
<n-color-picker
<AksColorPicker
v-else-if="schema.type === 'color'"
:value="modelValue"
:modes="['hex']"
:show-alpha="schema.showAlpha || false"
:disabled="schema.disabled"
@update:value="emit('update:modelValue', $event)"
:model-value="typeof modelValue === 'string' ? modelValue : ''"
:alpha="!!schema.showAlpha"
:disabled="!!schema.disabled"
clearable
@update:model-value="emit('update:modelValue', $event)"
/>
<div v-else-if="schema.type === 'palette'" class="aksrt-field__palette">
@@ -315,9 +315,9 @@
@update:value="emit('update:modelValue', $event)"
/>
<!-- Object composites: dimensions / between_number / spacing / background -->
<!-- Object composite: between_number (min/max pair) -->
<AksCompositeField
v-else-if="['dimensions', 'between_number', 'spacing', 'background'].includes(schema.type)"
v-else-if="schema.type === 'between_number'"
:schema="schema"
:model-value="modelValue"
@update:model-value="emit('update:modelValue', $event)"
@@ -525,6 +525,7 @@ import AksMediaField from './AksMediaField.vue'
import AksGalleryField from './AksGalleryField.vue'
import AksCodeEditor from './AksCodeEditor.vue'
import AksCompositeField from './AksCompositeField.vue'
import AksColorPicker from './AksColorPicker.vue'
import AksImageSelectField from './AksImageSelectField.vue'
import AksSortHandle from './AksSortHandle.vue'
import AksSecretClear from './AksSecretClear.vue'
@@ -72,11 +72,9 @@
@click="form.color = swatch"
/>
<!-- 自定义色:未选中时显示色环,选中后显示当前色并带选中环。 -->
<n-color-picker
:value="previewColor"
:modes="['hex']"
:show-alpha="false"
@update:value="setColor"
<AksColorPicker
:model-value="previewColor"
@update:model-value="setColor"
>
<template #trigger="{ onClick, ref: setTriggerRef }">
<button
@@ -92,7 +90,7 @@
@click="onClick"
/>
</template>
</n-color-picker>
</AksColorPicker>
</div>
<n-input
v-model:value="form.color"
@@ -128,6 +126,8 @@
import { computed, h, onMounted, ref } from 'vue'
import { NButton, NSpace, useDialog, useMessage } from 'naive-ui'
import api from '@/api'
import AksColorPicker from '@/components/form/AksColorPicker.vue'
import { toHex } from '@/utils/color.js'
const message = useMessage()
const dialog = useDialog()
@@ -146,7 +146,6 @@ const editing = ref(null)
const rows = ref([])
const DEFAULT_COLOR = '#6b7280'
const HEX_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
/** 与 TicketStatusService::KEY_PATTERN 保持一致。 */
const KEY_RE = /^[a-z][a-z0-9_]{1,23}$/
@@ -157,16 +156,6 @@ function normalizeKey(value) {
return String(value || '').trim().toLowerCase()
}
/**
* 归一化为 TicketStatusService::sanitize_color() 接受的小写 #rgb / #rrggbb;
* 允许省略 #。非法值返回空串。
*/
function toHex(value) {
let hex = String(value || '').trim().toLowerCase()
if (hex && !hex.startsWith('#')) hex = `#${hex}`
return HEX_RE.test(hex) ? hex : ''
}
const rules = {
name: [
{ required: true, message: '请填写状态名称', trigger: 'blur' },
@@ -205,10 +194,7 @@ const currentHex = computed(() => 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
+35
View File
@@ -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})`
}
-1
View File
@@ -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;
+4 -7
View File
@@ -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 . ';';
}
+1 -1
View File
@@ -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) . ';' : '';
+1 -1
View File
@@ -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 '<style>body.nav-fixed:not(.body-scroll):not(.mobile-navbar-show) .header.show-slide{--header-color:' . esc_html($nav_color) . ';}</style>';
}
+6
View File
@@ -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);
}
+2 -2
View File
@@ -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']),
+1 -1
View File
@@ -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'] ?? []),
+20 -190
View File
@@ -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<int,mixed> $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<int,mixed> $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';
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
/**
* SchemaProvider OAuth 设置校验(代理模式 / 各平台凭据 / HTTPS 地址)。
*
* @package AKSRT
*/
namespace AKSRT\Admin;
defined('ABSPATH') || exit;
trait SchemaProviderOAuthValidation {
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']);
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* SchemaProvider 枚举字段取值归一化(大小写不敏感匹配 / 选项声明值回写)。
*
* @package AKSRT
*/
namespace AKSRT\Admin;
defined('ABSPATH') || exit;
trait SchemaProviderOptionMatch {
/**
* 对大小写不敏感字段(case_insensitive),把值归一化为选项中的规范写法;
* 匹配不到返回 null。
*
* @param mixed $value
* @param array<int,mixed> $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<int,mixed> $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;
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
/**
* 全站唯一的颜色值净化器(SPA 设置、小工具、悬浮按钮、工单状态、前台渲染共用)。
*
* 前端对应实现:assets/admin/src/utils/color.js(normalizeColor),两端规则保持一致。
*
* @package AKSRT
*/
namespace AKSRT\Core;
defined('ABSPATH') || exit;
final class Color {
private const HEX = '/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i';
private const HEX_ALPHA = '/^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i';
private const RGB = '/^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(可省略 #),输出小写并补 #。
* - $allow_alpha 时额外接受 #rgba / #rrggbbaa 与 rgb()/rgba(),
* rgb 形态统一输出为 `rgb(r, g, b)` / `rgba(r, g, b, a)`。
*
* @param mixed $value
*/
public static function sanitize($value, bool $allow_alpha = false): string {
if (!is_string($value)) {
return '';
}
$value = trim($value);
if ($value === '') {
return '';
}
if (preg_match($allow_alpha ? self::HEX_ALPHA : self::HEX, $value, $m) === 1) {
return '#' . strtolower($m[1]);
}
if (!$allow_alpha || preg_match(self::RGB, $value, $m) !== 1) {
return '';
}
$channels = [(int) $m[1], (int) $m[2], (int) $m[3]];
foreach ($channels as $channel) {
if ($channel > 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) . ')';
}
}
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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'] ?? '')) ?: '',
];
}
+2 -19
View File
@@ -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 '';
}
}
+2 -2
View File
@@ -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'] ?? '')) ?: '',
];
}
+4 -8
View File
@@ -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 '';
+2 -5
View File
@@ -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';
}
/**
+2 -14
View File
@@ -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;
}
+3 -3
View File
@@ -221,7 +221,7 @@ final class FormRenderer {
private static function background(SchemaWidget $widget, string $key, array $value): void {
echo '<div class="aksrt-widget-background">';
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 '</div></details>';
}
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 '<input class="widefat" type="' . esc_attr($type) . '" id="' . esc_attr($id) . '" name="' . esc_attr($name) . '" value="' . esc_attr($value) . '" placeholder="' . esc_attr($placeholder) . '">';
echo '<input class="' . esc_attr($class) . '" type="' . esc_attr($type) . '" id="' . esc_attr($id) . '" name="' . esc_attr($name) . '" value="' . esc_attr($value) . '" placeholder="' . esc_attr($placeholder) . '">';
}
}
@@ -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 {
@@ -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, /<AksColorPicker[\s\S]*?:alpha="!!schema\.showAlpha"/);
});
test('dead composite branches are gone', () => {
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\(\)/);
});
@@ -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 {
@@ -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';
@@ -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\('\/\^#/);
});
@@ -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, /<AksColorPicker/);
assert.doesNotMatch(status, /n-color-picker|HEX_RE/);
assert.match(status, /#6b7280/);
const color = read('assets/admin/src/utils/color.js');
assert.match(color, /export const HEX_RE = \/\^#\?\(\[0-9a-f\]\{3\}\|\[0-9a-f\]\{6\}\)\$\/i/);
const prereply = read('assets/admin/src/pages/users/partials/ticket/TicketPrereplyPanel.vue');
// 排序接口要求全量 ID。
@@ -62,6 +62,9 @@ namespace {
require dirname(__DIR__, 2) . '/src/Ledger/AssetAmount.php';
require dirname(__DIR__, 2) . '/src/Settings/PaymentRuntime.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaPayment.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';
require dirname(__DIR__, 2) . '/src/Content/VipServicePackages.php';