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' => ["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, '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' => [" $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, '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; } }