// +---------------------------------------------------------------------- namespace driver\upload\Local; use crmeb\services\upload\BaseUpload; use crmeb\exceptions\AdminException; use crmeb\utils\DownloadImage; use think\facade\Config; use think\facade\Filesystem; use think\File; use think\Image; /** * 本地上传 * Class Driver * @package driver\upload\Local */ class Driver extends BaseUpload { /** * 本地存储可用的访问域名 * @var array */ protected $localUrlBases = []; /** * 缩略图、水印图存放位置 * @var string */ public $thumbWaterPath = 'thumb_water'; /** * 初始化本地图片处理配置 * @param array $config * @return void */ public function initialize(array $config) { parent::initialize($config); $this->localUrlBases = array_filter([$config['uploadUrl'] ?? '', $config['cdn'] ?? '']); $this->waterConfig['watermark_text_font'] = app()->getRootPath() . 'public' . '/statics/font/simsunb.ttf'; } /** * 设置上传路径 * @param string $path * @return $this */ public function to(string $path) { $this->path = $this->normalizeLocalPath(ltrim(str_replace('\\', '/', $path), '/')); $this->beginLocalOperation(); return $this; } /** * 开启验证 * @param array|null $rules * @return $this */ public function validate(?array $rules = null) { $this->validate = $rules ?? Config::get('upload', []); return $this; } /** * 本地驱动不需要第三方客户端 * @return null */ protected function app() { // TODO: Implement app() method. } /** * 本地存储不支持云端临时凭据 * @return bool */ public function getTempKeys() { // TODO: Implement getTempKeys() method. return $this->setError('请检查您的上传配置,视频默认oss上传'); } /** * 生成上传文件目录 * @param $path * @param null $root * @return string */ public function uploadDir($path, $root = null) { $path = $this->normalizeLocalPath(ltrim(str_replace('\\', '/', (string)$path), '/')); if ($root === null) return $this->localDiskPath($path); return rtrim(str_replace('\\', '/', $root), '/') . '/uploads' . ($path !== '' ? '/' . $path : ''); } /** * 检查上传目录不存在则生成 * @param $dir * @return bool */ protected function validDir($dir) { return is_dir($dir) || @mkdir($dir, 0755, true) || is_dir($dir); } /** * 清理上一次操作的结果,避免缓存驱动泄漏文件信息 * @return void */ protected function beginLocalOperation() { $this->fileInfo = new \StdClass(); $this->downFileInfo = new \StdClass(); $this->error = null; } /** * 校验相对目录和对象名,不允许跳出存储根目录 * @param string $path * @param bool $allowEmpty * @return string */ protected function normalizeLocalPath(string $path, bool $allowEmpty = true) { $path = str_replace('\\', '/', $path); if (preg_match('/[\x00-\x1f\x7f:]/', $path) || str_starts_with($path, '/')) { throw new \InvalidArgumentException('不合法的文件路径'); } $parts = []; foreach (explode('/', $path) as $part) { if ($part === '..') throw new \InvalidArgumentException('不合法的文件路径'); if ($part !== '' && $part !== '.') $parts[] = $part; } $path = implode('/', $parts); if (!$allowEmpty && $path === '') throw new \InvalidArgumentException('文件名称不能为空'); return $path; } /** * 获取磁盘根目录并检查实际父目录,防止符号链接逃逸 * @param string $path * @param string $disk * @return string */ protected function localDiskPath(string $path, string $disk = 'public') { $path = $this->normalizeLocalPath($path); $root = rtrim(str_replace('\\', '/', (string)Config::get('filesystem.disks.' . $disk . '.root', '')), '/'); if ($root === '' || !str_starts_with($root, '/')) throw new \InvalidArgumentException('本地存储根目录配置错误'); $target = $root . ($path !== '' ? '/' . $path : ''); $realRoot = $this->resolveLocalPath($root); $realTarget = $this->resolveLocalPath($target); if (is_link($target) || ($realTarget !== $realRoot && !str_starts_with($realTarget, $realRoot . '/'))) { throw new \InvalidArgumentException('文件路径超出本地存储目录'); } return $target; } /** * 解析尚未创建的目录,同时校验其已有父目录 * @param string $path * @return string */ protected function resolveLocalPath(string $path) { $existing = $path; $suffix = ''; while (!file_exists($existing) && !is_link($existing)) { $parent = dirname($existing); if ($parent === $existing) throw new \InvalidArgumentException('无法解析本地存储目录'); $suffix = '/' . basename($existing) . $suffix; $existing = $parent; } $resolved = realpath($existing); if ($resolved === false) throw new \InvalidArgumentException('无法解析本地存储目录'); return rtrim(str_replace('\\', '/', $resolved), '/') . $suffix; } /** * 根据实际内容校验扩展名、大小和 MIME,忽略客户端声明类型 * @param string $name * @param string $source * @param bool $isFile * @return bool */ protected function validateLocalFile(string $name, string $source, bool $isFile = true) { $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); if (in_array($extension, ['php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'pht', 'phar', 'shtml'], true) || in_array(strtolower(basename($name)), ['.htaccess', '.user.ini'], true)) { return $this->setError('不合法的文件后缀'); } if ($isFile && (!is_file($source) || !is_readable($source))) return $this->setError('上传的文件不存在或不可读'); $size = $isFile ? filesize($source) : strlen($source); $finfo = new \finfo(FILEINFO_MIME_TYPE); $mime = $isFile ? $finfo->file($source) : $finfo->buffer($source); if ($this->validate) { if (isset($this->validate['fileExt']) && !in_array($extension, array_map('strtolower', $this->validate['fileExt']), true)) { return $this->setError('不合法的文件后缀'); } if (isset($this->validate['filesize']) && $size > (int)$this->validate['filesize']) return $this->setError('文件过大'); if (isset($this->validate['fileMime']) && !in_array($mime, $this->validate['fileMime'], true)) return $this->setError('不合法的文件类型'); } $imageMimes = ['png' => ['image/png'], 'jpg' => ['image/jpeg'], 'jpeg' => ['image/jpeg'], 'gif' => ['image/gif'], 'webp' => ['image/webp'], 'bmp' => ['image/bmp', 'image/x-ms-bmp'], 'ico' => ['image/x-icon', 'image/vnd.microsoft.icon']]; if (isset($imageMimes[$extension]) && !in_array($mime, $imageMimes[$extension], true)) { return $this->setError('文件内容与图片后缀不符'); } if (isset($imageMimes[$extension]) || in_array($mime, array_merge(...array_values($imageMimes)), true)) { return $this->validateLocalImage($source, $isFile); } return true; } /** * 图片头只用于尺寸预检,完整解码独立于缩略图和水印开关。 * @param string $source * @param bool $isFile * @return bool */ protected function validateLocalImage(string $source, bool $isFile = true) { $info = $isFile ? @getimagesize($source) : @getimagesizefromstring($source); if (!$info) return $this->setError('图片内容不合法'); if (!$this->validateLocalImageDimensions((int)($info[0] ?? 0), (int)($info[1] ?? 0))) return false; // GD 不支持 ICO,且本地驱动不会解码或变换此格式。 if (($info[2] ?? null) === IMAGETYPE_ICO) return true; if (!function_exists('imagecreatefromstring')) return $this->setError('图片解码组件不可用'); $content = $isFile ? @file_get_contents($source) : $source; if (!is_string($content)) return $this->setError('图片内容不可读'); $image = null; $warning = false; set_error_handler(static function (int $level) use (&$warning): bool { if ($level & (E_WARNING | E_NOTICE)) { $warning = true; return true; } return false; }); try { $image = imagecreatefromstring($content); if (!$image || $warning) return $this->setError('图片内容损坏'); return true; } catch (\Throwable $e) { return $this->setError('图片内容无法解码'); } finally { if ($image instanceof \GdImage) imagedestroy($image); restore_error_handler(); } } /** * 在解码和旋转分配内存前限制像素,使用除法避免维度乘法溢出。 * @param int $width * @param int $height * @return bool */ protected function validateLocalImageDimensions(int $width, int $height) { $maxPixels = filter_var($this->validate['imageMaxPixels'] ?? 20000000, FILTER_VALIDATE_INT); if ($maxPixels === false || $maxPixels <= 0) return $this->setError('图片像素限制配置不合法'); if ($width <= 0 || $height <= 0 || $width > intdiv($maxPixels, $height)) return $this->setError('图片像素超过限制'); return true; } /** * 写入前检查所有约定的衍生文件,避免覆盖后复用旧图或触碰符号链接。 * @param string $relative * @return array */ protected function localThumbnailPaths(string $relative) { $directory = dirname($relative); $paths = []; foreach ($this->thumb as $size) { $path = $this->localDiskPath(($directory !== '.' ? $directory . '/' : '') . $size . '_' . basename($relative)); if (file_exists($path) && !is_file($path)) throw new \InvalidArgumentException('缩略图路径不是文件'); $paths[] = $path; } return $paths; } /** * 每次成功写入后失效旧缩略图,即使本次关闭变换也不能保留旧缓存。 * @param array $paths * @return void */ protected function clearLocalThumbnails(array $paths) { foreach ($paths as $path) { if (is_file($path) && !@unlink($path)) throw new \RuntimeException('旧缩略图删除失败'); } } /** * 水印只在写入原图时应用一次,读取缩略图不能再次修改原图。 * @param string $target * @param string $url * @return void */ protected function transformLocalFile(string $target, string $url) { if (!$this->authThumb || !$this->checkImage($target) || in_array(strtolower(pathinfo($target, PATHINFO_EXTENSION)), ['ico', 'gif'], true)) return; $errorLevel = error_reporting(E_ALL & ~E_DEPRECATED); try { $this->water($url); $this->thumb($url, basename($target)); } finally { error_reporting($errorLevel); } } /** * 为未指定对象名的流根据实际 MIME 生成安全扩展名 * @param string $content * @return string */ protected function localStreamFileName(string $content) { $mime = (new \finfo(FILEINFO_MIME_TYPE))->buffer($content); $extensions = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp', 'image/x-icon' => 'ico', 'image/vnd.microsoft.icon' => 'ico', 'text/plain' => 'txt', 'application/zip' => 'zip']; return $this->saveFileName(null, $extensions[$mime] ?? null); } /** * 保存已校验文件,统一处理写入失败及返回元信息 * @param string $key * @param callable $save * @param bool $download * @return bool|\StdClass */ protected function saveLocalFile(string $key, callable $save, bool $download = false) { try { $key = $this->normalizeLocalPath($key, false); $relative = trim(($this->path ?? '') . '/' . $key, '/'); $target = $this->localDiskPath($relative); $thumbnailPaths = $this->localThumbnailPaths($relative); if (!$this->validDir(dirname($target))) return $this->setError('无法创建上传目录,请检查目录权限'); if (!$save($target)) return $this->setError('文件写入失败'); $this->clearLocalThumbnails($thumbnailPaths); $file = new File($target); $url = Filesystem::disk('public')->url($relative); if ($download) { $this->downFileInfo->downloadInfo = $file; $this->downFileInfo->downloadRealName = $key; $this->downFileInfo->downloadFileName = basename($key); $this->downFileInfo->downloadFilePath = $url; return $this->downFileInfo; } $this->fileInfo->uploadInfo = $file; $this->fileInfo->realName = $key; $this->fileInfo->fileName = basename($key); $this->fileInfo->filePath = $url; $this->transformLocalFile($target, $url); return $this->fileInfo; } catch (\Throwable $e) { $this->beginLocalOperation(); return $this->setError($e->getMessage()); } } /** * 检测filepath是否是远程地址 * @param string $filePath * @return bool */ public function checkFilePathIsRemote(string $filePath) { return (bool)preg_match('~^(?:https?:)?//~i', $filePath); } /** * 生成与配置相关的文件名称以及路径 * @param string $filePath 原地址 * @param string $toPath 保存目录 * @param array $config 配置相关参数 * @param string $root * @return string */ public function createSaveFilePath(string $filePath, string $toPath, array $config = [], $root = '/') { [$path, $ext] = $this->getFileName($filePath); $fileName = md5(json_encode($config) . $filePath); return $this->uploadDir($toPath, $root) . '/' . $fileName . '.' . $ext; } /** * 文件上传 * @param string $file * @param bool $realName * @return array|bool|mixed|\StdClass */ public function move(string $file = 'file', $realName = false) { $this->beginLocalOperation(); try { $fileHandle = app()->request->file($file); if (!$fileHandle || !$fileHandle->isValid()) return $this->setError('上传的文件不存在或不完整'); $originalName = $fileHandle->getOriginalName(); if (!$this->validateLocalFile($originalName, $fileHandle->getPathname())) return false; // 私钥和证书按扩展名进入私有磁盘,不能由客户端 MIME 决定目录。 $private = in_array(strtolower(pathinfo($originalName, PATHINFO_EXTENSION)), ['pem', 'key', 'crt', 'cer'], true); $disk = $private ? 'pem' : 'public'; $path = $private ? '' : ($this->path ?? ''); if ($realName && (str_contains($originalName, '/') || str_contains($originalName, '\\'))) { return $this->setError('不合法的文件名称'); } $key = $realName ? $originalName : $fileHandle->hashName($private ? function () { return md5(microtime(true)); } : null); $key = $this->normalizeLocalPath($key, false); $relative = trim($path . '/' . $key, '/'); $target = $this->localDiskPath($relative, $disk); $thumbnailPaths = $private ? [] : $this->localThumbnailPaths($relative); $fileName = Filesystem::disk($disk)->putFileAs($path, $fileHandle, $key); if (!$fileName) return $this->setError('文件写入失败'); $this->clearLocalThumbnails($thumbnailPaths); $this->fileInfo->uploadInfo = new File($target); $this->fileInfo->realName = $originalName; $this->fileInfo->fileName = basename($fileName); $this->fileInfo->filePath = Filesystem::disk($disk)->url($fileName); if (!$private) $this->transformLocalFile($target, $this->fileInfo->filePath); return $this->fileInfo; } catch (\Throwable $e) { $this->beginLocalOperation(); return $this->setError($e->getMessage()); } } /** * 文件流上传 * @param $fileContent * @param string|null $key * @return array|bool|mixed|\StdClass */ public function stream($fileContent, ?string $key = null) { $this->beginLocalOperation(); if (is_resource($fileContent)) $fileContent = stream_get_contents($fileContent); if (!is_string($fileContent)) return $this->setError('文件流内容不合法'); $key = $key ?: $this->localStreamFileName($fileContent); if (!$this->validateLocalFile($key, $fileContent, false)) return false; return $this->saveLocalFile($key, function ($target) use ($fileContent) { return @file_put_contents($target, $fileContent, LOCK_EX) === strlen($fileContent); }); } /** * 文件流下载保存图片 * @param string $fileContent * @param string|null $key * @return array|bool|mixed|\StdClass */ public function down(string $fileContent, ?string $key = null) { $this->beginLocalOperation(); $key = $key ?: $this->localStreamFileName($fileContent); if (!$this->validateLocalFile($key, $fileContent, false)) return false; return $this->saveLocalFile($key, function ($target) use ($fileContent) { return @file_put_contents($target, $fileContent, LOCK_EX) === strlen($fileContent); }, true); } /** * 生成缩略图 * @param string $filePath * @param string $fileName * @param string $type * @return array|mixed|string[] */ public function thumb(string $filePath = '', string $fileName = '', string $type = 'all') { $config = $this->thumbConfig; $data = ['big' => $filePath, 'mid' => $filePath, 'small' => $filePath]; $this->fileInfo->filePathBig = $this->fileInfo->filePathMid = $this->fileInfo->filePathSmall = $this->fileInfo->filePathWater = $filePath; if ($filePath === '') return $data; if ($type !== 'all' && !in_array($type, $this->thumb, true)) throw new \InvalidArgumentException('不支持的缩略图尺寸'); $source = $this->getFilePath($filePath); if (!$this->authThumb) return $data; $imageInfo = @getimagesize($source); if (!$imageInfo || in_array(strtolower(pathinfo($source, PATHINFO_EXTENSION)), ['ico', 'gif'], true)) return $data; $fileName = basename($source); $root = $this->localDiskPath(''); if (!str_starts_with($source, $root . '/')) throw new \InvalidArgumentException('图片不属于本地公开存储'); $relative = substr($source, strlen($root) + 1); $filePath = Filesystem::disk('public')->url($relative); $this->fileInfo->filePathBig = $this->fileInfo->filePathMid = $this->fileInfo->filePathSmall = $this->fileInfo->filePathWater = $filePath; $data = ['big' => $filePath, 'mid' => $filePath, 'small' => $filePath]; $errorLevel = error_reporting(E_ALL & ~E_DEPRECATED); try { if (!($config['image_thumb_status'] ?? 0)) return $data; if (!$this->validateLocalImageDimensions((int)$imageInfo[0], (int)$imageInfo[1])) throw new \InvalidArgumentException((string)$this->getError()); $sourceValidated = false; foreach ($this->thumb as $size) { if ($type !== 'all' && $type !== $size) continue; $height = (int)($config['thumb_' . $size . '_height'] ?? 0); $width = (int)($config['thumb_' . $size . '_width'] ?? 0); if ($height <= 0 || $width <= 0) throw new \InvalidArgumentException('缩略图尺寸必须大于零'); $directory = dirname($relative); $saveRelative = ($directory !== '.' ? $directory . '/' : '') . $size . '_' . $fileName; $savePath = $this->localDiskPath($saveRelative); // 已生成的缩略图也须同步返回元信息,避免依赖工作目录。 if (!is_file($savePath)) { if (!$sourceValidated) { if (!$this->validateLocalImage($source)) throw new \InvalidArgumentException((string)$this->getError()); $sourceValidated = true; } Image::open($source)->thumb($width, $height)->save($savePath); } $key = 'filePath' . ucfirst($size); $data[$size] = $this->fileInfo->$key = Filesystem::disk('public')->url($saveRelative); } } catch (\Throwable $e) { throw new AdminException($e->getMessage()); } finally { error_reporting($errorLevel); } return $data; } /** * 添加水印 * @param string $filePath * @return mixed|string */ public function water(string $filePath = '') { $waterConfig = $this->waterConfig; if (($waterConfig['image_watermark_status'] ?? 0) && $filePath) { switch ($waterConfig['watermark_type']) { case 1: if (!empty($waterConfig['watermark_image'])) $filePath = $this->image($filePath, $waterConfig); break; case 2: $filePath = $this->text($filePath, $waterConfig); break; } } return $filePath; } /** * 图片水印 * @param string $filePath * @param array $waterConfig * @return string */ public function image(string $filePath, array $waterConfig = []) { if (!$waterConfig) { $waterConfig = $this->waterConfig; } $watermark_image = $waterConfig['watermark_image'] ?? ''; if (!$watermark_image) throw new AdminException('请先配置水印图片'); $savePath = $this->getFilePath($filePath); try { $watermark_image = $this->getFilePath($watermark_image); } catch (\InvalidArgumentException $e) { if (!$this->checkFilePathIsRemote($watermark_image)) throw $e; // 外部水印只缓存到当前本地上传磁盘,不使用默认磁盘或当前工作目录。 [, $extension] = $this->getFileName($watermark_image); $name = 'water_image_' . md5($watermark_image) . '.' . $extension; $watermark_image = $this->localDiskPath($this->thumbWaterPath . '/' . $name); if (!is_file($watermark_image)) { try { /** @var DownloadImage $down */ $down = app()->make(DownloadImage::class); $data = $down->path($this->thumbWaterPath)->downloadImage($waterConfig['watermark_image'], $name); $watermark_image = $this->getFilePath($data['path'] ?? ''); } catch (\Throwable $e) { throw new AdminException('远程水印图片下载失败'); } } } $temporaryWatermark = null; try { if (!$this->validateLocalImage($savePath) || !$this->validateLocalImage($watermark_image)) { throw new \InvalidArgumentException((string)$this->getError()); } $rotate = $waterConfig['watermark_rotate'] ?? 0; if ($rotate !== '' && (!is_numeric($rotate) || !is_finite((float)$rotate) || abs((float)$rotate) > 360)) { throw new \InvalidArgumentException('水印旋转角度不合法'); } $degrees = fmod((float)$rotate, 360); if ($degrees != 0) { $info = getimagesize($watermark_image); $radians = deg2rad($degrees); $quarterTurn = abs(fmod($degrees, 180)); if ($quarterTurn == 90) { $width = $info[1]; $height = $info[0]; } elseif ($quarterTurn == 0) { $width = $info[0]; $height = $info[1]; } else { $width = (int)ceil($info[0] * abs(cos($radians)) + $info[1] * abs(sin($radians))); $height = (int)ceil($info[0] * abs(sin($radians)) + $info[1] * abs(cos($radians))); } if (!$this->validateLocalImageDimensions($width, $height)) throw new \InvalidArgumentException((string)$this->getError()); $temporaryWatermark = $this->localDiskPath($this->thumbWaterPath . '/water_rotate_' . bin2hex(random_bytes(16)) . '.png'); if (!$this->validDir(dirname($temporaryWatermark))) throw new \RuntimeException('无法创建临时水印目录'); Image::open($watermark_image)->rotate($degrees)->save($temporaryWatermark, 'png'); $watermark_image = $temporaryWatermark; } $Image = Image::open($savePath); $Image->water($watermark_image, $waterConfig['watermark_position'] ?: 1, (int)($waterConfig['watermark_opacity'] ?? 100))->save($savePath); } catch (\Throwable $e) { throw new AdminException($e->getMessage()); } finally { if ($temporaryWatermark !== null && is_file($temporaryWatermark) && !@unlink($temporaryWatermark)) { throw new AdminException('临时水印文件清理失败'); } } return $savePath; } /** * 文字水印 * @param string $filePath * @param array $waterConfig * @return string */ public function text(string $filePath, array $waterConfig = []) { if (!$waterConfig) { $waterConfig = $this->waterConfig; } if (!$waterConfig['watermark_text']) { throw new AdminException('请先配置水印文字'); } $savePath = $this->getFilePath($filePath); try { $angle = $waterConfig['watermark_text_angle'] ?? ''; $angle = filter_var($angle === '' ? 0 : $angle, FILTER_VALIDATE_INT); if ($angle === false || $angle < -360 || $angle > 360) throw new \InvalidArgumentException('水印旋转角度无效'); // 配置为顺时针正角度,GD 文字角度为逆时针;完整一周无需旋转。 $angle = -($angle % 360); if (!$this->validateLocalImage($savePath)) throw new \InvalidArgumentException((string)$this->getError()); $Image = Image::open($savePath); $color = ltrim((string)$waterConfig['watermark_text_color'], '#'); if (preg_match('/^[0-9a-f]{3}$/i', $color)) $color = $color[0] . $color[0] . $color[1] . $color[1] . $color[2] . $color[2]; if (!preg_match('/^[0-9a-f]{6}$/i', $color)) throw new \InvalidArgumentException('水印文字颜色格式错误'); $waterConfig['watermark_text_color'] = '#' . $color; $Image->text($waterConfig['watermark_text'], $waterConfig['watermark_text_font'], (float)$waterConfig['watermark_text_size'], $waterConfig['watermark_text_color'], $waterConfig['watermark_position'], [$waterConfig['watermark_x'], $waterConfig['watermark_y']], $angle)->save($savePath); } catch (\Throwable $e) { throw new AdminException($e->getMessage() . $e->getLine()); } return $savePath; } /** * 删除文件 * @param string $filePath * @return bool|mixed */ public function delete(string $filePath) { $this->beginLocalOperation(); try { $path = $this->getFilePath($filePath); if (!is_file($path)) return false; $root = $this->localDiskPath(''); $disk = str_starts_with($path, $root . '/') ? 'public' : 'pem'; $root = $this->localDiskPath('', $disk); $relative = substr($path, strlen($root) + 1); $directory = dirname($relative); $paths = []; // 删除前校验全部衍生路径,缺少缩略图不是错误。 foreach ($this->thumb as $size) { $paths[] = $this->localDiskPath(($directory !== '.' ? $directory . '/' : '') . $size . '_' . basename($path), $disk); } $paths[] = $path; foreach ($paths as $target) { if (is_file($target) && !@unlink($target)) return $this->setError('文件删除失败'); } return true; } catch (\Throwable $e) { return $this->setError($e->getMessage()); } } /** * 文件路径上传 * @param string $filePath * @param string $key * @return array|bool|mixed|\StdClass */ public function upload(string $filePath, string $key) { $this->beginLocalOperation(); if (!$this->validateLocalFile($key, $filePath)) return false; return $this->saveLocalFile($key, function ($target) use ($filePath) { return @copy($filePath, $target); }); } /** * 获取文件路径 * @param string $key * @return string */ public function getFilePath(string $key) { if ($key === '' || preg_match('/[\x00-\x1f\x7f]/', $key)) throw new \InvalidArgumentException('不合法的文件路径'); $key = str_replace('\\', '/', $key); foreach (['public', 'pem'] as $disk) { $root = $this->localDiskPath('', $disk); if ($key === $root || str_starts_with($key, $root . '/')) return $this->localDiskPath(ltrim(substr($key, strlen($root)), '/'), $disk); } $publicUrl = (string)Config::get('filesystem.disks.public.url', '/uploads'); $prefix = trim((string)parse_url($publicUrl, PHP_URL_PATH), '/'); if ($this->checkFilePathIsRemote($key)) { $url = parse_url(str_starts_with($key, '//') ? 'https:' . $key : $key); if (!$url || isset($url['user']) || isset($url['pass'])) throw new \InvalidArgumentException('不合法的本地文件地址'); $bases = array_merge($this->localUrlBases, [(string)sys_config('site_url'), $publicUrl]); foreach ($bases as $base) { $allowed = parse_url($base); if (!$allowed || !isset($allowed['host']) || strcasecmp($url['host'] ?? '', $allowed['host']) !== 0 || strtolower($url['scheme'] ?? '') !== strtolower($allowed['scheme'] ?? '') || ($url['port'] ?? ($url['scheme'] === 'https' ? 443 : 80)) !== ($allowed['port'] ?? (($allowed['scheme'] ?? '') === 'https' ? 443 : 80))) continue; $path = trim(rawurldecode($url['path'] ?? ''), '/'); $basePath = trim(rawurldecode($allowed['path'] ?? ''), '/'); if ($basePath !== '' && $path !== $basePath && !str_starts_with($path, $basePath . '/')) continue; $path = ltrim(substr($path, strlen($basePath)), '/'); if ($prefix !== '' && ($path === $prefix || str_starts_with($path, $prefix . '/'))) { $path = ltrim(substr($path, strlen($prefix)), '/'); } elseif ($basePath === '') { continue; } return $this->localDiskPath($path); } throw new \InvalidArgumentException('文件地址不属于本地存储'); } $path = preg_replace('~^\./~', '', $key); $plainPath = ltrim($path, '/'); if ($prefix !== '' && ($plainPath === $prefix || str_starts_with($plainPath, $prefix . '/'))) { return $this->localDiskPath(ltrim(substr($plainPath, strlen($prefix)), '/')); } if (str_starts_with($path, '/')) throw new \InvalidArgumentException('文件路径超出本地存储目录'); return $this->localDiskPath($path); } /** * 获取文件信息 * @return mixed */ public function getFileInfo() { return $this->fileInfo; } /** * 本地存储不提供云端存储桶列表 * @param string $region * @param bool $line * @param bool $shared * @return array */ public function listbuckets(string $region, bool $line = false, bool $shared = false) { return []; } /** * 本地存储不提供创建云端存储桶能力 * @param string $name * @param string $region * @return null */ public function createBucket(string $name, string $region) { return null; } /** * 本地存储不提供删除云端存储桶能力 * @param string $name * @return null */ public function deleteBucket(string $name) { return null; } /** * 本地存储无需设置云端跨域规则 * @param string $name * @param string $region * @return bool */ public function setBucketCors(string $name, string $region) { return true; } /** * 本地存储没有云端区域 * @return array */ public function getRegion() { return []; } /** * 本地存储无需绑定云端域名 * @param string $name * @param string $domain * @param string|null $region * @return bool */ public function bindDomian(string $name, string $domain, ?string $region = null) { return true; } /** * 本地存储没有云端域名列表 * @param string $name * @param string|null $region * @return array */ public function getDomian(string $name, ?string $region = null) { return []; } }