安全(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(市场 / 开发者中心 / 审核 / 分润)
237 lines
8.5 KiB
PHP
237 lines
8.5 KiB
PHP
<?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');
|
||
}
|
||
}
|