Files
lgp-admin-plus-api/app/Service/business/WxTemplateSchemaService.php

187 lines
6.1 KiB
PHP
Raw Permalink Normal View History

<?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 : [];
}
}