Files
YwxAppThink/ywxapp/middleware/MultiApp.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

351 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace ywxapp\middleware;
use Closure;
use think\App;
use think\exception\HttpException;
use think\Request;
use think\Response;
/**
* 多根目录多应用中间件
*
* 核心语义:
* - "blog" → 按 roots顺序查找,找到第一个匹配即用
* - "app2:blog" → 强制在 /app2/blog找,找不到直接 404
* - 都不支持"先查 app找不到再降级到 app2"再降级这种串行写法
* (显式前缀必须严格匹配,避免行为不可预测)
*/
class MultiApp
{
protected App $app;
/** @var array<string,string> 根目录标识 => 绝对路径 */
protected array $roots = [];
/** @var array<string,string> 根目录标识 => 命名空间前缀 */
protected array $namespaces = [];
public function __construct(App $app)
{
$this->app = $app;
$this->roots = $app->config->get('app.app_roots', [
'app' => $app->getBasePath(),
'addon' => $app->getRootPath() . 'addon' . DIRECTORY_SEPARATOR,
]);
// $this->namespaces = $app->config->get('app.app_namespaces', [
// 'app' => 'app',
// 'addon' => 'addon',
// ]);
$this->namespaces = [
'app' => 'app',
'addon' => 'addon',
];
}
public function handle($request, Closure $next)
{
if (!$this->parseMultiApp()) {
return $next($request);
}
return $this->app->middleware
->pipeline('app')
->send($request)
->then(function ($request) use ($next) {
return $next($request);
});
}
protected function getRoutePath(): string
{
return $this->app->getAppPath() . 'route' . DIRECTORY_SEPARATOR;
}
/**
* 核心解析
*/
protected function parseMultiApp(): bool
{
$scriptName = $this->getScriptName();
$defaultApp = $this->app->config->get('app.default_app') ?: 'index';
$appName = $this->app->http->getName();
// ==================== 阶段 1:独立入口 / 显式绑定 ====================
if ($appName || ($scriptName && !in_array($scriptName, ['index', 'router', 'think']))) {
$appName = $appName ?: $scriptName;
$this->app->http->setBind();
// 独立入口可不走 domain_bind,直接解析
$resolved = $this->resolve($appName);
if (!$resolved) {
throw new HttpException(404, 'app not exists:' . $appName);
}
return $this->applyResolved($resolved, $appName);
}
// ==================== 阶段 2: 自动识别 ====================
$this->app->http->setBind(false);
$appName = null;
// -------- 2.1 域名绑定 --------
$bind = $this->app->config->get('app.domain_bind', []);
if (!empty($bind)) {
$subDomain = $this->app->request->subDomain();
$domain = $this->app->request->host(true);
if (isset($bind[$domain])) {
$appName = $bind[$domain];
$this->app->http->setBind();
} elseif (isset($bind[$subDomain])) {
$appName = $bind[$subDomain];
$this->app->http->setBind();
} elseif (isset($bind['*'])) {
$appName = $bind['*'];
$this->app->http->setBind();
}
}
if ($this->app->http->isBind()) {
// 域名绑定后的旧链接兼容:/docs/xxx 的首段与应用名相同时视为冗余前缀剥掉,
// 让插件路径模式下收录的旧链接在绑定域名后继续可用(新链接由插件层动态生成)。
$this->stripBoundAppPrefix($appName);
$resolved = $this->resolve($appName);
if (!$resolved) {
throw new HttpException(404, 'app not exists:' . $appName);
}
return $this->applyResolved($resolved, $appName);
}
// -------- 2.2 URL 路径识别 --------
$path = $this->app->request->pathinfo();
$map = $this->app->config->get('app.app_map', []);
$deny = $this->app->config->get('app.deny_app_list', []);
$fullName = current(explode('/', $path));
if (strpos($fullName, '.')) {
$fullName = strstr($fullName, '.', true);
}
// 分支1: 命中 app_map
if (isset($map[$fullName])) {
if ($map[$fullName] instanceof Closure) {
$mapped = call_user_func_array($map[$fullName], [$this->app]) ?: $fullName;
} else {
$mapped = $map[$fullName];
}
$resolved = $this->resolve($mapped);
}
// 分支 2: 黑名单 / map 显式禁用
elseif ($fullName !== '' && (false !== array_search($fullName, $map) || in_array($fullName, $deny))) {
throw new HttpException(404, 'app not exists:' . $fullName);
}
// 分支 3: map 通配 *
elseif ($fullName !== '' && isset($map['*'])) {
$resolved = $this->resolve($map['*']);
}
// 分支 4: URL 段本身带前缀(强制指定根)
elseif (str_contains($fullName, ':')) {
$resolved = $this->resolve($fullName);
}
// 分支 5: 默认行为 —— 多根顺序查找
else {
$name = $fullName !== '' ? $fullName : null;
// URL 没给应用名 → 用 default_app
$name ??= $defaultApp;
$resolved = $this->resolve($name); // ← prefix='',走多根查找
}
// ===== 查找失败处理 =====
if (!$resolved) {
// app_express=true 且不是显式前缀时 → 兜底 default_app
$express = $this->app->config->get('app.app_express', false);
if ($express && !str_contains($fullName, ':')) {
$resolved = $this->resolve($defaultApp);
}
if (!$resolved) {
throw new HttpException(404, 'app not exists:' . ($fullName ?: $defaultApp));
}
}
// 重写 pathinfo: 把 URL 第一段剥掉
if ($fullName) {
$this->app->request->setRoot('/' . $fullName);
$this->app->request->setPathinfo(
strpos($path, '/') ? ltrim(strstr($path, '/'), '/') : ''
);
}
return $this->applyResolved($resolved, $fullName);
}
/**
* 解析应用名为最终 [prefix, name, appPath]
* 支持:
* "app2:blog" → ['app2', 'blog', '/www/app2/blog/']
* "blog" → ['', 'blog', null] ← 调用方拿到 null 时按 roots 顺序兜底
*/
protected function resolve(string $appName): ?array
{
[$prefix, $name] = $this->splitAppName($appName);
if ($prefix !== '') {
// 显式前缀: 必须严格匹配,失败立即返回 null(不再降级)
if (!isset($this->roots[$prefix])) {
return null;
}
$path = $this->roots[$prefix] . $name . DIRECTORY_SEPARATOR;
return is_dir($path) ? [$prefix, $name, $path] : null;
}
// 多根顺序查找
foreach ($this->roots as $key => $root) {
$path = $root . $name . DIRECTORY_SEPARATOR;
if (is_dir($path)) {
return [$key, $name, $path];
}
}
return null;
}
/**
* 域名绑定模式下的冗余首段剥离
*
* 绑定 docs.example.com → docs 后,访问 /docs/xxx 时首段 "docs" 与应用名相同,
* 属于插件路径模式的冗余前缀,剥掉后按应用内路由 xxx 正常分发;
* 首段与应用名不同(正常的 /xxx 绑定访问)则不做任何处理。
*/
protected function stripBoundAppPrefix(string $appName): void
{
$path = $this->app->request->pathinfo();
if ($path === '' || $path === '/') {
return;
}
$fullName = current(explode('/', $path));
if (strpos($fullName, '.') !== false) {
$fullName = strstr($fullName, '.', true);
}
if ($fullName === '') {
return;
}
[, $pureName] = $this->splitAppName($appName);
if ($fullName !== $pureName) {
return;
}
$this->app->request->setRoot('/' . $fullName);
$this->app->request->setPathinfo(
strpos($path, '/') ? ltrim(strstr($path, '/'), '/') : ''
);
}
/**
* 拆分应用名
* "app2:blog" → ["app2", "blog"]
* "blog" → ["", "blog"]
*/
protected function splitAppName(string $fullName): array
{
if ($fullName !== '' && str_contains($fullName, ':')) {
[$prefix, $name] = explode(':', $fullName, 2);
return [$prefix ?: '', $name];
}
return ['', $fullName];
}
protected function getScriptName(): string
{
if (isset($_SERVER['SCRIPT_FILENAME'])) {
$file = $_SERVER['SCRIPT_FILENAME'];
} elseif (isset($_SERVER['argv'][0])) {
$file = realpath($_SERVER['argv'][0]);
}
return isset($file) ? pathinfo($file, PATHINFO_FILENAME) : '';
}
/**
* 把 resolve() 结果落地到 App上下文
*/
protected function applyResolved(array $resolved, string $urlName): bool
{
[$prefix, $name, $appPath] = $resolved;
$this->app->http->name($name);
// 应用目录
$this->app->setAppPath($appPath);
// 命名空间
$nsPrefix = $this->namespaces[$prefix] ?? $prefix;
$this->app->setNamespace($nsPrefix . '\\' . $name);
// 运行时: 按根 + 应用双层隔离
$this->app->setRuntimePath(
$this->app->getRuntimePath() . $prefix . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR
);
// 路由目录
$this->app->http->setRoutePath($this->getRoutePath());
// 应用专属配置/中间件/provider/语言包
$this->loadApp($name, $appPath);
return true;
}
protected function loadApp(string $appName, string $appPath): void
{
if (is_file($appPath . 'common.php')) {
include_once $appPath . 'common.php';
}
$files = glob($appPath . 'config' . DIRECTORY_SEPARATOR . '*' . $this->app->getConfigExt());
foreach ($files as $file) {
$this->app->config->load($file, pathinfo($file, PATHINFO_FILENAME));
}
if (is_file($appPath . 'event.php')) {
$this->app->loadEvent(include $appPath . 'event.php');
}
if (is_file($appPath . 'middleware.php')) {
$this->app->middleware->import(include $appPath . 'middleware.php', 'app');
}
if (is_file($appPath . 'provider.php')) {
$this->app->bind(include $appPath . 'provider.php');
}
$this->app->loadLangPack($this->app->lang->defaultLangSet());
// 插件语言包兜底:框架 loadLangPack 只按「应用根 lang」扫描,插件目录下的
// lang/<langset>.php 与 lang/<langset>/*.php 在部分场景不会被加载,
// 这里显式补一次,使 addon/<name>/lang/ 真正生效(此前只有插件自己在
// common.php 里 Lang::load() 才生效,其余插件的 lang 目录形同虚设)。
$this->loadAddonLang($appPath);
}
/**
* 加载插件语言包:lang/<langset>.php 与 lang/<langset>/*.php
*/
protected function loadAddonLang(string $appPath): void
{
$langset = $this->app->lang->defaultLangSet();
$langDir = $appPath . 'lang' . DIRECTORY_SEPARATOR;
if ($langset === '' || ! is_dir($langDir)) {
return;
}
$files = glob($langDir . $langset . '.*') ?: [];
$subDir = $langDir . $langset . DIRECTORY_SEPARATOR;
if (is_dir($subDir)) {
$files = array_merge($files, glob($subDir . '*' . $this->app->getConfigExt()) ?: []);
}
foreach ($files as $file) {
if (is_file($file)) {
$this->app->lang->load($file);
}
}
}
}