[程序发布] Flinthub1.0.45增量更新包发布。

👑Lv.11 元老 🌏 正式会员
2026-09-29 19:45:19

本增量包只适合从Flinthub1.0.40版本覆盖
如果全新安装请从1.0.23-26-39-40-45的顺序进行安装、升级覆盖。
给大家造成的不便,敬请谅解。baoquan


  • 生成日期:2026-09-29
  • 基线版本:本包携带 V1.0.45
  • 依据文档:核查结果和修复建议报告.md(对 审计/assets_audit_report.html、审计/splitdb_audit_report.html 的逐条核查结论)
  • 覆盖范围:核查报告中 P0 / P1 / P2 / P3 共 20 项修复建议,其中 18 项已实施,2 项因技术前提不成立而暂缓(见第五节)

一、包内容与校验

  • 镜像文件 24 个 + version.php,目录结构与站点根一致(非平铺)
  • 全部镜像与站点根对应文件 SHA256 逐字节一致,0 处不一致
  • 语法检查:php -l 21 个 PHP 文件全部 No syntax errors detected;node --check 3 个 JS 文件全部通过(php 8.2.9nts / node v24.14.0)
  • 未创建业务新文件;未修改 app/SplitDB/SearchIndexStore.php 以外的第三方目录

文件清单

# 文件 动作 对应审计编号
1 index.php 改(本次核查新发现) A-02
2 app/SplitDB/ExternStorage.php 改 F-01、F-04、F-07、M-01、M-02、M-05
3 app/SplitDB/ExternMerger.php 改 F-02、F-04、F-05、M-03、M-05、M-08
4 app/SplitDB/ExternNamespaceMigrator.php 改 F-03、M-02、M-03(新发现)
5 app/SplitDB/ExternIdxRepairer.php 改 M-03、M-08
6 app/SplitDB/BucketAutoScaler.php 改 M-04
7 app/SplitDB/Queue.php 改 F-06
8 app/SplitDB/QueueConsumer.php 改 F-06
9 app/SplitDB/ShardRouter.php 改 F-07
10 app/SplitDB/DBFactory.php 改 M-06、M-09
11 app/SplitDB/ViewCounter.php 改 M-07
12 app/SplitDB/Schema.php 改 F-08
13 app/SplitDB/SearchIndexStore.php 改 APP-04
14 app/Helpers/Permission.php 改(新增解释层) APP-01
15 app/Helpers/Search.php 改 APP-01、APP-04
16 app/Controllers/HomeController.php 改 APP-01
17 app/Controllers/ForumController.php 改 APP-01
18 app/Controllers/SearchController.php 改 APP-01
19 app/Controllers/ProfileController.php 改 APP-01
20 app/Controllers/ApiController.php 改 APP-02、APP-03、A-02
21 app/Views/layouts/main.php 改 APP-01
22 assets/js/editor.js 改 A-01、A-02、A-04
23 assets/js/auth.js 改 A-04
24 assets/lib/fhajax.js 改 A-03(仅关闭动态求值,CSP 未动)
25 version.php 改(V1.0.31 → V1.0.45) 部署必需:SW 缓存名由版本号派生

version.php 必须随包覆盖。sw.js 的缓存名 = flinthub-static- + 站点版本号,不改版本号时浏览器会继续命中旧的 editor.js / auth.js / fhajax.js,前端修复不会生效。

二、P0 修复(数据完整性与访问控制)

1. F-02 归档合并与编辑竞态(可静默丢失正文)

ExternMerger::mergeGroup() 重构为:先获取 $idxPath.lock → 再 glob 收集 .txt 清单 → 读 .idx → 追加 .bin → 写 .idx → 删 .txt,全程同一临界区;finally 统一解锁。 原实现「glob 早于加锁 + 删 .txt 在锁外」在并发编辑下会用旧快照覆盖 .idx 并删掉编辑刚写入的新 .txt,导致正文变空白且无任何日志。

2. M-05 锁失败必须中止

ExternStorage 索引锁与 ExternMerger 组锁:fopen 或 flock(LOCK_EX|LOCK_NB)失败即记录错误并放弃本次读改写,不再退化为「尽力而为」继续执行。

3. F-01 归档后的正文删除不生效

