573 lines
19 KiB
PHP
573 lines
19 KiB
PHP
<?php
|
|
/**
|
|
* 2025 D1m Group
|
|
*
|
|
* NOTICE OF LICENSE
|
|
*
|
|
* This source file is subject to the Open Software License (OSL 3.0) that is available
|
|
* through the world-wide-web at this URL: http://www.opensource.org/licenses/OSL-3.0
|
|
*
|
|
* @copyright 2025 D1m Group
|
|
* @license http://www.opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
|
|
*/
|
|
|
|
namespace Stemcell\Commerce\Model\Wechat;
|
|
|
|
use Magento\Framework\App\Filesystem\DirectoryList;
|
|
use Magento\Framework\Filesystem;
|
|
use Magento\Framework\Filesystem\Io\File;
|
|
use Magento\Framework\HTTP\Client\Curl;
|
|
use Magento\Store\Model\StoreManagerInterface;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
/**
|
|
* 微信头像服务类
|
|
*/
|
|
class WechatAvatarService
|
|
{
|
|
private const AVATAR_DIR = 'profile/wechat_avatar';
|
|
private const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
|
private const REQUEST_TIMEOUT = 30; // 30秒超时
|
|
|
|
/**
|
|
* WechatAvatarService constructor.
|
|
*
|
|
* @param Filesystem $filesystem
|
|
* @param File $ioFile
|
|
* @param Curl $curl
|
|
* @param StoreManagerInterface $storeManager
|
|
* @param LoggerInterface $logger
|
|
*/
|
|
public function __construct(
|
|
private Filesystem $filesystem,
|
|
private File $ioFile,
|
|
private Curl $curl,
|
|
private StoreManagerInterface $storeManager,
|
|
private LoggerInterface $logger
|
|
) {
|
|
// 使用PHP 7.4+构造函数属性提升,无需单独赋值
|
|
}
|
|
|
|
/**
|
|
* 下载并保存微信头像
|
|
*
|
|
* @param string $avatarUrl 微信头像URL
|
|
* @param string $openid 用户openid
|
|
* @return string|null 返回本地头像路径,失败返回null
|
|
*/
|
|
public function downloadAndSaveAvatar(string $avatarUrl, string $openid): ?string
|
|
{
|
|
try {
|
|
// 1. 输入验证
|
|
if (empty($avatarUrl) || empty($openid)) {
|
|
$this->logger->warning('WechatAvatarService: Empty avatar URL or openid', [
|
|
'avatar_url' => $avatarUrl,
|
|
'openid' => $openid
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
// 2. URL白名单验证
|
|
if (!$this->validateAvatarUrl($avatarUrl)) {
|
|
$this->logger->error('WechatAvatarService: Invalid avatar URL', [
|
|
'url' => $avatarUrl,
|
|
'openid' => $openid
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
// 3. 检查文件大小(通过HEAD请求)
|
|
if (!$this->validateFileSize($avatarUrl)) {
|
|
$this->logger->error('WechatAvatarService: File too large', [
|
|
'url' => $avatarUrl,
|
|
'openid' => $openid
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
// 4. 下载文件
|
|
$imageData = $this->downloadWithTimeout($avatarUrl);
|
|
|
|
if (empty($imageData)) {
|
|
$this->logger->error('WechatAvatarService: Failed to download avatar', [
|
|
'avatar_url' => $avatarUrl,
|
|
'openid' => $openid
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
// 5. 检测和验证文件格式
|
|
$fileExtension = $this->detectImageFormat($imageData, $avatarUrl);
|
|
if (!$fileExtension) {
|
|
$this->logger->error('WechatAvatarService: Unsupported image format', [
|
|
'avatar_url' => $avatarUrl,
|
|
'openid' => $openid
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
// 6. 验证文件内容
|
|
if (!$this->validateImageContent($imageData, $fileExtension)) {
|
|
$this->logger->error('WechatAvatarService: Invalid image content', [
|
|
'avatar_url' => $avatarUrl,
|
|
'openid' => $openid,
|
|
'extension' => $fileExtension
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
// 7. 保存文件
|
|
$fileName = md5($openid) . '.' . $fileExtension;
|
|
$fullPath = self::AVATAR_DIR . '/' . $fileName;
|
|
|
|
$mediaDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA);
|
|
$mediaDirectory->create(self::AVATAR_DIR);
|
|
$mediaDirectory->writeFile($fullPath, $imageData);
|
|
|
|
$this->logger->info('WechatAvatarService: Avatar saved successfully', [
|
|
'openid' => $openid,
|
|
'local_path' => $fullPath,
|
|
'file_extension' => $fileExtension
|
|
]);
|
|
|
|
return $fullPath;
|
|
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('WechatAvatarService: Exception while downloading avatar', [
|
|
'avatar_url' => $avatarUrl,
|
|
'openid' => $openid,
|
|
'exception' => $e->getMessage()
|
|
]);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 验证头像URL是否在白名单中
|
|
*
|
|
* @param string $url
|
|
* @return bool
|
|
*/
|
|
private function validateAvatarUrl(string $url): bool
|
|
{
|
|
// 只允许微信CDN域名
|
|
$allowedDomains = [
|
|
'thirdwx.qlogo.cn',
|
|
'wx.qlogo.cn',
|
|
'mmbiz.qpic.cn'
|
|
];
|
|
|
|
$parsedUrl = parse_url($url);
|
|
if (!isset($parsedUrl['host'])) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($allowedDomains as $domain) {
|
|
if (str_ends_with($parsedUrl['host'], $domain)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 验证文件大小
|
|
*
|
|
* @param string $url
|
|
* @return bool
|
|
*/
|
|
private function validateFileSize(string $url): bool
|
|
{
|
|
try {
|
|
// 使用HEAD请求获取文件大小
|
|
$headers = get_headers($url, 1);
|
|
if (isset($headers['Content-Length'])) {
|
|
$fileSize = (int)$headers['Content-Length'];
|
|
if ($fileSize > self::MAX_FILE_SIZE) {
|
|
$this->logger->warning('WechatAvatarService: File too large', [
|
|
'url' => $url,
|
|
'size' => $fileSize,
|
|
'max_size' => self::MAX_FILE_SIZE
|
|
]);
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
} catch (\Exception $e) {
|
|
$this->logger->warning('WechatAvatarService: Failed to validate file size', [
|
|
'url' => $url,
|
|
'exception' => $e->getMessage()
|
|
]);
|
|
return true; // 如果无法验证大小,允许下载但会在后续步骤中检查
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 带超时的文件下载
|
|
*
|
|
* @param string $url
|
|
* @return string
|
|
* @throws \Exception
|
|
*/
|
|
private function downloadWithTimeout(string $url): string
|
|
{
|
|
$this->curl->setTimeout(self::REQUEST_TIMEOUT);
|
|
$this->curl->setOption(CURLOPT_FOLLOWLOCATION, false); // 禁止重定向
|
|
$this->curl->setOption(CURLOPT_MAXREDIRS, 0);
|
|
$this->curl->setOption(CURLOPT_SSL_VERIFYPEER, true);
|
|
$this->curl->setOption(CURLOPT_SSL_VERIFYHOST, 2);
|
|
|
|
$this->curl->get($url);
|
|
|
|
if ($this->curl->getStatus() !== 200) {
|
|
throw new \Exception('Download failed with status: ' . $this->curl->getStatus());
|
|
}
|
|
|
|
return $this->curl->getBody();
|
|
}
|
|
|
|
/**
|
|
* 验证图片内容
|
|
*
|
|
* @param string $imageData
|
|
* @param string $extension
|
|
* @return bool
|
|
*/
|
|
private function validateImageContent(string $imageData, string $extension): bool
|
|
{
|
|
// 检查文件大小
|
|
if (strlen($imageData) > self::MAX_FILE_SIZE) {
|
|
$this->logger->warning('WechatAvatarService: Downloaded file too large', [
|
|
'size' => strlen($imageData),
|
|
'max_size' => self::MAX_FILE_SIZE
|
|
]);
|
|
return false;
|
|
}
|
|
|
|
// 对于SVG文件,进行额外的安全检查
|
|
if ($extension === 'svg') {
|
|
return $this->validateSvgContent($imageData);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 验证SVG文件内容
|
|
*
|
|
* @param string $svgData
|
|
* @return bool
|
|
*/
|
|
private function validateSvgContent(string $svgData): bool
|
|
{
|
|
// 检查是否包含危险标签
|
|
$dangerousTags = ['script', 'object', 'embed', 'iframe'];
|
|
foreach ($dangerousTags as $tag) {
|
|
if (stripos($svgData, "<$tag") !== false) {
|
|
$this->logger->warning('WechatAvatarService: SVG contains dangerous tag', ['tag' => $tag]);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// 检查是否包含JavaScript事件
|
|
$dangerousEvents = ['onload', 'onclick', 'onerror', 'onmouseover', 'onfocus'];
|
|
foreach ($dangerousEvents as $event) {
|
|
if (stripos($svgData, $event) !== false) {
|
|
$this->logger->warning('WechatAvatarService: SVG contains dangerous event', ['event' => $event]);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 检测图片格式并返回文件后缀
|
|
*
|
|
* @param string $imageData 图片数据
|
|
* @param string $avatarUrl 头像URL
|
|
* @return string|null 返回文件后缀,失败返回null
|
|
*/
|
|
private function detectImageFormat(string $imageData, string $avatarUrl): ?string
|
|
{
|
|
// 支持的图片格式
|
|
$supportedFormats = [
|
|
'jpeg' => ["\xFF\xD8\xFF"],
|
|
'jpg' => ["\xFF\xD8\xFF"],
|
|
'png' => ["\x89\x50\x4E\x47"],
|
|
'gif' => ["GIF87a", "GIF89a"],
|
|
'webp' => ["RIFF"],
|
|
'bmp' => ["BM"],
|
|
'svg' => ["<?xml", "<svg"]
|
|
];
|
|
|
|
// 首先尝试从URL中获取文件后缀
|
|
$urlExtension = $this->getExtensionFromUrl($avatarUrl);
|
|
if ($urlExtension && array_key_exists(strtolower($urlExtension), $supportedFormats)) {
|
|
$this->logger->info('WechatAvatarService: Detected format from URL', [
|
|
'extension' => $urlExtension,
|
|
'url' => $avatarUrl
|
|
]);
|
|
return strtolower($urlExtension);
|
|
}
|
|
|
|
// 通过文件头检测图片格式
|
|
foreach ($supportedFormats as $extension => $signatures) {
|
|
foreach ($signatures as $signature) {
|
|
if (strpos($imageData, $signature) === 0) {
|
|
$this->logger->info('WechatAvatarService: Detected format from file header', [
|
|
'extension' => $extension,
|
|
'signature' => bin2hex($signature)
|
|
]);
|
|
return $extension;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 特殊处理SVG格式(可能没有XML声明)
|
|
if (strpos($imageData, '<svg') !== false) {
|
|
$this->logger->info('WechatAvatarService: Detected SVG format from content');
|
|
return 'svg';
|
|
}
|
|
|
|
$this->logger->warning('WechatAvatarService: Unknown image format', [
|
|
'url' => $avatarUrl,
|
|
'data_length' => strlen($imageData),
|
|
'first_bytes' => bin2hex(substr($imageData, 0, 16))
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 从URL中提取文件后缀
|
|
*
|
|
* @param string $url
|
|
* @return string|null
|
|
*/
|
|
private function getExtensionFromUrl(string $url): ?string
|
|
{
|
|
$parsedUrl = parse_url($url);
|
|
if (!isset($parsedUrl['path'])) {
|
|
return null;
|
|
}
|
|
|
|
$path = $parsedUrl['path'];
|
|
$extension = pathinfo($path, PATHINFO_EXTENSION);
|
|
|
|
if (empty($extension)) {
|
|
return null;
|
|
}
|
|
|
|
// 清理扩展名(移除查询参数等)
|
|
$extension = strtolower(trim($extension, '.'));
|
|
|
|
return $extension;
|
|
}
|
|
|
|
/**
|
|
* 删除头像文件
|
|
*
|
|
* @param string $openid 用户openid
|
|
* @return bool
|
|
*/
|
|
public function deleteAvatar(string $openid): bool
|
|
{
|
|
try {
|
|
$mediaDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA);
|
|
$avatarDir = self::AVATAR_DIR;
|
|
|
|
// 支持的图片格式
|
|
$supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
|
|
|
|
// 尝试删除所有可能的文件格式
|
|
$deleted = false;
|
|
foreach ($supportedExtensions as $extension) {
|
|
$fileName = md5($openid) . '.' . $extension;
|
|
$fullPath = $avatarDir . '/' . $fileName;
|
|
|
|
if ($mediaDirectory->isExist($fullPath)) {
|
|
$mediaDirectory->delete($fullPath);
|
|
$this->logger->info('WechatAvatarService: Avatar deleted successfully', [
|
|
'openid' => $openid,
|
|
'file_path' => $fullPath,
|
|
'extension' => $extension
|
|
]);
|
|
$deleted = true;
|
|
}
|
|
}
|
|
|
|
return $deleted;
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('WechatAvatarService: Exception while deleting avatar', [
|
|
'openid' => $openid,
|
|
'exception' => $e->getMessage()
|
|
]);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 保存base64图片数据
|
|
*
|
|
* @param string $base64Data base64编码的图片数据
|
|
* @param string $openid 用户openid
|
|
* @return string|null 返回本地头像路径,失败返回null
|
|
*/
|
|
public function saveBase64Avatar(string $base64Data, string $openid): ?string
|
|
{
|
|
try {
|
|
// 1. 输入验证
|
|
if (empty($base64Data) || empty($openid)) {
|
|
$this->logger->warning('WechatAvatarService: Empty base64 data or openid', [
|
|
'openid' => $openid,
|
|
'data_length' => strlen($base64Data)
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
// 2. 解析base64数据
|
|
$imageData = $this->parseBase64Data($base64Data);
|
|
if (!$imageData) {
|
|
$this->logger->error('WechatAvatarService: Invalid base64 data', [
|
|
'openid' => $openid
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
// 3. 检查文件大小
|
|
if (strlen($imageData) > self::MAX_FILE_SIZE) {
|
|
$this->logger->error('WechatAvatarService: Base64 image too large', [
|
|
'openid' => $openid,
|
|
'size' => strlen($imageData),
|
|
'max_size' => self::MAX_FILE_SIZE
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
// 4. 检测图片格式
|
|
$fileExtension = $this->detectImageFormatFromData($imageData);
|
|
if (!$fileExtension) {
|
|
$this->logger->error('WechatAvatarService: Unsupported image format from base64', [
|
|
'openid' => $openid
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
// 5. 验证图片内容
|
|
if (!$this->validateImageContent($imageData, $fileExtension)) {
|
|
$this->logger->error('WechatAvatarService: Invalid image content from base64', [
|
|
'openid' => $openid,
|
|
'extension' => $fileExtension
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
// 6. 保存文件
|
|
$fileName = md5($openid) . '.' . $fileExtension;
|
|
$fullPath = self::AVATAR_DIR . '/' . $fileName;
|
|
|
|
$mediaDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA);
|
|
$mediaDirectory->create(self::AVATAR_DIR);
|
|
$mediaDirectory->writeFile($fullPath, $imageData);
|
|
|
|
$this->logger->info('WechatAvatarService: Base64 avatar saved successfully', [
|
|
'openid' => $openid,
|
|
'local_path' => $fullPath,
|
|
'file_extension' => $fileExtension,
|
|
'file_size' => strlen($imageData)
|
|
]);
|
|
|
|
return $fullPath;
|
|
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('WechatAvatarService: Exception while saving base64 avatar', [
|
|
'openid' => $openid,
|
|
'exception' => $e->getMessage()
|
|
]);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 解析base64数据
|
|
*
|
|
* @param string $base64Data
|
|
* @return string|null
|
|
*/
|
|
private function parseBase64Data(string $base64Data): ?string
|
|
{
|
|
try {
|
|
// 移除可能的数据URL前缀
|
|
if (strpos($base64Data, 'data:') === 0) {
|
|
$parts = explode(',', $base64Data, 2);
|
|
if (count($parts) !== 2) {
|
|
return null;
|
|
}
|
|
$base64Data = $parts[1];
|
|
}
|
|
|
|
// 解码base64数据
|
|
$decodedData = base64_decode($base64Data, true);
|
|
if ($decodedData === false) {
|
|
$this->logger->warning('WechatAvatarService: Invalid base64 encoding');
|
|
return null;
|
|
}
|
|
|
|
return $decodedData;
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('WechatAvatarService: Exception parsing base64 data', [
|
|
'exception' => $e->getMessage()
|
|
]);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 从图片数据检测格式
|
|
*
|
|
* @param string $imageData
|
|
* @return string|null
|
|
*/
|
|
private function detectImageFormatFromData(string $imageData): ?string
|
|
{
|
|
// 支持的图片格式
|
|
$supportedFormats = [
|
|
'jpeg' => ["\xFF\xD8\xFF"],
|
|
'jpg' => ["\xFF\xD8\xFF"],
|
|
'png' => ["\x89\x50\x4E\x47"],
|
|
'gif' => ["GIF87a", "GIF89a"],
|
|
'webp' => ["RIFF"],
|
|
'bmp' => ["BM"],
|
|
'svg' => ["<?xml", "<svg"]
|
|
];
|
|
|
|
// 通过文件头检测图片格式
|
|
foreach ($supportedFormats as $extension => $signatures) {
|
|
foreach ($signatures as $signature) {
|
|
if (strpos($imageData, $signature) === 0) {
|
|
$this->logger->info('WechatAvatarService: Detected format from data', [
|
|
'extension' => $extension,
|
|
'signature' => bin2hex($signature)
|
|
]);
|
|
return $extension;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 特殊处理SVG格式(可能没有XML声明)
|
|
if (strpos($imageData, '<svg') !== false) {
|
|
$this->logger->info('WechatAvatarService: Detected SVG format from data');
|
|
return 'svg';
|
|
}
|
|
|
|
$this->logger->warning('WechatAvatarService: Unknown image format from data', [
|
|
'data_length' => strlen($imageData),
|
|
'first_bytes' => bin2hex(substr($imageData, 0, 16))
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
}
|