// +---------------------------------------------------------------------- declare (strict_types = 1); namespace addon\docs\controller\backend; use think\Response; use ywxapp\controller\BackendBase; /** * UEditor 服务端统一入口 * * 按 action 参数分发: * config 返回编辑器配置 * uploadimage 图片上传 * uploadfile 附件上传 * uploadvideo 视频上传 * listimage 图片管理器列表 * listfile 附件管理器列表 * catchimage 远程图片抓取(粘贴 Word 时把外链图落地) * * @author ywxapp */ class Ueditor extends BackendBase { /** * 图片允许的扩展名 */ protected $imageExt = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']; /** * 附件允许的扩展名 */ protected $fileExt = ['zip', 'rar', '7z', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md']; /** * 视频允许的扩展名 */ protected $videoExt = ['mp4', 'webm', 'ogg', 'mov']; /** * 单文件大小上限(字节) */ protected $maxSize = 10485760; /** * 统一入口 */ public function index() { $action = (string) $this->request->param('action', ''); switch ($action) { case 'config': return $this->jsonp($this->editorConfig()); case 'uploadimage': return $this->jsonp($this->upload('upfile', $this->imageExt, 'image')); case 'uploadfile': return $this->jsonp($this->upload('upfile', $this->fileExt, 'file')); case 'uploadvideo': return $this->jsonp($this->upload('upfile', $this->videoExt, 'video')); case 'uploadscrawl': return $this->jsonp($this->uploadScrawl()); case 'listimage': return $this->jsonp($this->listFiles('image', $this->imageExt)); case 'listfile': return $this->jsonp($this->listFiles('file', $this->fileExt)); case 'catchimage': return $this->jsonp($this->catchImage()); default: return $this->jsonp(['state' => '请求地址出错']); } } /** * 输出 JSON,兼容 UEditor 的 jsonp 回调 * * @param array $data 响应数据 * @return Response */ protected function jsonp(array $data): Response { $callback = (string) $this->request->param('callback', ''); $json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); // 回调名必须是合法标识符,防止 XSS 注入 if ($callback !== '' && preg_match('/^[A-Za-z_][A-Za-z0-9_\.]*$/', $callback)) { return Response::create($callback . '(' . $json . ')', 'html') ->contentType('application/javascript'); } return Response::create($json, 'html')->contentType('application/json'); } /** * 编辑器服务端配置 * * @return array */ protected function editorConfig(): array { $prefix = '/' . trim($this->uploadDir(), '/') . '/'; return [ 'imageActionName' => 'uploadimage', 'imageFieldName' => 'upfile', 'imageMaxSize' => $this->maxSize, 'imageAllowFiles' => array_map(fn($e) => '.' . $e, $this->imageExt), 'imageCompressEnable' => true, 'imageCompressBorder' => 1600, 'imageInsertAlign' => 'none', 'imageUrlPrefix' => '', 'imagePathFormat' => $prefix . 'image/{yyyy}{mm}{dd}/{time}{rand:6}', 'scrawlActionName' => 'uploadscrawl', 'scrawlFieldName' => 'upfile', 'scrawlPathFormat' => $prefix . 'image/{yyyy}{mm}{dd}/{time}{rand:6}', 'scrawlMaxSize' => $this->maxSize, 'scrawlUrlPrefix' => '', 'scrawlInsertAlign' => 'none', 'catcherLocalDomain' => ['127.0.0.1', 'localhost'], 'catcherActionName' => 'catchimage', 'catcherFieldName' => 'source', 'catcherPathFormat' => $prefix . 'image/{yyyy}{mm}{dd}/{time}{rand:6}', 'catcherUrlPrefix' => '', 'catcherMaxSize' => $this->maxSize, 'catcherAllowFiles' => array_map(fn($e) => '.' . $e, $this->imageExt), 'videoActionName' => 'uploadvideo', 'videoFieldName' => 'upfile', 'videoPathFormat' => $prefix . 'video/{yyyy}{mm}{dd}/{time}{rand:6}', 'videoUrlPrefix' => '', 'videoMaxSize' => 102400000, 'videoAllowFiles' => array_map(fn($e) => '.' . $e, $this->videoExt), 'fileActionName' => 'uploadfile', 'fileFieldName' => 'upfile', 'filePathFormat' => $prefix . 'file/{yyyy}{mm}{dd}/{time}{rand:6}', 'fileUrlPrefix' => '', 'fileMaxSize' => 51200000, 'fileAllowFiles' => array_map(fn($e) => '.' . $e, $this->fileExt), 'imageManagerActionName' => 'listimage', 'imageManagerListPath' => $prefix . 'image/', 'imageManagerListSize' => 20, 'imageManagerUrlPrefix' => '', 'imageManagerInsertAlign' => 'none', 'imageManagerAllowFiles' => array_map(fn($e) => '.' . $e, $this->imageExt), 'fileManagerActionName' => 'listfile', 'fileManagerListPath' => $prefix . 'file/', 'fileManagerUrlPrefix' => '', 'fileManagerListSize' => 20, 'fileManagerAllowFiles' => array_map(fn($e) => '.' . $e, $this->fileExt), ]; } /** * 通用上传处理 * * @param string $field 表单字段名 * @param array $allowExt 允许的扩展名 * @param string $group 分组目录:image / file / video * @return array */ protected function upload(string $field, array $allowExt, string $group): array { $file = $this->request->file($field); if (!$file) { return ['state' => '未找到上传文件']; } $ext = strtolower($file->getOriginalExtension()); if (!in_array($ext, $allowExt, true)) { return ['state' => '不允许的文件类型:' . $ext]; } $limit = $group === 'video' ? 102400000 : $this->maxSize; if ($file->getSize() > $limit) { return ['state' => '文件大小超出限制']; } // 图片二次校验,防止改扩展名上传脚本 if ($group === 'image') { $info = @getimagesize($file->getRealPath()); if ($info === false) { return ['state' => '文件不是有效的图片']; } } $relativeDir = trim($this->uploadDir(), '/') . '/' . $group . '/' . date('Ymd'); $targetDir = public_path() . str_replace('/', DIRECTORY_SEPARATOR, $relativeDir); if (!is_dir($targetDir) && !@mkdir($targetDir, 0755, true) && !is_dir($targetDir)) { return ['state' => '上传目录创建失败,请检查权限']; } $original = $file->getOriginalName(); $saveName = date('His') . substr(md5(uniqid('', true)), 0, 10) . '.' . $ext; try { $file->move($targetDir, $saveName); } catch (\Throwable $e) { return ['state' => '文件保存失败:' . $e->getMessage()]; } $url = '/' . $relativeDir . '/' . $saveName; return [ 'state' => 'SUCCESS', 'url' => $url, 'title' => $original, 'original' => $original, 'type' => '.' . $ext, 'size' => (string) filesize($targetDir . DIRECTORY_SEPARATOR . $saveName), ]; } /** * 涂鸦上传:接收 base64 数据 * * @return array */ protected function uploadScrawl(): array { $base64 = (string) $this->request->post('upfile', ''); if ($base64 === '') { return ['state' => '未接收到涂鸦数据']; } $binary = base64_decode($base64, true); if ($binary === false || strlen($binary) > $this->maxSize) { return ['state' => '涂鸦数据无效或过大']; } $relativeDir = trim($this->uploadDir(), '/') . '/image/' . date('Ymd'); $targetDir = public_path() . str_replace('/', DIRECTORY_SEPARATOR, $relativeDir); if (!is_dir($targetDir) && !@mkdir($targetDir, 0755, true) && !is_dir($targetDir)) { return ['state' => '上传目录创建失败,请检查权限']; } $saveName = date('His') . substr(md5(uniqid('', true)), 0, 10) . '.png'; if (@file_put_contents($targetDir . DIRECTORY_SEPARATOR . $saveName, $binary) === false) { return ['state' => '涂鸦保存失败']; } return [ 'state' => 'SUCCESS', 'url' => '/' . $relativeDir . '/' . $saveName, 'title' => $saveName, 'original' => $saveName, 'type' => '.png', 'size' => (string) strlen($binary), ]; } /** * 远程图片抓取 * * 粘贴 Word / 网页内容时,UEditor 会把外链图片提交到这里落地保存。 * * @return array */ protected function catchImage(): array { $field = 'source'; $sources = $this->request->param($field, []); if (!is_array($sources)) { $sources = [$sources]; } if (empty($sources)) { return ['state' => '未接收到图片地址']; } $relativeDir = trim($this->uploadDir(), '/') . '/image/' . date('Ymd'); $targetDir = public_path() . str_replace('/', DIRECTORY_SEPARATOR, $relativeDir); if (!is_dir($targetDir) && !@mkdir($targetDir, 0755, true) && !is_dir($targetDir)) { return ['state' => '上传目录创建失败,请检查权限']; } $list = []; foreach ($sources as $remote) { $remote = (string) $remote; $item = ['state' => '抓取失败', 'source' => $remote, 'url' => '']; if (!$this->isSafeRemoteUrl($remote)) { $item['state'] = '非法的图片地址'; $list[] = $item; continue; } $binary = $this->fetchRemote($remote); if ($binary === null) { $list[] = $item; continue; } // 用图片指纹判定真实类型,忽略 URL 上的扩展名 $info = @getimagesizefromstring($binary); if ($info === false) { $item['state'] = '远程文件不是有效图片'; $list[] = $item; continue; } $ext = image_type_to_extension($info[2], false); if (!in_array(strtolower((string) $ext), $this->imageExt, true)) { $item['state'] = '不允许的图片类型'; $list[] = $item; continue; } $saveName = date('His') . substr(md5(uniqid('', true)), 0, 10) . '.' . $ext; if (@file_put_contents($targetDir . DIRECTORY_SEPARATOR . $saveName, $binary) === false) { $item['state'] = '图片保存失败'; $list[] = $item; continue; } $list[] = [ 'state' => 'SUCCESS', 'url' => '/' . $relativeDir . '/' . $saveName, 'size' => (string) strlen($binary), 'title' => $saveName, 'original' => basename(parse_url($remote, PHP_URL_PATH) ?: $saveName), 'source' => $remote, ]; } return ['state' => 'SUCCESS', 'list' => $list]; } /** * 图片 / 附件管理器列表 * * @param string $group 分组目录 * @param array $allowExt 允许的扩展名 * @return array */ protected function listFiles(string $group, array $allowExt): array { $start = (int) $this->request->param('start', 0); $size = (int) $this->request->param('size', 20); $size = $size > 0 && $size <= 100 ? $size : 20; $relativeRoot = trim($this->uploadDir(), '/') . '/' . $group; $rootDir = public_path() . str_replace('/', DIRECTORY_SEPARATOR, $relativeRoot); if (!is_dir($rootDir)) { return ['state' => 'SUCCESS', 'list' => [], 'start' => $start, 'total' => 0]; } $files = []; $iter = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($rootDir, \FilesystemIterator::SKIP_DOTS) ); foreach ($iter as $fileInfo) { if (!$fileInfo->isFile()) { continue; } if (!in_array(strtolower($fileInfo->getExtension()), $allowExt, true)) { continue; } $relative = str_replace('\\', '/', substr($fileInfo->getPathname(), strlen(public_path()))); $files[] = [ 'url' => '/' . ltrim($relative, '/'), 'mtime' => $fileInfo->getMTime(), ]; } // 新上传的排前面,符合使用直觉 usort($files, fn($a, $b) => $b['mtime'] <=> $a['mtime']); $total = count($files); $page = array_slice($files, $start, $size); return ['state' => 'SUCCESS', 'list' => $page, 'start' => $start, 'total' => $total]; } /** * 校验远程地址是否安全,阻断 SSRF * * @param string $url 远程地址 * @return bool */ protected function isSafeRemoteUrl(string $url): bool { $parts = parse_url($url); if (!$parts || empty($parts['scheme']) || empty($parts['host'])) { return false; } if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) { return false; } $host = $parts['host']; $ip = filter_var($host, FILTER_VALIDATE_IP) ? $host : gethostbyname($host); if (filter_var($ip, FILTER_VALIDATE_IP) === false) { return false; } // 拒绝内网与保留地址,防止服务端被用作内网探测跳板 $public = filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); return $public !== false; } /** * 下载远程内容 * * @param string $url 远程地址 * @return string|null */ protected function fetchRemote(string $url): ?string { if (function_exists('curl_init')) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 15, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_USERAGENT => 'YwxApp-Docs/1.0', CURLOPT_SSL_VERIFYPEER => true, ]); $body = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($body === false || $code !== 200 || strlen((string) $body) > $this->maxSize) { return null; } return (string) $body; } $ctx = stream_context_create(['http' => ['timeout' => 15, 'follow_location' => 0]]); $body = @file_get_contents($url, false, $ctx, 0, $this->maxSize + 1); if ($body === false || strlen($body) > $this->maxSize) { return null; } return $body; } /** * 读取上传目录配置 * * @return string */ protected function uploadDir(): string { $dir = 'uploads/docs'; try { $config = get_addon_config('docs'); if (is_array($config) && !empty($config['upload_dir'])) { $dir = (string) $config['upload_dir']; } } catch (\Throwable $e) { // 配置异常时使用默认目录 } // 只允许相对路径,杜绝目录穿越 $dir = str_replace('\\', '/', $dir); $dir = preg_replace('#\.\.+/#', '', $dir) ?? 'uploads/docs'; return trim($dir, '/') ?: 'uploads/docs'; } }