484 lines
17 KiB
PHP
484 lines
17 KiB
PHP
<?php
|
|
// +----------------------------------------------------------------------
|
|
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
|
// +----------------------------------------------------------------------
|
|
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
|
// +----------------------------------------------------------------------
|
|
// | Author: ywxapp<admin@ywxapp.cn>
|
|
// +----------------------------------------------------------------------
|
|
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 <admin@ywxapp.cn>
|
|
*/
|
|
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';
|
|
}
|
|
}
|