121 lines
5.2 KiB
PHP
121 lines
5.2 KiB
PHP
<?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)));
|
||
}
|
||
}
|