[attachment_upload]附件上传
- 附件上传ID attachment_upload版本 1.3.1插件制作者 bbs1免费914 行 / 74.6 KBHook / 路由 / 后台页支持在主题和回帖中上传图片与文件,可按后缀限制上传类型,并可将附件转发保存到自建的远程接收脚本;远程附件同样登记归属并按文件大小计入用户组空间。app/plugins/attachment_upload/plugin.php插件预览1 张开发日志已有 9 条
个人独立维护项目。
主楼点赞与点评1lecoy投币2026-09-12
插件质量报告
插件:
attachment_uploadHook 功能范围 循环 使用频率 文件读写 修改系统表 读写系统表 读写自己的表 reply.after_save 回帖保存后 否 3 - - - - topic.after_save 主题保存后 否 3 - - - - topic.before_save 主题发布/编辑表单提交 回调内(文件批处理) 3 读写 app_attachments app_attachments, app_users - attachment.uploader 主题/回帖编辑表单附件控件 否 2 - - - - reply.form_extra 回帖编辑表单 否 2 - - - - request.csrf_exempt 请求 CSRF 豁免检查 否 2 - - - - 颜色说明:黄色表示有数据读写,红色表示 Hook 在系统循环中执行,浅蓝色表示有文件写入。“列表跳过”表示回调在列表路径会提前返回,“回调内”表示操作位于回调自身或其帮助函数的循环体内。“文件批处理”表示逐个处理用户一次提交的文件,属于必要操作,不标记修正。只有操作实际处于需要优化的循环路径时,才会标记“修正❗️”。
数据字典
未检测到插件数据表声明。
这个附件要是能设置允许的格式就好
版本 1.0.12 更新:
增加文件后缀白名单设置。多个后缀以逗号分隔,留空不限制;前端选择器过滤且服务端强制校验增加一个用户组限制呗 限定用户组可使用
这个问题修复一下:fileinfo要是安装不了,粘贴图片一直有问题,加下面的代码,兼容代码
// 服务器没装 fileinfo 扩展时(上面两个函数都由它提供),用 getimagesize 兜底。
$info = @getimagesize($path);🔍 插件审查报告(对照《AI 开发规则》与开发者文档)
🔒 安全问题
- 【高】SVG 文件以
.svg后缀落盘并被 Web 直出,存在存储型 XSS 风险。plugin.php:127 白名单含svg,plugin.php:138 的return $ext . (attachment_upload_allowed_ext($ext) ? '' : '1');使 svg 保留原后缀,最终存为app/upload/<hash前2位>/<hash>.svg;而图片走 plugin.php:63-65 的asset_url('app/upload/…')直出(plugin.php:224),说明 upload 目录可被浏览器直接访问。攻击者上传内嵌<script>的 SVG 即可在站内同源执行脚本。建议:svg 不保留原后缀(强制.svg1)或移出白名单,附件统一走 download 路由并加X-Content-Type-Options: nosniff与 attachment 处置。
- 【中】plugin.php:307-310 在
ajax_request()时豁免attachment_upload上传接口的 CSRF 校验,只依赖可伪造的X-Requested-With头;前端 plugin.php:374 本已携带_csrf却未校验。建议移除豁免,走核心默认 CSRF 校验。
⚡ 性能问题
未发现明显问题。
🐛 功能/规范缺陷
- plugin.php:190 自定义 Hook
attachment.before_upload未加插件前缀,易与核心或其他插件冲突,建议改为attachment_upload.before_upload。 - plugin.php:303 Cookie 名
__attachment_upload_history_clear未加插件前缀,建议改为bbs1_attachment_upload_history_clear。
✅ 修复优先级
- 修 SVG 后缀直出(高);2. 撤销 CSRF 豁免(中);3. 补 Hook 与 Cookie 前缀(低)。
- 【高】SVG 文件以
“app/upload”里如果有“孤儿文件”是怎么处理的?
不是这个,就是用户在发消息并上传了附件,后来用户删除了该消息,然后附件里的文件好像没有被删除,而且也没有被引用过从而成为了“孤儿”,这样的文件还躺在“app/upload”我觉得有点浪费,好像没有个安全接口管理它,还有问了ai说附件文件也不太建议保存在数据库里
🔍 插件审查报告(AI 自动审查,对照《AI 开发规则》与开发者文档;本插件未被 8/18 楼上 350 的报告覆盖,属独立首次审查)
插件:attachment_upload v1.0.12
🔒 安全问题
未发现明显问题。上传文件按内容检测 MIME 类型(非仅信任后缀),非图片类型统一中性化扩展名存盘;下载走独立路由并设置 nosniff,未见路径穿越。⚡ 性能问题
未发现明显问题。🐛 功能/规范缺陷
私有 Hook 未加插件前缀:本插件在上传流程 attachment_upload_markdown()(plugin.php 约190行)内自行定义并调用了扩展点 attachment.before_upload,供其它插件(如 user_review)在上传前做权限拦截,但该私有 Hook 未按规则加上自身插件 ID 前缀。规则要求私有 Hook 以插件 ID 开头,此处属于该情形。(附带说明:8/18 楼上 350 的报告曾在审查 user_review 插件时,误将 attachment.before_upload 判定为"核心不存在的无效 Hook",已在 user_review 主题帖的复核回帖中更正——该 Hook 实际由本插件定义,两插件同时启用时功能有效。)
✅ 修复优先级
低优先级规范项:建议将 attachment.before_upload 更名为带前缀的私有 Hook 名称(如 attachment_upload.before_upload),并同步更新监听方(如 user_review)的注册。版本 1.0.13 更新:
环境检测(图片直接显示模式)版本 1.0.13 更新:
增加预览希望增加权限管理、希望增加webp转换(节省服务器压力)
版本 1.0.13 更新:
优化细节版本 1.0.14 更新:
提升版本号版本 1.0.15 更新:
增加五级信任等级体系权限设置支持版本 1.1.4 更新:
优化按钮位置版本 1.1.7 更新:
支持粘贴图片上传版本 1.2.9 更新:
支持远程远程接收脚本 receiver.php 代码,请自行保存并部署:
<?php declare(strict_types=1); const RECEIVER_TOKEN = ''; const RECEIVER_DIR = 'uploads'; const RECEIVER_URL_BASE = ''; const RECEIVER_MAX_MB = 20; const RECEIVER_ALLOWED_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'bmp', 'ico', 'mp3', 'wav', 'flac', 'aac', 'm4a', 'ogg', 'oga', 'opus', 'mp4', 'm4v', 'mov', 'webm', 'ogv', 'mpeg', 'mpg', 'pdf', 'zip', 'rar', '7z', 'txt', 'md', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']; const RECEIVER_IMAGE_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp']; if (PHP_SAPI === 'cli') { $command = isset($argv[1]) ? (string)$argv[1] : ''; if ($command === '--token') { $token = bin2hex(random_bytes(32)); $self = (string)realpath(__FILE__); $source = $self !== '' ? @file_get_contents($self) : false; if (is_string($source) && $source !== '') { $written = preg_replace( "/^const RECEIVER_TOKEN = '[^']*';/m", "const RECEIVER_TOKEN = '" . $token . "';", $source, 1, $count ); if (is_string($written) && $count === 1 && @file_put_contents($self, $written, LOCK_EX) !== false) { echo $token, PHP_EOL; echo '已写入 ' . basename($self) . ' 的 RECEIVER_TOKEN,无需手动编辑。', PHP_EOL; exit(0); } } echo $token, PHP_EOL; fwrite(STDERR, '令牌已生成,但无法自动写入当前文件(可能不可写)。请手动把它填入 RECEIVER_TOKEN。' . PHP_EOL); exit(0); } if ($command === '--set-token') { $token = isset($argv[2]) ? trim((string)$argv[2]) : ''; if (preg_match('/^[A-Za-z0-9_\-]{16,256}$/', $token) !== 1) { fwrite(STDERR, '令牌需为 16-256 位字母、数字、下划线或短横线。' . PHP_EOL); exit(1); } $self = (string)realpath(__FILE__); $source = $self !== '' ? @file_get_contents($self) : false; if (!is_string($source) || $source === '') { fwrite(STDERR, '无法读取当前文件。' . PHP_EOL); exit(1); } $written = preg_replace( "/^const RECEIVER_TOKEN = '[^']*';/m", "const RECEIVER_TOKEN = '" . $token . "';", $source, 1, $count ); if (!is_string($written) || $count !== 1 || @file_put_contents($self, $written, LOCK_EX) === false) { fwrite(STDERR, '写入失败,请确认当前文件可写。' . PHP_EOL); exit(1); } echo '已更新 RECEIVER_TOKEN。', PHP_EOL; exit(0); } fwrite(STDERR, '用法:' . PHP_EOL . ' php receiver.php --token 生成随机令牌并自动写入本文件' . PHP_EOL . ' php receiver.php --set-token <令牌> 使用指定令牌(便于多台论坛共用同一个令牌)' . PHP_EOL); exit(1); } header('Content-Type: application/json; charset=utf-8'); header('X-Content-Type-Options: nosniff'); header('Cache-Control: no-store'); function receiver_out(int $status, array $payload): void { http_response_code($status); echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } function receiver_fail(string $message, int $status = 400): void { receiver_out($status, ['ok' => 0, 'message' => $message]); } function receiver_header(string $name): string { $key = 'HTTP_' . strtoupper(str_replace('-', '_', $name)); return isset($_SERVER[$key]) ? trim((string)$_SERVER[$key]) : ''; } function receiver_storage_dir(): string { return __DIR__ . '/' . trim(RECEIVER_DIR, '/'); } function receiver_url_base(): string { if (RECEIVER_URL_BASE !== '') return rtrim(RECEIVER_URL_BASE, '/'); $https = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off') || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower((string)$_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https'); $host = isset($_SERVER['HTTP_HOST']) ? trim((string)$_SERVER['HTTP_HOST']) : ''; if ($host === '' || preg_match('/^[A-Za-z0-9.\-]+(?::\d{1,5})?$/', $host) !== 1) return ''; $script = isset($_SERVER['SCRIPT_NAME']) ? str_replace('\\', '/', (string)$_SERVER['SCRIPT_NAME']) : '/receiver.php'; $base = rtrim(str_replace('\\', '/', dirname($script)), '/'); return ($https ? 'https' : 'http') . '://' . $host . $base . '/' . trim(RECEIVER_DIR, '/'); } if (strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? '')) !== 'POST') { receiver_fail('只接受 POST 请求', 405); } if (empty($_POST) && empty($_FILES) && (int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) { receiver_fail('上传体积超过服务器 post_max_size 限制(当前 ' . (string)ini_get('post_max_size') . ')', 413); } if (RECEIVER_TOKEN === '') { receiver_fail('接收脚本尚未配置令牌:请在本机运行 php receiver.php --token 生成一串令牌,填入 RECEIVER_TOKEN,并把同一串令牌填到论坛后台的「接收令牌」', 500); } $token = receiver_header('X-Upload-Token'); if ($token === '' && isset($_POST['token'])) $token = trim((string)$_POST['token']); if ($token === '' || !hash_equals(RECEIVER_TOKEN, $token)) { receiver_fail('令牌校验失败', 403); } if (isset($_POST['ping'])) { receiver_out(200, ['ok' => 1, 'pong' => 1]); } if (!isset($_FILES['file']) || !is_array($_FILES['file'])) { receiver_fail('缺少上传文件'); } $file = $_FILES['file']; $error = (int)($file['error'] ?? UPLOAD_ERR_NO_FILE); if ($error !== UPLOAD_ERR_OK) { receiver_fail($error === UPLOAD_ERR_NO_FILE ? '没有收到文件' : '上传失败(错误码 ' . $error . ')'); } $tmp = (string)($file['tmp_name'] ?? ''); if ($tmp === '' || !is_uploaded_file($tmp)) { receiver_fail('上传失败'); } $size = (int)($file['size'] ?? 0); if ($size <= 0) receiver_fail('文件为空'); if (RECEIVER_MAX_MB > 0 && $size > RECEIVER_MAX_MB * 1048576) { receiver_fail('文件超过 ' . RECEIVER_MAX_MB . 'MB 上限'); } $original = trim((string)preg_replace('/[\r\n]+/', ' ', basename((string)($file['name'] ?? '')))); if ($original === '') $original = 'file'; $ext = strtolower((string)pathinfo($original, PATHINFO_EXTENSION)); if ($ext === '' || !in_array($ext, RECEIVER_ALLOWED_EXT, true)) { receiver_fail('不支持的文件后缀' . ($ext !== '' ? ':' . $ext : '')); } $is_image = in_array($ext, RECEIVER_IMAGE_EXT, true); if ($is_image) { $info = @getimagesize($tmp); if (!is_array($info) || (int)($info[0] ?? 0) <= 0 || (int)($info[1] ?? 0) <= 0) { receiver_fail('图片内容与后缀不符'); } } $hash = hash_file('sha256', $tmp); if (!is_string($hash) || strlen($hash) !== 64) { receiver_fail('读取文件失败', 500); } $base_dir = receiver_storage_dir(); $dir = $base_dir . '/' . substr($hash, 0, 2); if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { $hint = is_dir($base_dir) ? '存储目录不可写:' . $base_dir : '无法创建存储目录:' . $base_dir . '(需保证其上级目录 ' . dirname($base_dir) . ' 对 PHP 可写)'; receiver_fail($hint, 500); } $name = $hash . '.' . $ext; $path = $dir . '/' . $name; if (!is_file($path) && !@move_uploaded_file($tmp, $path)) { receiver_fail('保存文件失败', 500); } @chmod($path, 0644); $url_base = receiver_url_base(); if ($url_base === '') { receiver_fail('无法自动判断公开访问地址,请在接收脚本里设置 RECEIVER_URL_BASE', 500); } $path = substr($hash, 0, 2) . '/' . rawurlencode($name); receiver_out(200, [ 'ok' => 1, 'url' => $url_base . '/' . $path, 'path' => $path, 'name' => $original, 'hash' => $hash, 'size' => $size, 'image' => $is_image ? 1 : 0, ]);最后由 bbs1 编辑于 2026-10-12 15:31版本 1.3.1 更新:
远程附件同样登记归属并按文件大小计入用户组空间
