Files
nl-admin-api/app/Service/WechatThemeService.php

188 lines
6.9 KiB
PHP
Raw Normal View History

<?php
namespace App\Service;
use App\BaseApp\BaseService;
use App\Models\wechat\WechatThemeModel;
/**
* 自定义排版主题服务:用户上传的模板 JSON 落库,供图文编辑器「我的模板」使用
*
* 安全设计两端后端同口径GF 版为 internal/logic/wechattheme
* - styles 只允许白名单元素键container/h1/h2/p/blockquote 21 个)
* - 每个样式值清洗:去掉 <>"\ 字符(防止逃逸 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) ?: '[]';
}
}