Files
YwxAppThink/app/backend/controller/Template.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

381 lines
15 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>
// +----------------------------------------------------------------------
declare (strict_types = 1);
namespace app\backend\controller;
use think\facade\View;
use Exception;
use ywxapp\controller\BackendBase;
use ywxapp\library\TemplateManager;
use ywxapp\library\TemplateInstaller;
use ywxapp\library\SiteTheme;
use ywxapp\library\TemplateMenu;
/**
* 模板管理(核心后台内置模块,参考 Discuz! 后台「界面」分类)。
* 站点级皮肤能力由 ywxapp/library/TemplateManager、TemplateInstaller 提供,
* 与插件解耦,本控制器仅承载后台管理 UI。
*/
class Template extends BackendBase
{
protected $noNeedLogin = [];
protected $noNeedVerify = [];
/**
* 模板中心页面 + 数据接口。
*/
public function index()
{
if ($this->request->isAjax()) {
$map = TemplateManager::getActiveMap();
$settings = TemplateManager::getSettings();
$data = [
'installed' => TemplateManager::listInstalled(),
'addon' => TemplateManager::listaddon(),
'active_map' => $map,
'global_default' => $map['*'] ?? 'default',
'allow_member_select' => ! empty($settings['allow_member_select']),
];
return $this->result->success($data, '获取成功');
}
return View::fetch('template/index');
}
/**
* 界面设置(Discuz 式:全站默认模板 + 会员自选开关)。
* GET 渲染页面;POST 保存。
*/
public function setting()
{
if ($this->request->isAjax() && $this->request->isPost()) {
try {
$default = (string) input('default_template', 'default');
$allow = (string) input('allow_member_select', '0');
if (! preg_match('/^[a-zA-Z0-9_]*$/', $default)) {
return $this->result->error('默认模板标识非法');
}
// 写入全站默认('default' 表示回退自带视图)
TemplateManager::setActive('*', $default);
$settings = TemplateManager::getSettings();
$settings['allow_member_select'] = ($allow === '1' || $allow === 'on');
TemplateManager::setSettings($settings);
return $this->result->success([], '已保存');
} catch (Exception $e) {
return $this->result->error($e->getMessage());
}
}
return View::fetch('template/setting');
}
/**
* 设为全站默认模板(写入 active_map['*'])。
*/
public function setDefault()
{
try {
$template = (string) input('template', '');
if (! preg_match('/^[a-zA-Z0-9_]*$/', $template)) {
return $this->result->error('模板标识非法');
}
TemplateManager::setActive('*', $template);
TemplateManager::clearOverlayCache();
$label = $template === 'default' ? '默认(自带视图)' : $template;
return $this->result->success([], '已设全站默认:' . $label);
} catch (Exception $e) {
return $this->result->error($e->getMessage());
}
}
/**
* 启用/禁用模板(禁用后即使被绑定也不生效)。
*/
public function toggle()
{
try {
$name = (string) input('name', '');
$enabled = (int) input('enabled', 1);
if (! preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $name)) {
return $this->result->error('模板标识非法');
}
TemplateManager::setEnabled($name, $enabled === 1);
TemplateManager::clearOverlayCache();
return $this->result->success([], $enabled === 1 ? '已启用' : '已禁用');
} catch (Exception $e) {
return $this->result->error($e->getMessage());
}
}
/**
* 设置某插件激活的模板。
* POST {addon, template};template='default'|'' 表示回退自带视图。
*/
public function setActive()
{
try {
$addon = (string) input('addon', '');
$template = (string) input('template', '');
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $addon)) {
return $this->result->error('插件标识非法');
}
if (!preg_match('/^[a-zA-Z0-9_]*$/', $template)) {
return $this->result->error('模板标识非法');
}
TemplateManager::setActive($addon, $template);
return $this->result->success([], '已保存');
} catch (Exception $e) {
return $this->result->error($e->getMessage());
}
}
/**
* 保存某模板的变量(配色)覆盖值。
* POST {name, vars: {primary:'#fff', ...}}
*/
public function saveVariables()
{
try {
$name = (string) input('name', '');
if (! preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $name)) {
return $this->result->error('模板标识非法');
}
$raw = input('vars/a', []);
if (! is_array($raw)) {
$raw = [];
}
TemplateManager::saveVariables($name, $raw);
return $this->result->success([], '配色已保存');
} catch (Exception $e) {
return $this->result->error($e->getMessage());
}
}
/**
* 上传并安装模板 zip 包(由 TemplateInstaller 落地文件)。
*/
public function upload()
{
try {
if (! $this->request->isPost()) {
return $this->result->error('仅允许 POST 上传');
}
$file = $this->request->file('file');
if (empty($file)) {
return $this->result->error('未收到上传文件');
}
// 来源校验(同源):非同源直接拒绝,轻量防 CSRF
$host = $this->request->host();
$referer = $this->request->server('HTTP_REFERER', '');
if ($referer !== '' && $host !== '' && stripos($referer, $host) === false) {
return $this->result->error('来源校验失败');
}
// 仅允许 zip 包(扩展名 + MIME 双重校验)
$ext = strtolower($file->getOriginalExtension());
if ($ext !== 'zip') {
return $this->result->error('仅支持 .zip 模板包');
}
if (! $file->checkMime(['application/zip', 'application/x-zip-compressed', 'application/octet-stream'])) {
return $this->result->error('文件类型不合法');
}
$tmpDir = runtime_path() . 'templates' . DIRECTORY_SEPARATOR . '_upload_' . time() . DIRECTORY_SEPARATOR;
if (!is_dir($tmpDir)) {
@mkdir($tmpDir, 0755, true);
}
$moved = $file->move($tmpDir, $file->getOriginalName());
if (!$moved) {
return $this->result->error('文件保存失败:' . $file->getError());
}
$zipPath = $tmpDir . $moved->getSaveName();
$info = TemplateInstaller::install($zipPath, root_path());
// 全量清理渲染缓存
TemplateManager::clearOverlayCache();
@unlink($zipPath);
@rmdir($tmpDir);
return $this->result->success($info, '模板已安装:' . ($info['title'] ?? $info['name']));
} catch (Exception $e) {
return $this->result->error($e->getMessage());
}
}
/**
* 卸载模板(删除文件 + 解除绑定)。
*/
public function uninstall()
{
try {
$name = (string) input('name', '');
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $name)) {
return $this->result->error('模板标识非法');
}
TemplateManager::uninstall($name);
return $this->result->success([], '已卸载');
} catch (Exception $e) {
return $this->result->error($e->getMessage());
}
}
/**
* 导出当前模板配置(active_map + settings + variables)为 JSON 文件下载。
* 用于备份 / 跨站点克隆皮肤配置。
*/
public function exportConfig()
{
$config = [
'type' => 'ywxapp-template-config',
'version' => '1.0',
'exported_at' => date('Y-m-d H:i:s'),
'active_map' => TemplateManager::getActiveMap(),
'settings' => TemplateManager::getSettings(),
];
$json = json_encode($config, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
$filename = 'template-config-' . date('Ymd-His') . '.json';
return response($json)->header([
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]);
}
/**
* 导入模板配置文件(JSON,由 exportConfig 生成)。
* 仅采纳「已安装模板」的绑定与变量;未安装的模板绑定会被安全跳过,避免脏数据。
*/
public function importConfig()
{
try {
if (! $this->request->isPost()) {
return $this->result->error('仅允许 POST 上传');
}
// 同源校验(轻量防 CSRF)
$host = $this->request->host();
$referer = $this->request->server('HTTP_REFERER', '');
if ($referer !== '' && $host !== '' && stripos($referer, $host) === false) {
return $this->result->error('来源校验失败');
}
$file = $this->request->file('file');
if (empty($file)) {
return $this->result->error('未收到上传文件');
}
if (strtolower($file->getOriginalExtension()) !== 'json') {
return $this->result->error('仅支持 .json 配置文件');
}
$cfg = json_decode(file_get_contents($file->getRealPath()), true);
if (! is_array($cfg) || ($cfg['type'] ?? '') !== 'ywxapp-template-config') {
return $this->result->error('不是有效的模板配置文件');
}
$installed = array_column(TemplateManager::listInstalled(), 'name');
$installedSet = array_fill_keys($installed, true);
// 清洗 active_map:仅保留合法标识且已安装的模板
$map = [];
$srcMap = $cfg['active_map'] ?? [];
foreach ($srcMap as $addon => $tpl) {
if ($addon !== '*' && ! preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', (string) $addon)) {
continue;
}
$tpl = (string) $tpl;
if ($tpl === '' || $tpl === 'default') {
$map[$addon] = 'default';
} elseif (preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $tpl) && isset($installedSet[$tpl])) {
$map[$addon] = $tpl;
}
}
// 清洗 settings:allow_member_select / disabled / variables
$settings = TemplateManager::getSettings();
$src = $cfg['settings'] ?? [];
if (is_array($src)) {
$settings['allow_member_select'] = ! empty($src['allow_member_select']);
$disabled = [];
foreach (($src['disabled'] ?? []) as $d) {
$d = (string) $d;
if (preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $d) && isset($installedSet[$d])) {
$disabled[] = $d;
}
}
$settings['disabled'] = $disabled;
$variables = [];
foreach (($src['variables'] ?? []) as $name => $vars) {
$name = (string) $name;
if (! preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $name) || ! isset($installedSet[$name]) || ! is_array($vars)) {
continue;
}
$clean = [];
foreach ($vars as $k => $v) {
$k = (string) $k;
if (preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $k)) {
$clean[$k] = (string) $v;
}
}
$variables[$name] = $clean;
}
$settings['variables'] = $variables;
}
TemplateManager::writeConfig($map, $settings);
TemplateManager::clearOverlayCache();
$msg = '配置已导入';
if (count($map) < count($srcMap)) {
$msg .= '(部分未安装模板的绑定已跳过)';
}
return $this->result->success([], $msg);
} catch (Exception $e) {
return $this->result->error($e->getMessage());
}
}
/**
* 站点 layui 配色(整站主题变量 config/theme.php)。
*
* 与分组级皮肤的区别:这里控制的是 layui 组件的底色(按钮/表单/分页/导航…),
* 对后台、会员中心、前台与全部插件前台一律生效(由 ThemeInject 中间件注入)。
*
* GET (AJAX) → 返回当前变量;POST → 保存并即时全站生效。
*/
public function siteTheme()
{
try {
if ($this->request->isPost()) {
$raw = input('vars/a', []);
if (! is_array($raw)) {
return $this->result->error('参数格式错误');
}
// enable 是开关,不参与 CSS 变量白名单校验
if (array_key_exists('enable', $raw)) {
SiteTheme::setEnabled(! empty($raw['enable']) && $raw['enable'] !== 'false');
unset($raw['enable']);
}
$saved = SiteTheme::save($raw);
TemplateManager::clearOverlayCache();
return $this->result->success(['saved' => $saved], '已保存,全站立即生效');
}
return $this->result->success([
'vars' => SiteTheme::raw(),
'enable' => SiteTheme::isEnabled(),
'menu_ok' => TemplateMenu::exists(),
], '获取成功');
} catch (Exception $e) {
return $this->result->error($e->getMessage());
}
}
/**
* 补建「界面」菜单入口(幂等)。
* 模板中心/界面设置是内置页面,但老站点可能没有菜单入口,管理员点一次即可修复。
*/
public function syncMenu()
{
$res = TemplateMenu::sync();
return $res['ok']
? $this->result->success(['created' => $res['created']], $res['message'])
: $this->result->error($res['message']);
}
}