ExternStorage::delete() 现在在同一把 .idx.lock 下把该 ID 从 .idx 移除并原子重写(复用 invalidateArchiveEntry());invalidateArchiveEntry() 返回类型 void → bool。 返回值语义收紧为:仅当「索引已不含该条目」且「.txt 已不存在」时返回 true,不再在归档正文仍可读时误报删除成功。.bin 内死字节保留(只读归档不可变)。

4. APP-01 权限三态语义 —— 5 处 fail-open 全部收口

getAuthorizedCategoryIds() 返回值契约不变(「数据」层),新增「解释」层:

Permission::isCategoryVisible(array $authorizedCategoryIds, int $categoryId): bool   // [] → false
Permission::filterVisibleCategories(array $authorizedCategoryIds, array $categoryIds): array  // [] → []

已修调用点:HomeController、ForumController(index + category 侧栏)、SearchController、ProfileController、Views/layouts/main.php 抽屉过滤。 彻底移除 !empty($ids) 与 ?: null 这类把 [](无可见版块)反转成「不限制」的隐式转换。 Search::searchLIKE() 同步收紧三态:null = 权限体系未启用(不限制,保持旧行为)、[] = AND 1=0、非空 = 白名单。

已全量 grep 复核全部调用点:TagsController、plugins/post_favorite、plugins/hottopics 原本就是正确的 fail-closed 实现,未改动。 app/Models/Thread.php 的 countActive/getIndexPaginated/getIndexCursor 用 !empty($categoryIds) 表示「空 = 全部版块」,这是模型的有意契约(后台 Admin\ThreadController 依赖 $categoryFilter=[] 表示全部版块),改模型会打挂后台列表,故改为在权限入口处短路(ForumController 的 $noVisibleCategory)。

5. APP-02 封禁用户的既有会话仍可调用上传接口

ApiController::upload() / fetchImage() 改为使用 Auth::getCurrentUser()(内含封禁校验 + 请求级缓存)作为唯一身份来源,不再用 isset($_SESSION['user_id'])。 顺带收口同控制器的 vote()(原用 Auth::isLoggedIn() 等价于 isset,并把 4 处 (int)($_SESSION['user_id'] ?? 0) 替换为 $userId)。

核查依据:app/Core/Controller.php 无构造函数,Auth::getCurrentUser() 只在渲染视图的 view() 内调用;app/init.php 只做 session_start() 与在线状态更新;Auth::isLoggedIn() 本身也只是 isset。因此原实现下封禁在上传链路上实际失效。

三、P1 修复(归档健壮性)

编号 修复内容
F-05 ExternMerger 写 .bin 时校验 fwrite:长度头必须写满 4 字节、正文必须写满 strlen($content);短写则不记 $newIdx、不推进偏移、停止本次追加(该 .txt 因不在 .idx 中而保留)。atomicWrite() 校验 filesize($tmp) === strlen($content)
F-04 ExternMerger 新增 MAX_ENTRY_BYTES = 16*1024*1024,与读端上限对齐;超限条目跳过并保留 .txt(不再写出读端必然拒读的归档条目)
M-02 ExternStorage::readFromBin() 与 ExternNamespaceMigrator 提取处校验 strlen($content) === $len,短读返回 null + 日志
M-03 ExternMerger::atomicWrite() / ExternIdxRepairer::atomicWrite() / ExternNamespaceMigrator::binAtomicWrite():仅当 is_file($tmp) && is_file($target) 时才允许删目标重试;无条件 unlink($target) 已移除
F-03 迁移按显式 $tMatch / $rMatch 计算,仅 XOR 为真才自动迁移;二者同时命中记为 ambiguous 并跳过(实现原与注释声明的规则矛盾)
F-06 task_queue 新增 lease_token TEXT / lease_until INTEGER(新库 CREATE TABLE 直接建;存量库经 ensureLeaseColumns() 以 PRAGMA table_info 探测 + ALTER TABLE 幂等补齐,覆盖 queue_0~2)。fetchTask() 抢占时写入随机 token 与租约到期时间;processTask() 完成/失败更新加 AND lease_token = :token;recoverTimeout() 打回 pending 时更换新 token,旧 worker 的迟到写入不再命中
M-04 BucketAutoScaler:加锁后重读 oldSize;备份 copy 失败即中止;配置写回改为「临时文件 + rename」原子替换(含短写校验)

四、P2 / P3 修复

