Files
lgp-admin-plus-api/app/Service/business/WxTemplateSchemaService.php
2026-08-19 08:16:49 +08:00

801 lines
30 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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', 'stack', 'coverflow', 'fade', 'peek', 'cube', 'caption'],
'category' => ['grid', 'scroll', 'card', 'sidebar', 'pills', 'mosaic', 'featured', 'tile', 'contents'],
'package' => ['card', 'scroll', 'featured', 'magazine'],
'product' => ['waterfall', 'list', 'grid'],
],
'product' => [
'gallery' => ['swiper', 'stack', 'fullbleed', 'peek', 'fade', 'coverflow', 'mosaic', 'filmstrip'],
'price' => ['inline', 'card', 'sticky'],
'action' => ['fixed', 'inline', 'split'],
],
'list' => [
'style' => ['card', 'table', 'timeline', 'compact', 'ticket', 'stacked'],
],
'mine' => [
'header' => ['gradient', 'image', 'plain', 'split', 'editorial'],
'menu' => ['grid', 'list', 'card', 'tile', 'compact'],
],
'effect' => [
'transition' => ['fade', 'slide', 'zoom', 'none'],
'skeleton' => ['shimmer', 'pulse', 'none'],
// 卡片装饰与图标风格:小程序 ThemeCardFrame / ThemeIcon 按枚举切换
'card' => ['plain', 'elevated', 'ornament', 'canvas-frame'],
'icon' => ['line', 'fill', 'duotone', 'block'],
],
];
/**
* 装修工作室可编排的页面与区块类型
*/
public const PAGE_SECTION_TYPES = [
'home' => ['search', 'hero', 'category', 'package', 'product'],
'package' => ['list'],
'catalog' => ['filter', 'list'],
'search' => ['search', 'list'],
'product' => ['gallery', 'info', 'spec', 'price', 'action'],
'cart' => ['list'],
'mine' => ['header', 'menu'],
];
/**
* 各区块允许的变体(与旧 LAYOUT_SCHEMA 对齐,方便小程序复用)
*/
public const SECTION_VARIANTS = [
'search' => ['bar', 'overlay', 'pill', 'float'],
'hero' => ['banner', 'carousel', 'split', 'fullscreen', 'stack', 'coverflow', 'fade', 'peek', 'cube', 'caption'],
'category' => ['grid', 'scroll', 'card', 'sidebar', 'pills', 'mosaic', 'featured', 'tile', 'contents'],
'package' => ['card', 'scroll', 'featured', 'magazine'],
'product' => ['waterfall', 'list', 'grid'],
'filter' => ['chip', 'bar', 'sidebar', 'hidden'],
'list' => [
'card', 'table', 'timeline', 'waterfall', 'grid', 'list',
'masonry', 'featured', 'shelf', 'compact', 'airy', 'mosaic', 'duo',
'ticket', 'stacked',
],
'gallery' => ['swiper', 'stack', 'fullbleed', 'peek', 'fade', 'coverflow', 'mosaic', 'filmstrip'],
'info' => ['plain', 'editorial', 'split', 'overlay'],
'spec' => ['plain', 'table', 'chips', 'cards'],
'price' => ['inline', 'card', 'sticky'],
'action' => ['fixed', 'inline', 'split'],
'header' => ['gradient', 'image', 'plain', 'split', 'editorial'],
'menu' => ['grid', 'list', 'card', 'tile', 'compact'],
];
/**
* 同一 type 在不同页面含义不同:列表是封面+型号,清单是收藏夹卡片
*/
public const PAGE_SECTION_VARIANTS = [
'home' => [
'product' => ['waterfall', 'grid', 'list'],
'package' => ['card', 'scroll', 'featured', 'magazine'],
],
'package' => [
'list' => ['card', 'featured', 'magazine', 'list'],
],
'catalog' => [
'list' => ['waterfall', 'grid', 'list', 'masonry', 'featured', 'shelf', 'compact', 'airy', 'mosaic', 'duo'],
],
'search' => [
'list' => ['waterfall', 'grid', 'list', 'masonry', 'featured', 'shelf', 'compact', 'airy', 'mosaic', 'duo'],
],
'cart' => [
'list' => ['card', 'table', 'timeline', 'compact', 'ticket', 'stacked'],
],
];
public const PAGE_SKINS = ['shop', 'magazine'];
public const PAGE_DENSITIES = ['compact', 'regular', 'airy'];
public const PAGE_FRAMES = ['none', 'inset', 'ornament'];
public const CHROME_TABBAR = ['plain', 'line', 'pill', 'dot'];
public const CHROME_TABBAR_ANIM = ['none', 'fade', 'slide', 'spring'];
public const CHROME_NAVBAR = ['solid', 'line'];
public const LAYER_FACES = ['sans', 'serif', 'kai', 'script'];
public const LAYER_TONES = [
'primary', 'primary_soft', 'primary_strong', 'accent',
'surface', 'surface_soft', 'bg_soft', 'text', 'text_soft', 'text_muted', 'border',
];
/**
* 工作室样式画廊用的中文名
*/
public const SECTION_VARIANT_LABELS = [
'search' => [
'bar' => '搜索条',
'overlay' => '压在封面',
'pill' => '胶囊',
'float' => '悬浮',
],
'hero' => [
'banner' => '横幅',
'carousel' => '轮播',
'split' => '分栏',
'fullscreen' => '全屏',
'stack' => '堆叠',
'coverflow' => '封面流',
'fade' => '叠化',
'peek' => '露边',
'cube' => '立方',
'caption' => '刊名条',
],
'category' => [
'grid' => '宫格',
'scroll' => '横滑',
'card' => '卡片',
'sidebar' => '侧栏',
'pills' => '胶囊',
'mosaic' => '马赛克',
'featured' => '首图',
'tile' => '瓷砖',
'contents' => '刊页目录',
],
'package' => [
'card' => '卡片',
'scroll' => '横滑',
'featured' => '首图',
'magazine' => '刊名条',
],
'product' => [
'waterfall' => '瀑布',
'grid' => '双列',
'list' => '单列',
],
'filter' => [
'chip' => '分类条',
'bar' => '仅搜索',
'sidebar' => '侧栏',
'hidden' => '隐藏',
],
'list' => [
'waterfall' => '瀑布',
'grid' => '双列',
'list' => '单列',
'masonry' => '砌石',
'featured' => '首图',
'shelf' => '货架',
'compact' => '紧凑',
'airy' => '疏朗',
'mosaic' => '马赛克',
'duo' => '对开',
'card' => '卡片',
'table' => '表格',
'timeline' => '时间线',
'ticket' => '票根',
'stacked' => '层叠',
],
'gallery' => [
'swiper' => '轮播',
'stack' => '叠图',
'fullbleed' => '全出血',
'peek' => '露边',
'fade' => '叠化',
'coverflow' => '封面流',
'mosaic' => '拼贴',
'filmstrip' => '胶卷',
],
'info' => [
'plain' => '常规',
'editorial' => '编辑',
'split' => '分栏',
'overlay' => '压字',
],
'spec' => [
'plain' => '规格表',
'table' => '表格',
'chips' => '芯片',
'cards' => '卡片',
],
'price' => [
'inline' => '跟规格走',
'card' => '独立报价卡',
'sticky' => '吸顶报价',
],
'action' => [
'fixed' => '钉在底部',
'inline' => '跟正文走',
'split' => '左右拆开',
],
'header' => [
'gradient' => '渐变',
'image' => '头图',
'plain' => '素底',
'split' => '分栏',
'editorial' => '编辑',
],
'menu' => [
'grid' => '宫格',
'list' => '列表',
'card' => '卡片',
'tile' => '瓷砖',
'compact' => '紧凑',
],
];
/**
* 卡片图层允许的 type / bind / font
*/
public const LAYER_TYPES = ['photo', 'text', 'deco', 'shape'];
public const LAYER_BINDS = ['cover', 'gallery', 'upload', 'name', 'price', 'custom'];
public const LAYER_FONTS = ['title', 'body', 'caption', 'price'];
public const DECO_ASSETS = [
'paper-edge', 'pearl-line', 'walnut-frame', 'gold-foil', 'washi-1',
'botanical', 'museum-mat', 'folio-line', 'wax-seal', 'ribbon',
'newsprint', 'gold-corner', 'handwriting', 'arch-mat', 'double-frame',
'inset-shadow', 'torn-edge', 'film-sprocket', 'corner-bracket',
'emboss-plate', 'circle-crop', 'vignette', 'folio-number', 'stamp-ring',
'lace-corner', 'thin-rule', 'shadow-deck', 'cap-rail', 'gutter-fold',
'pearl-bead', 'vert-caption',
];
/**
* 危险片段:出现即拒绝整个值
*/
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;
}
/**
* 清洗布局:旧枚举 + pages.sections + card_scheme
*
* 非法枚举在宽松模式退回第一项strict导入直接报错。
* pages / card_scheme 是装修工作室写入的,必须保留,否则保存后拖拽结果会丢。
*/
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;
}
}
$scheme = trim((string) ($layout['card_scheme'] ?? ''));
if ($scheme !== '' && $this->isSafeCode($scheme)) {
$clean['card_scheme'] = $scheme;
}
if (isset($layout['pages']) && is_array($layout['pages'])) {
$clean['pages'] = $this->sanitizePages($layout['pages'], $strict);
}
if (isset($layout['page_presets']) && is_array($layout['page_presets'])) {
$clean['page_presets'] = $this->sanitizePagePresets($layout['page_presets']);
}
if (isset($layout['chrome']) && is_array($layout['chrome'])) {
$chrome = [];
$tabbar = (string) ($layout['chrome']['tabbar'] ?? '');
$navbar = (string) ($layout['chrome']['navbar'] ?? '');
$anim = (string) ($layout['chrome']['tabbar_anim'] ?? '');
if (in_array($tabbar, self::CHROME_TABBAR, true)) {
$chrome['tabbar'] = $tabbar;
} elseif ($strict && $tabbar !== '') {
UtilsService::getInstance()->errorThrow('chrome.tabbar 只能是:' . implode('/', self::CHROME_TABBAR));
}
if (in_array($navbar, self::CHROME_NAVBAR, true)) {
$chrome['navbar'] = $navbar;
} elseif ($strict && $navbar !== '') {
UtilsService::getInstance()->errorThrow('chrome.navbar 只能是:' . implode('/', self::CHROME_NAVBAR));
}
if (in_array($anim, self::CHROME_TABBAR_ANIM, true)) {
$chrome['tabbar_anim'] = $anim;
} elseif ($strict && $anim !== '') {
UtilsService::getInstance()->errorThrow('chrome.tabbar_anim 只能是:' . implode('/', self::CHROME_TABBAR_ANIM));
}
foreach (['tabbar_color', 'tabbar_color_on'] as $colorKey) {
$raw = trim((string) ($layout['chrome'][$colorKey] ?? ''));
if ($raw === '') {
continue;
}
if ($this->isSafeHexColor($raw)) {
$chrome[$colorKey] = $raw;
} elseif ($strict) {
UtilsService::getInstance()->errorThrow('chrome.' . $colorKey . ' 必须是 #RGB 或 #RRGGBB');
}
}
if ($chrome !== []) {
$clean['chrome'] = $chrome;
}
}
return $clean;
}
/**
* 清洗页面区块列表:只留白名单 type/variantprops 只留安全短值
*/
public function sanitizePages(array $pages, bool $strict = false): array
{
$clean = [];
foreach (self::PAGE_SECTION_TYPES as $page => $types) {
$pageBlock = $pages[$page] ?? [];
if (!is_array($pageBlock)) {
continue;
}
$source = $pageBlock['sections'] ?? $pageBlock;
if (!is_array($source)) {
continue;
}
// 兼容 { sections: [] } 与直接数组
$list = isset($source['sections']) && is_array($source['sections']) ? $source['sections'] : $source;
$sections = [];
foreach ($list as $index => $item) {
if (!is_array($item)) {
continue;
}
$type = (string) ($item['type'] ?? '');
if (!in_array($type, $types, true)) {
if ($strict) {
UtilsService::getInstance()->errorThrow("页面 {$page} 不支持区块 {$type}");
}
continue;
}
$allowed = $this->variantsFor($page, $type);
$variant = (string) ($item['variant'] ?? $allowed[0]);
// 旧杂志卡带报价,商品流收成瀑布;套餐 magazine 是刊名条,不要改
if ($variant === 'magazine' && in_array($page, ['home', 'catalog', 'search'], true) && $type !== 'package') {
$variant = 'waterfall';
}
if (!in_array($variant, $allowed, true)) {
if ($strict) {
UtilsService::getInstance()->errorThrow("区块 {$type} 变体只能是:" . implode('/', $allowed));
}
$variant = $allowed[0];
}
$id = $this->isSafeCode((string) ($item['id'] ?? ''))
? (string) $item['id']
: $type . '-' . $index;
$sections[] = [
'id' => $id,
'type' => $type,
'variant' => $variant,
'visible' => !empty($item['visible']) || !array_key_exists('visible', $item),
'props' => $this->sanitizeSectionProps($item['props'] ?? []),
];
}
$row = ['sections' => $sections];
$skin = (string) ($pageBlock['skin'] ?? '');
$density = (string) ($pageBlock['density'] ?? '');
$frame = (string) ($pageBlock['frame'] ?? '');
if (in_array($skin, self::PAGE_SKINS, true)) {
$row['skin'] = $skin;
}
if (in_array($density, self::PAGE_DENSITIES, true)) {
$row['density'] = $density;
}
if (in_array($frame, self::PAGE_FRAMES, true)) {
$row['frame'] = $frame;
}
$pageScheme = trim((string) ($pageBlock['card_scheme'] ?? ''));
if ($pageScheme !== '' && $this->isSafeCode($pageScheme)) {
$row['card_scheme'] = $pageScheme;
}
$caption = $pageBlock['card_caption'] ?? '';
if (is_string($caption) && $caption !== '' && mb_strlen($caption) <= 80 && $this->isSafeValue($caption)) {
$row['card_caption'] = $caption;
}
$clean[$page] = $row;
}
return $clean;
}
/**
* 清洗卡片图层:坐标数值化,非法 type 丢弃
*/
public function sanitizeLayers(mixed $layers, bool $strict = false): array
{
$layers = $this->toArray($layers);
$clean = [];
foreach ($layers as $index => $item) {
if (!is_array($item)) {
continue;
}
$type = (string) ($item['type'] ?? '');
if (!in_array($type, self::LAYER_TYPES, true)) {
if ($strict) {
UtilsService::getInstance()->errorThrow('图层 type 只能是 photo/text/deco/shape');
}
continue;
}
$id = $this->isSafeCode((string) ($item['id'] ?? ''))
? (string) $item['id']
: 'layer-' . $index;
$row = [
'id' => $id,
'type' => $type,
'x' => $this->num($item['x'] ?? 0),
'y' => $this->num($item['y'] ?? 0),
'w' => $this->num($item['w'] ?? 40),
'h' => $this->num($item['h'] ?? 40),
'rotate' => $this->num($item['rotate'] ?? 0),
'z' => (int) ($item['z'] ?? 1),
'visible' => !array_key_exists('visible', $item) || !empty($item['visible']),
'locked' => !empty($item['locked']),
];
if (!empty($item['bind']) && in_array((string) $item['bind'], self::LAYER_BINDS, true)) {
$row['bind'] = (string) $item['bind'];
}
if (!empty($item['font']) && in_array((string) $item['font'], self::LAYER_FONTS, true)) {
$row['font'] = (string) $item['font'];
}
foreach (['font_zh', 'font_en'] as $faceKey) {
$face = (string) ($item[$faceKey] ?? '');
if ($face !== '' && in_array($face, self::LAYER_FACES, true)) {
$row[$faceKey] = $face;
}
}
if (!empty($item['asset']) && $this->isSafeCode((string) $item['asset'])) {
$row['asset'] = (string) $item['asset'];
}
$tone = (string) ($item['tone'] ?? '');
if ($tone !== '' && in_array($tone, self::LAYER_TONES, true)) {
$row['tone'] = $tone;
}
if (isset($item['fill']) && $this->isSafeValue($item['fill'])) {
$row['fill'] = (string) $item['fill'];
}
if (isset($item['text']) && is_string($item['text']) && mb_strlen($item['text']) <= 80) {
$row['text'] = $item['text'];
}
if (!empty($item['src']) && $this->isSafeUrl((string) $item['src'])) {
$row['src'] = (string) $item['src'];
}
$clean[] = $row;
}
return $clean;
}
/**
* 某页某区块允许的变体:先看页面覆盖,再回落到全局
*
* @return array<int, string>
*/
public function variantsFor(string $page, string $type): array
{
$override = self::PAGE_SECTION_VARIANTS[$page][$type] ?? null;
if (is_array($override) && $override !== []) {
return $override;
}
return self::SECTION_VARIANTS[$type] ?? ['plain'];
}
/**
* 工作室拉一次就能画样式画廊,不必再写死中文名
*/
public function describe(): array
{
$pages = [];
foreach (self::PAGE_SECTION_TYPES as $page => $types) {
$modules = [];
foreach ($types as $type) {
$variants = $this->variantsFor($page, $type);
$labels = self::SECTION_VARIANT_LABELS[$type] ?? [];
$modules[] = [
'type' => $type,
'variants' => array_map(static function (string $value) use ($labels) {
return [
'value' => $value,
'label' => $labels[$value] ?? $value,
];
}, $variants),
];
}
$pages[] = [
'key' => $page,
'modules' => $modules,
];
}
return [
'tokens' => self::TOKEN_SCHEMA,
'layout' => self::LAYOUT_SCHEMA,
'pages' => self::PAGE_SECTION_TYPES,
'variants' => self::SECTION_VARIANTS,
'page_variants' => self::PAGE_SECTION_VARIANTS,
'labels' => self::SECTION_VARIANT_LABELS,
'studio' => $pages,
'page_presets' => $this->describePagePresets(),
'card_families' => $this->describeCardFamilies(),
'chrome' => [
'tabbar' => self::CHROME_TABBAR,
'tabbar_anim' => self::CHROME_TABBAR_ANIM,
'navbar' => self::CHROME_NAVBAR,
],
'layer_faces' => self::LAYER_FACES,
];
}
/**
* 工作室整页预设画廊:只下发 code/name/家族,套用时再取完整 sections
*/
public function describePagePresets(): array
{
$out = [];
foreach ((array) config('wx_page_presets', []) as $page => $items) {
foreach ((array) $items as $item) {
if (!is_array($item) || empty($item['code'])) {
continue;
}
$out[$page][] = [
'code' => (string) $item['code'],
'name' => (string) ($item['name'] ?? $item['code']),
'family' => (string) ($item['family'] ?? ''),
'skin' => (string) ($item['skin'] ?? 'shop'),
'card_scheme' => (string) ($item['card_scheme'] ?? ''),
'density' => (string) ($item['density'] ?? 'regular'),
'frame' => (string) ($item['frame'] ?? 'none'),
'hero' => $this->presetHeroHint($item),
'gallery' => $this->presetTypeHint($item, 'gallery'),
'list' => $this->presetListHint($item),
'sections' => is_array($item['sections'] ?? null) ? $item['sections'] : [],
];
}
}
return $out;
}
/**
* 按 code 取完整预设(工作室一键套用)
*/
public function pagePresetByCode(string $code): ?array
{
foreach ((array) config('wx_page_presets', []) as $items) {
foreach ((array) $items as $item) {
if (is_array($item) && (string) ($item['code'] ?? '') === $code) {
return $item;
}
}
}
return null;
}
/**
* 卡片家族:工作室缩略旁标轻奢 / 欧美 / 手作
*/
public function describeCardFamilies(): array
{
$map = [];
foreach ((array) config('wx_card_schemes', []) as $item) {
$code = (string) ($item['code'] ?? '');
if ($code === '') {
continue;
}
$family = (string) ($item['family'] ?? 'lux');
$map[$code] = match ($family) {
'editorial' => '欧美',
'atelier' => '手作',
default => '轻奢',
};
}
return $map;
}
/**
* 只留各页合法的预设 code
*/
private function sanitizePagePresets(array $presets): array
{
$clean = [];
foreach (array_keys(self::PAGE_SECTION_TYPES) as $page) {
$code = trim((string) ($presets[$page] ?? ''));
if ($code !== '' && $this->isSafeCode($code)) {
$clean[$page] = $code;
}
}
return $clean;
}
private function presetHeroHint(array $item): string
{
foreach ((array) ($item['sections'] ?? []) as $section) {
if (($section['type'] ?? '') === 'hero') {
return (string) ($section['variant'] ?? '');
}
}
return '';
}
private function presetListHint(array $item): string
{
return $this->presetTypeHint($item, 'list');
}
private function presetTypeHint(array $item, string $type): string
{
foreach ((array) ($item['sections'] ?? []) as $section) {
if (($section['type'] ?? '') === $type) {
return (string) ($section['variant'] ?? '');
}
}
return '';
}
/**
* 小程序侧要的扁平 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;
}
/**
* 区块 props只留高度/列数/间距/卡片方案,避免塞任意 CSS
*/
private function sanitizeSectionProps(mixed $props): array
{
if (!is_array($props)) {
return [];
}
$clean = [];
foreach (['height', 'cols', 'spacing'] as $key) {
if (isset($props[$key]) && is_numeric($props[$key])) {
$clean[$key] = (int) $props[$key];
}
}
if (!empty($props['card_scheme']) && $this->isSafeCode((string) $props['card_scheme'])) {
$clean['card_scheme'] = (string) $props['card_scheme'];
}
return $clean;
}
/** 底栏独立色只收 hex拒绝 CSS 语句 */
private function isSafeHexColor(string $value): bool
{
return preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $value) === 1;
}
private function isSafeCode(string $value): bool
{
return $value !== '' && preg_match('/^[a-zA-Z0-9_-]{1,50}$/', $value) === 1;
}
private function isSafeUrl(string $value): bool
{
if ($value === '' || mb_strlen($value) > 500) {
return false;
}
foreach (self::FORBIDDEN as $needle) {
if ($needle === 'url(') {
continue;
}
if (stripos($value, $needle) !== false) {
return false;
}
}
return (bool) preg_match('/^https?:\\/\\//i', $value);
}
private function num(mixed $value): float
{
return is_numeric($value) ? (float) $value : 0;
}
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 : [];
}
}