Files
zyt/server/app/mcp/service/FileFetcher.php
T
2026-09-24 09:45:44 +08:00

121 lines
5.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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)));
}
}