Files
YwxAppThink/ywxapp/library/SiteTheme.php
T
yangwx 18101cf11a feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

237 lines
8.5 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
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
namespace ywxapp\library;
/**
* 站点级 layui 主题变量层(整站换肤的唯一入口)。
*
* 与 SkinVariables(模板级、仅插件上下文生效)的定位区别:
* - SkinVariables:某套皮肤自己的变量(--primary / --secondary),
* 由 template.json 声明,用于该皮肤自己的视图与 CSS。
* - SiteTheme:整站 layui 组件的底色(--ywx-primary / --ywx-accent 等),
* 对所有页面生效(后台 / 会员 / 前台 / 插件前台),与皮肤层互不干扰。
*
* 变量名对应见 config/theme.php;CSS 消费方是 public/static/theme/layui-theme.css
* (由 layui.css 自动提取生成:主色硬编码已改写为 var(--ywx-*))。
*
* 安全:变量值会被拼进 <style>,因此写入前必须做白名单校验,
* 防止通过 `}` / `;` 逃逸出 CSS 上下文(CSS injection)。
*
* @author ywxapp <admin@ywxapp.cn>
*/
class SiteTheme
{
/**
* 变量名 => 默认值(与 layui-theme.css 内 :root 保持一致)
*/
public const DEFAULTS = [
'primary' => '#16b777',
'accent' => '#16baaa',
'radius' => '2px',
'font' => "-apple-system, BlinkMacSystemFont, 'Microsoft YaHei', 'PingFang SC', sans-serif",
];
/**
* 主题是否启用。
*/
public static function isEnabled(): bool
{
return (bool) config('theme.enable', true);
}
/**
* 当前生效变量(不含 CSS 变量前缀),即 config/theme.php 的键名 => 值。
* 值缺失(空)时回落到 DEFAULTS,保证后台表单永远有值可回填。
*/
public static function raw(): array
{
$conf = config('theme.variables', []);
if (! is_array($conf)) {
$conf = [];
}
$out = [];
foreach (self::DEFAULTS as $key => $default) {
$val = isset($conf[$key]) ? trim((string) $conf[$key]) : '';
$out[$key] = $val === '' ? $default : $val;
}
return $out;
}
/**
* 启用/禁用整站主题(写回 config/theme.php,用于排查样式冲突)。
*/
public static function setEnabled(bool $enable): void
{
$conf = config('theme', []);
$vars = is_array(config('theme.variables', [])) ? config('theme.variables', []) : [];
self::writeConfig(['enable' => $enable, 'variables' => $vars]);
unset($conf);
}
/**
* 当前生效变量(配置 + 默认值),返回带 -- 前缀的 CSS 变量名 => 值。
*/
public static function values(): array
{
$out = [];
foreach (self::raw() as $key => $val) {
$out['--ywx-' . $key] = $val;
}
return $out;
}
/**
* 生成 :root 内部声明片段(不含 :root{} 包裹)。
*/
public static function declaration(): string
{
$parts = [];
foreach (self::values() as $k => $v) {
$parts[] = $k . ':' . $v;
}
return implode(';', $parts);
}
/**
* 主题层 CSS 的 <link> 标签(须排在各页面自有 CSS 之后加载)。
*/
public static function linkTag(): string
{
return '<link rel="stylesheet" href="/static/theme/layui-theme.css">';
}
/**
* 站点变量 <style> 标签(含唯一 id,便于防重复注入)。
*/
public static function styleTag(): string
{
return '<style id="ywx-site-theme">:root{' . self::declaration() . '}</style>';
}
/**
* 保存后台配置的变量值到 config/theme.php(保留注释头),并同步内存配置。
*
* @param array $vars ['primary' => '#5b8cff', ...];非法值直接丢弃
* @return int 实际写入的变量数
*/
public static function save(array $vars): int
{
$clean = [];
foreach (self::DEFAULTS as $key => $default) {
if (! isset($vars[$key])) {
continue;
}
$val = trim((string) $vars[$key]);
if ($val === '') {
// 显式留空 = 恢复该变量的默认值(raw()/values() 会自动回落)
$clean[$key] = '';
continue;
}
if (! self::validate($key, $val)) {
continue; // 非法值直接丢弃,防止 CSS 注入
}
$clean[$key] = $val;
}
$current = config('theme', []);
$current['variables'] = array_merge(
is_array(config('theme.variables', [])) ? config('theme.variables', []) : [],
$clean
);
$current['enable'] = $current['enable'] ?? true;
self::writeConfig($current);
return count($clean);
}
/**
* 值白名单校验:只允许「CSS 安全」取值,禁止 ; { } < > @ url( 等逃逸字符。
*/
public static function validate(string $key, string $value): bool
{
$v = $value;
// 通用禁止:CSS 上下文逃逸字符
if (preg_match('/[;{}<>]/', $v)) {
return false;
}
if (stripos($v, 'url(') !== false || strpos($v, '@') !== false) {
return false;
}
// 控制字符(换行可注入新行导致解析异常)
if (preg_match('/[\x00-\x1F\x7F]/', $v)) {
return false;
}
switch ($key) {
case 'primary':
case 'accent':
// 颜色:hex / rgb() / rgba() / 颜色关键字
return (bool) preg_match('/^(#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|rgba?\(\s*[\d.,%\s]+\)|[a-zA-Z]{3,20})$/', $v);
case 'radius':
return (bool) preg_match('/^[\d.]+(?:px|rem|em|%)?$/', $v);
case 'font':
return (bool) preg_match('/^[\w\s,\'"\-]+$/u', $v);
default:
return false;
}
}
/**
* 写回 config/theme.php(含注释头),同步内存配置使当前请求立即生效。
*/
protected static function writeConfig(array $data): void
{
$file = config_path() . 'theme.php';
$vars = is_array($data['variables'] ?? null) ? $data['variables'] : [];
$lines = [];
foreach (self::DEFAULTS as $key => $default) {
$val = isset($vars[$key]) ? (string) $vars[$key] : '';
$lines[] = " '" . $key . "' => '" . addslashes($val) . "',";
}
$linesStr = implode("\n", $lines);
$enable = ! empty($data['enable']) ? 'true' : 'false';
$content = <<<PHP
<?php
// +----------------------------------------------------------------------
// | YwxApp 站点级 layui 主题配置
// +----------------------------------------------------------------------
// | 说明:这是「整站 layui 皮肤」的唯一变量源,对所有页面生效
// | (后台 / 会员中心 / 前台 / 各插件前台),由全局中间件 ThemeInject 注入。
// |
// | 变量名与 public/static/theme/layui-theme.css 中的 --ywx-* 一一对应:
// | primary -> --ywx-primary 主色(按钮/表单聚焦/进度条/时间轴)
// | accent -> --ywx-accent 强调色(导航/分页当前页/上传/logo)
// | radius -> --ywx-radius 统一圆角
// | font -> --ywx-font 正文字体
// |
// | 改这里即可全站换肤,无需改动任何视图文件、也无需清理缓存。
// | 后台可在「模板/主题」界面写入本文件(见 SiteTheme::save())。
// +----------------------------------------------------------------------
return [
// 主题开关:关闭后不注入任何主题样式(用于排查样式冲突)
'enable' => {$enable},
// 站点主题变量(值为空表示使用 layui-theme.css 内的兜底默认值)
'variables' => [
{$linesStr}
],
];
PHP;
file_put_contents($file, $content);
// 刚写入的 PHP 配置可能被 OPcache 缓存 2 秒(revalidate_freq),显式失效保证即时换肤
if (function_exists('opcache_invalidate')) {
@opcache_invalidate($file, true);
}
\think\facade\Config::set(['enable' => ! empty($data['enable']), 'variables' => $vars], 'theme');
}
}