编号 修复内容
F-07 ShardRouter::externRel() 校验 quarter 匹配 ^\d{4}Q[1-4]$、type ∈ {topic,reply},非法抛 InvalidArgumentException;ExternStorage 的 read/write/delete 拒绝含 NUL 或 .. 的 relPath,并以规范化字符串前缀校验最终路径位于 $dataPath 之内(不用 realpath,因目标文件可能不存在)
F-08 Schema::ensureProtectionFiles() 改为 fail-closed:写入后回读比对内容,失败返回 false 并由 bootstrap() 抛 RuntimeException 中止(原先只 error_log 后继续)。调用方 install.php / Database.php 已能捕获该异常
APP-03 fetchHopByHop() 改为流式截断:fopen 分支按 64 KiB 分块读、累计超 5 MiB 立即中止;cURL 分支加 CURLOPT_MAXFILESIZE + CURLOPT_WRITEFUNCTION 累计截断 + 连接/总超时;响应头累计超 64 KiB 中止;Content-Length 超限时不读正文直接拒绝。SSRF 防护(内网 host 阻塞、DNS 校验、IP 黑名单、逐跳校验、仅 http/https)原样保留
APP-04 SearchIndexStore::countTargets() 新增 ?array $allowedCategoryIds = null 参数并在 SQL 层按 category_id 过滤。因 search_index 表无 category_id 列(该列在另一个库的 main_index.topic_index),通过 ATTACH 挂载 main_index 后以子查询过滤;null = 不过滤、[] = 直接返回 0、非空 = 白名单。原高频词 OOM 优化(SQL 层聚合计数)保留
A-01 editor.js 的 cleanNode() 在判断 javascript: 前先 replace(/[\u0000-\u0020\u007F]+/g, ''),与服务端 Content.php 同口径(原先只 trim(),java	script: 可绕过,浏览器 URL 解析会剔除 Tab/LF/CR)
A-02 /api/fetch-image 改为 POST + CSRF:后端仅接受 POST(GET 返回 405)、校验 csrf 字段,身份/权限/限流/url 全部走 $_POST;前端改 POST,category_id 取自编辑器所在 <form> 内的 select[name="category_id"],csrf 取 window.CSRF_TOKEN。响应 JSON 结构不变
A-04 editor.js(tab 栏、引用块)与 auth.js(密码强度)改用 createElement + textContent,动态文案不再拼接 innerHTML;引用文案经 escapeHtml()。渲染结果与 mn- CSS 类名不变
A-03 fhajax.js 的 allowEval:true, allowScriptTags:true → 均为 false。已 grep 复核:全项目无 hx-on / hx-vars,仅有的 2 处 hx-vals 为普通 JSON 字面量,不依赖 eval。CSP 未改动(见第五节)
M-01 ExternStorage::cacheKey() 签名改为 (string $dataPath, int $id, string $type),键含规范化 dataPath 的 md5;read() 命中 APCu 后按 $limit 截断(原先命中即返回全文,绕过 limit)。5 个调用点同步更新
M-07 ViewCounter::inc() 检查 apcu_inc() 成功标志,失败记日志并回退数据库累加(不再静默丢浏览量)
M-06 DBFactory 检测 journal_mode 非 WAL 时打静态标志并抛 RuntimeException 拒绝使用该连接(不再只记日志继续)。详见第六节风险提示
M-08 ExternMerger::scan() 新增 MAX_SCAN_FILES = 200000(达上限记日志告警并停止收集;未扫到的 .txt 仍可读,语义安全);ExternIdxRepairer 新增 MAX_IDX_BYTES = 32MB,.idx 读取前判限并跳过
M-09 DBFactory 新增 $nativeTx 标记与 isInTransaction()(在 PDO::inTransaction() 之外,用「事务中再 BEGIN 必抛」探测原生事务),池淘汰/复用检查据此避开事务中的句柄;closeAll() 清理标记

五、暂缓 / 未纳入项(含原因)

