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:
yangwx committed 2026-10-22 00:53:39 +08:00
1 parent 1d49e6f5ee
commit 18101cf11a
636 files changed
+58517 -9519

No files matched your search

@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace addon\haonav\controller\trait;
/**
* Favicon 本地缓存:站点自有 favicon.ico → 解析 HTML <link rel=icon> → 第三方兜底
* 抓取顺序(可靠性从高到低、对网络环境最友好):
* 1) 目标站点根路径 /favicon.ico(绝大多数站点自带,不依赖第三方)
* 2) 抓取首页 HTML,解析 <link rel="icon/shortcut icon/apple-touch-icon"> 指示的图标
* 3) 第三方兜底(icon.horse / Google,国内常被墙/慢,仅作保底)
* 任一步成功即返回 ['ext'=>, 'data'=>],全部失败返回 false。
* 输出由 BaseTrait::serveFile / serveDefault 统一处理。
*/
trait FaviconTrait
{
/**
* 本地 favicon 代理 + 缓存
* 浏览器统一请求 /haonav/favicon.html?url=xxx,由服务端抓取并缓存到
* public/static/haonav/favicons/,避免直接依赖第三方服务、提升加载速度。
*/
public function favicon()
{
$url = $this->request->param('url', '');
$host = $url ? parse_url($url, PHP_URL_HOST) : '';
if (! $host) {
return $this->serveDefault();
}
$root = app()->getRootPath() . 'public/static/haonav/favicons/';
if (! is_dir($root)) {
@mkdir($root, 0755, true);
}
$key = md5($host);
// 失败负缓存:24 小时内不再尝试远程抓取,直接返回默认图
$failFile = $root . $key . '.fail';
if (is_file($failFile) && (time() - filemtime($failFile)) < 86400) {
return $this->serveDefault();
}
// 已缓存则直接返回
foreach (['png', 'ico', 'jpg', 'jpeg', 'svg', 'webp', 'gif'] as $ext) {
if (is_file($root . $key . '.' . $ext)) {
return $this->serveFile($root . $key . '.' . $ext);
}
}
$img = $this->fetchFavicon($host);
if ($img) {
$path = $root . $key . '.' . $img['ext'];
@file_put_contents($path, $img['data']);
return $this->serveFile($path);
}
@touch($failFile);
return $this->serveDefault();
}
/**
* 远程抓取 favicon。
* 任一步成功即返回 ['ext'=>, 'data'=>],全部失败返回 false。
*/
protected function fetchFavicon(string $host)
{
if (! class_exists(\GuzzleHttp\Client::class)) {
return false;
}
$ua = ['Member-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'];
$client = new \GuzzleHttp\Client(['timeout' => 4, 'verify' => false]);
// 1) 站点自有 /favicon.ico(先 https 后 http)
foreach (['https://' . $host, 'http://' . $host] as $base) {
try {
$resp = $client->get($base . '/favicon.ico', ['headers' => $ua]);
if ($resp->getStatusCode() === 200) {
$ct = strtolower($resp->getHeaderLine('Content-Type'));
$body = (string)$resp->getBody();
// 服务器可能把 404 页面当 html 返回,需排除
if (strlen($body) >= 32 && stripos($ct, 'html') === false) {
return ['ext' => $this->extFromCt($ct, 'ico'), 'data' => $body];
}
}
} catch (\Throwable $e) {
// 继续尝试
}
}
// 2) 抓首页 HTML,解析 <link rel="icon*"> 拿到真实图标地址
foreach (['https://' . $host, 'http://' . $host] as $base) {
try {
$resp = $client->get($base . '/', ['headers' => $ua]);
if ($resp->getStatusCode() !== 200) {
continue;
}
$html = (string)$resp->getBody();
$icon = $this->parseIconHref($html, $base);
if ($icon) {
$resp2 = $client->get($icon, ['headers' => $ua]);
if ($resp2->getStatusCode() === 200) {
$ct = strtolower($resp2->getHeaderLine('Content-Type'));
$body = (string)$resp2->getBody();
if (strlen($body) >= 32 && stripos($ct, 'html') === false) {
return ['ext' => $this->extFromCt($ct, 'ico'), 'data' => $body];
}
}
}
} catch (\Throwable $e) {
// 继续尝试
}
}
// 3) 第三方兜底(可能被墙/慢,仅保底)
$sources = [
'https://www.google.com/s2/favicons?domain=' . $host . '&sz=64',
'https://icon.horse/icon/' . $host,
];
foreach ($sources as $u) {
try {
$resp = $client->get($u, ['headers' => $ua]);
$body = (string)$resp->getBody();
if (strlen($body) < 32) {
continue;
}
$ct = strtolower($resp->getHeaderLine('Content-Type'));
$ext = 'png';
if (stripos($ct, 'svg') !== false) {
$ext = 'svg';
} elseif (stripos($ct, 'ico') !== false) {
$ext = 'ico';
} elseif (stripos($ct, 'jpeg') !== false) {
$ext = 'jpg';
} elseif (stripos($ct, 'webp') !== false) {
$ext = 'webp';
} elseif (stripos($ct, 'gif') !== false) {
$ext = 'gif';
}
return ['ext' => $ext, 'data' => $body];
} catch (\Throwable $e) {
continue;
}
}
return false;
}
/**
* 根据 Content-Type 推断图片扩展名
*/
protected function extFromCt(string $ct, string $default = 'ico'): string
{
if (stripos($ct, 'svg') !== false) {
return 'svg';
}
if (stripos($ct, 'png') !== false) {
return 'png';
}
if (stripos($ct, 'jpeg') !== false) {
return 'jpg';
}
if (stripos($ct, 'webp') !== false) {
return 'webp';
}
if (stripos($ct, 'gif') !== false) {
return 'gif';
}
if (stripos($ct, 'ico') !== false || stripos($ct, 'x-icon') !== false) {
return 'ico';
}
return $default;
}
/**
* 从首页 HTML 中解析图标地址,兼容相对/绝对/协议相对路径与 apple-touch-icon。
* 优先返回普通 icon(体积小),apple-touch-icon 作为兜底。
*/
protected function parseIconHref(string $html, string $base): string
{
if (! preg_match_all('/<link\b[^>]*rel=["\'][^"\']*icon[^"\']*["\'][^>]*>/is', $html, $matches)) {
return '';
}
$candidates = [];
foreach ($matches[0] as $tag) {
if (! preg_match('/href=["\']([^"\']+)["\']/i', $tag, $m)) {
continue;
}
$candidates[] = [
'href' => $m[1],
'apple' => stripos($tag, 'apple-touch-icon') !== false,
];
}
usort($candidates, fn($a, $b) => ($a['apple'] <=> $b['apple']));
$base = rtrim($base, '/');
foreach ($candidates as $c) {
$href = $c['href'];
if (preg_match('/^https?:\/\//i', $href)) {
return $href;
}
if (strpos($href, '//') === 0) {
return 'https:' . $href;
}
return $base . ($href[0] === '/' ? $href : '/' . $href);
}
return '';
}
}