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,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 '';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user