安全(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(市场 / 开发者中心 / 审核 / 分润)
205 lines
7.5 KiB
PHP
205 lines
7.5 KiB
PHP
<?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 '';
|
||
}
|
||
} |