188 lines
6.9 KiB
PHP
188 lines
6.9 KiB
PHP
<?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) ?: '[]';
|
||
}
|
||
}
|