本增量包只适合从Flinthub1.0.40版本覆盖
如果全新安装请从1.0.23-26-39-40-45的顺序进行安装、升级覆盖。
给大家造成的不便,敬请谅解。
- 生成日期: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 -l21 个 PHP 文件全部No syntax errors detected;node --check3 个 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 依赖清单 | 未执行 | 需新建依赖清单/校验文件,属新增文件而非修复,未纳入 |
六、风险提示
- M-06 WAL fail-closed 是本包风险最高的一项。
为避免「上线即 500」,已在本地实测:
data/bucket/active/2026Q3/*.sqlite中全部非空库(5/5)当前持久化journal_mode均为wal,且实测PRAGMA journal_mode = WAL在本机文件系统上可成功启用。故该判定在当前环境不会触发。 但部署到新环境(网络盘 / 不支持共享内存的文件系统)前,务必先确认 WAL 可启用,否则站点会因抛异常而不可用。 /api/fetch-image由 GET 改为 POST 是破坏性接口变更。 已同步修改站点路由(index.php由$r->get改为$r->post)与前端调用。若存在第三方客户端/浏览器插件调用该接口,需同步更新。- 务必覆盖
version.php。 否则 Service Worker 会继续提供缓存的旧editor.js/fhajax.js,前端修复(A-01 / A-03 / A-04)表现为「改了没生效」。 fhajax.js关闭allowScriptTags后,/api/vote的 htmx 响应片段中附带的<script>window.CSRF_TOKEN=...</script>不再执行。因Csrf::token()仅在登录/注销时轮换、请求间保持不变,window.CSRF_TOKEN仍然有效,功能无回归。
七、部署步骤
- 备份站点根
app/、assets/、index.php、version.php。 - 将本包内容按同名相对路径覆盖到站点根。
- 确认
date/bucket/**/*.sqlite均为wal(新环境必须确认)。 - 删除第五节列出的冗余副本(可选但建议)。
- 清理
data/与runtime/下的缓存目录(如有 OPcache,重启 PHP-FPM / 重启 Web 服务)。 - 浏览器强刷一次,确认
sw.js注册 URL 已带?v=V1.0.45。
八、验收清单
- 前台首页、论坛列表、分类页、搜索页在「用户组无任何
can_view记录」时不显示任何版块/帖子(原为显示全部) - 搜索结果的总数与实际可见结果条数一致
- 被封禁用户的既有会话访问
/api/upload、/api/fetch-image、/api/vote均返回 401 - 编辑器插入远程图片可正常抓取(POST + CSRF 生效);用 GET 直连返回 405
- 编辑器粘贴
java	script:alert(1)链接,预览中点击不执行 - 帖子/回复编辑后正文可正常读写;删除帖子后归档正文不可再被读出
- 后台队列任务正常运行;观察
task_queue的lease_token/lease_until被正确写入 - 归档维护(合并 / 修复 / 迁移)执行后
.idx与.txt状态一致,日志无「短写」「锁失败」报错 -
Schema初始化在data/防护文件写不进去时明确报错中止,而非静默继续
本包为增量覆盖包,未包含安装程序与主题覆盖层。