项 状态 原因
A-03 收紧 CSP(去掉 'unsafe-eval' / 'unsafe-inline') 暂缓 已实测 assets/lib/fhstate.js(Alpine.js v3.15.12 重打包版)在约 L130-138 使用 new Function(["scope"], ...) 求值表达式、约 L82-90 用 async function 构造。移除 'unsafe-eval' 会直接使 Alpine 全面失效。要收紧必须先换用 Alpine 的 CSP 构建或改用 nonce,属独立改造,不在本包范围。这也是核查报告 A-03 中「CSP 已下发但为最弱形态」结论的落地结论:htmx 侧已关闭动态求值,CSP 侧维持现状
Xenforo 主题副本(assets/themes/Xenforo/,约 30 个模板 + layouts/main.php) 不纳入 已实测确认该主题未启用:站点 settings.default_theme = modern-azure,Theme::getAvailable() 只扫描 assets/themes/ 目录且仅含 Xenforo,而 default_theme / DEFAULT_THEME(modern) / 用户主题(modern-azure) 均不等于 Xenforo → TemplateCompiler 找不到同名目录,全部回退 app/Views。该覆盖层当前处于休眠状态。其 layouts/main.php 仍保留同类 !empty($allowedIds) 写法,若日后切换主题需同步 port 本包的 APP-01 改动
assets/js/editor.php(陈旧副本) 不纳入 经全项目 grep,无任何视图/布局引用它(main.php 加载的是 js/editor.js)。该副本仍以 GET 调用 /api/fetch-image,若被启用需同步改 POST + CSRF。建议下个版本直接删除该冗余副本与 assets/js/editor.zip
A-05 删除冗余副本 未执行 增量包无法表达「删除文件」,需运维手动删除:assets/js/editor.php、assets/js/editor.zip、assets/themes/Xenforo.zip、assets/css/themes/themes.zip、assets/assets.zip、app/app.zip、app/SplitDB.zip
A-06 依赖清单 未执行 需新建依赖清单/校验文件,属新增文件而非修复,未纳入

六、风险提示

  1. M-06 WAL fail-closed 是本包风险最高的一项。 为避免「上线即 500」,已在本地实测:data/bucket/active/2026Q3/*.sqlite 中全部非空库(5/5)当前持久化 journal_mode 均为 wal,且实测 PRAGMA journal_mode = WAL 在本机文件系统上可成功启用。故该判定在当前环境不会触发。 但部署到新环境(网络盘 / 不支持共享内存的文件系统)前,务必先确认 WAL 可启用,否则站点会因抛异常而不可用。
  2. /api/fetch-image 由 GET 改为 POST 是破坏性接口变更。 已同步修改站点路由(index.php 由 $r->get 改为 $r->post)与前端调用。若存在第三方客户端/浏览器插件调用该接口,需同步更新。
  3. 务必覆盖 version.php。 否则 Service Worker 会继续提供缓存的旧 editor.js / fhajax.js,前端修复(A-01 / A-03 / A-04)表现为「改了没生效」。
  4. fhajax.js 关闭 allowScriptTags 后,/api/vote 的 htmx 响应片段中附带的 <script>window.CSRF_TOKEN=...</script> 不再执行。因 Csrf::token() 仅在登录/注销时轮换、请求间保持不变,window.CSRF_TOKEN 仍然有效,功能无回归。

七、部署步骤

  1. 备份站点根 app/、assets/、index.php、version.php。
  2. 将本包内容按同名相对路径覆盖到站点根。
  3. 确认 date/bucket/**/*.sqlite 均为 wal(新环境必须确认)。
  4. 删除第五节列出的冗余副本(可选但建议)。
  5. 清理 data/ 与 runtime/ 下的缓存目录(如有 OPcache,重启 PHP-FPM / 重启 Web 服务)。
  6. 浏览器强刷一次,确认 sw.js 注册 URL 已带 ?v=V1.0.45。

八、验收清单

  • 前台首页、论坛列表、分类页、搜索页在「用户组无任何 can_view 记录」时不显示任何版块/帖子(原为显示全部)
  • 搜索结果的总数与实际可见结果条数一致
  • 被封禁用户的既有会话访问 /api/upload、/api/fetch-image、/api/vote 均返回 401
  • 编辑器插入远程图片可正常抓取(POST + CSRF 生效);用 GET 直连返回 405
  • 编辑器粘贴 java&#9;script:alert(1) 链接,预览中点击不执行
  • 帖子/回复编辑后正文可正常读写;删除帖子后归档正文不可再被读出
  • 后台队列任务正常运行;观察 task_queue 的 lease_token / lease_until 被正确写入
  • 归档维护(合并 / 修复 / 迁移)执行后 .idx 与 .txt 状态一致,日志无「短写」「锁失败」报错
  • Schema 初始化在 data/ 防护文件写不进去时明确报错中止,而非静默继续

本包为增量覆盖包,未包含安装程序与主题覆盖层。

Last edited by flinthub at 2026-09-29 20:10
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| Views 7 | Replies 0

All Replies (0)

No replies yet

Please Log in