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(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
1 parent
1d49e6f5ee
commit
18101cf11a
636 files changed
+58517
-9519
No files matched your search
@@ -0,0 +1,255 @@
|
||||
<?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\library;
|
||||
|
||||
use think\facade\Db;
|
||||
use ywxapp\library\AdminLogger;
|
||||
use ywxapp\library\DbBackup;
|
||||
use ywxapp\model\CommonAttachment;
|
||||
|
||||
/**
|
||||
* 系统信息采集与缓存清理
|
||||
*
|
||||
* 从 System 控制器里抽出来,供「系统信息」页与「控制台」首页共用一份数据,
|
||||
* 避免同一套巡检逻辑在两个控制器里各写一遍(改一处、忘一处)。
|
||||
*
|
||||
* 只读采集为主;唯一的写操作 cleanRuntime() 只删 runtime/cache 与 runtime/temp,
|
||||
* 绝不碰 runtime/log(审计日志要留着)与业务文件。
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class SystemInfo
|
||||
{
|
||||
/**
|
||||
* 采集环境 / 目录 / 磁盘 / 统计信息
|
||||
*
|
||||
* 任何一项失败都不抛异常(巡检页不能因为一张表缺失就 500)。
|
||||
*
|
||||
* @return array{env:array,dirs:array,disk:array,stat:array}
|
||||
*/
|
||||
public static function collect(): array
|
||||
{
|
||||
$root = app()->getRootPath();
|
||||
|
||||
/* ---------------- 运行环境 ---------------- */
|
||||
$mysql = '-';
|
||||
try {
|
||||
$ver = Db::query('SELECT VERSION() AS v');
|
||||
$mysql = $ver[0]['v'] ?? '-';
|
||||
} catch (\Throwable $e) {
|
||||
$mysql = '读取失败';
|
||||
}
|
||||
|
||||
$disable = array_filter(array_map('trim', explode(',', (string) ini_get('disable_functions'))));
|
||||
$env = [
|
||||
['YwxApp 版本', (string) config('ywxapp.version', '-')],
|
||||
['ThinkPHP 版本', app()->version()],
|
||||
['PHP 版本', PHP_VERSION . '(' . PHP_SAPI . ')'],
|
||||
['MySQL 版本', $mysql],
|
||||
['操作系统', PHP_OS . ' ' . php_uname('r')],
|
||||
['服务器时间', date('Y-m-d H:i:s') . '(' . (string) config('app.default_timezone', date_default_timezone_get()) . ')'],
|
||||
['上传限制', ini_get('upload_max_filesize') . ' / POST ' . ini_get('post_max_size')],
|
||||
['内存限制', ini_get('memory_limit')],
|
||||
['脚本超时', ini_get('max_execution_time') . ' 秒'],
|
||||
['禁用函数', $disable ? implode(', ', array_slice($disable, 0, 6)) . (count($disable) > 6 ? ' …' : '') : '无'],
|
||||
];
|
||||
|
||||
/* ---------------- 目录可写 ---------------- */
|
||||
$dirs = [
|
||||
'runtime' => $root . 'runtime',
|
||||
'public/storage' => public_path() . 'storage',
|
||||
'config' => $root . 'config',
|
||||
'addon' => $root . 'addon',
|
||||
];
|
||||
$dirList = [];
|
||||
foreach ($dirs as $label => $path) {
|
||||
$dirList[] = [
|
||||
'name' => $label,
|
||||
'exists' => is_dir($path),
|
||||
'writable' => is_dir($path) && is_writable($path),
|
||||
'path' => $path,
|
||||
];
|
||||
}
|
||||
|
||||
/* ---------------- 磁盘 ---------------- */
|
||||
$diskTotal = @disk_total_space($root) ?: 0;
|
||||
$diskFree = @disk_free_space($root) ?: 0;
|
||||
$disk = [
|
||||
'total' => $diskTotal,
|
||||
'free' => $diskFree,
|
||||
'used' => $diskTotal - $diskFree,
|
||||
'total_text' => self::formatBytes($diskTotal),
|
||||
'free_text' => self::formatBytes($diskFree),
|
||||
'used_text' => self::formatBytes($diskTotal - $diskFree),
|
||||
'percent' => $diskTotal > 0 ? round(($diskTotal - $diskFree) / $diskTotal * 100, 1) : 0,
|
||||
];
|
||||
|
||||
/* ---------------- 运行统计 ---------------- */
|
||||
$dbSize = 0;
|
||||
try {
|
||||
foreach ((new DbBackup())->tables() as $t) {
|
||||
$dbSize += (int) ($t['size'] ?? 0);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 统计失败不阻断
|
||||
}
|
||||
$attachSize = 0;
|
||||
try {
|
||||
$attachSize = (int) CommonAttachment::where('delete_at', 0)->sum('size');
|
||||
} catch (\Throwable $e) {
|
||||
$attachSize = 0;
|
||||
}
|
||||
|
||||
$stat = [
|
||||
'admin' => self::safeCount('backend_admin'),
|
||||
'attachment' => self::safeCount('common_attachment', function ($q) {
|
||||
$q->where('delete_at', 0);
|
||||
}),
|
||||
'attach_size' => $attachSize,
|
||||
'attach_size_text' => self::formatBytes($attachSize),
|
||||
'log_week' => self::safeCount('backend_log', function ($q) {
|
||||
$q->where('create_at', '>', time() - 7 * 86400);
|
||||
}),
|
||||
'login_fail' => self::safeCount('backend_log', function ($q) {
|
||||
$q->where('action', '登录失败')->where('create_at', '>', time() - 7 * 86400);
|
||||
}),
|
||||
'db_size' => $dbSize,
|
||||
'db_size_text' => self::formatBytes($dbSize),
|
||||
'addon' => count(self::addonDirs()),
|
||||
];
|
||||
|
||||
return [
|
||||
'env' => $env,
|
||||
'dirs' => $dirList,
|
||||
'disk' => $disk,
|
||||
'stat' => $stat,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 runtime 缓存:cache / temp / all(不动 log)
|
||||
*
|
||||
* @param string $type
|
||||
* @return int 删除项数
|
||||
* @throws \InvalidArgumentException 未知类型
|
||||
*/
|
||||
public static function cleanRuntime(string $type): int
|
||||
{
|
||||
$targets = [
|
||||
'cache' => ['cache'], // 数据缓存
|
||||
'temp' => ['temp'], // 模板编译缓存
|
||||
'all' => ['cache', 'temp'],
|
||||
];
|
||||
if (! isset($targets[$type])) {
|
||||
throw new \InvalidArgumentException('未知清理类型:' . $type);
|
||||
}
|
||||
|
||||
$root = runtime_path();
|
||||
$files = 0;
|
||||
foreach ($targets[$type] as $dir) {
|
||||
$files += self::clearDir($root . $dir);
|
||||
}
|
||||
|
||||
// 路由缓存文件在 runtime 根目录,清 all 时一并删掉(下次请求自动重建)
|
||||
if ($type === 'all') {
|
||||
$routeCache = $root . 'route_list.php';
|
||||
if (is_file($routeCache)) {
|
||||
@unlink($routeCache);
|
||||
$files++;
|
||||
}
|
||||
}
|
||||
|
||||
AdminLogger::write('console/clean', '清理类型:' . $type . ',删除 ' . $files . ' 项');
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全计数:表缺失 / 查询异常时返回 0,不阻断巡检页
|
||||
*
|
||||
* @param string $table
|
||||
* @param callable|null $where
|
||||
* @return int
|
||||
*/
|
||||
public static function safeCount(string $table, callable $where = null): int
|
||||
{
|
||||
try {
|
||||
$q = Db::name($table);
|
||||
if ($where) {
|
||||
$where($q);
|
||||
}
|
||||
return (int) $q->count();
|
||||
} catch (\Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件目录清单(一级目录即插件名)
|
||||
* @return array
|
||||
*/
|
||||
public static function addonDirs(): array
|
||||
{
|
||||
$base = app()->getRootPath() . 'addon';
|
||||
if (! is_dir($base)) {
|
||||
return [];
|
||||
}
|
||||
$dirs = glob($base . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR) ?: [];
|
||||
return array_map('basename', $dirs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归删除目录下的内容(保留目录本身)
|
||||
*
|
||||
* @param string $dir
|
||||
* @return int 删除项数
|
||||
*/
|
||||
protected static function clearDir(string $dir): int
|
||||
{
|
||||
if (! is_dir($dir)) {
|
||||
return 0;
|
||||
}
|
||||
$count = 0;
|
||||
$items = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($items as $item) {
|
||||
if ($item->isDir()) {
|
||||
@rmdir($item->getPathname());
|
||||
} else {
|
||||
@unlink($item->getPathname());
|
||||
}
|
||||
$count++;
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 字节数格式化
|
||||
*
|
||||
* 注意:disk_total_space() 返回 float,参数必须收 float,
|
||||
* 否则严格类型下直接 TypeError(页面 500)。
|
||||
*
|
||||
* @param float $bytes
|
||||
* @return string
|
||||
*/
|
||||
public static function formatBytes(float $bytes): string
|
||||
{
|
||||
if ($bytes <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$i = (int) min(count($units) - 1, floor(log($bytes, 1024)));
|
||||
return round($bytes / (1024 ** $i), 2) . ' ' . $units[$i];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user