chore: 重写初始提交(清空历史,整理后全量提交)
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\forum\controller\backend;
|
||||
|
||||
use addon\forum\model\ForumBoard;
|
||||
use ywxapp\controller\BackendBase;
|
||||
|
||||
class Board extends BackendBase
|
||||
{
|
||||
protected $noNeedLogin = [];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
public function index()
|
||||
{
|
||||
$list = ForumBoard::order('sort asc,id asc')->select();
|
||||
$this->assign('list', $list);
|
||||
return $this->fetch('board/index');
|
||||
}
|
||||
|
||||
public function add()
|
||||
{
|
||||
return $this->fetch('board/add');
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$data = $this->request->post();
|
||||
$data['create_at'] = time();
|
||||
$data['update_at'] = time();
|
||||
ForumBoard::insert($data);
|
||||
return $this->success([], '保存成功');
|
||||
}
|
||||
|
||||
public function edit($id = 0)
|
||||
{
|
||||
$row = ForumBoard::find($id);
|
||||
$this->assign('row', $row);
|
||||
return $this->fetch('board/edit');
|
||||
}
|
||||
|
||||
public function update($id = 0)
|
||||
{
|
||||
$row = ForumBoard::find($id);
|
||||
if (!$row) {
|
||||
return $this->error('记录不存在');
|
||||
}
|
||||
$row->save($this->request->post());
|
||||
return $this->success([], '更新成功');
|
||||
}
|
||||
|
||||
public function delete($id = 0)
|
||||
{
|
||||
ForumBoard::where('id', $id)->delete();
|
||||
return $this->success([], '删除成功');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?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\forum\controller\backend;
|
||||
|
||||
use think\facade\Config;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\service\AddonService;
|
||||
|
||||
/**
|
||||
* 编辑器(UEditor)配置
|
||||
*
|
||||
* 在后台可视化管理发帖富文本编辑器的参数:
|
||||
* 上传目录、图片/附件/视频的允许类型与大小上限、远程图片抓取开关、初始高度。
|
||||
* 数据持久化到框架统一的插件配置表(wxapp_addon_config),运行时由
|
||||
* AddonService::config('forum') 读取并与 config.php 默认值合并。
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Editor extends BackendBase
|
||||
{
|
||||
/**
|
||||
* 可被后台保存的配置字段 => 默认值
|
||||
*/
|
||||
protected array $fields = [
|
||||
'editor_upload_dir' => 'uploads/forum',
|
||||
'editor_image_ext' => 'jpg,jpeg,png,gif,bmp,webp',
|
||||
'editor_image_size' => 10,
|
||||
'editor_file_ext' => 'zip,rar,7z,pdf,doc,docx,xls,xlsx,ppt,pptx,txt,md',
|
||||
'editor_file_size' => 50,
|
||||
'editor_video_ext' => 'mp4,webm,ogg,mov',
|
||||
'editor_video_size' => 100,
|
||||
'editor_catch_image' => 1,
|
||||
'editor_height' => 360,
|
||||
];
|
||||
|
||||
/**
|
||||
* 读取当前有效配置(config.php 默认 + 后台保存覆盖)
|
||||
*/
|
||||
protected function currentConfig(): array
|
||||
{
|
||||
$defaults = (array) Config::get('forum', []);
|
||||
$saved = AddonService::config('forum');
|
||||
// 用 $fields 的默认值兜底,确保所有后台字段都有值(模板无需 default 过滤器)
|
||||
return array_merge($this->fields, $defaults, $saved);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置页
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$cfg = $this->currentConfig();
|
||||
$this->assign('cfg', $cfg);
|
||||
return $this->fetch('editor/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
$post = (array) $this->request->post();
|
||||
|
||||
$values = [];
|
||||
foreach ($this->fields as $key => $default) {
|
||||
if (!isset($post[$key])) {
|
||||
continue;
|
||||
}
|
||||
$raw = $post[$key];
|
||||
|
||||
if (is_bool($default)) {
|
||||
$values[$key] = $raw ? 1 : 0;
|
||||
} elseif (is_int($default)) {
|
||||
$values[$key] = (int) $raw;
|
||||
} else {
|
||||
// 字符串 / 逗号分隔扩展名列表:去空格、过滤空项、小写
|
||||
$str = trim((string) $raw);
|
||||
if (str_contains($str, ',')) {
|
||||
$parts = array_filter(array_map(
|
||||
fn($s) => strtolower(trim($s, " .\t\n\r\0\x0B")),
|
||||
explode(',', $str)
|
||||
), fn($s) => $s !== '');
|
||||
$str = implode(',', $parts);
|
||||
}
|
||||
$values[$key] = $str;
|
||||
}
|
||||
}
|
||||
|
||||
// 上传目录安全校验:仅允许相对路径,禁止目录穿越
|
||||
if (isset($values['editor_upload_dir'])) {
|
||||
$dir = preg_replace('#\.+/#', '', str_replace('\\', '/', $values['editor_upload_dir']));
|
||||
$values['editor_upload_dir'] = trim($dir, '/') ?: $this->fields['editor_upload_dir'];
|
||||
}
|
||||
|
||||
AddonService::config('forum', $values);
|
||||
|
||||
return $this->result->success([], '编辑器配置已保存');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\forum\controller\backend;
|
||||
|
||||
use addon\forum\model\ForumReply;
|
||||
use ywxapp\controller\BackendBase;
|
||||
|
||||
class Reply extends BackendBase
|
||||
{
|
||||
protected $noNeedLogin = [];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
public function index()
|
||||
{
|
||||
// layui.table 动态加载
|
||||
if ($this->request->isAjax()) {
|
||||
$page = $this->request->param('page/d', 1);
|
||||
$limit = $this->request->param('limit/d', 20);
|
||||
|
||||
$map = [];
|
||||
// 主题 ID 过滤
|
||||
if ($topicId = $this->request->param('topic_id/d')) {
|
||||
$map[] = ['r.topic_id', '=', $topicId];
|
||||
}
|
||||
// 关键词搜索(内容模糊匹配)
|
||||
if ($keyword = $this->request->param('keyword/s')) {
|
||||
$map[] = ['r.content', 'like', '%' . $keyword . '%'];
|
||||
}
|
||||
|
||||
$rows = ForumReply::alias('r')
|
||||
->join('forum_topic t', 't.id = r.topic_id', 'left')
|
||||
->join('user u', 'u.uid = r.user_id', 'left')
|
||||
->where($map)
|
||||
->field([
|
||||
'r.*',
|
||||
't.title as topic_title',
|
||||
'u.nickname as user_name',
|
||||
])
|
||||
->order('r.create_at', 'desc')
|
||||
->paginate([
|
||||
'list_rows' => $limit,
|
||||
'page' => $page,
|
||||
]);
|
||||
|
||||
$list = $rows->items();
|
||||
$total = $rows->total();
|
||||
foreach ($list as &$item) {
|
||||
$item['content_preview'] = mb_substr(strip_tags($item['content'] ?? ''), 0, 40);
|
||||
}
|
||||
unset($item);
|
||||
|
||||
return $this->result->setCount($total)->success($list);
|
||||
}
|
||||
|
||||
$this->assign('title', '回复管理');
|
||||
return $this->fetch('reply/index');
|
||||
}
|
||||
|
||||
public function delete($id = 0)
|
||||
{
|
||||
$id = $id ?: $this->request->param('id/d');
|
||||
$row = ForumReply::find($id);
|
||||
if ($row) {
|
||||
$row->status = -1;
|
||||
$row->save();
|
||||
}
|
||||
return $this->success([], '删除成功');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\forum\controller\backend;
|
||||
|
||||
use addon\forum\model\ForumTag;
|
||||
use ywxapp\controller\BackendBase;
|
||||
|
||||
class Tag extends BackendBase
|
||||
{
|
||||
protected $noNeedLogin = [];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
public function index()
|
||||
{
|
||||
$list = ForumTag::order('id desc')->select();
|
||||
$this->assign('list', $list);
|
||||
return $this->fetch('tag/index');
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$name = trim((string) $this->request->post('name', ''));
|
||||
if ($name === '') {
|
||||
return $this->error('名称不能为空');
|
||||
}
|
||||
ForumTag::create(['name' => $name, 'create_at' => time()]);
|
||||
return $this->success([], '保存成功');
|
||||
}
|
||||
|
||||
public function delete($id = 0)
|
||||
{
|
||||
ForumTag::where('id', $id)->delete();
|
||||
return $this->success([], '删除成功');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\forum\controller\backend;
|
||||
|
||||
use addon\forum\model\ForumTopic;
|
||||
use addon\forum\model\ForumBoard;
|
||||
use ywxapp\controller\BackendBase;
|
||||
|
||||
class Topic extends BackendBase
|
||||
{
|
||||
protected $noNeedLogin = [];
|
||||
protected $noNeedVerify = ['*'];
|
||||
|
||||
public function index()
|
||||
{
|
||||
$status = $this->request->param('status', '');
|
||||
$q = ForumTopic::with(['board']);
|
||||
if ($status !== '' && in_array((int) $status, [-1, 0, 1], true)) {
|
||||
$q->where('status', (int) $status);
|
||||
}
|
||||
$list = $q->order('id desc')->paginate(20);
|
||||
$this->assign('list', $list);
|
||||
$this->assign('typeMap', ForumTopic::$typeMap);
|
||||
$this->assign('filterStatus', $status);
|
||||
return $this->fetch('topic/index');
|
||||
}
|
||||
|
||||
public function edit($id = 0)
|
||||
{
|
||||
$row = ForumTopic::find($id);
|
||||
$boards = ForumBoard::enabledList();
|
||||
$this->assign('row', $row);
|
||||
$this->assign('boards', $boards);
|
||||
$this->assign('typeMap', ForumTopic::$typeMap);
|
||||
return $this->fetch('topic/edit');
|
||||
}
|
||||
|
||||
public function update($id = 0)
|
||||
{
|
||||
$row = ForumTopic::find($id);
|
||||
if (!$row) {
|
||||
return $this->error('记录不存在');
|
||||
}
|
||||
$row->save($this->request->post());
|
||||
\addon\forum\service\ForumSearchService::sync($id);
|
||||
return $this->success([], '更新成功');
|
||||
}
|
||||
|
||||
public function delete($id = 0)
|
||||
{
|
||||
$row = ForumTopic::find($id);
|
||||
if ($row) {
|
||||
$row->status = -1;
|
||||
$row->save();
|
||||
\addon\forum\service\ForumSearchService::remove($id);
|
||||
}
|
||||
return $this->success([], '删除成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 置顶/加精/审核状态切换
|
||||
*/
|
||||
public function state($id = 0)
|
||||
{
|
||||
$field = $this->request->post('field', '');
|
||||
$value = (int) $this->request->post('value', 0);
|
||||
if (!in_array($field, ['is_top', 'is_essence', 'status'])) {
|
||||
return $this->error('非法字段');
|
||||
}
|
||||
ForumTopic::where('id', $id)->update([$field => $value]);
|
||||
return $this->success([], '操作成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核通过(人工复核):置为正常可见
|
||||
*/
|
||||
public function audit($id = 0)
|
||||
{
|
||||
$row = ForumTopic::find($id);
|
||||
if (!$row) {
|
||||
return $this->error('帖子不存在');
|
||||
}
|
||||
$row->status = 1;
|
||||
$row->audit_reason = '人工审核通过';
|
||||
$row->save();
|
||||
return $this->success([], '审核通过');
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核驳回:下架并填写理由
|
||||
*/
|
||||
public function reject($id = 0)
|
||||
{
|
||||
$row = ForumTopic::find($id);
|
||||
if (!$row) {
|
||||
return $this->error('帖子不存在');
|
||||
}
|
||||
$reason = trim((string) $this->request->post('reason', ''));
|
||||
$row->status = -1;
|
||||
$row->audit_reason = $reason === '' ? '人工审核驳回' : $reason;
|
||||
$row->save();
|
||||
return $this->success([], '已驳回');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
<?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\forum\controller\backend;
|
||||
|
||||
use think\facade\Config;
|
||||
use think\Response;
|
||||
use ywxapp\controller\BackendBase;
|
||||
use ywxapp\service\AddonService;
|
||||
|
||||
/**
|
||||
* UEditor 服务端统一入口(论坛发帖富文本)
|
||||
*
|
||||
* 按 action 参数分发:
|
||||
* config 返回编辑器配置
|
||||
* uploadimage 图片上传
|
||||
* uploadfile 附件上传
|
||||
* uploadvideo 视频上传
|
||||
* listimage 图片管理器列表
|
||||
* listfile 附件管理器列表
|
||||
* catchimage 远程图片抓取(粘贴 Word 时把外链图落地)
|
||||
*
|
||||
* 全部参数(上传目录、允许类型、大小上限、抓取开关)均由后台「编辑器配置」页
|
||||
* 持久化到插件配置表,运行期与 config.php 默认值合并后生效。
|
||||
*
|
||||
* @author ywxapp <admin@ywxapp.cn>
|
||||
*/
|
||||
class Ueditor extends BackendBase
|
||||
{
|
||||
/**
|
||||
* 合并后的有效配置
|
||||
*/
|
||||
protected array $cfg = [];
|
||||
|
||||
/**
|
||||
* 从 config.php 默认值 + 后台保存值(插件配置表)合并出当前配置
|
||||
*/
|
||||
protected function cfg(): array
|
||||
{
|
||||
if ($this->cfg) {
|
||||
return $this->cfg;
|
||||
}
|
||||
$defaults = (array) Config::get('forum', []);
|
||||
$saved = AddonService::config('forum');
|
||||
$this->cfg = array_merge($defaults, $saved);
|
||||
return $this->cfg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取逗号分隔扩展名列表为小写数组
|
||||
*/
|
||||
protected function extArray(string $key, array $fallback): array
|
||||
{
|
||||
$raw = $this->cfg()[$key] ?? '';
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
return $fallback;
|
||||
}
|
||||
return array_values(array_filter(array_map(
|
||||
fn($e) => strtolower(trim($e, " .\t\n\r\0\x0B")),
|
||||
explode(',', $raw)
|
||||
), fn($e) => $e !== ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* 取图片/附件/视频的允许扩展名
|
||||
*/
|
||||
protected function imageExt(): array
|
||||
{
|
||||
return $this->extArray('editor_image_ext', ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']);
|
||||
}
|
||||
|
||||
protected function fileExt(): array
|
||||
{
|
||||
return $this->extArray('editor_file_ext', ['zip', 'rar', '7z', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md']);
|
||||
}
|
||||
|
||||
protected function videoExt(): array
|
||||
{
|
||||
return $this->extArray('editor_video_ext', ['mp4', 'webm', 'ogg', 'mov']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取大小上限(MB -> 字节)
|
||||
*/
|
||||
protected function sizeBytes(string $key, int $mbFallback): int
|
||||
{
|
||||
$v = (int) ($this->cfg()[$key] ?? $mbFallback);
|
||||
return max(1, $v) * 1048576;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一入口
|
||||
*/
|
||||
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 回调
|
||||
*/
|
||||
protected function jsonp(array $data): Response
|
||||
{
|
||||
$callback = (string) $this->request->param('callback', '');
|
||||
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器服务端配置
|
||||
*/
|
||||
protected function editorConfig(): array
|
||||
{
|
||||
$prefix = '/' . trim($this->uploadDir(), '/') . '/';
|
||||
$imageExt = $this->imageExt();
|
||||
$fileExt = $this->fileExt();
|
||||
$videoExt = $this->videoExt();
|
||||
$imageSize = $this->sizeBytes('editor_image_size', 10);
|
||||
$fileSize = $this->sizeBytes('editor_file_size', 50);
|
||||
$videoSize = $this->sizeBytes('editor_video_size', 100);
|
||||
|
||||
return [
|
||||
'imageActionName' => 'uploadimage',
|
||||
'imageFieldName' => 'upfile',
|
||||
'imageMaxSize' => $imageSize,
|
||||
'imageAllowFiles' => array_map(fn($e) => '.' . $e, $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' => $imageSize,
|
||||
'scrawlUrlPrefix' => '',
|
||||
'scrawlInsertAlign' => 'none',
|
||||
|
||||
'catcherLocalDomain' => ['127.0.0.1', 'localhost'],
|
||||
'catcherActionName' => 'catchimage',
|
||||
'catcherFieldName' => 'source',
|
||||
'catcherPathFormat' => $prefix . 'image/{yyyy}{mm}{dd}/{time}{rand:6}',
|
||||
'catcherUrlPrefix' => '',
|
||||
'catcherMaxSize' => $imageSize,
|
||||
'catcherAllowFiles' => array_map(fn($e) => '.' . $e, $imageExt),
|
||||
|
||||
'videoActionName' => 'uploadvideo',
|
||||
'videoFieldName' => 'upfile',
|
||||
'videoPathFormat' => $prefix . 'video/{yyyy}{mm}{dd}/{time}{rand:6}',
|
||||
'videoUrlPrefix' => '',
|
||||
'videoMaxSize' => $videoSize,
|
||||
'videoAllowFiles' => array_map(fn($e) => '.' . $e, $videoExt),
|
||||
|
||||
'fileActionName' => 'uploadfile',
|
||||
'fileFieldName' => 'upfile',
|
||||
'filePathFormat' => $prefix . 'file/{yyyy}{mm}{dd}/{time}{rand:6}',
|
||||
'fileUrlPrefix' => '',
|
||||
'fileMaxSize' => $fileSize,
|
||||
'fileAllowFiles' => array_map(fn($e) => '.' . $e, $fileExt),
|
||||
|
||||
'imageManagerActionName' => 'listimage',
|
||||
'imageManagerListPath' => $prefix . 'image/',
|
||||
'imageManagerListSize' => 20,
|
||||
'imageManagerUrlPrefix' => '',
|
||||
'imageManagerInsertAlign' => 'none',
|
||||
'imageManagerAllowFiles' => array_map(fn($e) => '.' . $e, $imageExt),
|
||||
|
||||
'fileManagerActionName' => 'listfile',
|
||||
'fileManagerListPath' => $prefix . 'file/',
|
||||
'fileManagerUrlPrefix' => '',
|
||||
'fileManagerListSize' => 20,
|
||||
'fileManagerAllowFiles' => array_map(fn($e) => '.' . $e, $fileExt),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用上传处理
|
||||
*/
|
||||
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'
|
||||
? $this->sizeBytes('editor_video_size', 100)
|
||||
: ($group === 'file' ? $this->sizeBytes('editor_file_size', 50) : $this->sizeBytes('editor_image_size', 10));
|
||||
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 数据
|
||||
*/
|
||||
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->sizeBytes('editor_image_size', 10)) {
|
||||
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),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程图片抓取
|
||||
*/
|
||||
protected function catchImage(): array
|
||||
{
|
||||
if ((int) ($this->cfg()['editor_catch_image'] ?? 1) !== 1) {
|
||||
return ['state' => '远程图片抓取已关闭', 'list' => []];
|
||||
}
|
||||
|
||||
$field = 'source';
|
||||
$sources = $this->request->param($field, []);
|
||||
if (!is_array($sources)) {
|
||||
$sources = [$sources];
|
||||
}
|
||||
|
||||
if (empty($sources)) {
|
||||
return ['state' => '未接收到图片地址'];
|
||||
}
|
||||
|
||||
$imageExt = $this->imageExt();
|
||||
$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;
|
||||
}
|
||||
|
||||
$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), $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];
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片 / 附件管理器列表
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载远程内容
|
||||
*/
|
||||
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-Forum/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->sizeBytes('editor_image_size', 10)) {
|
||||
return null;
|
||||
}
|
||||
return (string) $body;
|
||||
}
|
||||
|
||||
$ctx = stream_context_create(['http' => ['timeout' => 15, 'follow_location' => 0]]);
|
||||
$body = @file_get_contents($url, false, $ctx, 0, $this->sizeBytes('editor_image_size', 10) + 1);
|
||||
|
||||
if ($body === false || strlen($body) > $this->sizeBytes('editor_image_size', 10)) {
|
||||
return null;
|
||||
}
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取上传目录配置(后台可配,安全校验防穿越)
|
||||
*/
|
||||
protected function uploadDir(): string
|
||||
{
|
||||
$dir = (string) ($this->cfg()['editor_upload_dir'] ?? 'uploads/forum');
|
||||
$dir = str_replace('\\', '/', $dir);
|
||||
$dir = preg_replace('#\.+/#', '', $dir) ?? 'uploads/forum';
|
||||
return trim($dir, '/') ?: 'uploads/forum';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user