"\ 字符(防止逃逸 style 属性注入标签)、 * 去掉 javascript: / expression( 危险片段,长度截断 2000 * - palette 仅接受合法 HEX 色值数组(最多 8 个) */ class WechatThemeService extends BaseService { /** 样式表允许的元素键(与前端 ThemeStyleSheet 类型一一对应) */ private const ALLOWED_STYLE_KEYS = [ 'container', 'h1', 'h2', 'h3', 'p', 'blockquote', 'blockquoteP', 'ul', 'ol', 'li', 'strong', 'em', 'a', 'hr', 'img', 'code', 'pre', 'preCode', 'table', 'th', 'td', ]; /** 自定义主题数量上限(防止无限膨胀拖慢编辑器加载) */ private const MAX_THEME_COUNT = 100; public function __construct() { parent::__construct(); $this->model = WechatThemeModel::class; } /** * 全量列表(编辑器主题面板一次性加载,非分页) * palette/styles 解码成数组返回,前端拿到即可注册渲染 */ public function list(): array { $rows = WechatThemeModel::where('deleted_at', 0) ->orderByDesc('id') ->limit(self::MAX_THEME_COUNT) ->get(['id', 'name', 'palette', 'styles', 'created_by', 'created_at', 'updated_at']) ->toArray(); foreach ($rows as &$row) { $row['palette'] = $this->decodeJson($row['palette'] ?? ''); $row['styles'] = $this->decodeJson($row['styles'] ?? ''); } unset($row); return $rows; } /** * 导入模板:清洗校验后入库,返回新 id */ public function create($params): mixed { [$name, $palette, $styles] = $this->validatePayload($params); $count = WechatThemeModel::where('deleted_at', 0)->count(); if ($count >= self::MAX_THEME_COUNT) { $this->utils->errorThrow('自定义模板已达上限(' . self::MAX_THEME_COUNT . ' 个),请先删除不用的模板'); } $id = WechatThemeModel::insertGetId([ 'name' => $name, 'palette' => $this->encodeJson($palette), 'styles' => $this->encodeJson($styles), 'created_by' => $this->userId, 'created_at' => time(), 'updated_at' => 0, 'deleted_at' => 0, ]); return ['id' => $id]; } /** * 更新模板(名称/色板/样式整体覆盖,与导入同一套清洗) */ public function update($id, $params): mixed { $id = (int) $id; $info = WechatThemeModel::where('id', $id)->where('deleted_at', 0)->first(); if (empty($info)) { return $this->utils->notFound('模板不存在'); } [$name, $palette, $styles] = $this->validatePayload($params); return WechatThemeModel::where('id', $id)->update([ 'name' => $name, 'palette' => $this->encodeJson($palette), 'styles' => $this->encodeJson($styles), 'updated_at' => time(), ]); } /** * 批量软删除 */ public function delete($id): mixed { $ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id]))); if (empty($ids)) { $this->utils->errorThrow('参数错误'); } return WechatThemeModel::whereIn('id', $ids)->where('deleted_at', 0)->update([ 'deleted_at' => time(), 'updated_at' => time(), ]); } /** * 入参校验 + 清洗:返回 [name, palette, styles] * 为什么集中在一处:create/update 共用,保证任何入口都过同一套白名单 */ private function validatePayload(array $params): array { $name = mb_substr(trim((string) ($params['name'] ?? '')), 0, 50); if ($name === '') { $this->utils->errorThrow('请填写模板名称'); } $styles = $params['styles'] ?? []; if (is_string($styles)) { $styles = $this->decodeJson($styles); } if (!is_array($styles) || empty($styles)) { $this->utils->errorThrow('模板样式(styles)不能为空'); } $cleanStyles = []; foreach (self::ALLOWED_STYLE_KEYS as $key) { if (!array_key_exists($key, $styles)) { continue; } $value = $this->cleanStyleValue((string) $styles[$key]); if ($value !== '') { $cleanStyles[$key] = $value; } } if (empty($cleanStyles)) { $this->utils->errorThrow('styles 里没有可用的元素样式(支持键:' . implode('/', self::ALLOWED_STYLE_KEYS) . ')'); } $palette = $params['palette'] ?? []; if (is_string($palette)) { $palette = $this->decodeJson($palette); } $cleanPalette = []; foreach (is_array($palette) ? $palette : [] as $colorItem) { $colorItem = trim((string) $colorItem); // 只收合法 HEX(#rgb/#rrggbb/#rrggbbaa),防止色板被塞入任意字符串 if (preg_match('/^#[0-9a-fA-F]{3,8}$/', $colorItem)) { $cleanPalette[] = strtolower($colorItem); } if (count($cleanPalette) >= 8) { break; } } return [$name, $cleanPalette, $cleanStyles]; } /** * 单个样式值清洗:内联 CSS 会被前端注入 style="" 属性, * 必须去掉能逃逸属性/注入脚本的字符与片段 */ private function cleanStyleValue(string $value): string { // 去掉双引号/尖括号/反斜杠:双引号会截断 style 属性,尖括号可注入标签 $value = str_replace(['"', '<', '>', '\\'], '', $value); // 去掉历史遗留的危险 CSS 片段(旧 IE expression、javascript 伪协议) $value = preg_replace('/javascript\s*:/i', '', $value) ?? ''; $value = preg_replace('/expression\s*\(/i', '', $value) ?? ''; return mb_substr(trim($value), 0, 2000); } /** JSON 解码兜底:解不开返回空数组,不让脏数据打崩接口 */ private function decodeJson($raw): array { if (is_array($raw)) { return $raw; } $decoded = json_decode((string) $raw, true); return is_array($decoded) ? $decoded : []; } /** JSON 编码(保留中文与斜杠原样,方便库内直读排查) */ private function encodeJson(array $data): string { return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]'; } }