Files
Nexus/docs/audit/2026-06-01-files-page-row-click.md
T

184 lines
8.0 KiB
Markdown
Raw Normal View History

# 审计记录:文件管理行点击与自动选服
## 审计信息
- **日期**: 2026-06-01
- **审计人**: Claude (AI)
- **触发原因**: Bug 修复 — `#/files` 无法进入子目录、未选服务器时空列表
- **关联 changelog**: `docs/changelog/2026-06-01-files-page-row-click.md`
## 审计范围(实际改动文件清单)
| 文件 | 行数 | 状态 |
|------|------|------|
| `frontend/src/pages/FilesPage.vue` | 1429 | ☑ 全文已审 |
| `frontend/src/composables/useServerList.ts` | 37 | ☑ 全文已审 |
| `docs/changelog/2026-06-01-files-page-row-click.md` | 25 | ☑ 已审 |
## 变更摘要(审计对象)
1. **Vuetify 4 兼容**:移除无效的 `@click:row` / `@dblclick:row` / `@contextmenu:row`,改用 `:row-props="fileRowProps"` 绑定 `onClick` / `onDblclick` / `onContextmenu`
2. **自动选服**`onMounted``loadServers()` 后调用 `resolveInitialServerId()`URL → sessionStorage → 第一台),并 `browse()`
3. **记忆服务器**`onServerChange` 写入 `sessionStorage``nexus:files:last_server_id`
4. **列表加载**`useServerList` 改用 `http.getList`,兼容数组与 `{ items, total }`
---
## 审计 8 步结果
### Step 1: 登记 ✅
已登记 3 个文件;变更动机与 changelog 一致。
### Step 2: 全文 Read ✅
- `FilesPage.vue`:template(工具栏、表格、对话框、FileEditorWorkbench+ script setup 全量通读。
- `useServerList.ts`:全量 37 行。
- 交叉引用:`remotePath.ts``fileBrowse.ts``api/index.ts``getList`)未改,仅核对调用契约。
### Step 3: 规则扫描 H
| ID | 规则 | 扫描位置 | 命中 |
|----|------|----------|------|
| H1 | XSS(未消毒用户输入进 DOM) | `FilesPage.vue` template | 否 — 文件名来自 APIVue 默认转义 |
| H2 | 凭据泄露 | sessionStorage、路由 query | 否 — 仅存 `server_id` 数字 |
| H3 | IDOR(越权 server_id | `browse()``resolveInitialServerId` | 否 — 后端 JWT + `get_by_id`;前端校验 ID 在列表内(初始化路径) |
| H4 | 路径穿越(`../` | `openEntry` 符号链接绝对路径 | 否(既有)— `normalizeRemotePath`;穿越依赖 SSH 返回的 link_target,属管理员可信面 |
| H5 | 静默吞错 | `useServerList` catch | 既有模式 — `servers=[]`,页面有空状态提示 |
| H6 | 事件委托冲突(checkbox/按钮误触发行导航) | `fileRowProps` | 已缓解 — `isRowActionTarget` + `closest` |
| H7 | Vuetify API 废弃 | `@click:row` | 已修复 — 改用 `row-props` |
| H8 | 未选服务器调用 API | `browse()` | 否 — 入口 `if (!selectedServer.value) return` |
| H9 | 双击/单击竞态 | 目录行 click + dblclick | 否 — `onRowDblClick` 仅处理 `file` 类型 |
| H10 | sessionStorage 污染 | `FILES_LAST_SERVER_KEY` | 否 — 写入前 `onServerChange` 有值;读取时 `serverList.some` 校验 |
| H11 | 类型安全 | `fileRowProps(data)` | 可接受 — `data.item``FileEntry` 一致 |
| H12 | 常量声明顺序 | `FILES_LAST_SERVER_KEY``onServerChange` 之后 | 见 Closure — 运行时无 TDZ 问题,可读性欠佳 |
### Step 4: Closure 表
| H | 判定 | 依据 |
|---|------|------|
| H1 | 通过 | 无 `v-html``{{ item.name }}` 等文本插值 |
| H2 | 通过 | `sessionStorage``String(serverId)` |
| H3 | 通过 | `resolveInitialServerId` L857-868`Number.isFinite` + `serverList.some`API 仍由后端鉴权 |
| H4 | 通过(既有) | 符号链接绝对路径 L751-752 走 `normalizeRemotePath`;后端 `sync_v2.browse_directory` 亦有规范化 |
| H5 | 通过(既有) | `useServerList` L29-30 与多页一致;Files 页 `onMounted` L1363-1365 有 snackbar |
| H6 | 通过 | `isRowActionTarget` L891-894 排除 `.v-selection-control, .v-btn, …` |
| H7 | 通过 | L187 `:row-props="fileRowProps"`L896-914 实现与 Vuetify 4 `RowPropsFunction` 一致 |
| H8 | 通过 | `browse` L800-801`navigateToPath` L759-762 |
| H9 | 通过 | `onRowDblClick` L923-926 仅 `isRegularFile` |
| H10 | 通过 | L861-866、L844-845 |
| H11 | 通过 | TypeScript `type-check` 已通过(本地构建前) |
| H12 | 建议 | `FILES_LAST_SERVER_KEY` 宜上移至 `// ── State ──` 区,非功能缺陷 |
**附带观察(非本次引入,不记 FINDING)**
- `watch(route.query.server_id)` L1378-1390 未校验 `serverList`,非法 `?server_id=abc` 可能得到 `NaN`(原逻辑已存在)。建议在后续小改中与 `resolveInitialServerId` 对齐。
### Step 5: 入口表
| 入口类型 | 位置 | 说明 |
|----------|------|------|
| 用户点击 | `fileRowProps.onClick/onDblclick/onContextmenu` | 行级导航/编辑/菜单 |
| 用户选择 | `v-select` `selectedServer` | 切换服务器 → `onServerChange` |
| 路由 query | `server_id`, `path` | `onMounted` / `watch` / `syncRouteQuery` |
| sessionStorage | `nexus:files:last_server_id` | 恢复上次服务器 |
| HTTP GET | `/servers/` | `useServerList.loadServers` |
| HTTP POST | `/sync/browse` 等 | 既有文件操作(未改契约) |
### Step 6: 输入 → Sink
```
[URL server_id / sessionStorage / 默认第一台]
→ resolveInitialServerId (校验 ∈ serverList)
→ selectedServer
→ browse() → POST /sync/browse { server_id, path }
→ 后端 JWT + ServerRepository + SSH ls
[行点击 item.name]
→ openEntry → joinRemotePath + normalizeRemotePath
→ browse()(同上)
[行点击 非目录]
→ onRowClick 无操作(仅目录/符号链接)
[双击文件]
→ editFile → readRemoteFileContent → FileEditorWorkbench
```
**控制措施**:路径片段经 `joinRemotePath`/`validatePathSegment`;服务器 ID 初始化路径白名单校验;API 鉴权在后端。
### Step 7: 归类
```
FilesPage.vue 1429行 12H 0 FINDING
useServerList.ts 37行 1H 0 FINDING
changelog 25行 0H 0 FINDING
────────────────────────────────────────────
总计 13H 0 FINDING
```
### Step 8: DoD ✅
| 维度 | 项 | 结果 |
|------|-----|------|
| 安全 | 无新增 XSS/IDOR/凭据泄露 | ✅ |
| 逻辑 | 目录单击可进入;无 server 时自动选服并 browse | ✅ |
| 逻辑 | 复选框/操作列不误触发目录进入 | ✅ |
| 性能 | `row-props` 每行创建闭包(与 Vuetify 常规用法一致) | ✅ |
| 质量 | TS type-check | ✅(实现时已通过) |
| 质量 | `FILES_LAST_SERVER_KEY` 声明位置 | ⚠️ 建议上移(不阻断) |
| 文档 | changelog ≥10 行 | ✅ |
| 部署 | 生产 tar 需在 WSL/Git Bash 重跑;prune 路径为仓库根 `deploy/prune_frontend_assets.py` | ⚠️ 运维注意 |
---
## FINDING 列表
无(0 FINDING)。
---
## 审查 4 维度(摘要)
### 安全(12 项抽样)
- JWT 请求经既有 `http` 客户端 ✅
- 不在前端存储 SSH 密码 ✅
- `server_id` 仅数字、列表内校验(初始化) ✅
- 行事件不执行 `eval`/动态 HTML ✅
### 逻辑(8 项)
- 根因:Vuetify 4 无 `click:row` → 已用 `row-props` 修复 ✅
- 根因:无 query 不 browse → 已自动选服 ✅
- 空服务器列表提示 ✅
- 符号链接相对路径不盲目 navigate ✅
### 性能(5 项)
- `displayedFiles` 仍为 computed 过滤,未增加额外请求 ✅
- `preloadDirectoryFiles` 行为未改 ✅
### 质量(5 项)
- 注释说明 Vuetify 4 变更原因 ✅
- `useServerList``SchedulesPage`/`RetriesPage` 列表解析策略统一 ✅
- 常量位置可优化(H12 建议)⚠️
---
## 结论
**审计通过,0 FINDING,可合并/可部署**
**部署前提醒**(非代码缺陷):
1. Windows `tar``-C web/app` 可能失败,请用 WSL 或 `deploy/deploy-frontend.sh` 打包上传。
2. 生产 prune`cd /www/wwwroot/api.synaglobal.vip && python3 deploy/prune_frontend_assets.py`
3. 浏览器验证 `#/files` 后建议 **Ctrl+F5** 强刷。
**可选后续小改**(不阻断):
-`FILES_LAST_SERVER_KEY` 移至 state 区顶部。
- `watch(route.query.server_id)` 增加与 `resolveInitialServerId` 相同的列表校验。