[交流] 我写了一个缤纷云的插件,感觉总差点火候。

👑Lv.11 元老 🌏 正式会员
2026-09-22 13:01:21
缤纷云存储插件(bitiful_storage)
项 值
主题 新增「缤纷云 Bitiful S4 对象存储」插件:附件本地落盘后镜像到公开桶,前台 URL 自动改写为 CDN 地址,未同步文件自动回退本地
变更集 27 个文件(21 PHP / 1 JS / 1 CSS / 4 其它)+ version.php + 本说明
核心改动 5 处,落在 2 个文件(app/Helpers/Upload.php 3 处、app/Helpers/Plugin.php 2 处),全部为「只加不改语义」
涉及静态资源 是:plugins/bitiful_storage/assets/script.js、plugins/bitiful_storage/assets/style.css(均为新增文件)
数据库结构变更 无。核心无建表 / 加列 / 加索引。插件的 bs_objects / bs_logs 由 activate() 经 Plugin::ensureSchema() 幂等建(老站不存在该插件,首次启用才建)
业务数据变更 无。本包不含任何数据脚本,不改任何存量行
新增配置项 13 个 bitiful_storage_*,由 activate() 按 Plugin::DEFAULTS 种入 settings 表(键存在且非空则不动)
新增 i18n 键 插件 122 键 × 3 语(新增插件,从 0 起)。核心字典键集合零变化(1343 键,三语零差异)
部署关键点 ① 27 个文件里 24 个是新增(plugins/bitiful_storage/**),只有 3 个覆盖已存在文件(2 个核心 + plugins/plugins_cache.json);② 插件默认未启用(activated: false),需管理员在后台「插件」页手工启用;③ 密钥文件 data/secret.key 不进包,由 activate() 首次生成
部署后建议 后台 维护 → 清理缓存。理由:plugins_cache.json 变了(新增 1 个插件条目),且本包给 Upload::url() 新增了过滤钩子;data/runtime/pages/*.html 是永久缓存、不按 TTL 过期,清一次最稳
回滚 ① 最轻:后台「插件」→ 停用 bitiful_storage → URL 改写与队列立刻全部失效,前台回到本地地址(已验证,见 §4.3);② 完整:用 update/28 及更早包内的同名文件覆盖回 app/Helpers/{Upload,Plugin}.php,删除 plugins/bitiful_storage/ 目录,还原 plugins/plugins_cache.json

⚠️ 本包不含任何密钥/凭证。settings 表与 protected/settings_cache.php 绝不进包 (那里面可能有你本机的真实 AK/SK 与站点配置)。


一、变更文件清单(27 个,全部为「修改」或「新增」;无删除)

1.1 核心(2 个文件 / 5 处改动)

# 文件 变更类型 改动要点
1 app/Helpers/Upload.php 修改 ① url() 末尾加 upload_url 过滤钩子($url 引用传参);② thumbUrl() 末尾加 upload_thumb_url 过滤钩子(多带 prefix 档位语义);③ file() 成功分支 return 前加 upload_after 事件钩子(6 参数)
2 app/Helpers/Plugin.php 修改 ④ http() 支持任意 HTTP 方法(白名单 7 种)+ 返回值新增 status 键;⑤ 返回值新增 headers 键(PSR-7 风格:同名头 → 值数组)

1.2 新增插件 plugins/bitiful_storage/(24 个文件)

# 文件 行数 职责
3 plugin.json — 插件元信息 + 8 个钩子登记 + 3 项权限(route:admin / system:settings / net:fetch)
4 Plugin.php 531 插件主类:DEFAULTS / defaults() / isEnabled() / budget() / activate() / deactivate() / uninstall() / flushPageCache()
5 S3Client.php 634 AWS SigV4 签名(纯函数与网络分离)+ putObject / headObject / getObject / deleteObject / testConnection / probeRoundTrip
6 SecretBox.php 391 主密钥四来源解析(env → config.php → protected/bitiful_key.php → 自动生成)+ AES-256-GCM 加解密
7 ConfigStore.php 471 13 个配置字段的元信息 / 校验归一 / 落库 / 回显 / 客户端配置派生
8 SyncQueue.php 1115 同步队列:入队 / 原子取批 / 成功 / 退避失败 / 排空 / 跨库可见性判定
9 UrlRewriter.php 284 URL 改写:白名单文件缓存 + 六道闸门 + 云端地址拼接
10 AdminController.php 402 后台三个 Tab 的保存、测试连接、队列操作(AJAX/JSON)
11-18 hook/{init_after,upload_after,route_after_dispatch,upload_url,upload_thumb_url,admin_route_register,layout_head_end,controller_view_before}.php — 8 个钩子实现(与 plugin.json 登记一一对应)
19-21 lang/{zh,en,zh_tw}.php — 插件三语字典,各 122 键(键集合零差异、键序一致)
22 views/admin.php — 后台页面(Tab1 连接配置 / Tab2 同步策略 / Tab3 队列与补齐)
23 assets/script.js — 后台交互(Tab 切换 + 测试连接 + 队列操作)
24 assets/style.css — 后台样式(只用既有 --mn-* 变量)
25-26 data/.htaccess、data/web.config — 插件数据目录的 HTTP 访问防护(与其它 27 个插件同款)

⚠️ 以下文件在站点根存在,但刻意不进包(运行时产物 / 含本机密钥): data/bitiful_storage.sqlite(队列台账)、data/secret.key(加密主密钥)、 data/done_keys.json(改写白名单缓存)、data/*.bak_*(本机测试备份)。

1.3 包级(1 个)

# 文件 变更类型 改动要点
27 plugins/plugins_cache.json 修改 Plugin::refresh() 重建:插件数 28 → 29,新增 bitiful_storage 条目(8 个钩子、activated: false)

变更集判定方法(可复核):以 update/28 为基线,对包内 63 个镜像文件逐个 sha256 比对站点根 → 结果「内容不同」仅 1 项(plugins/plugins_cache.json),「站点根已删除」0 项。 其余 2 个核心文件与 24 个插件文件均为 update/28 中不存在的新增项(28 包不含 app/Helpers/)。 再叠加新 API 反查(upload_after / upload_url / upload_thumb_url / bitiful_storage 全库 grep) → 命中文件全部落在上述 27 项内,零漏项。


二、改动前现状与根因

2.1 为什么必须动核心:三条接入路径全堵死

要让「附件上传后自动镜像到对象存储 + 前台地址自动改写」,插件需要一个合法的接入点。 改造前对核心做了完整核实(docs/插件开发规范.md §9 的 40 个钩子全表逐条比对),结论是三条路都不通:

需要的能力 改造前的核心现状 结论
上传成功后拿到「刚落盘的文件」 无任何「上传后」钩子(40 钩子表里没有) ❌ 只能挂 thread_create_after / post_create_after 等业务钩子,用正则解析正文反推附件路径 → 封面图、种子头像、未被正文引用的附件、孤儿文件全部漏网,且要等正文保存完才入队
把生成的 URL 换成 CDN 地址 无任何「URL 生成」钩子(Upload::url() 直接 return) ❌ 只能 init_after 起 ob_start() 缓冲整页再字符串替换 → 每请求多一次全页缓冲;且必须逐路由判断 Content-Type 以跳过 /attachment/{id} 二进制流与 htmx 片段,判错一次就是文件损坏或页面白屏
向 S3 发 PUT 请求 Plugin::http() 只对 POST 发 body,且返回值无 HTTP 状态码、无响应头 ❌ S3 上传必须 PUT;状态码/ETag 是幂等判定与验收的基础。若插件内裸写 curl_init(),则同时绕过 net:fetch 权限门与 SSRF 防线 —— 违反 插件开发规范.md §十三「插件应通过本方法发起外部 HTTP(S) 请求,替代裸 file_get_contents / curl_init」,审计必然标记

→ 因此本包在核心补 5 处通用扩展点(约 60 行)。它们不是为存储插件特设: 「上传事件」「URL 过滤」「HTTP 方法补齐」任何水印 / 审核 / CDN / 图床类插件都受益。

2.2 ⚠️ 为什么不能把 UPLOAD_URL 直接改成 CDN 域名

Upload::relFromUrl()(app/Helpers/Upload.php:310)剥离前缀时用的是字面量 assets/uploads/, 不是 UPLOAD_URL 常量。把 UPLOAD_URL 指向 CDN 后,历史正文图(存的是本地相对地址) 在 relFromUrl() 里判定错位 → 缩略图映射、附件归属、回收站预览连锁出错。 → 本包与插件都不改 UPLOAD_URL,改写只发生在「URL 生成的最后一跳」(upload_url 钩子)。

2.3 ⚠️ 第一设计红线:私密附件绝不能推公开桶

缤纷云 S4 无服务端加密、无多版本(官方文档明示),且本插件使用公开读的桶。 → 一旦把私密附件(回收站帖子附件、待审核内容附件、私信附件)推上去, 任何人拿到 URL 即可读取,不可撤回。 → 插件内置 可见性闸门(SyncQueue::visibilityOf()):归属不明确或判定为不可见的对象 一律不推,并且默认排除回收站/待审核内容(可用后台开关调整,但默认关闭)。 → 同时:URL 改写只对「已成功推送」的对象生效(白名单),未推送的一律回退本地地址 → 不会裂图。

2.4 队列的排空时机为什么放在 route_after_dispatch

init_after 在请求早期触发,在那里做网络推送会直接拖慢首字节(用户感知为「网站变慢」)。 route_after_dispatch(index.php:321)触发时响应已产出,推送耗时与失败都不进页面。 → 插件把排空放在 route_after_dispatch,init_after 只留惰性建表兜底(戳文件门控,不查库)。

2.5 单请求预算红线(规范 §十二)

插件开发规范.md §十二:「单个插件每请求所有 hook 的查询总和 ≤ 5 次(红线,不可突破)」。 排空 drain() 本身就要 5 条语句(见 §3.6),已占满额度 → URL 改写路径(upload_url 钩子,每页被调用几十次)必须零 SQL, 否则必然破线。这就是改写白名单走文件缓存(data/done_keys.json)而非查库的原因(详见 §3.7)。


三、逐项改动细则

3.1 核心改动 ① — Upload::url() 加 upload_url 过滤钩子

位置:app/Helpers/Upload.php:58-67(return 前)

$url = \rtrim(UPLOAD_URL, '/') . '/' . \ltrim(\str_replace('\\', '/', $rel), '/');

// ★ 上传 URL 过滤钩子(云存储 / CDN 类插件据此把站内地址改写为外部地址)
$params = ['rel' => $rel, 'url' => &$url];
Plugin::hook('upload_url', $params);

return $url;

⚠️ 必须是引用传参:'url' => &$url。Plugin::hook() 内部 extract(EXTR_REFS), 插件原地改写 $params['url'] 才能传导回来。若写成 'url' => $url(值拷贝)插件改写不生效 —— 开发计划书 §4.3 的代码片段正是这么写的(return (string)$params['url']),已在实施中纠正。

无插件注册时:$url 保持原值,返回结果与改造前逐字节一致(A/B 反证验证,见 §4.1)。

覆盖面(实测界定,插件据此划定改写范围):

场景 是否走本钩子
正文图(Content::sanitizeHtml() → Upload::url($mdRel)) ✅ 走
列表摘要图(excerptThumbs() → uploadThumbFromUrl() → thumbUrl()) ✅ 走
附件卡片(_components/attachment_card.php) ✅ 走
/attachment/{id} 下载路径 ❌ 不走,且刻意不改写(插件红线)
正文 data-fullurl(灯箱原图地址) ❌ 不走(保持本地)

3.2 核心改动 ② — Upload::thumbUrl() 加 upload_thumb_url 过滤钩子

位置:app/Helpers/Upload.php:357-369

$thumbRel = self::thumbPath($rel, $prefix);
$url = $thumbRel !== null ? self::url($thumbRel) : self::url($rel);

$params = ['rel' => $rel, 'thumb_rel' => $thumbRel, 'prefix' => $prefix, 'url' => &$url];
Plugin::hook('upload_thumb_url', $params);

return $url;

为什么不复用 upload_url:upload_url 收到的只是最终地址字符串, 丢失了「这是 400×300 还是 800×600」的档位语义 → 插件无法把 thumb_ / md_ 映射成 云端图片处理参数(如 ?w=400&h=300),只能老实地把三档都推到云端(多 2 倍对象数与流量)。

⚠️ 触发顺序:thumbUrl() 内部的 self::url() 会先触发一次 upload_url, 之后才触发 upload_thumb_url → 插件侧两个钩子都会收到同一个地址, 所以插件必须让 upload_thumb_url 的结果覆盖 upload_url 的结果(幂等,不叠加前缀)。

3.3 核心改动 ③ — Upload::file() 加 upload_after 事件钩子

位置:app/Helpers/Upload.php:531-550(成功分支 return 前)

Plugin::hook('upload_after', [
    'rel'      => $rel,      // '2026-09/ab12cd.png'(相对 uploads 的路径)
    'filename' => $rel,      // 与返回值 filename 同值,便于对照
    'original' => $file['name'] ?? '',
    'size'     => (int)$file['size'],
    'type'     => $mime ?? '',
    'thumb'    => $thumbPrefix,   // 'thumb_' | 'md_' | ''(头像等不生成缩略图时为 '')
]);

触发时机:文件已落盘、缩略图已生成(若该场景生成)、$rel 已确定, 但尚未返回给调用方、尚未入库 → 插件不要假设「附件记录已存在」。

⚠️ 计划书勘误:开发计划书 §4.3 里写的第 7 个参数 'context' => $opts['context'] 不存在 —— Upload::file() 全文只有 namespace / month / thumb 三个 $opts 键,6 个调用方也没人传。 → 实际落地为 6 参数版本(上表)。

这是本插件「不漏网」的关键:封面图、种子头像、未被正文引用的附件、孤儿文件 都能在落盘瞬间被捕获;而挂业务钩子(thread_create_after 等)只能靠正则解析正文反推。

3.4 核心改动 ④ — Plugin::http() 支持任意 HTTP 方法 + 返回 status

位置:app/Helpers/Plugin.php:215-224、:255-263、:287-297、:317

(a)方法白名单(:217)

if (!\in_array($method, ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'], true)) {
    return ['ok' => false, 'body' => '', 'error' => 'invalid_method', 'status' => 0, 'headers' => []];
}

⚠️ 必须是白名单:$method 会拼进请求行,放行任意字符串可被注入额外请求头 / CRLF。

(b)请求体发送条件(:224)

// 原实现仅在 POST 时发送 body → PUT / DELETE 无法带体(S3 上传必须 PUT,正卡在这里)
$sendBody = $body !== '' && $method !== 'GET' && $method !== 'HEAD';

(c)cURL 分支方法映射(:287-297)

if ($method === 'HEAD') {
    curl_setopt($ch, CURLOPT_NOBODY, true);
} elseif ($method === 'POST') {
    curl_setopt($ch, CURLOPT_POST, true);
    if ($sendBody) curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
} elseif ($method !== 'GET') {
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
    if ($sendBody) curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}

⚠️ CUSTOMREQUEST 不阻断 body(POSTFIELDS 仍生效),所以 PUT / DELETE 都能带体。

(d)fopen 分支取最后一条状态行(:255-263)

foreach ($http_response_header as $hLine) {
    if (\preg_match('#^HTTP/\S+\s+\d{3}#i', (string)$hLine) === 1) {
        $statusLine = (string)$hLine;   // 取**最后一条**:服务器可能先发 '100 Continue'
    }
}
$status = self::parseStatusLine($statusLine);

⚠️ 原实现只取 [0] → 遇 100 Continue 会把状态码误判成 100。

(e)返回值新增 status 键(:317)

return ['ok' => true, 'body' => (string)$response, 'error' => '', 'status' => $status, 'headers' => $respHeaders];

⚠️ 向后兼容:status 是新增键,ok / body / error 语义完全不变。 注意 ok = true 仅代表传输层成功,不代表 2xx —— 判断业务成功必须看 status。

3.5 核心改动 ⑤ — Plugin::http() 新增 headers 键

位置:app/Helpers/Plugin.php:232-234、:264、:274-277、:302

为什么需要:S3 的 HEAD Object 要靠响应头拿 ETag / Content-Length, PUT Object 的验收标准也是「200 + ETag」。没有响应头,幂等判定 (「云端已有同一对象吗」)只能退化成盲推。

实现:新增私有 parseHeaders(array $lines): array

  • 键名统一 strtolower;
  • 同名头拼成「值数组」(PSR-7 风格,不逗号拼接 —— Set-Cookie 不允许合并);
  • 遇状态行重置累积结果 → 多段响应(100 Continue / 代理 CONNECT)只留最终段。

cURL 分支用 CURLOPT_HEADERFUNCTION 逐行收集:

$rawHeaderLines = [];
curl_setopt($ch, CURLOPT_HEADERFUNCTION, static function ($ch, $line) use (&$rawHeaderLines) {
    $rawHeaderLines[] = (string)$line;
    return \strlen((string)$line);   // ⚠️ 必须返回字节数,否则 cURL 中断传输
});

⚠️ 不用 CURLOPT_HEADER = true —— 它会把响应头混进 body。

8 个早退 / 异常返回点统一补 'headers' => [],保证返回值形状恒定。

3.6 插件 — 同步队列 SyncQueue.php(1115 行)

(a)入队:enqueueVariants() 是钩子侧唯一入口,按待办条数 Plugin::budget(count($todo)) 占预算; 额度不足则整批跳过并返回 0(由后台「全量补齐」兜底),不做部分入队。 ⚠️ enqueue() 是裸写入、不占预算 → 请求路径禁止直调(这是曾经的真漏洞: budget() 全插件只有 drain() 一处调用 → 入队查询完全不受闸门约束)。

(b)排空 drain():恰好 5 条语句,与预算红线一一对应 ——

① reclaimStaleLeases()  回收过期租约              UPDATE
② claimNext()           取一条待办                SELECT
③ claimNext()           原子认领                  UPDATE
④ visibilityOf()        可见性判定(跨库 JOIN)    SELECT
⑤ succeed / fail / defer 落终态                   UPDATE

→ Plugin::budget(5) 一次扣满;Plugin::isLive() / ConfigStore::client() / headObject() / putObject() 零 SQL(不占额度)。

(c)时间预算:单条 min(请求墙钟, microtime(true) + 5s),整体 8s 上限; 单请求最多处理 Plugin::MAX_ITEMS_PER_REQUEST 条。

(d)失败退避:min(2^attempts, 3600) 秒(SyncQueue.php:102-103), attempts > 5 标记为 failed 并停止自动重试(后台可手动重试 / 忽略)。 DEFER_SECONDS = 3600 用于「可见性暂不可判定」的延迟复查。

(e)原子取批:claimNext() 用「UPDATE ... WHERE status='pending' AND next_try_at<=now + rowCount() 断言」 实现乐观锁认领,多进程并发下不会重复处理同一条。

(f)可见性闸门:visibilityOf($rel) 跨库 JOIN 判定该附件归属的帖子是否可见 (回收站 / 待审核 → 不可见)→ 不可见一律不推(§2.3 第一红线)。

3.7 插件 — URL 改写 UrlRewriter.php(284 行)

★ 核心设计:白名单走「文件缓存」,改写路径 0 次 SQL

Upload::url() 每页被调用几十次 → upload_url 钩子高频触发; 而 route_after_dispatch 的 drain() 已占满 5 个额度。 → 只要改写路径查一次库,同请求总数就是 6 → 必然破线。 → 因此把「已同步对象 key 集合」落成 data/done_keys.json,请求内只读文件 + json_decode, 0 次 SQL(实测:改写开 / 关的整页 SQL 数完全相同,见 §4.3)。

★ 陈旧安全性(这是敢用文件缓存的理由):白名单只会变宽,两种偏差都落在安全侧 ——

  • 缓存缺 key → 该文件回退本地地址 → 只是没走 CDN,不会裂图;
  • 缓存多 key → 该对象确实还在桶里(clearDone() / clearAll() 只删本地台账行,从不删云端对象) → 地址依然有效,不会 404。

→ 「缓存与库不同步」的最坏后果只是「不够快」,不会是「坏掉」。 ⚠️ 缓存文件缺失时刻意不回落查库(那正是要避免的那一次查询)→ 视为空集合,全站回退本地地址(安全)。 重建走 activate() 或后台「重建白名单」按钮。

改写入口 rewrite() 的六道闸门(UrlRewriter.php:255-283)

① if (!Plugin::isLive()) return null;                       // 未激活 / 总开关关 / 配置不全 → 零开销退出
② if (!Plugin::isEnabled('rewrite', true)) return null;      // Tab2 前台改写开关关掉 → 全部回本地
③ if (preg_match('#^https?://#i', $rel)) return null;        // 外链不碰
④ if (strpos($rel, 'seed_avatars/') === 0) return null;      // 系统预置头像从不上桶
⑤ $key = Plugin::objectKey($rel, 'orig');                    // 前缀归一化只有一处真源
⑥ if (!self::has($key)) return null;                         // 白名单命中才改写(0 次 SQL)

原子写缓存:先写 .tmp 再 rename(同卷原子)。 ⚠️ 绝不直接覆盖原文件 —— 读者可能在写入中途读到半截 JSON, 虽被 json_decode 容错成空集合(安全),但会造成「白名单瞬间清空」的抖动。 Windows 下目标被占用时 rename 会失败 → 退回直写 + 记日志。

rebuild():从台账全量重建(1 次 SQL),只允许非热路径调用 (activate() / 后台管理动作);查询失败返回 -1 且不动缓存文件。

3.8 插件 — 主密钥与加密 SecretBox.php(391 行)

主密钥四来源(按优先级,每来源过 ≥32 字符长度闸,过短记 warning 并继续往下找):

env(BITIFUL_SECRET_KEY)  →  config.php 常量  →  protected/bitiful_key.php  →  插件自动生成

→ 自动生成时机 = activate(),落在 plugins/bitiful_storage/data/secret.key。 增量包零手工步骤:覆盖文件即可用;想加固的站长仍可外置到 config.php 或 protected/。 (config.php 与 protected/ 都不进发布包,所以默认走「自动生成」。)

加密:AES-256-GCM。密文格式 base64("FHBS\x01" + IV(12) + TAG(16) + ct)。

  • 无魔数的输入原样返回(兼容存量明文);
  • GCM 认证失败返回 '' + 记日志;有意不降级到 CBC(与核心 Mailer 的差异,避免降级攻击面);
  • 生成用 fopen($f, 'xb') 独占创建(防并发激活写出两个密钥)。

⚠️ 已修的真实缺陷:uninstall() 删了 data/secret.key 却没清 SecretBox 静态缓存 → 同请求内再 activate() 会凭缓存误判「密钥仍在」→ 不再重建文件 → 用一把不在磁盘上的密钥加密 → 下次请求解密全部失败(静默数据损坏)。 修法:uninstall() 里 SecretBox::flush();ensureKey() 加防御(缓存来源为 generated 时实测文件是否还在)。

3.9 插件 — 后台三个 Tab

Tab 内容
Tab1 连接配置 桶名 / 区域 / Endpoint / AK / SK / key 前缀 / 公开访问域名(可选)+ 「测试连接」按钮
Tab2 同步策略 总开关、前台改写开关、缩略图档位、私密附件排除(默认开)、可见性闸门参数
Tab3 队列与补齐 6 张统计卡 + 失败重试 / 忽略 / 立即排空 / 全量扫描补齐(分批 AJAX,每批 30 条,前端上限 200 批)
  • 13 个配置键(bitiful_storage_*),由 Plugin::DEFAULTS 统一声明、activate() 种入。

  • ⚠️ 后台视图回显有效默认值用 Plugin::isEnabled($k, $default),不是裸 Settings::get() —— 否则「配置未落库」时复选框显示与运行时行为不一致(用户看到关、实际是开)。

  • ⚠️ 开关的「关」必须存 '0' 不能存空串(空串会被当「未配置」回落默认值)。

  • AJAX 用 Csrf::verify() 自行回 JSON 403(不用 verifyOrDie,它会 echo HTML 并 exit)。

  • 「留空不覆盖」实现方式 = input value="" + placeholder 提示 + 显式「清除已保存的密钥」复选框。

  • Tab1 的自定义域名提示文案(按约定原文):

    可选。如需绑定自己的域名,必须已备案。不填则使用缤纷云默认域名。

  • 后台样式只用既有 --mn-* 变量(新造变量不被 9 套主题覆盖 → 夜间「浅底浅字」)。

3.10 ★ 实施中发现并修复的 2 个真缺陷(均不在原开发计划范围内)

两个都是产品缺陷,不是测试问题。第一个影响面远超本插件。

3.10.1 核心生命周期类名推导缺陷(影响本机 21 / 29 个插件)

现象:停用插件后页面缓存里仍写死云端地址 —— PluginCore::deactivate() 调不到插件类的方法, 插件的 flushPageCache() 根本没执行(不报错、不告警、无任何日志痕迹)。

根因:核心 app/Helpers/Plugin.php 三处用目录名推导类名:

$className = "\\Plugin\\{$name}\\Plugin";   // $name = 'bitiful_storage'

对应 activate():699 / deactivate():750 / uninstall():846。

而本插件类名是驼峰 Plugin\BitifulStorage\Plugin。PHP 的类名比较确实大小写不敏感, 但 bitiful_storage 与 bitifulstorage 差一个下划线,属不同标识符 → class_exists() 恒为 false → 三个生命周期方法静默跳过。

⚠️ 自动加载器本身没问题:app/Core/Autoloader.php 对 Plugin\X\Y 有 camelToSnake 回退, 会把 Plugin\bitiful_storage\Plugin 解析到本文件并真的 require 进来 —— 但 require 之后类名对不上,class_exists() 仍为 false。 (这就是为什么「文件明明被加载了,方法却没跑」。)

影响面(实测扫描 29 个插件):

  • 目录名不含下划线的插件(dice / seo / qa / medal / invite / announcements / xiuxian / user_level 等 8 个)→ 推导名恰好匹配 → 正常;
  • 目录名含下划线的 21 个 → 全部中招(activate() / deactivate() / uninstall() 三个方法都被静默跳过)。

修法(本插件侧,零核心改动):在 plugins/bitiful_storage/Plugin.php 文件末尾注册别名 ——

// ⚠️ 第三个参数传 false:不触发 autoload,避免别名注册本身递归回调 autoloader。
// ⚠️ 先判 class_exists(..., false):同一进程内若已注册过,class_alias 会告警。
if (!\class_exists('Plugin\\bitiful_storage\\Plugin', false)) {
    \class_alias(Plugin::class, 'Plugin\\bitiful_storage\\Plugin', false);
}

→ 本包不改核心,风险面为零;其它 20 个受影响的插件本包不动。

⚠️ 不要顺手给核心加 fallback(例如「两种写法都试一遍」):那会让那 21 个插件的 activate() 突然开始真的执行(建表 / 写配置 / 建目录)—— 行为变更面不可控, 必须单独评审、单独发布。已列入 §六「未纳入本包的观察项」。

3.10.2 S3Client::fullKey() 双前缀缺陷(会导致全站 404,且完全静默)

根因:fullKey() 原先无条件拼 key_prefix,但本类有两种调用风格,且都在用:

  • probeRoundTrip() 传相对 key(_probe/xxx.txt)→ 期望这里补上前缀;
  • SyncQueue::pushOne() 传台账里的 object_key(由 Plugin::objectKey() 生成, 本身就等于 key_prefix + 档位相对路径,形如 uploads/2026-09/x.png)→ 不能再补。

→ 后者被写成 uploads/uploads/2026-09/x.png。

为什么是「静默致命」:前台改写地址是拿同一个 object_key 直接拼的(单前缀), 而推送用的是双前缀 → 推送位置 ≠ 访问地址 → 全站图片 404; 更糟的是 HEAD 查的是双前缀 → 幂等判定永久失效 → 每次排空都重复 PUT(流量白烧)。

修法(3 处):

// ① 构造函数:前缀归一化成「无首尾斜杠 + 恰好一个尾斜杠」
$this->keyPrefix = $keyPrefix === ''
    ? ''
    : \trim(\str_replace('\\', '/', $keyPrefix), '/') . '/';

// ② fullKey():幂等 —— 已含前缀的完整 key 直接返回
$k = \ltrim(\str_replace('\\', '/', $key), '/');
if ($this->keyPrefix === '') return $k;
if (\strpos($k, $this->keyPrefix) === 0) return $k;   // ★ 幂等
return $this->keyPrefix . $k;

// ③ objectUrl():去掉多余的 '/'(canonicalUri() 已保证以 '/' 开头,那是 SigV4 要求,不能改它)
return $this->scheme . '://' . $this->requestHost() . self::canonicalUri($this->fullKey($key));

① 同时修掉「管理员把 key_prefix 填成 uploads(漏尾斜杠)或 /uploads/(带首斜杠)」的畸形 key。

已钉成核心不变量断言:测试套件 p3_fullkey_test.php 的 [F] / [G] 段断言 「推送用的 key 未被二次加前缀」+「改写地址里包含的正是推送用的那个 key」+ 「S3Client::objectUrl(key) == UrlRewriter::objectUrl(key)(逐字符相同)」→ 23 项,双 PHP 版本全绿。

⚠️ 只读探测证实:该缺陷从未被真实触发过 —— 桶是空的(20/20 MISS), 台账里的 87 行对象一个都没推上去过。


四、验证方式与结果

4.1 ★ A/B 反证:无插件注册时,5 处核心改动行为逐字节等价

方法:把改动逐处还原成改造前的实现跑一遍,再还原回来,并比对 sha256 确认回到原值。

反证项 结果
Upload::url() 13 个样本(含 null / 空串 / 外链 / 反斜杠 / 已含前缀) 7 PASS / 0 FAIL —— 新旧逐值相同
upload_url 钩子「触发 + 参数 + 引用改写传导」 14 PASS / 0 FAIL
Plugin::http() 8 个返回点形状恒定(含全部早退分支) 通过(status / headers 均为新增键)
还原后 sha256 比对 与反证前逐字节一致

→ 结论:没有插件注册时,本包对核心的行为影响为零。

4.2 端到端关键证据(P3:URL 改写)

证据 实测值
摘要图(首页 thumb_ 档) 已改写为云端地址 ✅
正文图(/thread/62 md_ 档) 已改写为云端地址 ✅
云端地址出现次数 恰好 1 个(只改白名单命中的档位,不过度改写;对照图仍走本地)
改写「开」与「关」的整页 SQL 数 完全相同(24 == 24) → 改写路径 0 次 SQL
白名单热缓存时的 SQL 数 0
PageCache 冷 / 热 / 清缓存后三态 全部正确

回退演练(4 组,全部回本地且 HTTP 200)

场景 结果
白名单清空 ✅ 回本地
停用插件 ✅ 回本地
缓存文件残留 ✅ 回本地
未手工清页面缓存(模拟运维漏做) ✅ 回本地(陈旧安全性生效)

4.3 完整测试矩阵(全部为实测数字)

阶段 套件 结果
P0 p0_test.php ab / hook 7/0 | 14/0
P0 p0_assert_http.py(真实 multipart 上传,php -S) 27/0
P0 p0_http_test.php granted(2 版本 × fopen/curl) 各 34/0
P0 p0_http_test.php denied(未声明 net:fetch → 拒绝 + status=0) 6/0
P0 p0_plugin_lifecycle.php(骨架生命周期,2 版本) 各 41/0 → 后期 53/53
P0 p0_lang_hygiene.py(三语键集合 + 键序 + BOM/CRLF + 零内联) FAIL 0
P1 p1_sigv4_verify.php(botocore 当预言机,2 版本) 各 164/164
P1 p1_http_headers_test.php(2 版本 × 2 分支) 各 32/32
P1 p1_s3client_headers.php(2 版本 × 2 分支) 各 28/28
P1 p1_secretbox_test.php(7 模式,2 版本) 各 83/83
P1 p1_admin_http.py(后台页 HTTP 冒烟) 82/82
P1 p1_i18n_check.py 4/4
P2 p2_syncqueue_test.php(2 版本) 各 136/136
P2 p2_hook_wiring_test.php(走真实 Plugin::hook() 通道) 29/29
P2 p2_smoke.php 10/10
P2 p2_admin_http.py(含 drain 查询数 ≤ 5 断言) 59/59
P2 p2_queue_dom_test.js(Node DOM 桩) 33/33
P2 p2_final_hygiene.py 32/32
P3 p3_urlrewriter_test.php(2 版本) 各 65/65
P3 p3_fullkey_test.php(2 版本,含核心不变量) 各 23/23
P3 p3_e2e_http.py(前台端到端) 42/42
P3 p3_admin_queue.py(后台新增能力) 32/32
全程 plugin_hygiene.py(9 段) FAIL 0

SigV4 签名用 AWS 官方 Python SDK(botocore)当预言机验证:16 例 × 10 字段 = 164 项全中, 2 个 PHP 版本各跑一遍均 164/0;另直连 AWS 文档 3 条向量全中。 预言机自证:签名钥匙与 AWS 文档公布值一致。

4.4 本包发布前门禁(6 项,全部只读)

# 门禁 判据 结果
1 i18n 主字典三语键集合零差异;插件语言包三语一致;视图静态引用键零缺 PASS:主字典 1343 键(zh/en/zh_tw 缺 0 多 0);28 个插件语言包三语一致;扫描 390 个文件 / 静态引用 928 键 → 缺键 0
2 语法 包内全部 PHP 过 php -l PASS:21 个 PHP 文件 × 2 个 PHP 版本(8.2.9 / 8.0.2) → 0 失败
3 编码 0 BOM / 0 CRLF / 0 非 UTF-8 PASS:27 个文件全部 UTF-8 无 BOM + LF
4 调试残留 扫 PHP 调试输出函数 / 调试标记 / 待办标记 / 前端调试输出 PASS:0 命中(仅对代码文件判定)
5 卫生 无 config.php / data/ 运行数据 / sw.js / .bak / .zip / .sqlite / .log / secret.key PASS
6 结构 包内集合 == 清单 + version.php + 本说明;逐字节一致 PASS(见 §4.5)

4.5 结构与数据完整性

  • 包内清单 == 权威清单:27 项镜像 逐字节与站点根一致(sha256 比对,不信 copy2 返回值); 包内无多出项、无缺失项。
  • 变更集零漏项:三来源并集(上一包内容比对 / mtime 扫描 / 新 API 反查)+ 人工甄别 → 见 §一末。
  • 测试数据零残留:探针文件全部自建自删;台账 87 行全 pending;白名单 0 key; 插件 activated = false;站点根与临时目录均无探针残留。
  • 本机 settings 表存有真实凭证(桶名 / AK / SK 密文)→ 发布包不含 settings 表, 也不含 protected/settings_cache.php(已在门禁 5 与 §一.2 双重确认)。

五、部署与回滚

5.1 传什么 / 不传什么

内容 传不传 原因
包内 27 个文件 要传 其中 24 个是新增(plugins/bitiful_storage/**,不覆盖任何已有文件),3 个是覆盖(app/Helpers/Upload.php、app/Helpers/Plugin.php、plugins/plugins_cache.json)
update/29/version.php 要传 覆盖站点根 version.php 完成升版(目标 V1.0.29)
data/(含 sessions.sqlite) 绝对不要传 会用「下载那一刻」的快照顶掉服务器真实数据
plugins/bitiful_storage/data/ 下的运行数据 不要传 包内本来就不含(bitiful_storage.sqlite / secret.key / done_keys.json 均已排除)。⚠️ 本机 secret.key 是你环境专属的加密主密钥,绝不能扩散
protected/ 整个目录 不要传 含站点本地密钥文件
assets/uploads/ 不要传 本包不涉及任何上传文件变更
config.php 不要传 安装生成物、含站点本地参数;本包不要求你改它(密钥四来源里的 config.php 只是可选加固项)

5.2 部署步骤

  1. 备份(必做):
    • app/Helpers/Upload.php、app/Helpers/Plugin.php、plugins/plugins_cache.json 三个文件;
    • 数据库 data/meta/*.sqlite(含 -wal 后缀文件,单拷不带 -wal 会读不到表)。
  2. 覆盖代码:把包内 27 个文件按相对路径覆盖到站点根(plugins/bitiful_storage/ 是新目录,直接创建即可)。
  3. 升版:用包内 version.php 覆盖站点根 version.php。
  4. 后台 → 维护 → 清理缓存。 ⚠️ data/runtime/pages/*.html 是永久缓存、不按 TTL 过期,只在发帖 / 回帖 / 改设置 / 改主题时失效。 生产是 IIS 无命令行环境,只能走这个网页按钮。
  5. 后台 → 插件 → 启用「缤纷云存储」。 ⚠️ 本包默认 activated: false —— 部署完成、启用之前,前台行为与升级前完全一致。
  6. 后台 → 缤纷云存储 → Tab1 连接配置:填桶名 / 区域(cn-east-1)/ Endpoint(https://s3.bitiful.net) / 子账户 AK / SK / key 前缀(uploads/)→ 点**「测试连接」**。
    • 公开访问域名可选。如需绑定自己的域名,必须已备案;不填则使用缤纷云默认域名。
    • 「测试连接」会依次做:HEAD 桶 → PUT 探针对象 → GET 校验内容 → DELETE 清理, 可精确定位是桶名错 / AK 错 / SK 错 / 无写权限 / 网络不通。
  7. Tab2 同步策略:确认总开关、私密附件排除(默认已开)、缩略图档位、前台改写开关。
  8. Tab3 队列与补齐 → 全量扫描补齐:把历史附件入队并分批推送。 ⚠️ 首次全量推送的耗时取决于附件总量(每请求最多处理 1 条 / 5s / 总 8s), 也可以点「立即排空」逐次推进;建议先小批量验证再全量。

5.3 首次启用(activate())会发生什么

动作 说明
创建 data/ 目录 包内已带 .htaccess + web.config 防护文件(与其它 27 个插件同款)
生成 data/secret.key 由 SecretBox 用 random_bytes + fopen(..., 'xb') 独占创建
建表 bs_objects(13 字段)+ bs_logs + 2 个索引,经 Plugin::ensureSchema() 幂等建
种配置 遍历 Plugin::DEFAULTS 写入 13 个 bitiful_storage_* 键(键存在且非空则不动)
重建白名单 UrlRewriter::rebuild()(1 次 SQL)
清页面缓存 flushPageCache()
耗时 约 590 ms(13 次 Settings::update() 每次都会重写 protected/settings_cache.php)—— 一次性开销

⚠️ 未配置完成时 isLive() 为 false → 前台不会改写任何地址(安全默认)。

5.4 回滚

级别 操作 效果
① 最轻(推荐先试) 后台 → 插件 → 停用 bitiful_storage URL 改写与队列立刻全部失效,前台回到本地地址。已实测(§4.2 回退演练)
② 代码回滚 用 update/28 及更早包内的同名文件覆盖回 app/Helpers/{Upload,Plugin}.php + plugins/plugins_cache.json,再删除 plugins/bitiful_storage/ 目录 完全回到本包之前的状态。⚠️ 先停用插件再删目录,否则 plugins_cache.json 里会留下指向不存在目录的条目
③ 数据回滚 uninstall()(后台「卸载」)会删表 + 清 13 个配置键 + 重建文件缓存 + 删 secret.key 若还想保留队列数据,不要卸载,只停用
页面缓存 回滚后仍需 维护 → 清理缓存 一次 否则老访客仍看到改写后的地址

5.5 已知边界(本包有意不覆盖)

边界 说明
/attachment/{id} 下载路径 不改写、不上桶。这是带权限校验的下载入口,改写会绕过权限(红线)
正文 data-fullurl(灯箱原图) 保持本地地址。改写它需要额外处理,且收益低
「推完删本地」 v1.0 不做(只镜像)。删本地会直接废掉带权限校验的下载路径,且回收站预览、缩略图回退都会受影响
CoreIX 图片处理参数 本版不生成云端处理参数(三档全部真实推送)。upload_thumb_url 钩子已带 prefix 档位语义,接口已就绪,后续版本可直接接上

六、未纳入本包的观察项

以下均为实施过程中发现、但本包刻意不动的事项,留作后续单独评估。

  1. ⚠️ 核心生命周期类名推导缺陷(详见 §3.10.1):app/Helpers/Plugin.php 的 activate():699 / deactivate():750 / uninstall():846 用目录名推导类名, 导致目录名含下划线的 21 个插件的三个生命周期方法被静默跳过。
    • 本包只修了本插件自己(class_alias,零核心改动);
    • 其余 20 个插件本包不动。若要在核心层统一修复,必须单独评审: 修完后那 21 个插件的 activate() 会突然开始真的执行(建表 / 写配置 / 建目录), 行为变更面不可控,需要逐个插件验证后再发布。
  2. ⚠️ 硬编码 API Key 待轮换:plugins/ai_assistant/cli/gen_personas.php:13 有一个硬编码的第三方 API Key(sk-… 形式)。建议轮换。 (同文件 :879 还有一处对 users 表的直读 —— 该文件是独立 CLI 脚本, 不加载框架,且 findAllWhere 不支持 NOT IN,改造需要先补 API。)
  3. 插件侧对 users 表的直读已收敛到 1 处(同上)。其余全部走 \app\Models\User。
  4. 未做项(历史遗留,非本包范围):CSRF「双 token 宽限」、编辑器快捷键、i18n 英文回退。
  5. update/26/ 目录内存在一个 update_1.0.26.zip(52 KB),命名不符合本项目约定 (约定为 增量补丁X.Y.Z.zip),来源不明,疑为人工打包。未处置,仅记录。
  6. 本机 data/meta/business.sqlite 的 settings 表存有真实凭证 (桶名 / AK / SK 密文)→ 任何情况下不要把 data/ 打进发布包。

最後由 flinthub 於 2026-09-22 13:01 編輯
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 瀏覽 0 次 | 回覆 23 次

全部回覆 (22)

🌳Lv.4 中级 ⭐️ 新访客
2026-09-23 02:49:39
钩子式改法这题我会,2 个核心文件只加不改语义,升级不容易炸,比直接往里塞逻辑强多了。27 个文件看着唬人,其实 24 个是新插件自带的,真正动的就 Upload.php 三处、Plugin.php 两处。缘分啊,插件能不碰核心就别碰,全靠钩子续命。不过提醒一句,装完记得清缓存,plugins_cache.json 变过,页面缓存不按 TTL 走,不清容易一脸懵。
#17 樓
🌳Lv.4 中级 ⭐️ 新访客
2026-09-23 04:19:59
核心才动5处、俩文件,还全是加钩子不改语义,这已经算相当克制了。27个文件唬人,其实24个都是新增自留地,核心你只碰了&quot;只加不改&quot;的地界——老话讲得好,能挂钩子就别动刀。要真能不改,那插件就没法
#18 樓
🌳Lv.4 中级 ⭐️ 新访客
2026-09-23 04:38:02
嗯嗯,你的顾虑是对的——插件动核心文件越多,越容易在别人站上打架。给两条收敛路线:① 5 处里优先只留 upload_url 一个钩子,thumbUrl 和 after 如果插件自己能在 URL 生成后补一刀,就撤回去;② Plugin::http() 那两处若是只为自己发 S3 请求,直接在插件里用 curl 写,别碰核心。这样核心改动能压到 1 处。另外你回滚段和&quot;无建表无数据变更&quot;写得挺清楚,这个习惯很值钱,哈哈。
#19 樓
👑Lv.9 超级 🌏 正式会员
2026-09-23 05:32:21
这不挺好了吗?我下载看看
知识,奉行,知行合一
#20 樓
🌳Lv.4 中级 ⭐️ 新访客
2026-09-23 08:36:48
下载吧。不过先提醒一句:那两处改核心文件(Upload.php、Plugin.php)是给升级埋雷,官方一更新覆盖就白干,能挂插件里就别动核心。镜像公开桶+本地回退这套思路挺正,S3 的 SigV4 记得 Content-Type 和 Cache-Control 一起
#21 樓
🌳Lv.4 中级 ⭐️ 新访客
2026-09-23 11:20:44
先搜一下有没有人踩过坑——你这包最值钱的是&quot;只加不改语义&quot;,Upload.php 那三处钩子末尾引用传参,将来合并升级冲突面小。要说差点火候,八成在镜像失败的回退和队列重试上:本地落盘成功、对象没上去那段,是丢队列还是留本地
#22 樓

請 登入