187 lines
6.1 KiB
PHP
187 lines
6.1 KiB
PHP
<?php
|
||
|
||
namespace App\Service\business;
|
||
|
||
use App\Service\common\UtilsService;
|
||
|
||
/**
|
||
* 装修模板 JSON 的白名单校验
|
||
*
|
||
* 导入的 JSON 会被小程序当成 CSS 变量注入根节点,一份坏 JSON 能把线上小程序打成白屏,
|
||
* 所以入库前必须过白名单:键名不在表里的丢弃,值必须是安全的短字符串。
|
||
* 这里只允许「值」,不允许任何 CSS 语句片段(分号、url()、表达式一律拒绝)。
|
||
*/
|
||
class WxTemplateSchemaService
|
||
{
|
||
private static mixed $_instance;
|
||
|
||
/**
|
||
* 允许的令牌键,分组 => 键名清单
|
||
*/
|
||
public const TOKEN_SCHEMA = [
|
||
'color' => [
|
||
'primary', 'primary_soft', 'primary_strong', 'accent', 'bg', 'bg_soft',
|
||
'surface', 'surface_soft', 'text', 'text_soft', 'text_muted', 'border',
|
||
'price', 'success', 'warning', 'danger', 'mask',
|
||
],
|
||
'font' => [
|
||
'family', 'family_title', 'size_xs', 'size_sm', 'size_md', 'size_lg',
|
||
'size_xl', 'size_title', 'weight_normal', 'weight_bold', 'line_height', 'letter_spacing',
|
||
],
|
||
'radius' => ['none', 'sm', 'md', 'lg', 'xl', 'pill'],
|
||
'shadow' => ['none', 'sm', 'md', 'lg'],
|
||
'space' => ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'page'],
|
||
'motion' => ['fast', 'base', 'slow', 'easing', 'easing_in', 'easing_out'],
|
||
];
|
||
|
||
/**
|
||
* 允许的布局键与可选值
|
||
*/
|
||
public const LAYOUT_SCHEMA = [
|
||
'home' => [
|
||
'hero' => ['banner', 'carousel', 'split', 'fullscreen'],
|
||
'category' => ['grid', 'scroll', 'card', 'sidebar'],
|
||
'product' => ['waterfall', 'list', 'grid', 'magazine'],
|
||
],
|
||
'product' => [
|
||
'gallery' => ['swiper', 'stack', 'fullbleed'],
|
||
'price' => ['inline', 'card', 'sticky'],
|
||
'action' => ['fixed', 'inline'],
|
||
],
|
||
'list' => [
|
||
'style' => ['card', 'table', 'timeline'],
|
||
],
|
||
'mine' => [
|
||
'header' => ['gradient', 'image', 'plain'],
|
||
'menu' => ['grid', 'list'],
|
||
],
|
||
'effect' => [
|
||
'transition' => ['fade', 'slide', 'zoom', 'none'],
|
||
'skeleton' => ['shimmer', 'pulse', 'none'],
|
||
],
|
||
];
|
||
|
||
/**
|
||
* 危险片段:出现即拒绝整个值
|
||
*/
|
||
private const FORBIDDEN = ['<', '>', ';', '{', '}', 'url(', 'expression', 'javascript:', 'import'];
|
||
|
||
public static function getInstance(): null|static
|
||
{
|
||
$name = get_called_class();
|
||
if (!isset(self::$_instance[$name])) {
|
||
self::$_instance[$name] = new static();
|
||
}
|
||
return self::$_instance[$name];
|
||
}
|
||
|
||
/**
|
||
* 清洗令牌:只保留白名单键,值必须安全
|
||
*
|
||
* @param mixed $tokens 数组或 JSON 字符串
|
||
* @param bool $strict true 时遇到非法值直接报错(导入场景),false 时静默丢弃
|
||
*/
|
||
public function sanitizeTokens(mixed $tokens, bool $strict = false): array
|
||
{
|
||
$tokens = $this->toArray($tokens);
|
||
$clean = [];
|
||
foreach (self::TOKEN_SCHEMA as $group => $keys) {
|
||
$source = $tokens[$group] ?? [];
|
||
if (!is_array($source)) {
|
||
continue;
|
||
}
|
||
foreach ($keys as $key) {
|
||
if (!array_key_exists($key, $source)) {
|
||
continue;
|
||
}
|
||
$value = $source[$key];
|
||
if (!$this->isSafeValue($value)) {
|
||
if ($strict) {
|
||
UtilsService::getInstance()->errorThrow("模板令牌 {$group}.{$key} 的值不合法");
|
||
}
|
||
continue;
|
||
}
|
||
$clean[$group][$key] = (string) $value;
|
||
}
|
||
}
|
||
return $clean;
|
||
}
|
||
|
||
/**
|
||
* 清洗布局:值必须是枚举里的选项,非法值退回该项的第一个选项
|
||
*/
|
||
public function sanitizeLayout(mixed $layout, bool $strict = false): array
|
||
{
|
||
$layout = $this->toArray($layout);
|
||
$clean = [];
|
||
foreach (self::LAYOUT_SCHEMA as $page => $options) {
|
||
$source = $layout[$page] ?? [];
|
||
if (!is_array($source)) {
|
||
continue;
|
||
}
|
||
foreach ($options as $key => $allowed) {
|
||
if (!array_key_exists($key, $source)) {
|
||
continue;
|
||
}
|
||
$value = (string) $source[$key];
|
||
if (!in_array($value, $allowed, true)) {
|
||
if ($strict) {
|
||
UtilsService::getInstance()->errorThrow("模板布局 {$page}.{$key} 只能是:" . implode('/', $allowed));
|
||
}
|
||
$value = $allowed[0];
|
||
}
|
||
$clean[$page][$key] = $value;
|
||
}
|
||
}
|
||
return $clean;
|
||
}
|
||
|
||
/**
|
||
* 小程序侧要的扁平 CSS 变量表:--color-primary 这种
|
||
*/
|
||
public function toCssVariables(array $tokens): array
|
||
{
|
||
$vars = [];
|
||
foreach ($tokens as $group => $items) {
|
||
if (!is_array($items)) {
|
||
continue;
|
||
}
|
||
foreach ($items as $key => $value) {
|
||
$vars['--' . str_replace('_', '-', $group . '-' . $key)] = $value;
|
||
}
|
||
}
|
||
return $vars;
|
||
}
|
||
|
||
private function isSafeValue(mixed $value): bool
|
||
{
|
||
if (is_int($value) || is_float($value)) {
|
||
return true;
|
||
}
|
||
if (!is_string($value)) {
|
||
return false;
|
||
}
|
||
$value = trim($value);
|
||
if ($value === '' || mb_strlen($value) > 64) {
|
||
return false;
|
||
}
|
||
foreach (self::FORBIDDEN as $needle) {
|
||
if (stripos($value, $needle) !== false) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private function toArray(mixed $value): array
|
||
{
|
||
if (is_string($value)) {
|
||
$value = json_decode($value, true);
|
||
if (!is_array($value)) {
|
||
UtilsService::getInstance()->errorThrow('模板 JSON 解析失败');
|
||
}
|
||
}
|
||
return is_array($value) ? $value : [];
|
||
}
|
||
}
|