• bbs1
    附件上传
    ID attachment_upload版本 1.3.1插件制作者 bbs1免费914 行 / 74.6 KBHook / 路由 / 后台页
    支持在主题和回帖中上传图片与文件,可按后缀限制上传类型,并可将附件转发保存到自建的远程接收脚本;远程附件同样登记归属并按文件大小计入用户组空间。
    app/plugins/attachment_upload/plugin.php
    开发日志已有 9 条

    个人独立维护项目。

    • bbs1
      bbs1
      版本 1.3.1 1bc7ee349ab8 更新:远程附件同样登记归属并按文件大小计入用户组空间release
    • bbs1
      bbs1
      版本 1.2.9 1578dbd9163d 更新:支持远程
    • bbs1org
      bbs1org
      版本 1.1.7 67418ebabd50 更新:支持粘贴图片上传
    • bbs1org
      bbs1org
      版本 1.1.4 963114dd73fa 更新:优化按钮位置
    • bbs1org
      bbs1org
      版本 1.0.15 29df7bd829f6 更新:增加五级信任等级体系权限设置支持
    • bbs1org
      bbs1org
      版本 1.0.14 ebdd5656fd4c 更新:提升版本号
    • bbs1org
      bbs1org
      版本 1.0.13 5e6b7583e5f9 更新:优化细节
    • bbs1org
      bbs1org
      版本 1.0.13 56c20712b850 更新:增加预览
    • bbs1org
      bbs1org
      版本 1.0.12 63f4c707a201 更新:增加文件后缀白名单设置。多个后缀以逗号分隔,留空不限制;前端选择器过滤且服务端强制校验
    主楼
  • 质量报告

    插件质量报告

    插件:attachment_upload

    Hook功能范围循环使用频率文件读写修改系统表读写系统表读写自己的表
    reply.after_save回帖保存后否3----
    topic.after_save主题保存后否3----
    topic.before_save主题发布/编辑表单提交回调内(文件批处理)3读写app_attachmentsapp_attachments, app_users-
    attachment.uploader主题/回帖编辑表单附件控件否2----
    reply.form_extra回帖编辑表单否2----
    request.csrf_exempt请求 CSRF 豁免检查否2----

    颜色说明:黄色表示有数据读写,红色表示 Hook 在系统循环中执行,浅蓝色表示有文件写入。“列表跳过”表示回调在列表路径会提前返回,“回调内”表示操作位于回调自身或其帮助函数的循环体内。“文件批处理”表示逐个处理用户一次提交的文件,属于必要操作,不标记修正。只有操作实际处于需要优化的循环路径时,才会标记“修正❗️”。

    数据字典

    未检测到插件数据表声明。

    #1
  • 等风来

    这个附件要是能设置允许的格式就好

    #2
  • bbs1

    版本 1.0.12 更新:
    增加文件后缀白名单设置。多个后缀以逗号分隔,留空不限制;前端选择器过滤且服务端强制校验

    #3
  • 秦始黄

    增加一个用户组限制呗 限定用户组可使用

    #4
  • bbs1

    可以

    #5
  • nodebuy

    这个问题修复一下:fileinfo要是安装不了,粘贴图片一直有问题,加下面的代码,兼容代码
    // 服务器没装 fileinfo 扩展时(上面两个函数都由它提供),用 getimagesize 兜底。
    $info = @getimagesize($path);

    #6
  • 350

    🔍 插件审查报告(对照《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。

    ✅ 修复优先级

    1. 修 SVG 后缀直出(高);2. 撤销 CSRF 豁免(中);3. 补 Hook 与 Cookie 前缀(低)。
    #7
  • bbc

    “app/upload”里如果有“孤儿文件”是怎么处理的?

    #8
  • bbs1

    通过占用空间大小设置来阻止用户垃圾文件的上传。

    #9
  • bbc

    不是这个,就是用户在发消息并上传了附件,后来用户删除了该消息,然后附件里的文件好像没有被删除,而且也没有被引用过从而成为了“孤儿”,这样的文件还躺在“app/upload”我觉得有点浪费,好像没有个安全接口管理它,还有问了ai说附件文件也不太建议保存在数据库里

    #10
  • bbs1

    目前是有这个问题。默认存储是廉价的。附件存储在本地文件的

    #11
  • Pei

    f26a0d886fbd66a25ed248cb8baf707b.jpg

    #12
  • 399

    🔍 插件审查报告(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)的注册。

    #13
  • bbs1

    版本 1.0.13 更新:
    环境检测(图片直接显示模式)

    #14
  • bbs1

    版本 1.0.13 更新:
    增加预览

    #15
  • dew556

    希望增加权限管理、希望增加webp转换(节省服务器压力)

    #16
  • bbs1

    用户组空间大小可以设置

    #17
  • bbs1

    版本 1.0.13 更新:
    优化细节

    #18
  • bbs1

    版本 1.0.14 更新:
    提升版本号

    #19
  • bbs1

    版本 1.0.15 更新:
    增加五级信任等级体系权限设置支持

    #20
  • bbs1

    版本 1.1.4 更新:
    优化按钮位置

    #21
  • bbs1

    版本 1.1.7 更新:
    支持粘贴图片上传

    #22
  • bbs1

    版本 1.2.9 更新:
    支持远程

    #23
  • bbs1

    远程接收脚本 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
    #24
  • 583455189

    给一个教程吧

    #25
  • bbs1

    插件里面有说明。再看不明白,问豆包怎么部署远程。

    #26
  • bbs1

    版本 1.3.1 更新:
    远程附件同样登记归属并按文件大小计入用户组空间

    #27

发表回复

登录后回复