This commit is contained in:
gr
2026-09-24 09:45:44 +08:00
parent dbf474ddd7
commit bd22e5f476
38 changed files with 19152 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace app\mcp\service;
use app\common\service\ConfigService;
use GuzzleHttp\Client;
/**
* 读取记录里的附件(图片、PDF)。只读取本系统存储里的文件:
* 本地存储直接读 public 目录;云存储只允许配置的存储域名,防止被当成任意地址的下载代理。
*/
class FileFetcher
{
/** 字段值(字符串、逗号分隔、JSON 数组、[{url:..}])→ URL 列表 */
public static function urls($value): array
{
if (is_string($value)) {
$value = trim($value);
if ($value !== '' && $value[0] === '[') {
$decoded = json_decode($value, true);
return is_array($decoded) ? self::urls($decoded) : [];
}
return array_values(array_filter(array_map('trim', explode(',', $value))));
}
if (!is_array($value)) {
return [];
}
$urls = [];
foreach ($value as $item) {
$url = is_array($item) ? ($item['url'] ?? $item['uri'] ?? null) : $item;
if (is_string($url) && trim($url) !== '') {
$urls[] = trim($url);
}
}
return $urls;
}
/** 返回 MCP 工具结果:图片为 image 内容,PDF/文本为嵌入资源 */
public static function content(string $url, string $label): array
{
$bytes = self::read($url);
$mime = (new \finfo(FILEINFO_MIME_TYPE))->buffer($bytes) ?: 'application/octet-stream';
$size = round(strlen($bytes) / 1024) . ' KB';
if (str_starts_with($mime, 'image/')) {
return ['content' => [['type' => 'text', 'text' => $label . '(图片,' . $size . ')'],
['type' => 'image', 'data' => base64_encode($bytes), 'mimeType' => $mime]], 'isError' => false];
}
if ($mime === 'application/pdf' || str_starts_with($mime, 'text/')) {
return ['content' => [['type' => 'text', 'text' => $label . '(' . $mime . ',' . $size . ')'],
['type' => 'resource', 'resource' => ['uri' => 'zyt-file://' . hash('sha256', $url), 'mimeType' => $mime, 'blob' => base64_encode($bytes)]]], 'isError' => false];
}
return ['content' => [['type' => 'text', 'text' => $label . ':该附件类型(' . $mime . ')不支持直接读取']], 'isError' => true];
}
private static function read(string $url): string
{
$max = McpConfig::maxFileBytes();
$local = self::localPath($url);
if ($local !== null) {
if (filesize($local) > $max) {
throw new McpException('附件超过 ' . round($max / 1048576, 1) . ' MB,无法读取', 'invalid');
}
return (string) file_get_contents($local);
}
$parts = parse_url($url);
$host = strtolower((string) ($parts['host'] ?? ''));
if (!in_array($parts['scheme'] ?? '', ['http', 'https'], true) || $host === '' || !in_array($host, self::allowedHosts(), true)) {
throw new McpException('附件不在本系统的存储空间内,无法读取', 'denied');
}
$response = (new Client(['timeout' => 10, 'allow_redirects' => false, 'http_errors' => false]))->get($url, ['stream' => true]);
if ($response->getStatusCode() !== 200) {
throw new McpException('附件读取失败(HTTP ' . $response->getStatusCode() . ')', 'invalid');
}
$body = $response->getBody();
$bytes = '';
while (!$body->eof()) {
$bytes .= $body->read(65536);
if (strlen($bytes) > $max) {
throw new McpException('附件超过 ' . round($max / 1048576, 1) . ' MB,无法读取', 'invalid');
}
}
return $bytes;
}
/** 本地存储:相对路径或本站域名下的 uploads 路径 → public 目录里的真实文件 */
private static function localPath(string $url): ?string
{
$path = $url;
if (preg_match('#^https?://#i', $url)) {
$host = strtolower((string) parse_url($url, PHP_URL_HOST));
if ($host !== strtolower((string) request()->host(true))) {
return null;
}
$path = (string) parse_url($url, PHP_URL_PATH);
}
$path = ltrim(str_replace('\\', '/', $path), '/');
if ($path === '' || str_contains($path, '..') || !preg_match('#^(uploads|storage)/#', $path)) {
return null;
}
$public = realpath(public_path());
$full = realpath(public_path() . $path);
return ($full && $public && str_starts_with($full, $public) && is_file($full)) ? $full : null;
}
private static function allowedHosts(): array
{
$hosts = [strtolower((string) request()->host(true))];
$default = ConfigService::get('storage', 'default', 'local');
if ($default !== 'local') {
$storage = ConfigService::get('storage', $default);
$domain = is_array($storage) ? (string) ($storage['domain'] ?? '') : '';
$host = parse_url(str_contains($domain, '://') ? $domain : 'https://' . $domain, PHP_URL_HOST);
if ($host) {
$hosts[] = strtolower($host);
}
}
return array_values(array_unique(array_filter($hosts)));
}
}