安全(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(市场 / 开发者中心 / 审核 / 分润)
391 lines
15 KiB
PHP
391 lines
15 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace ywxapp\utils;
|
||
|
||
use think\App;
|
||
use think\route\Url;
|
||
|
||
/**
|
||
* 多根模式 URL 生成
|
||
*
|
||
* 用法差异(对比 think\app\Url):
|
||
* - url('app2:manage/user/read') → 跨应用跳转 /app2/manage/user/read
|
||
* - url('manage/user/read') → 当前应用内(自动加 prefix:name 前缀)
|
||
* - url('app:admin/user/list') → 强制 /app/admin/user/list
|
||
* - url('app2:manage\Blog::read') → 类解析 +跨应用
|
||
* - url('[blog_read]') → 仅当前应用(官方限制)
|
||
*/
|
||
class UrlBuild extends Url
|
||
{
|
||
/** prefix:name之间的分隔符 */
|
||
protected string $prefixSep = ':';
|
||
|
||
// ============================================================
|
||
// 1. 工具方法:prefix解析与反推
|
||
// ============================================================
|
||
|
||
/**
|
||
* 从 namespace 反推当前应用的 prefix
|
||
* app\manage → 'app'
|
||
* app2\manage → 'app2'
|
||
*/
|
||
protected function getCurrentPrefix(): string
|
||
{
|
||
$ns = trim($this->app->getNamespace(), '\\');
|
||
if ($ns === '') {
|
||
return '';
|
||
}
|
||
$root = explode('\\', $ns)[0] ?? '';
|
||
|
||
$namespaces = $this->app->config->get('app.app_namespaces', []);
|
||
foreach ($namespaces as $key => $nsPrefix) {
|
||
if ($root === trim($nsPrefix, '\\')) {
|
||
return $key;
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* 获取当前应用的完整名(prefix:name)
|
||
*/
|
||
protected function getCurrentAppName(): string
|
||
{
|
||
$name = $this->app->http->getName();
|
||
$prefix = $this->getCurrentPrefix();
|
||
return $prefix !== '' ? ($prefix . $this->prefixSep . $name) : $name;
|
||
}
|
||
|
||
/**
|
||
* 拆分 URL 字符串中的 prefix:name/...
|
||
* "app2:manage/user/read" → ["app2", "manage/user/read"]
|
||
* "manage/user/read" → ["", "manage/user/read"]
|
||
* "blog/list" → ["", "blog/list"]
|
||
*/
|
||
protected function splitPrefix(string $url): array
|
||
{
|
||
if (!str_contains($url, $this->prefixSep)) {
|
||
return ['', $url];
|
||
}
|
||
$firstSlash = strpos($url, '/');
|
||
$firstColon = strpos($url, $this->prefixSep);
|
||
// colon 必须在第一段(/之前)
|
||
if ($firstSlash === false || $firstColon < $firstSlash) {
|
||
return [
|
||
substr($url, 0, $firstColon),
|
||
substr($url, $firstColon + 1),
|
||
];
|
||
}
|
||
return ['', $url];
|
||
}
|
||
|
||
/**
|
||
* 覆盖父类 getAppName(用于 build() 内部拼接)
|
||
*官方 think\app\Url 的实现会套 app_map 反向替换 key
|
||
* 多根模式: 直接返回 prefix:name 完整形式,反向替换由 parseUrl 自己处理
|
||
*/
|
||
protected function getAppName(): string
|
||
{
|
||
return $this->getCurrentAppName();
|
||
}
|
||
|
||
// ============================================================
|
||
// 2. 核心:parseUrl 重写
|
||
// ============================================================
|
||
|
||
/**
|
||
* 直接解析 URL 地址
|
||
*/
|
||
protected function parseUrl(string $url, string|bool &$domain): string
|
||
{
|
||
$request = $this->app->request;
|
||
|
||
// (0) 第4参兜底:不含点号的裸名不是合法域名,按 应用/插件名 处理
|
||
// url('index/index', [], '', 'articles') → 拼成 articles/index/index 后
|
||
// 交给后面的插件/应用前缀逻辑统一接管:
|
||
// - 绑定了域名(domain_bind) → 自动切到绑定域名并去掉前缀
|
||
// - 未绑定域名 → 保留路径前缀访问,不生成域名
|
||
if (is_string($domain) && '' !== $domain && !str_contains($domain, '.') && !str_contains($domain, '://')) {
|
||
$map = $this->app->config->get('app.app_map', []);
|
||
$realApp = isset($map[$domain]) && is_string($map[$domain]) ? $map[$domain] : $domain;
|
||
$alias = array_search($realApp, $map, true);
|
||
$url = ($alias === false ? $domain : (string) $alias) . '/' . ltrim($url, '/');
|
||
$domain = false;
|
||
}
|
||
|
||
// (1) 以 / 开头 → 直接作为路由地址
|
||
if (str_starts_with($url, '/')) {
|
||
return substr($url, 1);
|
||
}
|
||
|
||
// (2) 包含 \\ → 解析到类
|
||
// 支持: app2:manage\Blog\Article::read
|
||
if (str_contains($url, '\\')) {
|
||
return ltrim(str_replace('\\', '/', $url), '/');
|
||
}
|
||
|
||
// (3) 以 @ 开头 → 解析到控制器
|
||
if (str_starts_with($url, '@')) {
|
||
return substr($url, 1);
|
||
}
|
||
|
||
// (4) 空 URL → 当前 controller/action
|
||
if ($url === '') {
|
||
$url = $request->controller() . '/' . $request->action();
|
||
if (!$this->app->http->isBind()) {
|
||
$url = $this->getAppName() . '/' . $url;
|
||
}
|
||
return $url;
|
||
}
|
||
|
||
// ====== 多根模式特有逻辑 ======
|
||
|
||
// 拆分 prefix:name
|
||
[$prefix, $rest] = $this->splitPrefix($url);
|
||
$effectiveUrl = $prefix !== '' ? $rest : $url;
|
||
|
||
// ====== 插件路由:首段是已安装插件目录 → 插件名即访问前缀,跳过应用名解析 ======
|
||
// url('haonav/rank') → /haonav/rank(未绑定域名)
|
||
// url('haonav/rank') 且插件绑定域名 → http://绑定域名/rank(去插件前缀从根访问)
|
||
$firstSeg = strtok($effectiveUrl, '/') ?: '';
|
||
if ($prefix === '' && $firstSeg !== '' && $this->isAddonDir($firstSeg)) {
|
||
$bind = $this->app->config->get('app.domain_bind', []);
|
||
if ($bindDomain = $this->findBindDomain($firstSeg, $bind)) {
|
||
$domain = $domain ?: $bindDomain;
|
||
return ltrim(substr($effectiveUrl, strlen($firstSeg)), '/');
|
||
}
|
||
return $effectiveUrl;
|
||
}
|
||
|
||
// 拆分 controller/action/app
|
||
$controller = $request->controller();
|
||
$path = explode('/', $effectiveUrl);
|
||
$action = array_pop($path);
|
||
$controller = empty($path) ? $controller : array_pop($path);
|
||
|
||
// 应用名
|
||
if ($prefix !== '') {
|
||
// 跨应用: prefix 已指定,path 最后一段就是应用名
|
||
$appName = empty($path) ? $this->getCurrentAppName() : array_pop($path);
|
||
} else {
|
||
// 当前应用内
|
||
$appName = empty($path) ? $this->getCurrentAppName() : array_pop($path);
|
||
}
|
||
|
||
$url = $controller . '/' . $action;
|
||
|
||
// ====== 域名绑定处理 ======
|
||
$bind = $this->app->config->get('app.domain_bind', []);
|
||
$map = $this->app->config->get('app.app_map', []);
|
||
|
||
if ($prefix !== '') {
|
||
// 跨应用跳转(prefix:name 语法) → 找目标应用绑定的域名
|
||
$targetApp = $prefix . $this->prefixSep . $appName;
|
||
if ($key = array_search($targetApp, $bind)) {
|
||
// 用户没显式传 domain 时,才用绑定域名
|
||
$domain = $domain ?: $key;
|
||
}
|
||
} elseif (!$this->app->http->isBind()) {
|
||
// 自动多应用模式(免冒号写法)
|
||
$realApp = $map[$appName] ?? $appName; // 别名 → 真实应用名
|
||
$alias = array_search($realApp, $map, true); // 真实名 → URL 段(别名优先)
|
||
$alias = $alias === false ? $realApp : (string) $alias;
|
||
$current = [$this->app->http->getName(), $this->getCurrentAppName()];
|
||
|
||
if (in_array($realApp, $current, true) || in_array($appName, $current, true)) {
|
||
// 当前应用内 → 检查当前应用是否绑定域名
|
||
if ($key = array_search($this->app->http->getName(), $bind)) {
|
||
// 当前域名就是绑定的 → 强制使用
|
||
if (isset($bind[$_SERVER['SERVER_NAME'] ?? ''])) {
|
||
$domain = $_SERVER['SERVER_NAME'];
|
||
}
|
||
$domain = is_bool($domain) ? $key : $domain;
|
||
} else {
|
||
$url = $alias . '/' . $url;
|
||
}
|
||
} elseif ($bindDomain = $this->findBindDomain($realApp, $bind)) {
|
||
// 目标应用绑定了域名 → 自动切到该域名(路径无需应用前缀)
|
||
$domain = $domain ?: $bindDomain;
|
||
} else {
|
||
// 目标应用未绑定域名 → 按别名走路径访问
|
||
$url = $alias . '/' . $url;
|
||
}
|
||
}
|
||
|
||
return $url;
|
||
}
|
||
|
||
/**
|
||
* 在 domain_bind 中查找应用绑定的域名
|
||
* 兼容多种配置值写法: 'backend'(真实名) / 'app:backend'(多根前缀)
|
||
*/
|
||
protected function findBindDomain(string $app, array $bind): string
|
||
{
|
||
foreach ($bind as $bindDomain => $bindApp) {
|
||
if ($bindApp === $app || str_ends_with($bindApp, $this->prefixSep . $app)) {
|
||
return (string) $bindDomain;
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* 是否已安装插件(addon/<name> 目录存在),结果进程内缓存
|
||
*/
|
||
protected function isAddonDir(string $name): bool
|
||
{
|
||
static $cache = [];
|
||
if (!isset($cache[$name])) {
|
||
$cache[$name] = is_dir($this->app->getRootPath() . 'addon' . DIRECTORY_SEPARATOR . $name);
|
||
}
|
||
return $cache[$name];
|
||
}
|
||
|
||
// ============================================================
|
||
// 3. build() 重写:处理跨应用域名拼接
|
||
// ============================================================
|
||
|
||
public function build(): string
|
||
{
|
||
$url = $this->url;
|
||
$suffix = $this->suffix;
|
||
$domain = $this->domain;
|
||
$request = $this->app->request;
|
||
$vars = $this->vars;
|
||
|
||
// [name] 路由名
|
||
if (str_starts_with($url, '[') && $pos = strpos($url, ']')) {
|
||
$name = substr($url, 1, $pos - 1);
|
||
$url = 'name' . substr($url, $pos + 1);
|
||
}
|
||
|
||
if (!str_contains($url, '://') && !str_starts_with($url, '/')) {
|
||
$info = parse_url($url);
|
||
$url = !empty($info['path']) ? $info['path'] : '';
|
||
if (isset($info['fragment'])) {
|
||
$anchor = $info['fragment'];
|
||
if (str_contains($anchor, '?')) {
|
||
[$anchor, $info['query']] = explode('?', $anchor, 2);
|
||
}
|
||
if (str_contains($anchor, '@')) {
|
||
[$anchor, $domain] = explode('@', $anchor, 2);
|
||
}
|
||
} elseif (str_contains($url, '@') && !str_contains($url, '\\')) {
|
||
[$url, $domain] = explode('@', $url, 2);
|
||
}
|
||
}
|
||
|
||
$rule = [];
|
||
if ($url) {
|
||
// 显式传入域名(第4参)时跳过路由名反查:路由名按「路由地址」全局索引(跨应用共享),
|
||
// 反查会误中其它应用的规则(如 index/index 反查出 blog 插件的 /blog),直接走 parseUrl
|
||
$explicitDomain = !isset($name) && is_string($domain) && '' !== $domain;
|
||
if (!$explicitDomain) {
|
||
$checkName = $name ?? $url . (isset($info['query']) ? '?' . $info['query'] : '');
|
||
$checkDomain = $domain && is_string($domain) ? $domain : null;
|
||
$rule = $this->route->getName($checkName, $checkDomain);
|
||
if (empty($rule) && isset($info['query'])) {
|
||
$rule = $this->route->getName($url, $checkDomain);
|
||
parse_str($info['query'], $params);
|
||
$vars = array_merge($params, $vars);
|
||
unset($info['query']);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 反查结果过滤:带插件前缀的规则(blog/xxx、haonav/xxx)只在当前应用就是该插件时
|
||
// 才参与「按路由地址」的反查(该索引跨应用共享,否则 url('index/index') 会反查出
|
||
// 其它插件的首页规则);显式 [name] 命名路由不受此限制(跨应用链接插件页面依赖它)
|
||
if ($rule && !isset($name)) {
|
||
$curApp = $this->app->http->getName();
|
||
$rule = array_values(array_filter($rule, function ($item) use ($curApp) {
|
||
$first = strtok((string) ($item['rule'] ?? ''), '/') ?: '';
|
||
return !$this->isAddonDir($first) || $first === $curApp;
|
||
}));
|
||
}
|
||
|
||
if (!empty($rule) && $match = $this->getRuleUrl($rule, $vars, $domain)) {
|
||
// 路由名命中
|
||
$url = $match[0];
|
||
if ($domain && !empty($match[1])) {
|
||
$domain = $match[1];
|
||
}
|
||
if (!is_null($match[2])) {
|
||
$suffix = $match[2];
|
||
}
|
||
// 未绑定域名 → 加当前应用前缀(别名优先,如 frontend→home);
|
||
// 插件路由规则已自带插件前缀(如 haonav/xxx),跳过避免重复
|
||
if (!$this->app->http->isBind() && !$this->isAddonDir(strtok($url, '/') ?: '')) {
|
||
$map = $this->app->config->get('app.app_map', []);
|
||
$alias = array_search($this->getAppName(), $map, true);
|
||
$url = ($alias === false ? $this->getAppName() : (string) $alias) . '/' . $url;
|
||
}
|
||
} elseif (!empty($rule) && isset($name)) {
|
||
throw new \InvalidArgumentException('route name not exists:' . $name);
|
||
} else {
|
||
// URL 绑定
|
||
$bind = (string) $this->route->getDomainBind($domain && is_string($domain) ? $domain : null);
|
||
if ($bind && str_starts_with($url, $bind)) {
|
||
$url = substr($url, strlen($bind) + 1);
|
||
}
|
||
$url = $this->parseUrl($url, $domain);
|
||
if (isset($info['query'])) {
|
||
parse_str($info['query'], $params);
|
||
$vars = array_merge($params, $vars);
|
||
}
|
||
}
|
||
|
||
// 还原分隔符
|
||
$depr = $this->route->config('pathinfo_depr');
|
||
$url = str_replace('/', $depr, $url);
|
||
$file = $request->baseFile();
|
||
if ($file && !str_starts_with($request->url(), $file)) {
|
||
$file = str_replace('\\', '/', dirname($file));
|
||
}
|
||
$url = rtrim($file, '/') . '/' . $url;
|
||
|
||
// 后缀
|
||
if (str_ends_with($url, '/') || '' == $url) {
|
||
$suffix = '';
|
||
} else {
|
||
$suffix = $this->parseSuffix($suffix);
|
||
}
|
||
|
||
// 锚点
|
||
$anchor = !empty($anchor) ? '#' . $anchor : '';
|
||
|
||
// 参数
|
||
if (!empty($vars)) {
|
||
if ($this->route->config('url_common_param')) {
|
||
$vars = http_build_query($vars);
|
||
$url .= $suffix . ($vars ? '?' . $vars : '') . $anchor;
|
||
} else {
|
||
foreach ($vars as $var => $val) {
|
||
$val = (string) $val;
|
||
if ('' !== $val) {
|
||
$url .= $depr . $var . $depr . urlencode($val);
|
||
}
|
||
}
|
||
$url .= $suffix . $anchor;
|
||
}
|
||
} else {
|
||
$url .= $suffix . $anchor;
|
||
}
|
||
|
||
// 域名(★ 多根模式下,parseDomain 会按当前域名处理)
|
||
$domain = $this->parseDomain($url, $domain);
|
||
|
||
return $domain . rtrim($this->root, '/') . '/' . ltrim($url, '/');
|
||
}
|
||
|
||
/**
|
||
* parseDomain 重写:跨应用跳转时,使用 parseUrl 已选定的 $domain
|
||
*官方实现依赖 Route::getDomains,多根场景下不够灵活
|
||
*/
|
||
protected function parseDomain(string &$url, string|bool $domain): string
|
||
{
|
||
// parseUrl 已经根据 domain_bind 选好了 $domain,这里直接信任
|
||
return parent::parseDomain($url, $domain);
|
||
}
|
||
} |