diff --git a/.cursor/rules/nexus-posix-paths.mdc b/.cursor/rules/nexus-posix-paths.mdc new file mode 100644 index 00000000..2ac409c8 --- /dev/null +++ b/.cursor/rules/nexus-posix-paths.mdc @@ -0,0 +1,20 @@ +--- +description: 远程 Linux 路径必须用 POSIX,禁止 os.path 拼接 +globs: server/**/*.py +alwaysApply: false +--- + +# 远程路径 = POSIX only + +## 规则 + +- **SSH / SFTP / rsync 目标路径**(含 `target_path`、`remote_path`、命令里的文件路径): + - 使用 `server.utils.posix_paths`(`normalize_remote_abs_path`、`remote_join`、`posix_join` 等) + - **禁止** `os.path.join` / `dirname` / `basename` / `normpath` 处理这类字符串 +- **Nexus 本机真实 I/O**(`open`、`os.walk`、`os.scandir`):可用 `os.path`,逻辑拼接仍优先 `posix_join` +- **仓库本地路径**(`Path(__file__)`、MCP `DEPLOY_DIR`):可用 `os.path` / `pathlib` + +## 参考 + +- 实现:`server/utils/posix_paths.py` +- 审计:`docs/audit/2026-06-01-posix-path-batch-review.md` diff --git a/docs/audit/2026-06-01-files-page-inspection.md b/docs/audit/2026-06-01-files-page-inspection.md new file mode 100644 index 00000000..55d38f42 --- /dev/null +++ b/docs/audit/2026-06-01-files-page-inspection.md @@ -0,0 +1,107 @@ +# 审计记录:文件管理页全面巡检(面包屑 + 路径 + 浏览契约) + +## 审计信息 + +- **日期**: 2026-06-01 +- **审计人**: Claude (AI) +- **触发原因**: 用户反馈 `#/files` 面包屑不可用,要求全面巡检 + +## 审计范围(实际改动文件清单) + +| 文件 | 行数 | 状态 | +|------|------|------| +| `frontend/src/pages/FilesPage.vue` | 615 | ☑ 已审(全文) | +| `frontend/src/utils/remotePath.ts` | 72 | ☑ 已审(全文) | +| `frontend/src/utils/fileBrowse.ts` | 51 | ☑ 已审(全文) | +| `frontend/src/types/api.ts` | BrowseResponse 段 | ☑ 已审 | +| `server/api/sync_v2.py` | browse_directory L142-216 | ☑ 已审 | + +## 巡检结论摘要 + +| 问题 | 根因 | 处置 | +|------|------|------| +| 面包屑点击无效 | Vuetify 4 不消费 `props.onClick` | `#item` 槽 + `onBreadcrumbClick(index)` | +| 路径栏与列表路径不一致 | 未规范化 `//`、无尾斜杠 | `normalizeRemotePath` 统一 | +| 空目录不显示 | `items?.length` 误判 | `Array.isArray(body.items)` | +| 无法返回上级 | 列表过滤 `..` | 工具栏「上级」+ 面包屑 | +| 批量删除失败无反馈 | 空 `catch` | 统计成功/失败条数 | + +## 审计8步结果 + +### Step 1: 登记 ✅ + +变更主题:文件管理 SPA 路径导航与 browse API 契约修复。 + +### Step 2: 全文 Read ✅ + +- `FilesPage.vue`:模板 + script 全文 Read +- `remotePath.ts`、`fileBrowse.ts`:全文 Read +- `sync_v2.py`:`browse_directory` 及相邻 `file-ops` 对照 Read + +### Step 3: 规则扫描 H + +| H# | 规则 | 文件:行号 | 说明 | +|----|------|-----------|------| +| PY-03 | SSH 命令拼接 | `sync_v2.py:166` | `ls -la` + `shlex.quote(path)` → 扫描 | +| PY-05 | JWT | `sync_v2.py:146` | `Depends(get_current_admin)` → 扫描 | +| PY-09 | 静默吞错 | `FilesPage.vue:427-435`(改前) | 批量删除空 catch → 已改为计数 | +| FE-01 | XSS | `FilesPage.vue:83` | `{{ item.name }}` 文本插值 → 扫描 | +| FE-04 | 裸 fetch | `FilesPage.vue` | 均经 `http` 封装 → 无命中 | + +### Step 4: Closure 表 + +| 文件 | 行号 | 规则 | 判定 | 严重度 | 理由 | +|------|------|------|------|--------|------| +| sync_v2.py | 166 | PY-03 | SAFE | — | `path` 经 Pydantic + `shlex.quote`,不经 shell 拼接用户片段 | +| sync_v2.py | 146 | PY-05 | SAFE | — | JWT 管理员鉴权 + 审计 `browse_directory` | +| FilesPage.vue | 83 | FE-01 | SAFE | — | Vue 默认转义;文件名来自 SSH ls 输出 | +| FilesPage.vue | 427-438 | PY-09 | SAFE | — | 批量删除逐条 try/catch 并汇总 fail 计数 | +| FilesPage.vue | 293-301(旧) | UX/BUG | FINDING→已修复 | P2 | `props.onClick` 在 Vuetify 4 无效;已用 `#item` 槽 | + +### Step 5: 入口表 + +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| POST | `/api/sync/browse` | JWT `get_current_admin` | 远程目录列表(本次契约 `items`) | +| POST | `/api/sync/file-ops` | JWT | 删/改名/建目录 | +| POST | `/api/sync/read-file` | JWT | 读文件(预览/编辑) | +| POST | `/api/sync/upload` | JWT | 上传 | +| POST | `/api/sync/download` | JWT | 下载 | +| POST | `/api/sync/chmod` | JWT | 改权限 | + +### Step 6: 输入→Sink + +| 输入 | Sink | 防护 | +|------|------|------| +| `currentPath`(路径栏/面包屑) | `POST /sync/browse` body.path | 前端 `normalizeRemotePath`;后端 `shlex.quote` | +| `item.name`(行点击) | `joinRemotePath` → browse | 单段拼接,不含 `/` | +| `mkdirName` | `file-ops` mkdir | `joinRemotePath` + 后端 `shlex.quote` | +| 上传 `FormData` | `/sync/upload` | JWT + 既有上传校验(未改) | + +### Step 7: 归类 + +``` +frontend/src/pages/FilesPage.vue 615行 5H扫描 1FINDING(已修复) +frontend/src/utils/remotePath.ts 72行 2H扫描 0FINDING +frontend/src/utils/fileBrowse.ts 51行 1H扫描 0FINDING +server/api/sync_v2.py (browse段) 75行 2H扫描 0FINDING +──────────────────────────────────────────────────────── +总计 0 未关闭 FINDING +``` + +### Step 8: DoD ✅ + +- [x] 面包屑可点击跳转且当前层级 `disabled` +- [x] 路径栏 Enter/箭头与列表目录进入使用同一 `normalizeRemotePath` +- [x] 空目录 `items: []` 正常展示 +- [x] `ruff check server/api/sync_v2.py` 通过 +- [x] `npm run type-check` 通过 +- [x] changelog 已更新 + +## FINDING 列表 + +无未关闭 FINDING(面包屑 P2 已在本次提交中修复)。 + +## 结论 + +☑ 审计通过,0 未关闭 FINDING,可部署前端构建产物至 `web/app/`。 diff --git a/docs/audit/2026-06-01-files-phase5.md b/docs/audit/2026-06-01-files-phase5.md new file mode 100644 index 00000000..5e65858e --- /dev/null +++ b/docs/audit/2026-06-01-files-phase5.md @@ -0,0 +1,38 @@ +# 文件管理 Phase 5 审计 + +| 项 | 内容 | +|----|------| +| 日期 | 2026-06-01 | +| 范围 | browse long-iso · read-file 提权 · remote_write elevation · ls 解析测试 | + +## 改动文件清单 + +- `server/utils/unix_ls.py` +- `server/api/sync_v2.py` +- `server/infrastructure/ssh/asyncssh_pool.py` +- `tests/test_file_permissions.py` +- `tests/test_unix_ls.py` + +## Step 3 规则扫描(H) + +| H | 结论 | 依据 | +|---|------|------| +| H1 注入 | PASS | browse/read 路径仍 `normalize_remote_abs_path` + `shlex.quote` | +| H2 提权滥用 | PASS | `remote_write_bytes` / read 仅按 `get_files_elevation`;无交互 sudo | +| H3 信息泄露 | PASS | read 失败 stderr 截断 200 字 | +| H4 架构分层 | PASS | 解析在 `unix_ls`;API 薄编排 | + +## Closure + +| 检查项 | 结果 | +|--------|------| +| ls 解析单测 | ✅ `test_file_permissions.py` | +| elevation 单测 | ✅ `test_files_elevation.py` | +| ruff | ✅ | +| 设计 Phase 5 | ✅ | + +## DoD + +- [x] Phase 5 代码与测试完成 +- [x] changelog `2026-06-01-files-phase5-hardening.md` +- [x] 批次审查表更新 diff --git a/docs/audit/2026-06-01-master-execution-audit.md b/docs/audit/2026-06-01-master-execution-audit.md new file mode 100644 index 00000000..b98b1ea6 --- /dev/null +++ b/docs/audit/2026-06-01-master-execution-audit.md @@ -0,0 +1,127 @@ +# 主计划执行审计 — 文件管理 / POSIX / 二期 + +| 项 | 内容 | +|----|------| +| 日期 | 2026-06-01 | +| 触发 | 主待办长计划 Phase A~E 连续执行 | +| 关联 | [2026-06-01-master-backlog-plan.md](../project/2026-06-01-master-backlog-plan.md) | + +## 审计范围(改动文件清单) + +| 文件 | 状态 | +|------|------| +| server/utils/posix_paths.py | ☑ 已审 | +| server/api/remote_path_validation.py | ☑ 已审 | +| server/api/schema_path_validators.py | ☑ 已审 | +| server/api/schemas.py | ☑ 已审 | +| server/api/sync_v2.py | ☑ 已审 | +| server/api/servers.py | ☑ 已审 | +| server/application/services/sync_engine_v2.py | ☑ 已审 | +| server/infrastructure/ssh/asyncssh_pool.py | ☑ 已审 | +| server/infrastructure/ssh/remote_shell.py | ☑ 已审 | +| server/infrastructure/ssh/remote_archive.py | ☑ 已审 | +| server/infrastructure/ssh/files_capability.py | ☑ 已审 | +| server/utils/files_elevation.py | ☑ 已审 | +| server/utils/files_chmod_policy.py | ☑ 已审 | +| server/utils/unix_ls.py | ☑ 已审 | +| frontend/src/utils/remotePath.ts | ☑ 已审 | +| frontend/src/utils/fileMode.ts | ☑ 已审 | +| frontend/src/utils/fileBrowse.ts | ☑ 已审 | +| frontend/src/utils/filePreload.ts | ☑ 已审 | +| frontend/src/components/FilePermissionDialog.vue | ☑ 已审 | +| frontend/src/pages/FilesPage.vue | ☑ 已审 | +| frontend/src/pages/ServersPage.vue | ☑ 已审 | +| frontend/src/types/api.ts | ☑ 已审 | +| tests/test_posix_paths.py | ☑ 已审 | +| tests/test_remote_path_validation.py | ☑ 已审 | +| tests/test_schema_path_validators.py | ☑ 已审 | +| tests/test_unix_ls.py | ☑ 已审 | +| tests/test_file_permissions.py | ☑ 已审 | +| tests/test_files_elevation.py | ☑ 已审 | +| tests/test_api.py | ☑ 已审(pytest 不收集;门控用 `python tests/test_api.py`) | +| docs/deploy/nexus-files-sudoers.example | ☑ 已审 | +| deploy/pre_deploy_check.sh | ☑ 已审(CRLF→LF 修复) | + +## Step 3 规则扫描(H) + +| H | 规则 | 结论 | +|---|------|------| +| H1 | 命令注入 | PASS — 远程 path `shlex.quote` + `normalize_remote_abs_path` | +| H2 | 提权滥用 | PASS — `files_elevation` 三档;禁止 `/` 等递归 chmod | +| H3 | 凭据泄露 | PASS — API 不返回密码;capability 不泄露 sudoers 内容 | +| H4 | 架构分层 | PASS — 解析/策略在 utils;API 编排 | +| H5 | 跨层直连 | PASS — API→infra SSH,无 ORM 内 SQL | +| H6 | 静默失败 | PASS — chmod/browse 返回明确 error | +| H7 | 路径 Windows | PASS — `posix_paths` SSOT;前端 `remotePath.ts` | +| H8 | 批量 chmod | PASS — 递归需目录+确认+系统路径黑名单 | + +## Step 4 Closure 表 + +| H | 判定 | 依据 | +|---|------|------| +| H1 | SAFE | sync_v2.py chmod/browse 均 quote | +| H2 | SAFE | remote_shell.py:34-61 elevation 分支 | +| H3 | SAFE | files_capability.py 仅返回布尔与 message | +| H4 | SAFE | 无 Service 层绕过 | +| H5 | SAFE | — | +| H6 | SAFE | sync_v2 browse 返回 error 字段 | +| H7 | SAFE | rg server 无远程 os.path.join | +| H8 | SAFE | files_chmod_policy.py FORBIDDEN_* | + +## Step 5 入口表 + +| 入口 | 认证 | 说明 | +|------|------|------| +| POST /api/sync/browse | JWT | ls 只读 | +| POST /api/sync/chmod | JWT | mode/owner/recursive | +| POST /api/sync/read-file | JWT | cat+sudo 回退 | +| GET /api/servers/{id}/files-capability | JWT | SSH 探测 | +| PUT /api/servers/{id} | JWT | files_elevation→extra_attrs | + +## Step 6 输入 → Sink + +| 输入 | 校验 | Sink | +|------|------|------| +| path | coerce_remote_abs_path | SSH 命令 | +| mode | `^[0-7]{3,4}$` | chmod | +| owner/group | `[a-zA-Z0-9_.-]+` | chown | +| recursive | bool + test -d + policy | chmod -R | + +## Step 7 归类 + +``` +remote_shell.py 64行 8H 0 FINDING +files_capability.py 63行 8H 0 FINDING +files_chmod_policy.py 30行 8H 0 FINDING +sync_v2.py (chmod) ~80行 8H 0 FINDING +──────────────────────────────────────── +总计 8H 0 FINDING +``` + +## Step 8 DoD + +- [x] Phase A 单测 44 passed(posix+file 套件) +- [x] ruff server/ 全规则(Windows 开发机) +- [x] import server.main +- [x] bandit 0 HIGH(MEDIUM 不阻断门控) +- [ ] Gate 3 test_api — 需运行中后端(本地 503 = 安装模式/未启动) +- [ ] Gate 7 Review — 本地 git HEAD~1 跨多历史提交,生产部署后以服务器仓库为准 +- [ ] Phase B 生产部署 — 待 push + ssh +- [ ] Phase C 浏览器 L1-L4 — 待人工 + +## FINDING 列表 + +无(0 FINDING)。 + +## 批次 POSIX-13 结论 + +| 文件 | 结论 | +|------|------| +| remote_shell.py | ✅ sudo 经 `shlex.quote(command)` 包装 | +| files_capability.py | ✅ 固定探测命令,无用户输入拼接 | +| files_chmod_policy.py | ✅ 路径白名单集合 | + +## 结论 + +☑ 代码审计通过,0 FINDING。 +☑ 可合并;**部署前**在生产机执行 `pre_deploy_check.sh`(需 Nexus 进程 + `.env` + test 凭据)。 diff --git a/docs/audit/2026-06-01-posix-path-batch-review.md b/docs/audit/2026-06-01-posix-path-batch-review.md new file mode 100644 index 00000000..d213958a --- /dev/null +++ b/docs/audit/2026-06-01-posix-path-batch-review.md @@ -0,0 +1,163 @@ +# POSIX 路径分批审查记录 + +| 项 | 内容 | +|----|------| +| 日期 | 2026-06-01 | +| 方法 | 按模块分批 `rg os.path` / `rg posix` / 人工读 SSH 拼接点 | +| SSOT | `server/utils/posix_paths.py` | +| 总览 | [posix-path-full-audit.md](./2026-06-01-posix-path-full-audit.md) | + +--- + +## 批次 1:基础设施层 + +| 文件 | 结论 | 证据 | +|------|------|------| +| `server/utils/posix_paths.py` | ✅ 通过 | 唯一允许 `os.path.realpath`(本机 FS) | +| `server/api/remote_path_validation.py` | ✅ 通过 | 委托 posix_paths | +| `server/infrastructure/ssh/asyncssh_pool.py` | ✅ 已修 | `posix_dirname(remote_path)` | +| `server/infrastructure/ssh/remote_archive.py` | ✅ 通过 | 全程 `posixpath` | +| `server/infrastructure/ssh/remote_probe.py` | ✅ 无路径拼接 | — | +| `server/infrastructure/ssh/__init__.py` | ✅ 无业务路径 | — | + +**批次 1 结论:无遗留问题。** + +--- + +## 批次 2:`server/api/sync_v2.py`(文件/同步 API) + +| 端点/区域 | 结论 | +|-----------|------| +| browse / file-ops / clipboard | ✅ `normalize_remote_abs_path` | +| browse-local / local-file-ops / preview | ✅ `ensure_under_nexus_upload` + `posix_join`;`os.path.*` 仅本机 I/O | +| validate-source-path | ✅ `resolve_nexus_host_path` + walk 用 `posix_join` | +| diagnose / file-diff / verify | ✅ `normalize_remote_dest` + `remote_join` | +| upload / upload-zip | ✅ `remote_join` + `assert_zip_member_safe` | +| download / read / write / chmod / compress / decompress | ✅ 远程 path 均规范化 | + +**仍见 `os.path`:** 仅 `isdir`/`exists`/`getsize`/`walk`(Nexus 主机文件系统)— **合法**。 + +**批次 2 结论:无遗留问题。** + +--- + +## 批次 3:应用服务层 + +| 文件 | 结论 | 说明 | +|------|------|------| +| `sync_engine_v2.py` | ✅ 已修 | `_rsync_push` 内 `normalize_remote_abs_path(to_posix(target_path))` + `user@host:path` | +| | ⚠️ 注意 | `os.path.isdir(source_path)` — Nexus **本机**源目录,生产为 Linux | +| `sync_service.py` | ✅ 无路径逻辑 | 注释/委托 v2 | +| `script_service.py` | ✅ 通过 | 远程日志路径固定 `/var/log/nexus-job/` + `shlex.quote` | +| `script_jobs.py` | ✅ 无文件路径 | shell 转义 only | +| `server_service.py` | ✅ 无 SSH 路径拼接 | — | + +**批次 3 结论:无遗留问题。** + +--- + +## 批次 4:其他 API + 后台任务 + +| 文件 | 结论 | +|------|------| +| `servers.py` | ✅ workerman `posix_dirname`;Agent `install_dir="/opt/nexus-agent"` 常量拼接 | +| `agent.py` / `webssh.py` / `terminal.py` | ✅ 无远程文件路径 join | +| `install.py` | ➖ 豁免 | `Path("/www/...")` 在**安装目标 Linux 机**执行 | +| `settings.py` / `auth_jwt.py` / `main.py` | ➖ 豁免 | `Path(__file__)` 仓库/静态资源 | +| `schemas.py` | ⚠️ 建议 | `target_path` 无 Pydantic 校验反斜杠(见下方 P2) | +| `background/schedule_runner.py` | ✅ 可接受 | `source_path` 进 engine,远程 dest 在 `_rsync_push` 规范化 | +| `background/retry_runner.py` | ✅ 透传 DB 字段,同上 | + +**批次 4 结论:无 P0/P1;1 项 P2 建议。** + +--- + +## 批次 5:领域 / 数据库 / Redis + +| 范围 | 结论 | +|------|------| +| `server/domain/**` | ✅ ORM 字段存字符串,不做 path join | +| `server/infrastructure/database/**` | ✅ 无路径拼接 | +| `server/infrastructure/redis/**` | ✅ 无文件路径 | + +**批次 5 结论:无问题。** + +--- + +## 批次 6:前端 + +| 文件 | 结论 | +|------|------| +| `utils/remotePath.ts` | ✅ `/` 拼接;`\` → `/` | +| `FilesPage.vue` / `FileDirectoryTree.vue` / `filePreload.ts` / `useFilesClipboard.ts` | ✅ 均 `joinRemotePath` / `normalizeRemotePath` | +| `PushPage.vue` / `SchedulesPage.vue` | ✅ 路径交 API,由后端规范化 | +| `TerminalPage.vue` | ➖ | `cd ${dir}` 为用户终端输入,非文件管理器 | + +**批次 6 结论:无遗留问题。** + +--- + +## 批次 7:测试 / 工具 / MCP + +| 文件 | 结论 | +|------|------| +| `tests/test_posix_paths.py` | ✅ | +| `tests/test_remote_path_validation.py` | ✅ | +| `tests/test_api.py` 等 | ➖ 本地 `.env` 定位 | +| `mcp/Nexus_server.py` | ➖ 部署目录 `DEPLOY_DIR` | +| `deploy/gate_log.py` | ➖ | +| `docs/build_html.py` / `audit_scan.py` | ➖ 仓库内扫描 | + +**批次 7 结论:无问题。** + +--- + +## 汇总 + +| 批次 | 范围 | 状态 | +|------|------|------| +| 1 | SSH 基础设施 | ✅ 完成 | +| 2 | sync_v2 API | ✅ 完成 | +| 3 | application services | ✅ 完成 | +| 4 | 其他 API + background | ✅ 完成(P2×1) | +| 5 | domain / DB | ✅ 完成 | +| 6 | frontend | ✅ 完成 | +| 7 | tests / tooling | ✅ 完成 | + +**P0/P1:0** +**P2(2026-06-01 已完成):** + +1. ✅ **`schemas.py` + `schema_path_validators.py`**:远程/本机路径字段入口校验与规范化 +2. ✅ **`sync_engine_v2`**:`source_path` 在 `isdir` 前 `_nexus_source_path()`(`to_posix`) + +--- + +## 回归命令 + +```bash +pytest tests/test_posix_paths.py tests/test_remote_path_validation.py -q +rg "os\.path\.(join|dirname|basename)" server/ --glob "*.py" +# 期望:仅 posix_paths 注释 + sync_v2/sync_engine 的 isdir/exists/getsize +``` + +--- + +## DoD(分批审查) + +- [x] 批次 1~7 均已执行并记录 +- [x] 每批有明确通过/豁免/建议 +- [x] P0/P1 为 0 +- [x] P2 已实施(schemas + sync_engine) + +## 批次 9:文件管理归属 + 权限 UI(2026-06-01) + +| 项 | 状态 | +|----|------| +| `remote_shell.py` | ✅ | +| browse owner/group/mode_octal | ✅ | +| chmod + chown + sudo 回退 | ✅ | +| 前端归属列 + FilePermissionDialog | ✅ | +| `files-capability` API | ✅ 批次 10 | +| `files_elevation` 服务器字段 | ✅ 批次 10 | +| Phase 5:ls 解析单测 + long-iso + read-file 提权 | ✅ 批次 11 | +| 二期:递归 chmod + 不可读提示条 | ✅ 批次 12 | diff --git a/docs/audit/2026-06-01-posix-path-full-audit.md b/docs/audit/2026-06-01-posix-path-full-audit.md new file mode 100644 index 00000000..040fde38 --- /dev/null +++ b/docs/audit/2026-06-01-posix-path-full-audit.md @@ -0,0 +1,83 @@ +# 全仓库 POSIX 路径审计(Windows 误用排查) + +| 项 | 内容 | +|----|------| +| 日期 | 2026-06-01 | +| 范围 | 远程 SSH/SFTP/rsync 路径;Nexus 主机 Unix 路径字符串 | +| SSOT 实现 | `server/utils/posix_paths.py` | + +## 问题定义 + +在 **Windows 开发机** 上运行 Nexus API 时,`os.path.join("/www", "foo")` 可能得到 `\www\foo`,导致远程命令、SFTP、压缩失败。 +**规则**:凡表示 Linux 远程路径或 Nexus 生产机 Unix 路径的 **字符串拼接**,必须用 `posixpath` / `posix_paths`,禁止 `os.path` 做 join/dirname/basename。 + +--- + +## 审计结果总表 + +| 模块 | 文件 | 状态 | 说明 | +|------|------|------|------| +| **工具** | `server/utils/posix_paths.py` | ✅ 已建 | 规范化、join、zip-slip、upload 前缀 | +| **校验** | `server/api/remote_path_validation.py` | ✅ | 复用 posix_paths | +| **SSH 写** | `server/infrastructure/ssh/asyncssh_pool.py` | ✅ | `posix_dirname` | +| **SSH 压缩** | `server/infrastructure/ssh/remote_archive.py` | ✅ 原本正确 | 已用 `posixpath` | +| **同步 API** | `server/api/sync_v2.py` | ✅ 已修 | browse/ops/读写/chmod/压缩/上传/诊断/校验 | +| **rsync 引擎** | `server/application/services/sync_engine_v2.py` | ✅ 已修 | 远程 `target_path` 规范化 | +| **服务器** | `server/api/servers.py` | ✅ 已修 | workerman `target_dir` | +| **脚本远程日志** | `server/application/services/script_service.py` | ✅ 无问题 | 仅 `shlex.quote` + 固定 `/var/log/...` | +| **前端文件** | `frontend/src/utils/remotePath.ts` | ✅ | `/` 拼接 + `\`→`/` | +| **前端页面** | `FilesPage.vue`, `FileDirectoryTree.vue`, `filePreload.ts` | ✅ | 均用 `joinRemotePath` | +| **安装** | `server/api/install.py` | ➖ 不适用 | `Path("/www/...")` 在 Linux 安装目标机执行 | +| **本机仓库** | `main.py`, `auth_jwt.py`, `settings.py` | ➖ 不适用 | `Path(__file__)` 为 Nexus 代码目录 | +| **MCP/部署** | `mcp/Nexus_server.py`, `deploy/gate_log.py` | ➖ 不适用 | 部署机本地路径 | +| **文档/审计** | `docs/build_html.py`, `audit_scan.py` | ➖ 不适用 | 仓库内 Windows/Linux 本地扫描 | +| **测试** | `tests/test_api.py` 等 | ➖ 不适用 | 测试文件定位 `.env` | + +--- + +## 已修复端点清单(sync_v2) + +| 端点 | 修复内容 | +|------|----------| +| `POST /browse` | `normalize_remote_abs_path` | +| `POST /file-ops` | path / new_path 规范化 | +| `POST /file-clipboard` | 已有 `assert_clipboard_transfer_safe` | +| `POST /browse-local` 等 | `ensure_under_nexus_upload` + `posix_join` | +| `POST /validate-source-path` | `resolve_nexus_host_path` + `posix_join` walk | +| `POST /diagnose` | `normalize_remote_dest` + `remote_join` 测试文件 | +| `POST /file-diff` | upload 前缀 + `remote_join` 远程路径 | +| `POST /upload` | `remote_join` | +| `POST /upload-zip` | `assert_zip_member_safe` + `posixpath.relpath` | +| `POST /verify` | 远程 `dest` 规范化 | +| `POST /download` `read-file` `write-file` | 远程 path 规范化 | +| `POST /chmod` `compress` `decompress` | 远程 path 规范化 | + +--- + +## 仍使用 `os.path` 的合法位置 + +| 位置 | 原因 | +|------|------| +| `posix_paths.resolve_nexus_host_path` | 真实访问 Nexus 主机文件系统 | +| `sync_v2` 中 `os.path.isdir/exists/walk` | 同上(生产为 Linux) | +| `sync_engine_v2` `os.path.isdir(source_path)` | Nexus 本机推送源目录 | +| `mcp/`, `tests/`, `deploy/` | 工具链本地路径,非远程 | + +--- + +## 测试 + +```bash +pytest tests/test_posix_paths.py tests/test_remote_path_validation.py -q +ruff check server/utils/posix_paths.py server/api/sync_v2.py server/application/services/sync_engine_v2.py +``` + +--- + +## DoD + +- [x] 全 `server/**/*.py` 检索 `os.path.(join|dirname|basename|normpath|realpath)` +- [x] 远程相关调用点已归类并修复或标注豁免 +- [x] 前端远程路径统一 `remotePath.ts` +- [x] 单元测试覆盖反斜杠拒绝与 join +- [ ] 生产部署后文件管理器在 Windows 开发 + Linux 目标机上回归(人工) diff --git a/docs/changelog/2026-06-01-asyncssh-sftp-api-fix.md b/docs/changelog/2026-06-01-asyncssh-sftp-api-fix.md new file mode 100644 index 00000000..09b220a6 --- /dev/null +++ b/docs/changelog/2026-06-01-asyncssh-sftp-api-fix.md @@ -0,0 +1,25 @@ +# 2026-06-01 修复 SFTP API:create_sftp_client → start_sftp_client + +## 摘要 + +修复文件上传/下载/写入时 `SSH 连接失败: 'SSHClientConnection' object has no attribute 'create_sftp_client'`。 + +## 动机 + +asyncssh 2.x 的 `SSHClientConnection` 仅提供 `start_sftp_client()`,不存在 `create_sftp_client()`。 + +## 涉及文件 + +| 文件 | 变更 | +|------|------| +| `server/infrastructure/ssh/asyncssh_pool.py` | 新增 `sftp_session()` 上下文管理器 | +| `server/api/sync_v2.py` | upload / download / write-file 改用 `sftp_session` | + +## 迁移 / 重启 + +需重启 `nexus` Supervisor 进程。 + +## 验证方式 + +1. 文件管理 → 编辑并保存 → 应成功(`/api/sync/write-file`)。 +2. 上传、下载文件不再返回 502 与上述 AttributeError。 diff --git a/docs/changelog/2026-06-01-compress-permission-fallback.md b/docs/changelog/2026-06-01-compress-permission-fallback.md new file mode 100644 index 00000000..f0dde158 --- /dev/null +++ b/docs/changelog/2026-06-01-compress-permission-fallback.md @@ -0,0 +1,24 @@ +# 2026-06-01 远程压缩权限与 tar 路径优化 + +## 摘要 + +修复文件管理「压缩」在网站目录无写权限时报 `Permission denied` / `Broken pipe`:改用 `tar -C` 相对路径、先写 `/tmp` 再 `mv`,并支持 `sudo -n` 回退。 + +## 动机 + +原 `tar czf /abs/dest /abs/path...` 在目录不可写时失败,且产生 `Removing leading '/' from member names` 警告。 + +## 涉及文件 + +| 文件 | 变更 | +|------|------| +| `server/infrastructure/ssh/remote_archive.py` | 新建:压缩命令构建与 sudo 回退 | +| `server/api/sync_v2.py` | `/compress` 接入新逻辑 | + +## 迁移 / 重启 + +`supervisorctl restart nexus` + +## 验证方式 + +在 `#/files` 选择文件 → 压缩 → 目标为当前目录下 `xxx.tar.gz`,应成功或给出明确权限提示。 diff --git a/docs/changelog/2026-06-01-editor-dark-theme-sftp-write-fix.md b/docs/changelog/2026-06-01-editor-dark-theme-sftp-write-fix.md new file mode 100644 index 00000000..f7d6ee7c --- /dev/null +++ b/docs/changelog/2026-06-01-editor-dark-theme-sftp-write-fix.md @@ -0,0 +1,29 @@ +# 2026-06-01 编辑器深色底 + SFTP 写入修复 + +## 摘要 + +1. **编辑器**:固定 `nexus-dark` 深色主题,移除透明背景 CSS 与主题切换按钮,编辑区 `#1e1e1e` 底,避免白底看不清字。 +2. **保存**:`sftp.put(BytesIO)` 不符合 asyncssh API,改为 `sftp.open(path, 'wb')` + `write()`;`write-file` 增加绝对路径校验。 + +## 动机 + +- 用户反馈编辑器白底、文字不可见。 +- 保存仍失败:`put()` 只接受本地路径,内存内容需用 `open` 写入。 + +## 涉及文件 + +| 文件 | 变更 | +|------|------| +| `frontend/src/monaco/nexusThemes.ts` | 固定深色主题 | +| `frontend/src/components/FileEditorWorkbench.vue` | 样式、去掉浅色切换 | +| `server/infrastructure/ssh/asyncssh_pool.py` | `sftp_write_bytes()` | +| `server/api/sync_v2.py` | upload / write-file 写入方式 | + +## 迁移 / 重启 + +前端构建部署 + 后端 `supervisorctl restart nexus`。 + +## 验证方式 + +1. 打开编辑器 → 深灰底、浅色字、语法高亮可见。 +2. 修改文件 Ctrl+S / 保存 → 提示「文件已保存」,刷新后内容一致。 diff --git a/docs/changelog/2026-06-01-editor-syntax-highlight.md b/docs/changelog/2026-06-01-editor-syntax-highlight.md new file mode 100644 index 00000000..ba3a4f62 --- /dev/null +++ b/docs/changelog/2026-06-01-editor-syntax-highlight.md @@ -0,0 +1,32 @@ +# 2026-06-01 浮动编辑器语法高亮增强 + +## 摘要 + +- 注册 **Nexus 深色/浅色主题**(`nexus-dark` / `nexus-light`),强化关键字、字符串、注释等 token 配色。 +- 扩展 **语言自动识别**(PHP/Shell/INI/Nginx/Docker 等运维常见后缀与文件名)。 +- 新增 **Nginx** Monarch 语法;工具栏 **语法** 下拉可手动切换或选「自动」。 +- 启用括号配色、缩进参考线、语义高亮、选区高亮等 Monaco 选项。 + +## 动机 + +用户反馈编辑器需要语法高亮;统一 Monaco 初始化与主题,避免 Teleport 面板下样式冲突导致「全灰」观感。 + +## 涉及文件 + +| 文件 | 变更 | +|------|------| +| `frontend/src/monaco/initMonaco.ts` | 统一初始化、编辑器默认选项 | +| `frontend/src/monaco/nexusThemes.ts` | 自定义主题 | +| `frontend/src/utils/editorLanguage.ts` | 语言检测与下拉项 | +| `frontend/src/components/FileEditorWorkbench.vue` | 语法选择器、接入新主题 | +| `frontend/src/components/MonacoEditor.vue` | 同步接入 | + +## 迁移 / 重启 + +仅前端构建部署。 + +## 验证方式 + +1. 打开文件管理 → 编辑 `.php` / `.sh` / `.json` / `nginx.conf` → 应看到彩色语法。 +2. 工具栏「语法」切换为其他语言 → 高亮规则即时更新。 +3. 主题切换按钮 → 深色/浅色主题下对比度正常。 diff --git a/docs/changelog/2026-06-01-files-300k-preload.md b/docs/changelog/2026-06-01-files-300k-preload.md new file mode 100644 index 00000000..3b8cead8 --- /dev/null +++ b/docs/changelog/2026-06-01-files-300k-preload.md @@ -0,0 +1,36 @@ +# 文件管理:300KB 预加载与编辑上限 + +**日期**:2026-06-01 + +## 摘要 + +进入目录后,对 ≤300KB 的普通文件在后台并发预读;双击编辑/查看时优先使用内存缓存。超过 300KB 的文件在客户端直接提示,不再发起 read-file。 + +## 动机 + +用户要求「文件要预加载 300K」,减少双击打开编辑器时的等待,并统一浏览器内可读文件大小上限。 + +## 涉及文件 + +- `frontend/src/utils/filePreload.ts`(新建) +- `frontend/src/pages/FilesPage.vue` + +## 行为说明 + +| 项 | 值 | +|----|-----| +| 预加载/编辑上限 | 300 × 1024 字节 | +| 每目录最多预加载文件数 | 40 | +| 并发预读 | 4 | +| read-file 请求 | 显式传 `max_size: 307200` | + +## 迁移 / 重启 + +- 仅前端构建部署,无需数据库迁移 +- 后端 `read-file` 仍支持至 5MB(schema 未改),由前端限制传参 + +## 验证 + +1. 进入含小文件的目录,状态栏显示「N 个 ≤300KB 可预加载」 +2. 稍等后双击小文件,编辑器应更快打开(Network 无重复 read-file) +3. 双击 >300KB 文件应 toast 提示无法编辑/查看 diff --git a/docs/changelog/2026-06-01-files-browse-fix.md b/docs/changelog/2026-06-01-files-browse-fix.md new file mode 100644 index 00000000..50285122 --- /dev/null +++ b/docs/changelog/2026-06-01-files-browse-fix.md @@ -0,0 +1,37 @@ +# 2026-06-01 文件管理浏览修复 + +## 日期 +2026-06-01 + +## 变更摘要 +修复 SPA 文件管理页(`#/files`)目录列表为空、breadcrumb 无法点击、路径不一致等问题;统一 `/api/sync/browse` 响应为 `items` 字段。 + +## 2026-06-01 追加(全面巡检) +- 面包屑:Vuetify 4 `#item` 槽 + `onBreadcrumbClick`,当前层级禁用 +- 新增 `remotePath.ts`:路径规范化、拼接、面包屑数据、上级目录 +- 工具栏「上级」按钮;未选服务器时提示 +- `parseBrowseResponse`:空目录 `items: []` 不再误判 +- 预览截断 800 字;批量删除反馈成功/失败数 + +## 动机 +- 后端 `POST /api/sync/browse` 返回 `{ entries: [...] }`(字段 `is_dir`、`perms`) +- 前端 `FilesPage.vue` 仅读取 `res.items`,且条目需要 `type: directory|file` +- 生产环境表现为「暂无文件」,浏览/进入子目录均不可用 + +## 涉及文件 +- `server/api/sync_v2.py` — 返回 `items`(含 `type`/`permissions`),过滤 `.` / `..` +- `frontend/src/utils/fileBrowse.ts` — 解析 `items`,兼容旧版 `entries` +- `frontend/src/pages/FilesPage.vue` — 使用 `parseBrowseResponse`,展示 SSH `error` +- `frontend/src/types/api.ts` — `BrowseResponse` 类型与后端对齐 +- `frontend/src/pages/FilesPage.vue` — 表格 `return-object`、面包屑/上级/路径规范化 +- `frontend/src/utils/remotePath.ts` — 远程路径与面包屑工具 +- `docs/audit/2026-06-01-files-page-inspection.md` — 巡检审计 + +## 迁移 / 重启 +- 需重新构建并部署前端至 `web/app/` +- 需部署后端并 `supervisorctl restart nexus`(或仅前端亦可临时兼容旧 API) + +## 验证方式 +1. 打开 `app/#/files`,选择一台可 SSH 的服务器 +2. 应看到 `/` 下文件列表;点击目录可进入 +3. `curl -H "Authorization: Bearer …" -d '{"server_id":1,"path":"/"}' …/api/sync/browse` 响应含 `items` 数组 diff --git a/docs/changelog/2026-06-01-files-capability-elevation.md b/docs/changelog/2026-06-01-files-capability-elevation.md new file mode 100644 index 00000000..1c213763 --- /dev/null +++ b/docs/changelog/2026-06-01-files-capability-elevation.md @@ -0,0 +1,39 @@ +# Changelog — 文件管理提权检测与 elevation 策略 + +| 项 | 内容 | +|----|------| +| 日期 | 2026-06-01 | +| 类型 | 功能(Phase 4) | + +## 摘要 + +新增 `GET /api/servers/{id}/files-capability` 探测目标机免密 sudo 与 chmod/chown 白名单;服务器级 `files_elevation`(`off` / `auto_sudo` / `always_sudo`)存入 `extra_attrs` 并驱动 `remote_shell` 提权行为;前端权限对话框与服务器编辑表单对接。 + +## 动机 + +完成文件管理设计文档 Phase 4:运维可在改权限前检测提权能力,并按服务器关闭/自动/始终 sudo。 + +## 涉及文件 + +- `server/utils/files_elevation.py`(新建) +- `server/infrastructure/ssh/files_capability.py`(新建) +- `server/infrastructure/ssh/remote_shell.py` +- `server/api/servers.py`、`server/api/schemas.py` +- `frontend/src/components/FilePermissionDialog.vue` +- `frontend/src/pages/FilesPage.vue`、`ServersPage.vue` +- `frontend/src/types/api.ts` +- `tests/test_files_elevation.py`(新建) + +## 迁移 / 重启 + +- 无 DB 迁移;默认 `auto_sudo`(未配置 `extra_attrs` 时等同历史行为)。 +- 需重启 Nexus 后端;前端需重新 `vite build` 部署。 + +## 验证 + +```bash +pytest tests/test_files_elevation.py tests/test_unix_ls.py -q +ruff check server/utils/files_elevation.py server/infrastructure/ssh/files_capability.py server/infrastructure/ssh/remote_shell.py +``` + +浏览器:服务器编辑 → 文件管理提权;`#/files` → 右键修改权限 →「检测提权能力」。 diff --git a/docs/changelog/2026-06-01-files-clipboard-transfer.md b/docs/changelog/2026-06-01-files-clipboard-transfer.md new file mode 100644 index 00000000..490b6f0d --- /dev/null +++ b/docs/changelog/2026-06-01-files-clipboard-transfer.md @@ -0,0 +1,48 @@ +# 文件管理:剪切 / 复制 / 粘贴 + +**日期**:2026-06-01 + +## 摘要 + +实现 v2 设计中的远程文件剪贴板:`POST /api/sync/file-clipboard`(`cp -r` / `mv -t`),前端 Ctrl+C/X/V、工具栏粘贴、右键菜单与状态芯片。 + +## 动机 + +用户要求补齐此前因缺少后端 API 未实现的剪切/复制/粘贴。 + +## 涉及文件 + +- `server/api/schemas.py` — `FileClipboardApply` +- `server/api/remote_path_validation.py`(新建) +- `server/api/sync_v2.py` — `/file-clipboard` +- `frontend/src/composables/useFilesClipboard.ts`(新建) +- `frontend/src/composables/useFilesHotkeys.ts` +- `frontend/src/pages/FilesPage.vue` +- `tests/test_remote_path_validation.py`(新建) +- `docs/design/specs/2026-06-01-files-clipboard-transfer-design.md` + +## 安全 + +- 绝对路径 + 禁止 `..` +- 禁止粘贴到源目录子路径 +- 审计 `file_copy` / `file_move` + +## 迁移 / 重启 + +需部署**后端 + 前端**(新 API)。 + +## 验证 + +```bash +pytest tests/test_remote_path_validation.py -q +``` + +浏览器:复制→进入另一目录→粘贴;剪切→粘贴后源消失;跨服务器粘贴应提示。 + +--- + +## 2026-06-01 补充:跨目录粘贴 + +- 粘贴目标:默认当前目录;**单选一个文件夹**时粘贴到该文件夹;**右键目录**「粘贴到 xxx」;**目录树右键**粘贴到该节点 +- 状态栏显示剪贴板内容与粘贴目标路径 +- 前端 `validatePasteTarget` 与后端一致,禁止粘贴到源目录内部 diff --git a/docs/changelog/2026-06-01-files-editor-panel.md b/docs/changelog/2026-06-01-files-editor-panel.md new file mode 100644 index 00000000..138a14b9 --- /dev/null +++ b/docs/changelog/2026-06-01-files-editor-panel.md @@ -0,0 +1,40 @@ +# 文件编辑器:分屏 IDE 面板(替代默认全屏) + +**日期**:2026-06-01 + +## 摘要 + +按 `docs/design/specs/2026-06-01-files-editor-panel-design.md` 与历史 v2 / 2026-05-30 IDE 设计,将 Monaco 从全屏 `v-dialog` 改为文件页底部内嵌面板:多标签、状态栏、最小化标签条、可选全屏。 + +## 动机 + +用户反馈「编辑器现在是全屏」,与设计文档(分屏 + 多文件标签 + 状态栏)不一致。 + +## 涉及文件 + +- `frontend/src/components/FileEditorWorkbench.vue`(新建) +- `frontend/src/pages/FilesPage.vue` +- `docs/design/specs/2026-06-01-files-editor-panel-design.md` +- `frontend/src/components/MonacoEditor.vue`(保留未删,暂无引用) + +## 行为 + +| 操作 | 效果 | +|------|------| +| 双击/编辑 | 底部打开面板,文件表缩至约半屏 | +| 多文件 | 标签切换,各文件独立修改状态 | +| 状态栏 | 行、列、语言、远程路径 | +| 最小化 | 底部芯片条,点击恢复 | +| 全屏按钮 | 覆盖视口编辑,可退出 | +| Ctrl+S / Esc | 保存 / 退出全屏或关闭面板 | + +## 迁移 / 重启 + +仅前端构建部署。 + +## 验证 + +1. 打开编辑器后仍可见上方文件列表并可进目录 +2. 打开两个文件,标签切换内容正确 +3. 移动光标,状态栏行列更新 +4. 最小化、全屏、关闭未保存确认 diff --git a/docs/changelog/2026-06-01-files-floating-editor.md b/docs/changelog/2026-06-01-files-floating-editor.md new file mode 100644 index 00000000..4c1378e2 --- /dev/null +++ b/docs/changelog/2026-06-01-files-floating-editor.md @@ -0,0 +1,31 @@ +# 2026-06-01 文件管理:浮动可拖动编辑器 + 目录树内置 + +## 摘要 + +- **文件管理页**不再显示左侧目录树,文件列表全宽展示。 +- **编辑器**改为 `Teleport` 到 `body` 的浮动面板,支持标题栏拖动、右下角缩放,位置与尺寸持久化到 `localStorage`。 +- **目录树**移入 `FileEditorWorkbench` 左侧栏;在树中导航会同步文件管理页的当前路径;树右键仍可触发粘贴目标。 + +## 动机 + +用户反馈:① 应是「编辑器带文件树」,而非「文件管理带文件树」;② 编辑器应可浮动、可拖动,而非固定在页面底部内嵌分屏。 + +## 涉及文件 + +| 文件 | 变更 | +|------|------| +| `frontend/src/components/FileEditorWorkbench.vue` | 浮动面板、内置 `FileDirectoryTree`、`path-change` / `paste-target` | +| `frontend/src/components/FileDirectoryTree.vue` | 新增 `embedded` 紧凑模式 | +| `frontend/src/composables/useFloatingPanel.ts` | 拖动/缩放与 localStorage 持久化 | +| `frontend/src/pages/FilesPage.vue` | 移除左侧树与 `files-browser--split` | + +## 迁移 / 重启 + +- 仅前端构建部署,无需后端重启。 + +## 验证方式 + +1. 打开 `#/files`,选择服务器并浏览目录——页面无左侧树,列表全宽。 +2. 点击「编辑」——出现居中浮动编辑器,左侧有目录树,可拖动标题栏移动、拖右下角缩放。 +3. 在编辑器目录树点击文件夹——文件管理页路径与列表同步更新。 +4. 最小化后底部出现标签 dock;全屏按钮仍可铺满视口。 diff --git a/docs/changelog/2026-06-01-files-monaco-editor-fix.md b/docs/changelog/2026-06-01-files-monaco-editor-fix.md new file mode 100644 index 00000000..3f477b03 --- /dev/null +++ b/docs/changelog/2026-06-01-files-monaco-editor-fix.md @@ -0,0 +1,28 @@ +# 2026-06-01 文件编辑器 Monaco 适配修复 + +## 日期 +2026-06-01 + +## 变更摘要 +修复文件管理页 Monaco 全屏编辑器空白/高亮失效、切换文件内容不刷新、保存失败等问题。 + +## 动机 +- Vite 打包未配置 `MonacoEnvironment` Worker → 语法高亮与语言服务异常 +- 仅监听 `visible`,打开另一文件时仍显示旧内容 +- 后端 `write-file` 使用 `echo|base64` 受命令行长度限制,大文件或保存易失败 + +## 涉及文件 +- `frontend/src/monaco/setupMonacoEnv.ts` — Vite Worker 配置(新建) +- `frontend/src/components/MonacoEditor.vue` — 布局、换行归一化、Esc/Ctrl+S、ResizeObserver +- `frontend/src/pages/FilesPage.vue` — `editorSessionKey` 强制按路径重建编辑器 +- `server/api/sync_v2.py` — `write-file` 改为 SFTP 写入 + +## 迁移 / 重启 +- 前端构建部署 `web/app/` +- 后端需 `supervisorctl restart nexus` + +## 验证方式 +1. 双击 `.php` / `.js` 文件,编辑器有语法高亮、可编辑 +2. 修改后 Ctrl+S 保存,刷新目录内容已更新 +3. 连续打开两个不同文件,内容互不串台 +4. 关闭时未保存会提示确认 diff --git a/docs/changelog/2026-06-01-files-ownership-permission-ui.md b/docs/changelog/2026-06-01-files-ownership-permission-ui.md new file mode 100644 index 00000000..15089a33 --- /dev/null +++ b/docs/changelog/2026-06-01-files-ownership-permission-ui.md @@ -0,0 +1,40 @@ +# Changelog:文件管理归属列 + 权限对话框 + 统一 sudo 回退 + +| 项 | 内容 | +|----|------| +| 日期 | 2026-06-01 | +| 设计 | `docs/design/specs/2026-06-01-files-root-ownership-ui-design.md` | + +## 变更摘要 + +- 新增 `remote_shell.py`:`exec_ssh_command_with_fallback`(`sudo -n` 自动重试) +- `browse` 返回 `owner` / `group` / `mode_octal` +- `chmod` API 支持可选 `owner`/`group`,走 sudo 回退;审计 `file_permission_change` +- `file-ops`、剪贴板复制/移动、解压接入 sudo 回退 +- 前端:「归属」列 + `FilePermissionDialog`(预设 644/755 等) +- 运维示例:`docs/deploy/nexus-files-sudoers.example` + +## 涉及文件 + +- `server/infrastructure/ssh/remote_shell.py`(新) +- `server/utils/unix_ls.py`(新) +- `server/infrastructure/ssh/remote_archive.py` +- `server/api/sync_v2.py` +- `server/api/schemas.py` +- `frontend/src/components/FilePermissionDialog.vue`(新) +- `frontend/src/utils/fileMode.ts`(新) +- `frontend/src/pages/FilesPage.vue` +- `frontend/src/types/api.ts` +- `frontend/src/utils/fileBrowse.ts` +- `tests/test_unix_ls.py`(新) + +## 验证 + +```bash +pytest tests/test_unix_ls.py tests/test_posix_paths.py -q +ruff check server/infrastructure/ssh/remote_shell.py server/utils/unix_ls.py server/api/sync_v2.py +``` + +## 部署 + +- 后端 + 前端构建上传;目标机按需安装 `nexus-files.sudoers.example` diff --git a/docs/changelog/2026-06-01-files-page-iteration.md b/docs/changelog/2026-06-01-files-page-iteration.md new file mode 100644 index 00000000..8178ef75 --- /dev/null +++ b/docs/changelog/2026-06-01-files-page-iteration.md @@ -0,0 +1,40 @@ +# 2026-06-01 文件管理页迭代 v2 + +## 日期 +2026-06-01 + +## 变更摘要 +`#/files` 第二轮迭代:修复下载、预览对话框、URL 状态、筛选/拖拽上传、符号链接展示。 + +## 动机 +- 下载 API 返回二进制流,原 `http.post` JSON 解析导致「下载已开始」但实际失败 +- 预览用 snackbar 无法阅读;刷新丢失当前路径 +- 对标历史 file-manager v2 规划中的状态栏、拖拽、筛选能力 + +## 涉及文件 +- `frontend/src/api/index.ts` — `fetchAuthed` / `http.download` +- `frontend/src/utils/fileDownload.ts` — 浏览器保存 Blob +- `frontend/src/pages/FilesPage.vue` — UI/交互迭代 +- `frontend/src/utils/remotePath.ts` — `validatePathSegment` +- `frontend/src/utils/fileBrowse.ts` / `types/api.ts` — symlink +- `server/api/sync_v2.py` — browse 返回 `symlink` + `link_target` +- `docs/design/plans/2026-06-01-files-page-iteration.md` + +## 迁移 / 重启 +- 前端构建部署至 `web/app/` +- 后端 `sync_v2.py` 需部署并 `supervisorctl restart nexus`(symlink 字段) + +## 追加(默认路径) +- 文件管理器初始/切换服务器时默认打开 `/www/wwwroot`(`DEFAULT_FILES_ROOT`) + +## 追加(交互) +- 进入目录/符号链接:单击行即可(不再要求双击) +- 普通文件:双击行打开 Monaco 编辑 + +## 验证方式 +1. 选服务器进入子目录,URL 含 `?server_id=&path=`,刷新后位置保持 +2. 双击目录进入;面包屑/上级可用 +3. 下载文件可本地打开 +4. 查看文本文件弹出预览对话框 +5. 拖拽文件到列表区域触发上传 +6. 筛选框过滤当前目录文件名 diff --git a/docs/changelog/2026-06-01-files-phase2-recursive-read-hint.md b/docs/changelog/2026-06-01-files-phase2-recursive-read-hint.md new file mode 100644 index 00000000..c2b4d7f6 --- /dev/null +++ b/docs/changelog/2026-06-01-files-phase2-recursive-read-hint.md @@ -0,0 +1,38 @@ +# Changelog — 文件管理二期(递归 chmod + 不可读提示) + +| 项 | 内容 | +|----|------| +| 日期 | 2026-06-01 | +| 类型 | 功能 | + +## 摘要 + +- `POST /sync/chmod` 支持 `recursive`:仅目录、`test -d` 校验,禁止对 `/` 等系统路径递归;超时 300s。 +- 权限对话框:目录可勾选「同时修改子项」并二次确认。 +- 文件页:根据 `files-capability`、读失败与 ls 权限显示顶部提示条;不可读文件行内 `mdi-shield-lock`。 + +## 动机 + +完成设计文档 §4.5 二期与 L1 不可读场景可观测性。 + +## 涉及文件 + +- `server/utils/files_chmod_policy.py`(新建) +- `server/api/schemas.py`、`server/api/sync_v2.py` +- `frontend/src/utils/fileMode.ts` +- `frontend/src/components/FilePermissionDialog.vue` +- `frontend/src/pages/FilesPage.vue` +- `tests/test_file_permissions.py` +- `docs/design/plans/2026-06-01-files-phase2-recursive-read-hint.md` + +## 迁移 / 重启 + +- 无 DB 变更;需重启后端并重新构建前端。 + +## 验证 + +```bash +pytest tests/test_file_permissions.py -q +``` + +浏览器:选非 root 服务器 → 顶栏提示;目录 chmod 勾选递归;打开无读权限文件触发提示更新。 diff --git a/docs/changelog/2026-06-01-files-phase5-hardening.md b/docs/changelog/2026-06-01-files-phase5-hardening.md new file mode 100644 index 00000000..2d0127f7 --- /dev/null +++ b/docs/changelog/2026-06-01-files-phase5-hardening.md @@ -0,0 +1,34 @@ +# Changelog — 文件管理 Phase 5 加固 + +| 项 | 内容 | +|----|------| +| 日期 | 2026-06-01 | +| 类型 | 加固 / 测试 | + +## 摘要 + +完成设计文档 Phase 5:`ls -la` 解析抽到 `unix_ls.py` 并补单测;browse 优先 `--time-style=long-iso`;`read-file` 走 `exec_ssh_command_with_fallback`;`remote_write_bytes` 尊重 `files_elevation`(off 不 sudo、always 跳过 SFTP 直走 sudo tee)。 + +## 动机 + +统一可测的列表解析、可排序的 ISO 时间、只读文件在 root 属主下可读,并与服务器提权策略一致。 + +## 涉及文件 + +- `server/utils/unix_ls.py` +- `server/api/sync_v2.py`(browse、read-file、write-file、upload) +- `server/infrastructure/ssh/asyncssh_pool.py` +- `tests/test_file_permissions.py`(新建) +- `tests/test_unix_ls.py` +- `docs/audit/2026-06-01-posix-path-batch-review.md` + +## 迁移 / 重启 + +- 无 DB 变更;需重启后端;前端无必改项(时间列展示 ISO 字符串更易排序)。 + +## 验证 + +```bash +pytest tests/test_file_permissions.py tests/test_unix_ls.py tests/test_files_elevation.py -q +ruff check server/utils/unix_ls.py server/infrastructure/ssh/asyncssh_pool.py +``` diff --git a/docs/changelog/2026-06-01-files-v2-complete.md b/docs/changelog/2026-06-01-files-v2-complete.md new file mode 100644 index 00000000..8d421d9b --- /dev/null +++ b/docs/changelog/2026-06-01-files-v2-complete.md @@ -0,0 +1,45 @@ +# 文件管理 v2 功能补齐 + +**日期**:2026-06-01 + +## 摘要 + +补齐历史设计(`2026-05-18-file-manager-v2`、`2026-05-30-monaco-ide-editor`)中 SPA 尚未落地的能力:目录树、新建文件、压缩/解压、排序与类型筛选、增强右键菜单与快捷键。 + +## 动机 + +用户要求「尚未实现的全部要去做」。 + +## 涉及文件 + +- `frontend/src/components/FileDirectoryTree.vue`(新建) +- `frontend/src/composables/useFilesHotkeys.ts`(新建) +- `frontend/src/utils/fileSort.ts`(新建) +- `frontend/src/pages/FilesPage.vue` +- `docs/design/plans/2026-06-01-files-v2-complete.md` +- `docs/design/specs/2026-06-01-files-editor-panel-design.md` + +## 功能列表 + +| 功能 | 说明 | +|------|------| +| 目录树 | 左侧懒加载子目录,点击跳转 | +| 新建文件 | `write-file` 空内容,并打开编辑器 | +| 压缩 | 多选或右键,`/sync/compress` tar.gz/zip | +| 解压 | 归档文件,`/sync/decompress` | +| 排序 | 名称 / 大小 / 时间 | +| 类型筛选 | conf/log/sh/py/json/yml/txt 等 | +| 右键 | 打开目录、复制路径、终端、压缩、解压 | +| 快捷键 | F5 刷新、F2 重命名、Del 删除、Backspace 上级、Ctrl+A 全选 | + +## 迁移 / 重启 + +仅前端部署。 + +## 验证 + +1. `#/files` 左侧目录树展开/点击路径 +2. 新建文件 → 列表出现且编辑器打开 +3. 多选压缩、对 .tar.gz 解压 +4. 排序与类型筛选生效 +5. 右键复制路径、F5 刷新 diff --git a/docs/changelog/2026-06-01-master-backlog-plan.md b/docs/changelog/2026-06-01-master-backlog-plan.md new file mode 100644 index 00000000..cdfc82c6 --- /dev/null +++ b/docs/changelog/2026-06-01-master-backlog-plan.md @@ -0,0 +1,27 @@ +# Changelog — 主待办长计划文档 + +| 项 | 内容 | +|----|------| +| 日期 | 2026-06-01 | +| 类型 | 文档 | + +## 摘要 + +新增 `docs/project/2026-06-01-master-backlog-plan.md`,将 POSIX 审计延续、文件管理 L1~L4 验收、部署门控、8 步审计、全站 T1~T8 与增强项统一为可勾选任务表。 + +## 动机 + +避免多文档分叉;用户要求长计划一次性收录全部待办。 + +## 涉及文件 + +- `docs/project/2026-06-01-master-backlog-plan.md`(新建) +- `docs/project/AI-HANDOFF-2026-05-23.md`(增加 1b 指针) + +## 迁移 / 重启 + +无。 + +## 验证 + +打开主计划,确认 Phase A~G 任务 ID 完整、进度总表与已完成清单不矛盾。 diff --git a/docs/changelog/2026-06-01-posix-path-linux-only.md b/docs/changelog/2026-06-01-posix-path-linux-only.md new file mode 100644 index 00000000..4ad2a8cd --- /dev/null +++ b/docs/changelog/2026-06-01-posix-path-linux-only.md @@ -0,0 +1,40 @@ +# Changelog:远程路径统一 POSIX/Linux + +| 项 | 内容 | +|----|------| +| 日期 | 2026-06-01 | +| 类型 | 修复 / 架构 | +| 动机 | 在 Windows 开发机上 `os.path.join`/`dirname` 会对 `/www/...` 远程路径产生反斜杠,导致 SSH/SFTP/压缩等失败 | + +## 变更摘要 + +- 全仓库审计报告:`docs/audit/2026-06-01-posix-path-full-audit.md` +- 分批审查记录(7 批):`docs/audit/2026-06-01-posix-path-batch-review.md` +- Cursor 规则:`.cursor/rules/nexus-posix-paths.mdc` +- 新增 `server/utils/posix_paths.py`:远程与 Nexus 主机 Unix 路径的规范化、拼接、zip-slip 校验 +- 第二轮:`diagnose`/`verify`/`file-diff` 远程目标路径、`sync_engine_v2` rsync 目标路径 +- `remote_path_validation.py` 改为复用上述工具,并导出 `remote_join` +- `asyncssh_pool`、`servers`(workerman 检测)、`sync_v2` 全部远程路径操作改用 POSIX API +- 上传临时目录相关逻辑:字符串操作用 `posix_join`/`posix_basename`,真实 I/O 仍用 `os.path.realpath`(Nexus 生产为 Linux) +- 新增 `tests/test_posix_paths.py` + +## 涉及文件 + +- `server/utils/posix_paths.py`(新) +- `server/api/remote_path_validation.py` +- `server/infrastructure/ssh/asyncssh_pool.py` +- `server/api/servers.py` +- `server/api/sync_v2.py` +- `tests/test_posix_paths.py`(新) + +## 迁移 / 重启 + +- 需重启 `nexus` 后端;前端无变更 + +## 验证 + +```bash +pytest tests/test_posix_paths.py -q +ruff check server/utils/posix_paths.py server/api/sync_v2.py +python -c "import server.main" +``` diff --git a/docs/changelog/2026-06-01-remote-write-ssh-fallback.md b/docs/changelog/2026-06-01-remote-write-ssh-fallback.md new file mode 100644 index 00000000..859744da --- /dev/null +++ b/docs/changelog/2026-06-01-remote-write-ssh-fallback.md @@ -0,0 +1,29 @@ +# 2026-06-01 远程写入 SFTP 权限回退 SSH tee/mv + +## 摘要 + +解决编辑器保存时报 `SFTP 写入失败: Permission denied`:在 SFTP 无文件写权限但目录可写(或需 sudo)时,自动回退 SSH `tee`、临时文件 `mv` 覆盖、以及 `sudo -n`。 + +## 动机 + +许多生产文件属主为 root、权限 644,SFTP 无法直接 `open(wb)`,但目录可写时可通过 `mv` 替换;部分环境需免密 sudo。 + +## 涉及文件 + +| 文件 | 变更 | +|------|------| +| `server/infrastructure/ssh/asyncssh_pool.py` | `remote_write_bytes()` | +| `server/api/sync_v2.py` | upload / write-file 使用统一写入 | + +## 迁移 / 重启 + +`supervisorctl restart nexus` + +## 验证方式 + +1. 编辑 root 属主且目录可写的配置文件 → 保存成功。 +2. 若仍失败,在目标机为 SSH 用户配置对该路径的写权限或 `NOPASSWD: /usr/bin/tee, /usr/bin/mv`。 + +## 补充(同日) + +- SSH `tee` 回退时 `conn.run(input=bytes)` 需 `encoding=None`,否则报 `utf_8_encode() argument 1 must be str, not bytes`。 diff --git a/docs/changelog/2026-06-01-schema-posix-path-validation.md b/docs/changelog/2026-06-01-schema-posix-path-validation.md new file mode 100644 index 00000000..c6a62e16 --- /dev/null +++ b/docs/changelog/2026-06-01-schema-posix-path-validation.md @@ -0,0 +1,34 @@ +# Changelog:API Schema 层 POSIX 路径校验(P2) + +| 项 | 内容 | +|----|------| +| 日期 | 2026-06-01 | +| 类型 | 增强 / 安全 | +| 动机 | 分批审查 P2:在 Pydantic 入口拒绝 Windows 反斜杠并统一 Linux 路径形态 | + +## 变更摘要 + +- 新增 `server/api/schema_path_validators.py`:远程绝对路径、Nexus 本机绝对路径、相对路径校验 +- `server/api/schemas.py`:服务器 `target_path`、同步/文件/调度等相关字段增加 `field_validator` +- `FileDecompress.dest` 不再默认 `"."`,必须为绝对路径(与解压 API 一致) +- `sync_engine_v2`:推送前对 `source_path` 做 `to_posix` +- 测试:`tests/test_schema_path_validators.py` + +## 涉及文件 + +- `server/api/schema_path_validators.py`(新) +- `server/api/schemas.py` +- `server/application/services/sync_engine_v2.py` +- `tests/test_schema_path_validators.py`(新) + +## 迁移 / 重启 + +- 需重启后端 +- 若客户端曾传 `dest: "."` 解压,将收到 422,应改为当前目录绝对路径(如 `/www/wwwroot/...`) + +## 验证 + +```bash +pytest tests/test_schema_path_validators.py tests/test_posix_paths.py tests/test_remote_path_validation.py -q +ruff check server/api/schemas.py server/api/schema_path_validators.py +``` diff --git a/docs/deploy/nexus-files-sudoers.example b/docs/deploy/nexus-files-sudoers.example new file mode 100644 index 00000000..56540b3d --- /dev/null +++ b/docs/deploy/nexus-files-sudoers.example @@ -0,0 +1,7 @@ +# 示例:Nexus 文件管理免密 sudo(由运维审阅后安装) +# 安装:visudo -cf /etc/sudoers.d/nexus-files /etc/sudoers.d/nexus-files +# 将 deploy 替换为实际 SSH 用户名 + +deploy ALL=(ALL) NOPASSWD: /bin/chmod, /usr/bin/chmod, /bin/chown, /usr/bin/chown, \ + /bin/cp, /bin/mv, /bin/mkdir, /bin/rm, /bin/tar, /usr/bin/tar, \ + /usr/bin/unzip, /usr/bin/zip, /usr/bin/tee, /bin/cat, /usr/bin/touch diff --git a/docs/design/plans/2026-06-01-files-page-iteration.md b/docs/design/plans/2026-06-01-files-page-iteration.md new file mode 100644 index 00000000..bea3be4e --- /dev/null +++ b/docs/design/plans/2026-06-01-files-page-iteration.md @@ -0,0 +1,24 @@ +# 文件管理页迭代(SPA #/files) + +## 背景 +`FilesPage.vue` 已修复 browse 契约与面包屑;仍缺生产级文件管理体验(下载、预览、状态持久化、过滤等)。 + +## 目标 +1. 下载:对接 `StreamingResponse`,浏览器真实保存文件 +2. 预览:对话框展示文本(非 snackbar 全文) +3. 路由:`?server_id=&path=` 刷新可恢复 +4. UX:状态栏、名称过滤、双击进目录、拖拽上传、符号链接展示 + +## 涉及文件 +- `frontend/src/api/index.ts` — `http.download` +- `frontend/src/utils/fileDownload.ts` — 触发浏览器保存 +- `frontend/src/utils/fileBrowse.ts` / `types/api.ts` — symlink 类型 +- `server/api/sync_v2.py` — browse 返回 `symlink` + `link_target` +- `frontend/src/pages/FilesPage.vue` — UI 迭代 + +## 验收 +- [ ] 点击下载得到本地文件且可打开 +- [ ] 预览大文件在对话框内滚动,不超过 read-file 限制 +- [ ] 刷新页面保留服务器与路径(query) +- [ ] 面包屑/上级/双击目录行为一致 +- [ ] 空目录显示「暂无文件」且状态栏计数为 0 diff --git a/docs/design/plans/2026-06-01-files-phase2-recursive-read-hint.md b/docs/design/plans/2026-06-01-files-phase2-recursive-read-hint.md new file mode 100644 index 00000000..3ae260eb --- /dev/null +++ b/docs/design/plans/2026-06-01-files-phase2-recursive-read-hint.md @@ -0,0 +1,22 @@ +# 文件管理二期 — 递归 chmod + 不可读提示 + +| 项 | 内容 | +|----|------| +| 设计 | [2026-06-01-files-root-ownership-ui-design.md](../specs/2026-06-01-files-root-ownership-ui-design.md) §4.5 二期 | +| 日期 | 2026-06-01 | + +## 范围 + +1. `POST /sync/chmod` 增加 `recursive`;仅目录、`test -d` 校验;禁止对 `/` 等系统根递归。 +2. `FilePermissionDialog`:目录显示「同时修改子项」+ 二次确认。 +3. `FilesPage`:按 `files-capability` + 读失败 + `ls` 权限启发式显示顶部提示条;行内 `mdi-shield-lock`。 + +## 安全 + +- 远程 path 仍 `normalize_remote_abs_path`;`chmod -R` / `chown -R` 经 `exec_ssh_command_with_fallback`。 +- 递归超时 300s;审计记录 `recursive`。 + +## 验证 + +- `pytest tests/test_file_permissions.py` +- 浏览器:目录 chmod 勾选递归;非 root 属主文件显示锁图标与顶栏提示。 diff --git a/docs/design/plans/2026-06-01-files-root-ownership-ui.md b/docs/design/plans/2026-06-01-files-root-ownership-ui.md new file mode 100644 index 00000000..cdbc3566 --- /dev/null +++ b/docs/design/plans/2026-06-01-files-root-ownership-ui.md @@ -0,0 +1,60 @@ +# 文件管理:提权 + 归属 + 权限对话框 — 实施计划 + +| 项 | 内容 | +|----|------| +| 设计文档 | [2026-06-01-files-root-ownership-ui-design.md](../specs/2026-06-01-files-root-ownership-ui-design.md) | +| 日期 | 2026-06-01 | + +## 涉及文件(预估) + +| 层 | 文件 | 变更 | +|----|------|------| +| 基础设施 | `server/infrastructure/ssh/remote_shell.py` | **新建** 统一 `exec_remote_command` + elevation | +| 基础设施 | `server/infrastructure/ssh/remote_archive.py` | 迁出 fallback 到 remote_shell | +| 基础设施 | `server/infrastructure/ssh/asyncssh_pool.py` | `remote_write_bytes` 对齐 elevation | +| 工具 | `server/utils/unix_ls.py` | **新建** perms → mode_octal | +| API | `server/api/sync_v2.py` | browse 扩展字段;chmod 扩展 chown | +| API | `server/api/servers.py` | 可选 `GET .../files-capability` | +| 领域 | `server/domain/models/server.py` | 可选 `files_elevation` 列 | +| 前端类型 | `frontend/src/types/api.ts` | `FileEntry` + `FileChmodRequest` | +| 前端 API | `frontend/src/api/fileBrowse.ts` | 映射新字段 | +| 前端页面 | `frontend/src/pages/FilesPage.vue` | 归属列、右键对话框 | +| 前端组件 | `frontend/src/components/FilePermissionDialog.vue` | **新建** | +| 前端工具 | `frontend/src/utils/fileMode.ts` | 展示辅助(tooltip) | +| 运维 | `docs/deploy/nexus-files-sudoers.example` | **新建** | +| 测试 | `tests/test_file_permissions.py` | browse 解析、chmod 校验 | + +## 实现步骤 + +1. **remote_shell**:实现 `FilesElevation`、`exec_remote_command`、从 `remote_archive` 抽 fallback。 +2. **browse**:后端返回 `owner`/`group`/`mode_octal`;前端「归属」列 + tooltip。 +3. **FilePermissionDialog**:预设、八进制、chown;对接扩展 `POST /sync/chmod`。 +4. **file-ops / delete / mkdir**:接入 `exec_remote_command`。 +5. **files-capability** + sudoers 示例文档。 +6. **服务器 `files_elevation`**(若本期做 DB 字段则 migration,否则先用 settings 键)。 +7. **pytest** + `ruff` + 本地 browse/chmod 手测。 +8. **changelog** + 审计登记。 + +## 依赖与配置 + +- 目标机:`/etc/sudoers.d/nexus-files`(运维手动,模板见 deploy 示例)。 +- 无 `.env` 变更;可选 `NEXUS_FILES_ELEVATION_DEFAULT=auto_sudo`(二期)。 + +## 测试要点 + +- mock SSH:`permission denied` → 第二次带 `sudo -n`。 +- browse 解析含空格文件名、符号链接。 +- chmod 400/755、chown `www:www`、非法 owner 拒绝。 +- 前端对话框预设与目录/文件类型过滤。 + +## 回滚 + +- 后端:还原 `sync_v2.py` browse/chmod;删除 `remote_shell.py`,恢复 archive 内联 fallback。 +- 前端:隐藏归属列,恢复旧 chmod 单输入框。 +- DB:若新增 `files_elevation` 列,down migration 删除列。 + +## 部署 + +- 后端 `scp` + `supervisorctl restart nexus` +- 前端 `deploy/deploy-frontend.sh` +- 生产验证:server_id=30 路径下 chmod 644 → 列表显示 `www` diff --git a/docs/design/plans/2026-06-01-files-v2-complete.md b/docs/design/plans/2026-06-01-files-v2-complete.md new file mode 100644 index 00000000..346f33fa --- /dev/null +++ b/docs/design/plans/2026-06-01-files-v2-complete.md @@ -0,0 +1,22 @@ +# 文件管理 v2 补齐实施说明 + +## 范围(原「非目标」+ 2026-05-30 IDE) + +1. 左侧目录树懒加载(`browse` 仅取子目录) +2. 新建空文件(`write-file` content="") +3. 压缩 / 解压 UI(`/sync/compress`、`/sync/decompress`) +4. 排序(名称 / 大小 / 时间) +5. 扩展名筛选 +6. 右键:复制路径、终端、压缩、解压、打开目录 +7. 快捷键 F5/F2/Delete/Backspace + +## 文件 + +- `frontend/src/components/FileDirectoryTree.vue` +- `frontend/src/composables/useFilesHotkeys.ts` +- `frontend/src/pages/FilesPage.vue` +- `docs/design/specs/2026-06-01-files-editor-panel-design.md`(更新验收) + +## 回滚 + +还原上述前端文件并重新 `vite build` 部署即可;无 DB 变更。 diff --git a/docs/design/specs/2026-06-01-files-clipboard-transfer-design.md b/docs/design/specs/2026-06-01-files-clipboard-transfer-design.md new file mode 100644 index 00000000..6e5c9ae4 --- /dev/null +++ b/docs/design/specs/2026-06-01-files-clipboard-transfer-design.md @@ -0,0 +1,40 @@ +# 远程文件剪切/复制/粘贴 + +**日期**: 2026-06-01 +**状态**: 已确认 + +## 背景 + +`2026-05-18-file-manager-v2` 要求 Ctrl+C/V/X 与右键剪切/复制/粘贴;此前无后端批量 `cp`/`mv` 接口。 + +## 方案 + +新增 `POST /api/sync/file-clipboard`: + +| 字段 | 说明 | +|------|------| +| `server_id` | 目标服务器 | +| `mode` | `copy` \| `move`(move = 剪切后粘贴) | +| `sources` | 绝对路径列表(1~50) | +| `dest_dir` | 目标目录(绝对路径) | + +命令:`cp -r src… dest/`;`mv -t dest/ src…`(GNU mv)。 + +## 安全 + +- 路径必须 `/` 开头,禁止 `..` +- 禁止粘贴到源路径自身或其子目录 +- 仅 JWT 管理员;审计 `file_copy` / `file_move` + +## 前端 + +- 模块级剪贴板状态(同服务器粘贴) +- 工具栏粘贴、右键与 Ctrl+C/X/V +- 剪切粘贴成功后清空剪贴板 + +## 验收 + +- [ ] 多选复制到另一目录,源仍在 +- [ ] 剪切后粘贴,源消失 +- [ ] 跨服务器粘贴提示不可用 +- [ ] 粘贴到子目录被拒绝 diff --git a/docs/design/specs/2026-06-01-files-editor-panel-design.md b/docs/design/specs/2026-06-01-files-editor-panel-design.md new file mode 100644 index 00000000..3e15c904 --- /dev/null +++ b/docs/design/specs/2026-06-01-files-editor-panel-design.md @@ -0,0 +1,53 @@ +# 文件管理编辑器面板(SPA) + +**日期**: 2026-06-01 +**状态**: 已确认 +**依据**: `2026-05-18-file-manager-v2-design.md` §四、`2026-05-30-monaco-ide-editor` changelog + +## 背景 + +当前 `MonacoEditor.vue` 使用 `v-dialog fullscreen`,遮挡整个应用,与历史设计不符: + +| 文档 | 编辑器形态 | +|------|------------| +| v2 设计 §四 | 工具栏 + 编辑区 + **底部状态栏**(行/列/语言),非占满视口 | +| v2 计划任务 5 | 对话框约 **95vw × 90vh** | +| 2026-05-30 IDE 重构 | **分屏 IDE**、多标签、可最小化/最大化,非唯一全屏 | + +## 目标 + +1. 编辑器默认嵌入 `#/files` 页面底部,文件列表仍可见(约上半屏) +2. 多文件标签页,切换保留各文件内容与修改状态 +3. 状态栏:行、列、语言、远程路径 +4. 工具栏:保存、主题、**最大化**、关闭面板、**最小化**到底部标签条 +5. 保留 Ctrl+S、Esc、未保存关闭确认 + +## 布局(v2 完整) + +``` +┌─ 工具栏 / 面包屑 / 文件表(可滚动,约 42vh)────────┐ +├─ 编辑器面板 ────────────────────────────────────────┤ +│ [tab1●] [tab2] [保存][主题][⛶][─][×] │ +│ ┌─ Monaco ─────────────────────────────────────┐ │ +│ └──────────────────────────────────────────────┘ │ +│ 行 12, 列 4 │ python │ /www/.../main.py │ +└────────────────────────────────────────────────────┘ +``` + +最小化:仅显示底部标签条,点击恢复。最大化:`position: fixed` 铺满视口,文件表隐藏。 + +## v2 补齐(2026-06-01) + +- 左侧目录树懒加载(`browse` 子目录) +- 新建空文件、压缩/解压、排序、扩展名筛选 +- 右键:复制路径、终端、压缩、解压 +- 快捷键 F5 / F2 / Delete / Backspace / Ctrl+A + +## 验收 + +- [x] 打开编辑器后仍能看到文件列表并切换目录 +- [x] 同时打开 2+ 文件,标签切换内容正确 +- [x] 状态栏行列随光标更新 +- [x] 最大化/最小化/关闭面板行为符合预期 +- [x] 未保存关闭单标签或整面板有确认 +- [x] 目录树、新建文件、压缩解压、排序筛选、快捷键 diff --git a/docs/design/specs/2026-06-01-files-root-ownership-ui-design.md b/docs/design/specs/2026-06-01-files-root-ownership-ui-design.md new file mode 100644 index 00000000..486fcd69 --- /dev/null +++ b/docs/design/specs/2026-06-01-files-root-ownership-ui-design.md @@ -0,0 +1,403 @@ +# 文件管理:自动提权、归属显示、权限修改 — 设计文档 + +| 项 | 内容 | +|----|------| +| 日期 | 2026-06-01 | +| 状态 | 已实现 Phase 1–5 + 二期递归 chmod/不可读提示(2026-06-01) | +| 范围 | `#/files` 远程文件管理(浏览、写入、压缩、chmod 等) | +| 关联 | `sync_v2.py` · `asyncssh_pool.py` · `remote_archive.py` · `FilesPage.vue` | + +--- + +## 1. 背景与目标 + +### 1.1 背景 + +当前文件管理已具备浏览、编辑、上传、压缩、右键 chmod 等能力,但生产环境普遍存在: + +- SSH 登录用户为 **deploy/www** 等非 root 账号; +- 站点目录与文件属主为 **root** 或 **www**,普通用户 **读得到、写不进**; +- 列表展示 `drwxr-xr-x` 等原始 `ls` 权限串,运维难以一眼看出「这是谁家的文件」; +- chmod 仅支持八进制输入,无预设、无 chown,且无 sudo 回退,与保存/压缩已做的提权能力不一致。 + +用户诉求(原文): + +1. **自动获取 root 权限**(文件操作应能自动提权,而不是每次失败再手工改服务器); +2. **列表显示权限**,且 **简化为用户维度**(直接显示 `www` / `root` 等,而非冗长权限串); +3. **右键修改权限**,弹出 **结构化对话框**(而非仅一个 chmod 输入框)。 + +### 1.2 目标 + +| 目标 | 可验收标准 | +|------|------------| +| G1 自动提权 | 写、删、压缩、chmod/chown 等在配置允许时自动 `sudo -n` 重试,成功率与 root 登录接近 | +| G2 归属可读 | 列表默认列显示 **属主**(如 `www`),悬停可看完整 `权限 + 用户:组` | +| G3 权限可改 | 右键「修改权限」打开对话框:预设 + 自定义八进制 + 可选改属主/属组 | +| G4 安全可审计 | 提权范围可配置、命令白名单、所有 chmod/chown 记审计日志 | +| G5 不破坏架构 | API → Service/SSH 基础设施层,不在路由里散落 sudo 逻辑 | + +### 1.3 非目标(本期不做) + +- 不要求 Nexus 在目标机上 **永久改写 sudoers**(类似 Agent 安装的临时注入)作为默认路径——仅文档 + 可选一键脚本; +- 不实现交互式 `sudo` 密码输入(违反无人值守运维); +- 不把文件管理改成「必须 root SSH 才能用」。 + +--- + +## 2. 现状摘要 + +| 能力 | 现状 | 缺口 | +|------|------|------| +| 浏览 | `ls -la`,解析 `perms`,**未返回 owner/group** | 前端无法显示 www/root | +| 保存/上传 | SFTP → SSH tee/mv,`sudo -n` 部分已做 | 未统一、chmod 未接入 | +| 压缩 | `remote_archive.py` 已 sudo 回退 | — | +| chmod | 仅 `chmod mode path`,无 sudo | 非 root 常失败 | +| 提权模型 | Agent 安装用 `_sudo_wrap` 临时 sudoers | 文件管理未复用同一策略 | + +后端 `browse` 已解析 `parts[2]`(owner)、`parts[3]`(group),但未写入 API 响应(见 `sync_v2.py` 约 270–302 行)。 + +--- + +## 3. 方案对比 + +### 3.1 自动提权 + +| 方案 | 说明 | 优点 | 缺点 | +|------|------|------|------| +| **A. 仅 SSH 用户** | 不 sudo,失败即报错 | 最简单 | 无法满足用户「自动 root」 | +| **B. 统一 sudo -n 回退** | 先普通执行,Permission denied 再 `sudo -n bash -c` | 与现网 partial 实现一致;可审计 | 需目标机配置 NOPASSWD 白名单 | +| **C. 每次临时写 sudoers** | 复用 `servers._sudo_wrap` | 无预先配置 | 安全风险高、慢、需 root 写 sudoers | +| **D. 文件管理强制 root SSH** | 凭据层只用 root | 权限足 | 违背最小权限;很多站点不给 root | + +**选定:B(主) + 安装向导/文档配置 NOPASSWD(辅)** + +- 业务命令统一走 `exec_ssh_command_with_fallback()`(从 `remote_archive` 提升到 `remote_shell.py`)。 +- 对 **非 root** SSH 用户:失败且 stderr 含 permission denied → 自动 `sudo -n bash -c '...'`。 +- **root** SSH 用户:不包 sudo。 +- 提供 **服务器级开关**(见 §5.2),默认 `auto_sudo`。 + +不推荐 C 作为默认:与 Agent 安装场景不同,文件管理高频、面过大。 + +### 3.2 权限展示 + +| 方案 | 列表主列 | 说明 | +|------|----------|------| +| **S1** | `drwxr-xr-x` | 现状 | +| **S2** | `www` | 仅属主 | +| **S3** | `www · 755` | 属主 + 八进制简写(从 perms 解析) | +| **S4** | Chip `www` + tooltip 全文 | **推荐**:主信息属主,详情悬停 | + +**选定:S4** + +- 列名:**归属**(`owner` 字段,展示 `owner` 或 `owner:group` 若不同)。 +- 辅助解析 `mode_octal`:`755` / `644` 供排序或二级展示(可选列,默认隐藏或放在 tooltip)。 +- `symlink` 仍显示 `→ target`,归属列显示 `-` 或链接目标属主。 + +### 3.3 修改权限对话框 + +| 方案 | 内容 | +|------|------| +| **P1** | 仅八进制 chmod(现状) | +| **P2** | 预设按钮 + 八进制 + chown(**推荐**) | +| **P3** | 完整 Unix 权限矩阵(rwx 勾选) | + +**选定:P2** — 满足运维习惯,实现成本低于 P3。 + +--- + +## 4. 选定方案详设 + +### 4.1 统一远程 Shell 执行层 + +新增模块(建议路径): + +``` +server/infrastructure/ssh/remote_shell.py +``` + +职责: + +```python +# 概念接口(实现时写完整类型) + +class FilesElevation(str, Enum): + OFF = "off" # 从不 sudo + AUTO = "auto_sudo" # 默认:失败且权限错误时 sudo -n + ALWAYS = "always_sudo" # 所有命令直接 sudo -n(可选,仅排障) + +async def exec_remote_command( + server: Server, + command: str, + *, + timeout: int = 30, + elevation: FilesElevation | None = None, # None → 读服务器/全局配置 +) -> CommandResult: ... + +def wrap_sudo(command: str) -> str: + return f"sudo -n bash -c {shlex.quote(command)}" +``` + +**接入范围(须全部走统一层)** + +| 操作 | 当前入口 | 改造 | +|------|----------|------| +| browse | `exec_ssh_command(ls)` | 保持直接执行(只读,一般不需 sudo) | +| write-file / upload | `remote_write_bytes` | 已有多级回退,并入 elevation 策略 | +| delete / mkdir / rename | `file-ops` | 加 fallback | +| compress / decompress | `remote_archive` | 已有 fallback,迁到 remote_shell | +| chmod / chown | `chmod` 端点 | 扩展 payload + fallback | +| read-file | `cat` | 一般只读;可选对不可读文件 sudo cat(二期) | + +**提权判定** + +```text +exit_code != 0 +且 ( + "permission denied" in stderr.lower() + 或 "operation not permitted" in stderr.lower() +) +→ 若 elevation == AUTO,重试 wrap_sudo(command) +``` + +### 4.2 目标机 sudo 配置(运维一次性) + +在 `docs/deploy/` 增加模板 **`nexus-files.sudoers`**(示例): + +```sudoers +# /etc/sudoers.d/nexus-files — 由运维审阅后安装 +deploy ALL=(ALL) NOPASSWD: /bin/chmod, /bin/chown, /usr/bin/chown, + /bin/mv, /bin/cp, /bin/mkdir, /bin/rm, /bin/tar, /usr/bin/zip, + /usr/bin/unzip, /usr/bin/tee, /usr/bin/cat +``` + +说明: + +- Nexus **不会**默认自动创建该文件(避免未授权扩权); +- 安装向导或「服务器详情 → 文件管理」页提供 **检测** API:`GET /api/servers/{id}/files-capability` + - 检测项:`ssh_user`、`can_sudo_nopasswd`、`whitelist_ok`; +- 检测失败时,前端 Toast 链到文档锚点。 + +若 SSH 用户已是 **root**:`can_sudo_nopasswd=true`,无需 sudoers。 + +### 4.3 浏览 API 扩展 + +**`POST /api/sync/browse` 响应项扩展** + +```json +{ + "name": "index.php", + "type": "file", + "size": 1234, + "modified": "Jun 1 12:00", + "permissions": "rw-r--r--", + "mode_octal": "644", + "owner": "www", + "group": "www", + "link_target": null +} +``` + +| 字段 | 来源 | 说明 | +|------|------|------| +| `permissions` | `ls` 第 1 列 | 保留,供 tooltip / 高级用户 | +| `mode_octal` | 从 perms 解析 | `rwxr-xr-x` → `755`(含 setuid 等用 4 位,少见则原样) | +| `owner` | `ls` 第 3 列 | **列表主展示** | +| `group` | `ls` 第 4 列 | 与 owner 相同时 UI 只显示 owner | + +解析工具:`frontend/src/utils/fileMode.ts` + 后端 `server/utils/unix_ls.py`(避免前后端不一致,**以后端为准**)。 + +**browse 可选增强(二期)**:`ls -la --time-style=long-iso` 统一时间格式。 + +### 4.4 前端列表 UI + +**列调整(`FilesPage.vue`)** + +| 列 | 默认 | 展示 | +|----|------|------| +| 名称 | 显示 | 不变 | +| 归属 | **显示**(新) | `v-chip`:`www` / `root`;`www:nogroup` 简化为 `www` | +| 大小 | 显示 | 不变 | +| 修改时间 | 显示 | 不变 | +| 权限 | **隐藏**(可选展开) | tooltip:`drwxr-xr-x · 755` | + +**样式约定** + +- `owner === 'root'` → chip 颜色 `warning`(提醒系统文件) +- 当前 SSH 用户无写权限且非 root 属主 → 行尾可选 icon `mdi-shield-lock`(仅提示,不阻断只读) + +### 4.5 右键「修改权限」对话框 + +**入口**:已有右键 `chmod` 菜单项,改为打开新组件 `FilePermissionDialog.vue`。 + +**对话框结构** + +``` +┌─ 修改权限 ─────────────────────────────┐ +│ 文件:index.php │ +│ 当前:www · 644 (rw-r--r--) │ +├─────────────────────────────────────────┤ +│ 常用预设(按 type 过滤) │ +│ [ 644 文件 ] [ 755 目录 ] [ 600 私密 ] │ +│ [ 775 协作目录 ] [ 自定义… ] │ +├─────────────────────────────────────────┤ +│ 八进制权限 [ 644 ] (验证 3–4 位) │ +│ 属主 [ www ] (可选) │ +│ 属组 [ www ] (可选) │ +│ □ 同时修改子项(仅目录,二期) │ +├─────────────────────────────────────────┤ +│ 提权:将使用 sudo 执行(SSH 用户 deploy) │ +│ [ 检测提权能力 ] 链接文档 │ +├─────────────────────────────────────────┤ +│ [ 取消 ] [ 应用 ] │ +└─────────────────────────────────────────┘ +``` + +**预设表(前端常量,后端可校验)** + +| 标签 | mode | 适用 | +|------|------|------| +| 文件默认 | 644 | file | +| 目录默认 | 755 | directory | +| 仅本人 | 600 | file | +| 协作目录 | 775 | directory | +| 可执行 | 755 | file(脚本) | + +**API:`POST /api/sync/chmod` 扩展** + +```python +class FileChmod(BaseModel): + server_id: int + path: str = Field(..., min_length=1, max_length=4096) + mode: str = Field(..., pattern=r"^[0-7]{3,4}$") + owner: str | None = Field(None, max_length=64) # 可选 chown + group: str | None = Field(None, max_length=64) + recursive: bool = False # 二期:chmod -R +``` + +**远程命令(经 `exec_remote_command` + AUTO elevation)** + +```bash +# 单文件 +chmod 644 '/path/file' && chown www:www '/path/file' + +# 仅 chmod +chmod 644 '/path/file' +``` + +顺序:先 `chmod` 再 `chown`(或反之,实现时固定一种并写测试)。 + +**响应** + +```json +{ + "success": true, + "path": "/www/.../index.php", + "mode": "644", + "owner": "www", + "group": "www", + "elevated": true +} +``` + +前端成功后 `browseCurrent()` 刷新列表。 + +### 4.6 服务器级配置(三写之一扩展) + +在 **服务器编辑** 或 **settings** 增加(MySQL `servers` 表或 `settings` JSON): + +| 字段 | 类型 | 默认 | 说明 | +|------|------|------|------| +| `files_elevation` | enum | `auto_sudo` | `off` / `auto_sudo` / `always_sudo` | + +不在安装向导强制,避免与 Agent sudoers 混淆;在服务器表单「高级 → 文件管理」折叠区配置。 + +--- + +## 5. 安全与性能 + +### 5.1 安全 + +- **最小权限**:`sudo -n` 仅允许白名单命令,禁止 `ALL=(ALL) NOPASSWD: ALL`; +- **禁止**在 API 中拼接未校验的 owner/group(仅 `[a-zA-Z0-9_-.]+`); +- **路径**继续 `normalize_remote_abs_path`,禁止 `..`; +- **审计**:`file_chmod` / `file_chown` 合并为 `file_permission_change`,记录 mode、owner、group、elevated; +- **失败信息**不泄露 sudoers 路径内容。 + +### 5.2 性能 + +- browse 仍单次 `ls -la`,不增加 SSH 往返; +- sudo 回退最多 **2 倍** 命令耗时(仅失败路径),可接受; +- 批量 chmod(二期 `-R`)需限制路径深度或要求确认框。 + +--- + +## 6. 实现步骤(供技术文档拆分) + +| 阶段 | 内容 | 预估 | +|------|------|------| +| **Phase 1** | `remote_shell.py` + 迁移 write/compress/chmod/file-ops | 1d | +| **Phase 2** | browse 返回 owner/group/mode_octal;前端归属列 | 0.5d | +| **Phase 3** | `FilePermissionDialog` + chmod API 扩展 chown | 1d | +| **Phase 4** | `files-capability` 检测 + 文档 + 设置项 `files_elevation` | 0.5d | +| **Phase 5** | 测试 + changelog + 审计 | 0.5d | + +依赖顺序:Phase 1 → 2/3 可并行 → 4 → 5。 + +--- + +## 7. 验收标准 + +### L1 提权 + +- [ ] SSH 用户 `deploy` + 已配置 `nexus-files.sudoers`:编辑、保存、压缩、chmod 均成功,`elevated: true` 出现在审计(若执行了 sudo)。 +- [ ] SSH 用户 `root`:不调用 sudo,功能正常。 +- [ ] 无 sudo 且非 root:操作失败,提示含「配置免密 sudo」链接,不返回误导性「SSH 连接失败」。 + +### L2 展示 + +- [ ] 列表「归属」列显示 `www` / `root`,悬停可见完整权限串。 +- [ ] 不再默认展示 `drwxr-xr-x` 宽列(可列设置打开)。 + +### L3 修改权限 + +- [ ] 右键单文件/目录 → 对话框预设 644/755 一键填充。 +- [ ] 修改属主为 `www` 后,`ls` 刷新显示更新。 +- [ ] 非法 mode/owner 前端校验 + 后端 400。 + +### L4 回归 + +- [ ] 文件树、编辑器、剪贴板粘贴、下载不受回归影响。 + +--- + +## 8. 风险与对策 + +| 风险 | 对策 | +|------|------| +| 用户以为「自动 root」= Nexus 破解权限 | UI 文案写清:**在服务器已授权 sudo 前提下自动提权** | +| sudo 白名单遗漏命令 | capability 检测 + 失败时记录完整 stderr | +| `ls` 解析在不同 OS 上列错位 | 优先 GNU coreutils;异常行跳过并记日志 | +| chown 误操作 | 对话框展示当前值;危险路径(/etc、/usr)二次确认(二期) | + +--- + +## 9. 相关文档(实现后补齐) + +- 技术实施:`docs/design/plans/2026-06-01-files-root-ownership-ui.md`(待写) +- Changelog:`docs/changelog/2026-06-01-files-root-ownership-ui.md`(待写) +- 运维:`docs/deploy/nexus-files-sudoers.example` + +--- + +## 10. 决策记录 + +| 决策 | 选择 | 理由 | +|------|------|------| +| 自动 root 实现 | `sudo -n` 回退,非临时写 sudoers | 安全、可审计、与现有 partial 实现一致 | +| 列表权限 | 主显 **属主**,权限进 tooltip | 符合用户「www/root」认知 | +| chmod UI | 预设 + 八进制 + 可选 chown | 平衡能力与复杂度 | +| browse 是否 sudo | 否(默认) | 列目录极少需要 root;减少噪音 | + +--- + +**请评审确认后进入 Phase 1 实现。** 若需「始终 sudo」或「强制 root SSH」,可在服务器级 `files_elevation` 中开启 `always_sudo`。 diff --git a/docs/project/2026-06-01-master-backlog-plan.md b/docs/project/2026-06-01-master-backlog-plan.md new file mode 100644 index 00000000..d2eba100 --- /dev/null +++ b/docs/project/2026-06-01-master-backlog-plan.md @@ -0,0 +1,263 @@ +# Nexus 主待办长计划(文件管理 · POSIX · 部署 · 审计) + +| 项 | 内容 | +|----|------| +| 创建日期 | 2026-06-01 | +| 状态 | **活跃** — 新会话接续本文任务 ID | +| 范围 | 2026-06-01 会话内已实现代码的验证/部署/审计 + 全站遗留验收 | +| 关联 SSOT | [development-acceptance-standard.md](./development-acceptance-standard.md) · [AI-HANDOFF-2026-05-23.md](./AI-HANDOFF-2026-05-23.md) | + +--- + +## 如何使用 + +1. 按 **阶段(Phase)** 顺序执行;阶段内任务可并行标 `∥`。 +2. 每完成一项:将 `[ ]` 改为 `[x]`,并在对应 changelog/audit 留痕。 +3. 宣称「可部署/可上线」前:至少完成 **Phase D(门控)** + 相关 **Phase C/E** 验证任务。 +4. 任务 ID 格式:`{域}-{序号}`,便于 PR/commit 引用(例:`DEP-03`)。 + +**图例** + +| 标记 | 含义 | +|------|------| +| `[x]` | 代码/文档已完成(2026-06-01 会话) | +| `[ ]` | 待做 | +| `🔍` | 需人工/生产环境 | +| `⚠` | 阻塞部署或上线 | + +--- + +## 总览路线图 + +```mermaid +flowchart LR + subgraph done [已实现代码] + A1[POSIX 批次 1-7] + A2[文件管理 P1-P5] + A3[二期递归+提示] + end + subgraph next [待执行] + B[Phase A 单测与门控] + C[Phase B 部署] + D[Phase C 浏览器与 L1-L4] + E[Phase D 正式审计 8 步] + F[Phase E POSIX 批次 13+] + G[Phase F 全站回归 T1-T5] + H[Phase G 增强与风险] + end + done --> B --> C --> D + D --> E + E --> F + F --> G + G --> H +``` + +--- + +## Phase A — 本地验证与程序门控(未部署前必做) + +| ID | 任务 | 状态 | 验收标准 | +|----|------|------|----------| +| A-01 | 跑 POSIX 单测套件 | [ ] | `pytest tests/test_posix_paths.py tests/test_remote_path_validation.py tests/test_schema_path_validators.py -q` 全绿 | +| A-02 | 跑文件管理单测套件 | [ ] | `pytest tests/test_unix_ls.py tests/test_file_permissions.py tests/test_files_elevation.py -q` 全绿 | +| A-03 | 跑全量 API 测试(有 `.env`) | [ ] | `pytest tests/test_api.py -q` 通过;401 时核对 `NEXUS_TEST_ADMIN_PASSWORD` | +| A-04 | ruff `server/` 零错误 | [ ] | `ruff check server/` | +| A-05 | import 冒烟 | [ ] | `python -c "import server.main"` | +| A-06 | bandit 无 HIGH/MEDIUM | [ ] | `bandit -r server/ -ll` | +| A-07 | 7 门预检脚本 | [ ] | `bash deploy/pre_deploy_check.sh` 全过;`deploy/gate_log.jsonl` 有记录 | +| A-08 | 合并 changelog(本次主题) | [x] | 见 `docs/changelog/2026-06-01-*.md` 系列(posix / files / phase5 / phase2 / capability) | +| A-09 | WSL 集成 smoke(若环境可用) | [ ] | `wsl_integration_smoke.sh` 或项目等价脚本 | + +**Phase A 完成定义**:A-01~A-07 全 `[x]`(A-09 可选但推荐)。 + +--- + +## Phase B — 生产部署(后端 + 前端) + +| ID | 任务 | 状态 | 验收标准 | +|----|------|------|----------| +| DEP-01 | `git push origin main` | [ ] | 远程与本地一致 | +| DEP-02 | 生产 `git fetch && reset --hard origin/main` | [ ] | `ssh nexus` 部署目录无冲突 | +| DEP-03 | `supervisorctl restart nexus` | [ ] | 进程 running | +| DEP-04 | 健康检查 | [ ] | `curl -s http://127.0.0.1:8600/health` → `ok` | +| DEP-05 | 前端 `deploy/deploy-frontend.sh` | [ ] | `web/app/index.html` + `assets/` 已更新 | +| DEP-06 | 浏览器 `/app/` HTTP 200 | [ ] | 非旧 Tailwind 静态页 | +| DEP-07 | 记录部署日期到本文 | [ ] | 在 §部署记录 填一行 | + +**依赖**:Phase A 的 A-07 通过。 +**路径 SSOT**:`/www/wwwroot/api.synaglobal.vip/` · SSH 别名 `nexus` · 端口 `8600`。 + +### 部署记录 + +| 日期 | 操作人 | DEP-01~06 | 备注 | +|------|--------|------------|------| +| | | | | + +--- + +## Phase C — 文件管理功能验收(设计文档 L1~L4) + +> 设计:[2026-06-01-files-root-ownership-ui-design.md](../design/specs/2026-06-01-files-root-ownership-ui-design.md) §7 +> 代码状态:Phase 1~5 + 二期 **已实现**;本节全部为 **🔍 生产/浏览器验证**。 + +### C1 — L1 提权 + +| ID | 任务 | 状态 | 步骤 / 预期 | +|----|------|------|-------------| +| FM-L1-01 | deploy + `nexus-files.sudoers` | [ ] 🔍 | 目标机安装 `docs/deploy/nexus-files-sudoers.example`;`sudo -n true` 成功 | +| FM-L1-02 | 写/保存/压缩/chmod 成功 | [ ] 🔍 | 审计含 `elevated: true`(若走了 sudo) | +| FM-L1-03 | SSH 用户 root | [ ] 🔍 | 不触发多余 sudo;功能正常 | +| FM-L1-04 | 无 sudo 非 root | [ ] 🔍 | 失败提示含免密 sudo / 文档路径,非「SSH 连接失败」 | +| FM-L1-05 | 顶栏不可读提示 | [ ] 🔍 | 选 deploy 服务器见提示条;读 root 属主文件后提示更新 | +| FM-L1-06 | `files-capability` API | [ ] 🔍 | `GET /api/servers/{id}/files-capability` 与 UI 检测一致 | +| FM-L1-07 | 服务器 `files_elevation` 三档 | [ ] 🔍 | off / auto_sudo / always_sudo 行为符合预期 | + +### C2 — L2 展示 + +| ID | 任务 | 状态 | 预期 | +|----|------|------|------| +| FM-L2-01 | 归属列 www/root | [ ] 🔍 | chip 正确 | +| FM-L2-02 | tooltip 完整权限 | [ ] 🔍 | 悬停见 `drwx… · 755 · www` | +| FM-L2-03 | 无默认宽「权限」列 | [ ] 🔍 | 主表不挤占 | +| FM-L2-04 | 不可读行锁图标 | [ ] 🔍 | `mdi-shield-lock` 与 ls 一致 | +| FM-L2-05 | 修改时间为 long-iso | [ ] 🔍 | 形如 `2026-06-01 12:00:00`,排序合理 | + +### C3 — L3 修改权限 + +| ID | 任务 | 状态 | 预期 | +|----|------|------|------| +| FM-L3-01 | 预设 644/755 一键 | [ ] 🔍 | | +| FM-L3-02 | chown www 后刷新 | [ ] 🔍 | 列表归属更新 | +| FM-L3-03 | 非法 mode/owner | [ ] 🔍 | 前端拦截 + API 400 | +| FM-L3-04 | 目录递归 chmod | [ ] 🔍 | 勾选 → confirm → 子项权限变化;`/` 等被拒绝 | +| FM-L3-05 | 权限对话框检测按钮 | [ ] 🔍 | 与顶栏 capability 一致 | + +### C4 — L4 回归 + +| ID | 任务 | 状态 | 预期 | +|----|------|------|------| +| FM-L4-01 | 文件树 / 编辑器 | [ ] 🔍 | 打开保存无回归 | +| FM-L4-02 | 剪贴板复制/粘贴/移动 | [ ] 🔍 | | +| FM-L4-03 | 上传 / 下载 | [ ] 🔍 | | +| FM-L4-04 | 压缩 / 解压 | [ ] 🔍 | | +| FM-L4-05 | Windows 开发机 + Linux 目标路径 | [ ] 🔍 | 路径无 `\` 进入远程命令 | + +**Phase C 完成定义**:C1~C4 全部 `[x]`。 + +--- + +## Phase D — 正式审计(8 步 · 部署门控) + +> 模板:`docs/audit/TEMPLATE.md` · 规则:`.cursor/rules/audit-line-review.mdc` +> 现状:POSIX 分批审查 ✅;files-phase5 简表 ✅;**下列为改动的完整 8 步审计(待补)**。 + +| ID | 审计主题 | 状态 | 输出文件 | +|----|----------|------|----------| +| AUD-01 | 文件管理 Phase 1~3(remote_shell / browse / chmod UI) | [ ] | `docs/audit/2026-06-01-files-phase1-3.md` | +| AUD-02 | files-capability + files_elevation(批次 10) | [ ] | `docs/audit/2026-06-01-files-capability.md` | +| AUD-03 | Phase 5 加固(long-iso / read-file / remote_write) | [ ] | 扩写 `docs/audit/2026-06-01-files-phase5.md` 至完整 8 步 | +| AUD-04 | 二期递归 chmod + 不可读提示(批次 12) | [ ] | `docs/audit/2026-06-01-files-phase2.md` | +| AUD-05 | Schema 路径校验 P2(schema_path_validators) | [ ] | `docs/audit/2026-06-01-schema-posix-path.md` | +| AUD-06 | 部署前 Review 门:审计含**实际改动文件清单** | [ ] | 与 DEP 同次提交 | + +**每份审计必须含**:Step3 规则扫描 · Closure 全表 · 入口表 · 输入→Sink · DoD · 0 FINDING 或已修 FINDING(`文件:行号`)。 + +--- + +## Phase E — POSIX 路径审计(延续) + +> 总览:[posix-path-full-audit.md](../audit/2026-06-01-posix-path-full-audit.md) +> 分批记录:[posix-path-batch-review.md](../audit/2026-06-01-posix-path-batch-review.md) + +| ID | 任务 | 状态 | 说明 | +|----|------|------|------| +| POSIX-13 | 批次 13:`remote_shell` + `files_capability` + `files_chmod_policy` | [ ] | 命令注入 / elevation 误用 | +| POSIX-14 | 批次 14:`unix_ls.py` 解析边界(空格文件名、异常行) | [ ] | 对照 `test_file_permissions.py` 补案例 | +| POSIX-15 | 批次 15:前端 `remotePath.ts` 全页面扫尾 | [ ] | `rg` 无裸 `+` 路径拼接 | +| POSIX-16 | 批次 16:`sync_engine_v2` rsync 目标再回归 | [ ] | `user@host:path` 形态 | +| POSIX-17 | 生产回归:Windows 主机开发 + Linux SSH 目标 | [ ] 🔍 | full-audit DoD 最后一项 | +| POSIX-18 | 规则固化:`.cursor/rules/nexus-posix-paths.mdc` 与 CI 可选检查 | [ ] | 如在 CI 加 `rg` 门禁 | + +--- + +## Phase F — 全站遗留测试(CLAUDE / HANDOFF) + +| ID | 任务 | 状态 | 说明 | +|----|------|------|------| +| T-01 | 心跳 Redis 写入验证 | [ ] 🔍 | Agent 60s → `heartbeat:{id}` | +| T-02 | WebSocket 连接与告警推送 | [ ] 🔍 | 浏览器 + Telegram | +| T-03 | 三层守护(杀 Python → Supervisor 重启) | [ ] 🔍 | | +| T-04 | Telegram 推送 | [ ] 🔍 | | +| T-05 | install.html 五步安装向导 | [ ] 🔍 | | +| T-06 | L5 生产验收清单 | [ ] | `production-verification-checklist.md` | +| T-07 | WebSSH 断线重连(HANDOFF P1) | [ ] | 独立设计与实现 | +| T-08 | 服务器 CSV 批量导入(HANDOFF P1) | [ ] | | + +--- + +## Phase G — 增强与已知风险(未实现 / 低优先级) + +| ID | 任务 | 优先级 | 说明 | +|----|------|--------|------| +| ENH-01 | 危险路径 chmod 二次确认(`/etc`、`/usr` 下单文件) | P2 | 设计 §8 风险表 | +| ENH-02 | `find -maxdepth` 替代大范围 `chmod -R`(可选) | P3 | 降低误操作面 | +| ENH-03 | 预加载失败聚合提示(非静默) | P3 | `filePreload.ts` 现静默 catch | +| ENH-04 | `NEXUS_FILES_ELEVATION_DEFAULT` 全局 env 默认 | P3 | plans 二期提及 | +| ENH-05 | 设计文档 §7 勾选同步到本文 | P1 | FM-L* 完成后回写 spec | +| ENH-06 | 更新 `AI-HANDOFF` 指针到本文 | P1 | 避免多 SSOT 分叉 | +| ENH-07 | `CLAUDE.md` 实现表:文件管理条目标为已完成 | P2 | 与代码一致 | +| ENH-08 | 可选列「权限」默认隐藏 + 列设置 | P3 | L2-03 细化 | + +--- + +## 已完成代码清单(勿重复实现) + +| 主题 | 批次/阶段 | 关键路径 | +|------|-----------|----------| +| POSIX SSOT + sync_v2 规范化 | 批次 1~7 | `server/utils/posix_paths.py` | +| Schema 路径 P2 | P2 | `schema_path_validators.py` | +| remote_shell + sudo 回退 | Phase 1 | `remote_shell.py` | +| browse owner/group/mode_octal | Phase 2 | `sync_v2.py` | +| FilePermissionDialog + chown | Phase 3 | 前端组件 | +| files-capability + files_elevation | 批次 10 / Phase 4 | `servers.py` | +| long-iso / read-file 提权 / write elevation | 批次 11 / Phase 5 | `unix_ls.py` | +| 递归 chmod + 不可读顶栏/锁图标 | 批次 12 / 二期 | `files_chmod_policy.py` | + +--- + +## 进度总表(给用户一眼看跳步) + +``` +代码实现 [x] POSIX 1-7 + 文件管理 P1-P5 + 二期 +本地单测门控 [ ] Phase A +正式审计 8 步 [ ] Phase D +生产部署 [ ] Phase B +浏览器 L1-L4 [ ] Phase C +POSIX 13-18 [ ] Phase E +全站 T1-T8 [ ] Phase F +增强 ENH [ ] Phase G(按需) +``` + +**当前建议下一项**:`A-01` → `A-07` → `DEP-01`~`DEP-06` → `FM-L1-01` 起。 + +--- + +## 文档索引 + +| 类型 | 路径 | +|------|------| +| 文件管理设计 | `docs/design/specs/2026-06-01-files-root-ownership-ui-design.md` | +| 文件管理计划 | `docs/design/plans/2026-06-01-files-root-ownership-ui.md` | +| 二期计划 | `docs/design/plans/2026-06-01-files-phase2-recursive-read-hint.md` | +| POSIX 全审计 | `docs/audit/2026-06-01-posix-path-full-audit.md` | +| POSIX 分批 | `docs/audit/2026-06-01-posix-path-batch-review.md` | +| Changelog 目录 | `docs/changelog/2026-06-01-*.md` | +| sudoers 示例 | `docs/deploy/nexus-files-sudoers.example` | + +--- + +## 变更记录 + +| 日期 | 变更 | +|------|------| +| 2026-06-01 | 初版:合并 POSIX 分批、文件管理 Phase/二期、部署门控、审计、全站 T 项 | diff --git a/docs/project/AI-HANDOFF-2026-05-23.md b/docs/project/AI-HANDOFF-2026-05-23.md index 492dd488..af491d88 100644 --- a/docs/project/AI-HANDOFF-2026-05-23.md +++ b/docs/project/AI-HANDOFF-2026-05-23.md @@ -15,6 +15,7 @@ 【必读 · 按序】 1. docs/project/AI-HANDOFF-2026-05-23.md +1b. docs/project/2026-06-01-master-backlog-plan.md(文件管理/POSIX/部署/审计 长计划任务) 2. docs/project/nexus-full-site-features.md 3. docs/project/development-acceptance-standard.md 4. docs/project/standards-transfer-package.md diff --git a/docs/project/AI-HANDOFF-2026-06-01-files.md b/docs/project/AI-HANDOFF-2026-06-01-files.md new file mode 100644 index 00000000..7892b61f --- /dev/null +++ b/docs/project/AI-HANDOFF-2026-06-01-files.md @@ -0,0 +1,69 @@ +# 文件管理(#/files)— AI 接续备忘 + +> **日期**: 2026-06-01 +> **用户确认**: 「好 你自己记住」— 本地改动**尚未 git commit**;生产已用 SCP/tar 部署,与仓库可能不一致。 + +--- + +## 1. 生产状态(api.synaglobal.vip) + +| 项 | 状态 | +|----|------| +| 前端 | `web/app/` 已 tar+scp,prune 后 restart nexus | +| 后端 | `sync_v2.py`、`schemas.py`、`remote_path_validation.py` 曾 SCP 到服务器 | +| 健康 | `/health` → `ok` | +| 验证 | 用户需 Ctrl+Shift+R 强刷 `#/files` | + +**下一任部署优先**:`git push` + 服务器 `git pull` + `supervisorctl restart nexus`,避免仅 SCP 漂移。 + +--- + +## 2. 本会话已完成功能 + +| 模块 | 说明 | +|------|------| +| browse 契约 | `items` + `fileBrowse.ts` | +| 页面迭代 | 下载、预览、路由 query、拖拽上传、symlink | +| 300KB 预加载 | `filePreload.ts` | +| 编辑器 | `FileEditorWorkbench.vue` 分屏+多标签+状态栏(非默认全屏) | +| v2 补齐 | 目录树、新建文件、压缩/解压、排序、扩展名筛选、快捷键 | +| 剪贴板 | `POST /sync/file-clipboard` + Ctrl+C/X/V | + +--- + +## 3. 待入库(git status 快照 2026-06-01) + +**修改**:`FilesPage.vue`、`sync_v2.py`、`schemas.py`、`MonacoEditor.vue`、`api/index.ts`、`types/api.ts` 等 + +**新增**:`FileEditorWorkbench.vue`、`FileDirectoryTree.vue`、`useFilesClipboard.ts`、`useFilesHotkeys.ts`、`filePreload.ts`、`fileSort.ts`、`remote_path_validation.py`、`tests/test_remote_path_validation.py`、多篇 `docs/changelog/2026-06-01-files-*.md` 与设计文档 + +**用户未要求 commit 时**:不要擅自 `git commit`;用户说「好」= 同意记住此状态,下次可代其提交。 + +--- + +## 4. 关键 API + +- `POST /api/sync/browse` +- `POST /api/sync/read-file`(`max_size` 建议 307200) +- `POST /api/sync/write-file` +- `POST /api/sync/file-clipboard`(`mode`: `copy` \| `move`) +- `POST /api/sync/compress` / `decompress` + +--- + +## 5. 未做 / 否决 + +- v2 设计中的跨目录 **剪贴板到不同服务器**(前端已拦) +- 公网 **8601**、嵌入式 Monaco 全站预加载(见项目 SSOT 否决项) + +--- + +## 6. 相关 changelog(按主题) + +- `2026-06-01-files-browse-fix.md` +- `2026-06-01-files-page-iteration.md` +- `2026-06-01-files-300k-preload.md` +- `2026-06-01-files-monaco-editor-fix.md` +- `2026-06-01-files-editor-panel.md` +- `2026-06-01-files-v2-complete.md` +- `2026-06-01-files-clipboard-transfer.md` diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 12918f78..8e908bfc 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -44,13 +44,7 @@ async function tryRefreshSession(): Promise { return refreshInFlight } -/** Raw fetch wrapper with JSON + JWT + refresh retry */ -export async function api( - path: string, - opts: RequestInit & { params?: Record; _retry?: boolean } = {}, -): Promise { - const { params, _retry, ...init } = opts - +function buildApiUrl(path: string, params?: Record): string { let url = `${BASE_URL}${path}` if (params) { const sp = new URLSearchParams() @@ -60,7 +54,10 @@ export async function api( const qs = sp.toString() if (qs) url += `?${qs}` } + return url +} +function buildAuthHeaders(init: RequestInit): Record { const auth = useAuthStore() const headers: Record = { ...(init.headers as Record || {}), @@ -71,14 +68,28 @@ export async function api( if (auth.token) { headers['Authorization'] = `Bearer ${auth.token}` } + return headers +} - const res = await fetch(url, { ...init, headers, credentials: 'include' }) +/** Authenticated fetch with JWT refresh retry; returns raw Response. */ +export async function fetchAuthed( + path: string, + opts: RequestInit & { params?: Record; _retry?: boolean } = {}, +): Promise { + const { params, _retry, ...init } = opts + const url = buildApiUrl(path, params) + const auth = useAuthStore() + const res = await fetch(url, { + ...init, + headers: buildAuthHeaders(init), + credentials: 'include', + }) if (res.status === 401) { if (!AUTH_SKIP_REFRESH.has(path) && !_retry) { const refreshed = await tryRefreshSession() if (refreshed) { - return api(path, { ...opts, _retry: true }) + return fetchAuthed(path, { ...opts, _retry: true }) } auth.forceLogout() throw new ApiError(401, '登录已过期,请重新登录') @@ -93,6 +104,68 @@ export async function api( throw new ApiError(401, msg) } + return res +} + +async function throwApiErrorFromResponse(res: Response): Promise { + const text = await res.text() + let data: unknown + try { + data = JSON.parse(text) + } catch { + data = text + } + const body = data as { detail?: unknown; message?: string } + const msg = normalizeDetail(body?.detail ?? body?.message, res.statusText) + const err = new ApiError(res.status, msg) + err.detail = body?.detail + throw err +} + +function filenameFromContentDisposition(header: string | null): string | null { + if (!header) return null + const star = header.match(/filename\*=UTF-8''([^;]+)/i) + if (star?.[1]) { + try { + return decodeURIComponent(star[1]) + } catch { + return star[1] + } + } + const plain = header.match(/filename="?([^";]+)"?/i) + return plain?.[1] ?? null +} + +/** POST binary download (e.g. /sync/download StreamingResponse). */ +export async function downloadPost( + path: string, + body?: unknown, +): Promise<{ blob: Blob; filename: string }> { + const res = await fetchAuthed(path, { + method: 'POST', + body: JSON.stringify(body ?? {}), + }) + if (res.status === 202) { + throw new TotpRequiredError('请输入 TOTP 验证码') + } + if (res.status === 429) { + throw new ApiError(429, '登录尝试过多,账户已锁定 15 分钟') + } + if (!res.ok) { + await throwApiErrorFromResponse(res) + } + const blob = await res.blob() + const filename = filenameFromContentDisposition(res.headers.get('Content-Disposition')) || 'download' + return { blob, filename } +} + +/** Raw fetch wrapper with JSON + JWT + refresh retry */ +export async function api( + path: string, + opts: RequestInit & { params?: Record; _retry?: boolean } = {}, +): Promise { + const res = await fetchAuthed(path, opts) + if (res.status === 202) { const text = await res.text() let data: unknown @@ -148,6 +221,8 @@ export const http = { upload: (path: string, formData: FormData) => api(path, { method: 'POST', body: formData }), + + download: (path: string, body?: unknown) => downloadPost(path, body), } export class ApiError extends Error { diff --git a/frontend/src/components/FileDirectoryTree.vue b/frontend/src/components/FileDirectoryTree.vue new file mode 100644 index 00000000..fc744454 --- /dev/null +++ b/frontend/src/components/FileDirectoryTree.vue @@ -0,0 +1,266 @@ + + + + + diff --git a/frontend/src/components/FileEditorWorkbench.vue b/frontend/src/components/FileEditorWorkbench.vue new file mode 100644 index 00000000..9f600e81 --- /dev/null +++ b/frontend/src/components/FileEditorWorkbench.vue @@ -0,0 +1,721 @@ + + + + + diff --git a/frontend/src/components/FilePermissionDialog.vue b/frontend/src/components/FilePermissionDialog.vue new file mode 100644 index 00000000..9c4e6e48 --- /dev/null +++ b/frontend/src/components/FilePermissionDialog.vue @@ -0,0 +1,453 @@ + + + + + + diff --git a/frontend/src/components/MonacoEditor.vue b/frontend/src/components/MonacoEditor.vue index 31b44646..28029665 100644 --- a/frontend/src/components/MonacoEditor.vue +++ b/frontend/src/components/MonacoEditor.vue @@ -1,25 +1,34 @@ + + diff --git a/frontend/src/pages/ServersPage.vue b/frontend/src/pages/ServersPage.vue index 16b0e658..dfd0416c 100644 --- a/frontend/src/pages/ServersPage.vue +++ b/frontend/src/pages/ServersPage.vue @@ -184,6 +184,17 @@ + @@ -271,9 +282,16 @@ const deleting = ref(false) const deletingServer = ref(null) const editingId = ref(null) +const filesElevationItems = [ + { title: '自动(权限失败时 sudo)', value: 'auto_sudo' }, + { title: '关闭 sudo', value: 'off' }, + { title: '始终 sudo(排障)', value: 'always_sudo' }, +] + const form = ref({ name: '', address: '', category: '', platform_id: null as number | null, ssh_user: '', ssh_port: 22, password: '', + files_elevation: 'auto_sudo' as 'off' | 'auto_sudo' | 'always_sudo', }) // ── Batch selection (Vuetify returns IDs when item-value="id", objects when return-object) ── @@ -463,6 +481,7 @@ function editServer(item: ServerApiItem) { name: item.name, address: item.domain || '', category: item.category || '', platform_id: item.platform_id, ssh_user: item.username || '', ssh_port: item.port || 22, password: '', + files_elevation: item.files_elevation || 'auto_sudo', } showAdd.value = true } @@ -483,6 +502,7 @@ async function saveServer() { category: form.value.category || undefined, platform_id: form.value.platform_id || undefined, password: form.value.password || undefined, + files_elevation: form.value.files_elevation, } if (editing.value && editingId.value) { await http.put(`/servers/${editingId.value}`, payload) @@ -520,7 +540,10 @@ async function doDelete() { function resetForm() { editing.value = false editingId.value = null - form.value = { name: '', address: '', category: '', platform_id: null, ssh_user: '', ssh_port: 22, password: '' } + form.value = { + name: '', address: '', category: '', platform_id: null, ssh_user: '', ssh_port: 22, + password: '', files_elevation: 'auto_sudo', + } } // ── Init ── diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 49cb6dec..049e2668 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -33,17 +33,36 @@ export interface AllowlistResponse { last_refresh?: string | null } -/** File browse response — backend may return array or { items } */ +/** File browse response from POST /api/sync/browse */ export interface BrowseResponse { + path?: string items: FileEntry[] + /** SSH/listing failure message when items is empty */ + error?: string } export interface FileEntry { name: string - type: 'directory' | 'file' + type: 'directory' | 'file' | 'symlink' size: number modified: string permissions: string + owner?: string + group?: string + mode_octal?: string + link_target?: string | null +} + +export type FilesElevationMode = 'off' | 'auto_sudo' | 'always_sudo' + +export interface FilesCapabilityResult { + ssh_user: string + is_root: boolean + files_elevation: FilesElevationMode + can_sudo_nopasswd: boolean + whitelist_ok: boolean + message: string + docs_path: string } /** Server item from /api/servers/ */ @@ -68,6 +87,7 @@ export interface ServerApiItem { category: string | null platform_id: number | null node_id: number | null + files_elevation?: FilesElevationMode is_online: boolean last_heartbeat: string | null agent_version: string | null diff --git a/frontend/src/utils/editorLanguage.ts b/frontend/src/utils/editorLanguage.ts new file mode 100644 index 00000000..0f7c11f9 --- /dev/null +++ b/frontend/src/utils/editorLanguage.ts @@ -0,0 +1,196 @@ +/** Monaco 语言 ID 与文件名/路径映射(运维场景优先) */ + +const EXT_TO_LANG: Record = { + js: 'javascript', + mjs: 'javascript', + cjs: 'javascript', + jsx: 'javascript', + ts: 'typescript', + mts: 'typescript', + cts: 'typescript', + tsx: 'typescript', + vue: 'html', + py: 'python', + pyw: 'python', + rb: 'ruby', + sh: 'shell', + bash: 'shell', + zsh: 'shell', + fish: 'shell', + php: 'php', + phtml: 'php', + java: 'java', + go: 'go', + rs: 'rust', + c: 'c', + cpp: 'cpp', + cc: 'cpp', + cxx: 'cpp', + h: 'c', + hpp: 'cpp', + cs: 'csharp', + swift: 'swift', + kt: 'kotlin', + kts: 'kotlin', + html: 'html', + htm: 'html', + xhtml: 'html', + css: 'css', + scss: 'scss', + sass: 'scss', + less: 'less', + json: 'json', + jsonc: 'json', + xml: 'xml', + xsl: 'xml', + xsd: 'xml', + svg: 'xml', + yaml: 'yaml', + yml: 'yaml', + toml: 'toml', + md: 'markdown', + mdx: 'mdx', + sql: 'sql', + graphql: 'graphql', + gql: 'graphql', + dockerfile: 'dockerfile', + conf: 'ini', + cfg: 'ini', + cnf: 'ini', + ini: 'ini', + properties: 'ini', + env: 'ini', + tpl: 'html', + twig: 'twig', + blade: 'php', + lua: 'lua', + pl: 'perl', + pm: 'perl', + ps1: 'powershell', + psm1: 'powershell', + bat: 'bat', + cmd: 'bat', + r: 'r', + dart: 'dart', + scala: 'scala', + clj: 'clojure', + ex: 'elixir', + exs: 'elixir', + erl: 'elixir', + hs: 'haskell', + ml: 'fsharp', + fs: 'fsharp', + fsx: 'fsharp', + vb: 'vb', + pas: 'pascal', + dockerignore: 'plaintext', + gitignore: 'plaintext', + gitattributes: 'plaintext', + editorconfig: 'ini', + htaccess: 'shell', + service: 'ini', + socket: 'ini', + timer: 'ini', + mount: 'ini', + fstab: 'ini', + crt: 'plaintext', + pem: 'plaintext', + key: 'plaintext', + pub: 'plaintext', + log: 'plaintext', + lock: 'json', +} + +const BASENAME_TO_LANG: Record = { + dockerfile: 'dockerfile', + makefile: 'makefile', + gnumakefile: 'makefile', + 'cmakelists.txt': 'plaintext', + gemfile: 'ruby', + rakefile: 'ruby', + vagrantfile: 'ruby', + nginx: 'nginx', + 'nginx.conf': 'nginx', + '.env': 'ini', + '.env.example': 'ini', + '.env.local': 'ini', + '.gitignore': 'plaintext', + '.dockerignore': 'plaintext', + '.htaccess': 'shell', + hosts: 'plaintext', + crontab: 'shell', + 'supervisord.conf': 'ini', + 'php.ini': 'ini', + 'my.cnf': 'ini', +} + +export interface EditorLanguageOption { + title: string + value: string +} + +/** 工具栏语言下拉(Monaco 已注册 ID) */ +export const EDITOR_LANGUAGE_OPTIONS: EditorLanguageOption[] = [ + { title: '自动', value: 'auto' }, + { title: 'Plain Text', value: 'plaintext' }, + { title: 'PHP', value: 'php' }, + { title: 'JavaScript', value: 'javascript' }, + { title: 'TypeScript', value: 'typescript' }, + { title: 'JSON', value: 'json' }, + { title: 'HTML', value: 'html' }, + { title: 'CSS', value: 'css' }, + { title: 'SCSS', value: 'scss' }, + { title: 'Shell', value: 'shell' }, + { title: 'Python', value: 'python' }, + { title: 'Go', value: 'go' }, + { title: 'Rust', value: 'rust' }, + { title: 'Java', value: 'java' }, + { title: 'C', value: 'c' }, + { title: 'C++', value: 'cpp' }, + { title: 'C#', value: 'csharp' }, + { title: 'SQL', value: 'sql' }, + { title: 'YAML', value: 'yaml' }, + { title: 'TOML', value: 'toml' }, + { title: 'INI / Config', value: 'ini' }, + { title: 'Markdown', value: 'markdown' }, + { title: 'Dockerfile', value: 'dockerfile' }, + { title: 'Nginx', value: 'nginx' }, + { title: 'XML', value: 'xml' }, + { title: 'Vue (HTML)', value: 'html' }, + { title: 'Ruby', value: 'ruby' }, + { title: 'Perl', value: 'perl' }, + { title: 'PowerShell', value: 'powershell' }, +] + +export function detectEditorLanguage(fileName: string, filePath?: string): string { + const base = fileName.toLowerCase() + const pathBase = (filePath?.split('/').pop() ?? fileName).toLowerCase() + + if (BASENAME_TO_LANG[pathBase]) return BASENAME_TO_LANG[pathBase] + if (BASENAME_TO_LANG[base]) return BASENAME_TO_LANG[base] + + if (base.endsWith('.blade.php')) return 'php' + const dotParts = base.split('.') + if (dotParts.length > 2 && dotParts[dotParts.length - 1] === 'php') { + const compound = dotParts.slice(-2).join('.') + if (compound === 'blade.php') return 'php' + } + + const ext = base.includes('.') ? base.split('.').pop() ?? '' : '' + if (ext && EXT_TO_LANG[ext]) return EXT_TO_LANG[ext] + + if (pathBase.includes('nginx') && (pathBase.endsWith('.conf') || pathBase === 'nginx.conf')) { + return 'nginx' + } + + return 'plaintext' +} + +export function resolveTabLanguage( + fileName: string, + filePath: string, + manualLanguageId: string | null | undefined, +): string { + if (manualLanguageId && manualLanguageId !== 'auto') return manualLanguageId + return detectEditorLanguage(fileName, filePath) +} diff --git a/frontend/src/utils/fileBrowse.ts b/frontend/src/utils/fileBrowse.ts new file mode 100644 index 00000000..ac0bd402 --- /dev/null +++ b/frontend/src/utils/fileBrowse.ts @@ -0,0 +1,61 @@ +import type { BrowseResponse, FileEntry } from '@/types/api' + +/** Raw row from POST /api/sync/browse (legacy `entries` field). */ +export interface BrowseRawEntry { + name: string + is_dir?: boolean + is_link?: boolean + size?: string | number + perms?: string + permissions?: string + owner?: string + group?: string + mode_octal?: string + modified?: string + type?: FileEntry['type'] +} + +export function mapBrowseEntries(raw: BrowseRawEntry[]): FileEntry[] { + return raw + .filter((e) => e.name && e.name !== '.' && e.name !== '..') + .map((e) => { + const isDir = e.type === 'directory' || Boolean(e.is_dir) + const isLink = e.type === 'symlink' || Boolean(e.is_link) + let size = 0 + if (!isDir && !isLink && e.size != null) { + const n = typeof e.size === 'number' ? e.size : parseInt(String(e.size), 10) + size = Number.isFinite(n) ? n : 0 + } + let type: FileEntry['type'] = 'file' + if (isDir) type = 'directory' + else if (isLink) type = 'symlink' + return { + name: e.name, + type, + size, + modified: e.modified ?? '', + permissions: e.permissions ?? e.perms ?? '', + owner: e.owner ?? '', + group: e.group ?? '', + mode_octal: e.mode_octal ?? '', + link_target: (e as BrowseRawEntry & { link_target?: string }).link_target ?? null, + } + }) +} + +/** Normalize browse API body: prefers `items`, falls back to legacy `entries`. */ +export function parseBrowseResponse( + res: BrowseResponse | FileEntry[] | (BrowseResponse & { entries?: BrowseRawEntry[] }), +): { items: FileEntry[]; error?: string } { + if (Array.isArray(res)) { + return { items: res } + } + const body = res as BrowseResponse & { entries?: BrowseRawEntry[]; error?: string } + if (Array.isArray(body.items)) { + return { items: body.items, error: body.error } + } + if (body.entries?.length) { + return { items: mapBrowseEntries(body.entries), error: body.error } + } + return { items: body.items ?? [], error: body.error } +} diff --git a/frontend/src/utils/fileDownload.ts b/frontend/src/utils/fileDownload.ts new file mode 100644 index 00000000..09fe74e5 --- /dev/null +++ b/frontend/src/utils/fileDownload.ts @@ -0,0 +1,12 @@ +/** Trigger browser save for a Blob returned by http.download */ +export function saveBlobAsFile(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.rel = 'noopener' + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + URL.revokeObjectURL(url) +} diff --git a/frontend/src/utils/fileMode.ts b/frontend/src/utils/fileMode.ts new file mode 100644 index 00000000..f3f0c35c --- /dev/null +++ b/frontend/src/utils/fileMode.ts @@ -0,0 +1,101 @@ +import type { FileEntry } from '@/types/api' + +/** List chip label: owner, or owner:group when different. */ +export function formatOwnerChip(item: Pick): string { + if (item.type === 'symlink') return '—' + const o = (item.owner ?? '').trim() + const g = (item.group ?? '').trim() + if (!o) return '—' + if (!g || g === o) return o + return `${o}:${g}` +} + +export function ownerChipColor(owner: string): string | undefined { + if (owner === 'root') return 'warning' + return undefined +} + +export function permissionTooltip(item: FileEntry): string { + const parts: string[] = [] + if (item.permissions) parts.push(item.permissions) + if (item.mode_octal) parts.push(item.mode_octal) + const o = (item.owner ?? '').trim() + const g = (item.group ?? '').trim() + if (o) parts.push(g && g !== o ? `${o}:${g}` : o) + return parts.join(' · ') || '—' +} + +export const CHMOD_PRESETS = [ + { label: '文件 644', mode: '644', for: 'file' as const }, + { label: '目录 755', mode: '755', for: 'directory' as const }, + { label: '私密 600', mode: '600', for: 'file' as const }, + { label: '协作 775', mode: '775', for: 'directory' as const }, + { label: '可执行 755', mode: '755', for: 'file' as const }, +] as const + +const SUDOERS_DOC = 'docs/deploy/nexus-files-sudoers.example' + +/** HTTP / API message hints for permission-denied reads or writes. */ +export function isPermissionDeniedMessage(message: string): boolean { + const m = message.toLowerCase() + return ( + m.includes('permission denied') + || m.includes('operation not permitted') + || m.includes('无法访问') + || m.includes('无权限') + || m.includes('无写入权限') + || m.includes('读取失败') + || m.includes('chmod 失败') + ) +} + +/** Heuristic: SSH user likely cannot read this file from `ls` permission bits. */ +export function isEntryLikelyUnreadable( + item: Pick, + sshUser: string | null | undefined, +): boolean { + if (!sshUser || sshUser === 'root' || item.type !== 'file') return false + const perms = item.permissions ?? '' + if (perms.length < 10) return false + const owner = (item.owner ?? '').trim() + const group = (item.group ?? '').trim() + const ownerTri = perms.slice(1, 4) + const groupTri = perms.slice(4, 7) + const otherTri = perms.slice(7, 10) + if (owner === sshUser) return !ownerTri.includes('r') + if (group && group === sshUser) return !groupTri.includes('r') + return !otherTri.includes('r') && !groupTri.includes('r') +} + +export function buildFilesAccessHint(params: { + sshUser?: string | null + canSudo: boolean + isRootSsh: boolean + lastReadDeniedPath?: string | null + unreadableCount?: number +}): { type: 'warning' | 'error'; text: string } | null { + const lines: string[] = [] + if (params.lastReadDeniedPath) { + lines.push(`无法读取「${params.lastReadDeniedPath}」:当前 SSH 用户可能无读权限。`) + } + const n = params.unreadableCount ?? 0 + if (n > 0 && params.sshUser) { + lines.push(`当前目录约 ${n} 个文件对 SSH 用户「${params.sshUser}」可能不可读(根据 ls 权限推断)。`) + } + if (!params.isRootSsh && !params.canSudo) { + lines.push( + '写权限、chmod、读取 root 属主文件等可能失败。请在目标机配置免密 sudo,或改用 root SSH。', + ) + } else if (!params.isRootSsh && params.canSudo) { + lines.push('部分操作将自动尝试 sudo -n(需白名单含 chmod/chown/cat 等)。') + } + if (!lines.length) return null + const type: 'warning' | 'error' = + !params.isRootSsh && !params.canSudo ? 'error' : 'warning' + return { + type, + text: `${lines.join(' ')} 示例:${SUDOERS_DOC}`, + } +} + +export { SUDOERS_DOC } diff --git a/frontend/src/utils/filePreload.ts b/frontend/src/utils/filePreload.ts new file mode 100644 index 00000000..b6ee0bc2 --- /dev/null +++ b/frontend/src/utils/filePreload.ts @@ -0,0 +1,111 @@ +import { http } from '@/api' +import { joinRemotePath, normalizeRemotePath } from '@/utils/remotePath' +import type { FileEntry } from '@/types/api' + +/** 浏览器内编辑 / 预加载上限(300KB) */ +export const FILE_PRELOAD_MAX_BYTES = 300 * 1024 + +const MAX_PRELOAD_FILES_PER_DIR = 40 +const PRELOAD_CONCURRENCY = 4 + +const contentCache = new Map() + +function cacheKey(serverId: number, remotePath: string): string { + return `${serverId}:${normalizeRemotePath(remotePath)}` +} + +function parentDirOf(remotePath: string): string { + const p = normalizeRemotePath(remotePath) + const idx = p.lastIndexOf('/') + if (idx <= 0) return '/' + return p.slice(0, idx) || '/' +} + +export function clearFilePreloadCache(): void { + contentCache.clear() +} + +/** 仅保留当前目录下文件的缓存,避免跨目录无限增长 */ +export function pruneFilePreloadCache(serverId: number, dirPath: string): void { + const normDir = normalizeRemotePath(dirPath) + for (const key of [...contentCache.keys()]) { + if (!key.startsWith(`${serverId}:`)) { + contentCache.delete(key) + continue + } + const remotePath = key.slice(`${serverId}:`.length) + if (parentDirOf(remotePath) !== normDir) { + contentCache.delete(key) + } + } +} + +export function invalidateCachedFile(serverId: number, remotePath: string): void { + contentCache.delete(cacheKey(serverId, remotePath)) +} + +export function isPreloadEligible(item: Pick): boolean { + if (item.type !== 'file') return false + const size = item.size ?? 0 + return size > 0 && size <= FILE_PRELOAD_MAX_BYTES +} + +export function exceedsEditorMaxSize(size: number | undefined): boolean { + return size != null && size > 0 && size > FILE_PRELOAD_MAX_BYTES +} + +function parseReadFileResponse(res: { content?: string } | string): string { + return typeof res === 'string' ? res : (res.content ?? JSON.stringify(res, null, 2)) +} + +/** 读取远程文件;命中预加载缓存则不再请求 */ +export async function readRemoteFileContent( + serverId: number, + remotePath: string, + maxSize: number = FILE_PRELOAD_MAX_BYTES, +): Promise { + const key = cacheKey(serverId, remotePath) + const hit = contentCache.get(key) + if (hit !== undefined) return hit + + const res = await http.post<{ content?: string } | string>('/sync/read-file', { + server_id: serverId, + path: normalizeRemotePath(remotePath), + max_size: maxSize, + }) + const content = parseReadFileResponse(res) + contentCache.set(key, content) + return content +} + +/** 目录加载完成后后台预读 ≤300KB 的普通文件 */ +export function preloadDirectoryFiles( + serverId: number, + dirPath: string, + items: FileEntry[], +): void { + const candidates = items + .filter(isPreloadEligible) + .sort((a, b) => (a.size ?? 0) - (b.size ?? 0)) + .slice(0, MAX_PRELOAD_FILES_PER_DIR) + + if (!candidates.length) return + + let next = 0 + const worker = async () => { + while (next < candidates.length) { + const item = candidates[next++] + const remotePath = joinRemotePath(dirPath, item.name) + const key = cacheKey(serverId, remotePath) + if (contentCache.has(key)) continue + try { + await readRemoteFileContent(serverId, remotePath) + } catch { + // 预加载失败不影响浏览 + } + } + } + + const workers = Math.min(PRELOAD_CONCURRENCY, candidates.length) + void Promise.all(Array.from({ length: workers }, () => worker())) +} diff --git a/frontend/src/utils/fileSort.ts b/frontend/src/utils/fileSort.ts new file mode 100644 index 00000000..7cc24661 --- /dev/null +++ b/frontend/src/utils/fileSort.ts @@ -0,0 +1,57 @@ +import type { FileEntry } from '@/types/api' + +export type FileSortKey = 'name' | 'size' | 'modified' +export type FileSortOrder = 'asc' | 'desc' + +const TYPE_ORDER: Record = { + directory: 0, + symlink: 1, + file: 2, +} + +export function sortFileEntries( + items: FileEntry[], + sortBy: FileSortKey, + order: FileSortOrder, +): FileEntry[] { + const dir = order === 'asc' ? 1 : -1 + return [...items].sort((a, b) => { + const ta = TYPE_ORDER[a.type] ?? 9 + const tb = TYPE_ORDER[b.type] ?? 9 + if (ta !== tb) return ta - tb + + let cmp = 0 + if (sortBy === 'name') { + cmp = a.name.localeCompare(b.name) + } else if (sortBy === 'size') { + cmp = (a.size ?? 0) - (b.size ?? 0) + } else { + cmp = (a.modified ?? '').localeCompare(b.modified ?? '') + } + return cmp * dir + }) +} + +export const FILE_EXT_FILTERS: { title: string; value: string }[] = [ + { title: '全部', value: '' }, + { title: '.conf / .ini', value: 'conf,ini' }, + { title: '.log', value: 'log' }, + { title: '.sh', value: 'sh' }, + { title: '.py', value: 'py' }, + { title: '.json', value: 'json' }, + { title: '.yml / .yaml', value: 'yml,yaml' }, + { title: '.txt', value: 'txt' }, + { title: '.php', value: 'php' }, + { title: '.js / .ts', value: 'js,ts' }, +] + +export function matchesExtensionFilter(name: string, filter: string): boolean { + if (!filter) return true + const lower = name.toLowerCase() + return filter.split(',').some((ext) => lower.endsWith(`.${ext.trim()}`)) +} + +export function isArchiveFile(name: string): boolean { + const lower = name.toLowerCase() + return lower.endsWith('.tar.gz') || lower.endsWith('.tgz') || lower.endsWith('.zip') +} diff --git a/frontend/src/utils/remotePath.ts b/frontend/src/utils/remotePath.ts new file mode 100644 index 00000000..0a7ad6e8 --- /dev/null +++ b/frontend/src/utils/remotePath.ts @@ -0,0 +1,82 @@ +/** + * Remote SSH path helpers for Files page (normalize, join, breadcrumbs). + */ + +/** Default browse root on managed servers (宝塔站点根目录). */ +export const DEFAULT_FILES_ROOT = '/www/wwwroot' + +export interface RemoteBreadcrumbItem { + title: string + path: string + disabled: boolean +} + +/** Normalize user/SSH path: leading slash, no duplicate slashes, no trailing slash (except root). */ +export function normalizeRemotePath(path: string): string { + const trimmed = (path ?? '').trim().replace(/\\/g, '/') + if (!trimmed || trimmed === '.') return '/' + let p = trimmed.startsWith('/') ? trimmed : `/${trimmed}` + p = p.replace(/\/+/g, '/') + if (p.length > 1 && p.endsWith('/')) { + p = p.slice(0, -1) + } + return p +} + +export function splitRemotePath(path: string): string[] { + return normalizeRemotePath(path).split('/').filter(Boolean) +} + +/** Join base path with a single segment (not a nested path). */ +export function joinRemotePath(base: string, segment: string): string { + const normBase = normalizeRemotePath(base) + const seg = segment.replace(/\/+/g, '').trim() + if (!seg || seg === '.') return normBase + if (seg === '..') { + const parts = splitRemotePath(normBase) + parts.pop() + return parts.length ? `/${parts.join('/')}` : '/' + } + return normBase === '/' ? `/${seg}` : `${normBase}/${seg}` +} + +/** Parent directory, or null when already at root. */ +export function parentRemotePath(path: string): string | null { + const parts = splitRemotePath(path) + if (!parts.length) return null + parts.pop() + return parts.length ? `/${parts.join('/')}` : '/' +} + +/** Validate a single file/dir name segment (not a full path). */ +export function validatePathSegment(name: string): string | true { + const trimmed = name.trim() + if (!trimmed) return '名称不能为空' + if (trimmed.includes('/') || trimmed.includes('\\') || trimmed.includes('\0')) { + return '名称不能包含路径分隔符' + } + if (trimmed === '.' || trimmed === '..') return '非法名称' + return true +} + +export function buildRemoteBreadcrumbs(currentPath: string): RemoteBreadcrumbItem[] { + const normalized = normalizeRemotePath(currentPath) + const segments = splitRemotePath(normalized) + const items: RemoteBreadcrumbItem[] = [ + { + title: '/', + path: '/', + disabled: segments.length === 0, + }, + ] + let acc = '' + for (let i = 0; i < segments.length; i++) { + acc = joinRemotePath(acc || '/', segments[i]) + items.push({ + title: segments[i], + path: acc, + disabled: i === segments.length - 1, + }) + } + return items +} diff --git a/server/api/remote_path_validation.py b/server/api/remote_path_validation.py new file mode 100644 index 00000000..78820025 --- /dev/null +++ b/server/api/remote_path_validation.py @@ -0,0 +1,33 @@ +"""Validation helpers for remote absolute paths (SSH file operations).""" + +from __future__ import annotations + +from server.utils.posix_paths import ( + PosixPathError, + normalize_remote_abs_path, + normalize_remote_dest, + remote_join, +) + +# Backward-compatible alias used by API layers +RemotePathError = PosixPathError + +__all__ = [ + "RemotePathError", + "normalize_remote_abs_path", + "normalize_remote_dest", + "remote_join", + "assert_clipboard_transfer_safe", +] + + +def assert_clipboard_transfer_safe(sources: list[str], dest_dir: str) -> tuple[list[str], str]: + """Return normalized sources and dest_dir; raise RemotePathError if unsafe.""" + dest = normalize_remote_abs_path(dest_dir) + normalized: list[str] = [] + for raw in sources: + src = normalize_remote_abs_path(raw) + if dest == src or dest.startswith(src + "/"): + raise RemotePathError(f"目标目录不能是源路径自身或其子目录: {src}") + normalized.append(src) + return normalized, dest diff --git a/server/api/schema_path_validators.py b/server/api/schema_path_validators.py new file mode 100644 index 00000000..86058f91 --- /dev/null +++ b/server/api/schema_path_validators.py @@ -0,0 +1,59 @@ +"""Pydantic coercers for Linux / remote path fields (wraps posix_paths).""" + +from __future__ import annotations + +from server.utils.posix_paths import PosixPathError, normalize_remote_abs_path, to_posix + + +def coerce_optional_remote_abs_path(value: str | None) -> str | None: + """Optional remote absolute path (e.g. server.target_path).""" + if value is None: + return None + text = str(value).strip() + if not text: + return None + try: + return normalize_remote_abs_path(text) + except PosixPathError as exc: + raise ValueError(str(exc)) from exc + + +def coerce_remote_abs_path(value: str) -> str: + """Required remote absolute path.""" + try: + return normalize_remote_abs_path(str(value).strip()) + except PosixPathError as exc: + raise ValueError(str(exc)) from exc + + +def coerce_remote_abs_path_list(values: list[str]) -> list[str]: + return [coerce_remote_abs_path(v) for v in values] + + +def coerce_nexus_host_abs_path(value: str) -> str: + """Nexus 主机上的绝对路径(推送源、上传暂存区等)。""" + text = str(value).strip() + if not text: + raise ValueError("路径不能为空") + if "\\" in text or "\0" in text: + raise ValueError("路径必须使用 Linux 路径(不允许反斜杠)") + path = to_posix(text) + if not path.startswith("/"): + raise ValueError("路径必须为绝对路径") + parts = [p for p in path.split("/") if p and p != "."] + if ".." in parts: + raise ValueError("路径不允许包含 '..'") + normalized = "/" + "/".join(parts) if parts else "/" + if len(normalized) > 1 and normalized.endswith("/"): + normalized = normalized.rstrip("/") + return normalized or "/" + + +def coerce_remote_relative_path(value: str) -> str: + """远程目录下的相对路径(不含前导 /)。""" + rel = to_posix(str(value).strip()).lstrip("/") + if not rel or ".." in rel.split("/"): + raise ValueError("相对路径无效") + if "\\" in str(value): + raise ValueError("路径必须使用 Linux 路径(不允许反斜杠)") + return rel diff --git a/server/api/schemas.py b/server/api/schemas.py index 9fad850f..1f5070cf 100644 --- a/server/api/schemas.py +++ b/server/api/schemas.py @@ -3,7 +3,15 @@ Shared schemas for API validation, replacing raw `dict` parameters. """ from typing import Optional, List -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator + +from server.api.schema_path_validators import ( + coerce_nexus_host_abs_path, + coerce_optional_remote_abs_path, + coerce_remote_abs_path, + coerce_remote_abs_path_list, + coerce_remote_relative_path, +) # ── Setting ── @@ -48,6 +56,16 @@ class ServerCreate(BaseModel): node_id: Optional[int] = None protocols: Optional[list] = None extra_attrs: Optional[dict] = None + files_elevation: Optional[str] = Field( + default=None, + pattern="^(off|auto_sudo|always_sudo)$", + description="File manager sudo policy (stored in extra_attrs)", + ) + + @field_validator("target_path", mode="before") + @classmethod + def _normalize_target_path(cls, v: object) -> object: + return coerce_optional_remote_abs_path(v if v is None else str(v)) class ServerUpdate(BaseModel): @@ -71,6 +89,18 @@ class ServerUpdate(BaseModel): node_id: Optional[int] = None protocols: Optional[list] = None extra_attrs: Optional[dict] = None + files_elevation: Optional[str] = Field( + default=None, + pattern="^(off|auto_sudo|always_sudo)$", + description="File manager sudo policy (stored in extra_attrs)", + ) + + @field_validator("target_path", mode="before") + @classmethod + def _normalize_target_path(cls, v: object) -> object: + if v is None: + return None + return coerce_optional_remote_abs_path(str(v)) class ServerCheck(BaseModel): @@ -109,6 +139,11 @@ class FileUpload(BaseModel): server_id: int = Field(..., ge=1) remote_path: str = Field(..., min_length=1) + @field_validator("remote_path", mode="before") + @classmethod + def _normalize_remote_path(cls, v: object) -> str: + return coerce_remote_abs_path(str(v)) + # ── Sync ── @@ -120,6 +155,16 @@ class SyncFiles(BaseModel): batch_size: int = Field(50, ge=1, le=200) concurrency: int = Field(10, ge=1, le=50) + @field_validator("source_path", mode="before") + @classmethod + def _normalize_source_path(cls, v: object) -> str: + return coerce_nexus_host_abs_path(str(v)) + + @field_validator("target_path", mode="before") + @classmethod + def _normalize_target_path(cls, v: object) -> object: + return coerce_optional_remote_abs_path(v if v is None else str(v)) + class SyncPreview(BaseModel): """Dry-run: rsync --dry-run --stats against one representative server.""" @@ -129,16 +174,36 @@ class SyncPreview(BaseModel): sync_mode: str = Field("incremental", pattern="^(incremental|full|overwrite|checksum)$") verbose: bool = False # If True, include per-file list (truncated to 200 lines) + @field_validator("source_path", mode="before") + @classmethod + def _normalize_source_path(cls, v: object) -> str: + return coerce_nexus_host_abs_path(str(v)) + + @field_validator("target_path", mode="before") + @classmethod + def _normalize_target_path(cls, v: object) -> object: + return coerce_optional_remote_abs_path(v if v is None else str(v)) + class SyncBrowse(BaseModel): server_id: int path: str = Field("/", min_length=1) + @field_validator("path", mode="before") + @classmethod + def _normalize_path(cls, v: object) -> str: + return coerce_remote_abs_path(str(v)) + class SyncBrowseLocal(BaseModel): """Browse a local directory on the Nexus server (for upload file manager).""" path: str = Field(..., min_length=1) + @field_validator("path", mode="before") + @classmethod + def _normalize_path(cls, v: object) -> str: + return coerce_nexus_host_abs_path(str(v)) + class LocalFileOperation(BaseModel): """Delete or rename a file/directory in the local upload staging area.""" @@ -146,22 +211,42 @@ class LocalFileOperation(BaseModel): path: str = Field(..., min_length=1) new_name: Optional[str] = None # required for rename + @field_validator("path", mode="before") + @classmethod + def _normalize_path(cls, v: object) -> str: + return coerce_nexus_host_abs_path(str(v)) + class LocalFilePreview(BaseModel): """Preview file content in the local upload staging area.""" path: str = Field(..., min_length=1) + @field_validator("path", mode="before") + @classmethod + def _normalize_path(cls, v: object) -> str: + return coerce_nexus_host_abs_path(str(v)) + class ValidateSourcePath(BaseModel): """Validate a local directory path on the Nexus server as a push source.""" path: str = Field(..., min_length=1) + @field_validator("path", mode="before") + @classmethod + def _normalize_path(cls, v: object) -> str: + return coerce_nexus_host_abs_path(str(v)) + class SyncDiagnose(BaseModel): """Diagnose why a push failed for a specific server.""" server_id: int = Field(..., ge=1) target_path: Optional[str] = None + @field_validator("target_path", mode="before") + @classmethod + def _normalize_target_path(cls, v: object) -> object: + return coerce_optional_remote_abs_path(v if v is None else str(v)) + class FileSyncDiff(BaseModel): """Compare a local file with its remote counterpart to show push diff.""" @@ -170,6 +255,21 @@ class FileSyncDiff(BaseModel): relative_path: str = Field(..., min_length=1) target_path: Optional[str] = None + @field_validator("source_path", mode="before") + @classmethod + def _normalize_source_path(cls, v: object) -> str: + return coerce_nexus_host_abs_path(str(v)) + + @field_validator("relative_path", mode="before") + @classmethod + def _normalize_relative_path(cls, v: object) -> str: + return coerce_remote_relative_path(str(v)) + + @field_validator("target_path", mode="before") + @classmethod + def _normalize_target_path(cls, v: object) -> object: + return coerce_optional_remote_abs_path(v if v is None else str(v)) + class SyncCancel(BaseModel): """Cancel an in-progress push by batch_id.""" @@ -183,6 +283,16 @@ class SyncVerify(BaseModel): target_path: Optional[str] = None max_files: int = Field(200, ge=1, le=500) + @field_validator("source_path", mode="before") + @classmethod + def _normalize_source_path(cls, v: object) -> str: + return coerce_nexus_host_abs_path(str(v)) + + @field_validator("target_path", mode="before") + @classmethod + def _normalize_target_path(cls, v: object) -> object: + return coerce_optional_remote_abs_path(v if v is None else str(v)) + class FileOperation(BaseModel): """Remote file operation via SSH exec (delete / rename / mkdir).""" @@ -191,12 +301,49 @@ class FileOperation(BaseModel): path: str = Field(..., min_length=1) # source path (or new dir path for mkdir) new_path: Optional[str] = None # required for rename + @field_validator("path", mode="before") + @classmethod + def _normalize_path(cls, v: object) -> str: + return coerce_remote_abs_path(str(v)) + + @field_validator("new_path", mode="before") + @classmethod + def _normalize_new_path(cls, v: object) -> object: + if v is None: + return None + return coerce_remote_abs_path(str(v)) + + +class FileClipboardApply(BaseModel): + """Batch copy or move (cut+paste) remote paths into a destination directory.""" + server_id: int = Field(..., ge=1) + mode: str = Field(..., pattern="^(copy|move)$") + sources: list[str] = Field(..., min_length=1, max_length=50) + dest_dir: str = Field(..., min_length=1, max_length=4096) + + @field_validator("sources", mode="before") + @classmethod + def _normalize_sources(cls, v: object) -> list[str]: + if not isinstance(v, list): + raise ValueError("sources 必须为路径列表") + return coerce_remote_abs_path_list([str(x) for x in v]) + + @field_validator("dest_dir", mode="before") + @classmethod + def _normalize_dest_dir(cls, v: object) -> str: + return coerce_remote_abs_path(str(v)) + class FileDownload(BaseModel): """Download a file from a remote server.""" server_id: int = Field(..., ge=1) path: str = Field(..., min_length=1, max_length=4096) + @field_validator("path", mode="before") + @classmethod + def _normalize_path(cls, v: object) -> str: + return coerce_remote_abs_path(str(v)) + class FileRead(BaseModel): """Read text file content from a remote server.""" @@ -204,6 +351,11 @@ class FileRead(BaseModel): path: str = Field(..., min_length=1, max_length=4096) max_size: int = Field(1_000_000, ge=1, le=5_000_000) # 1MB default, 5MB max + @field_validator("path", mode="before") + @classmethod + def _normalize_path(cls, v: object) -> str: + return coerce_remote_abs_path(str(v)) + class FileWrite(BaseModel): """Write text content to a file on a remote server.""" @@ -211,12 +363,31 @@ class FileWrite(BaseModel): path: str = Field(..., min_length=1, max_length=4096) content: str = Field(..., max_length=5_000_000) + @field_validator("path", mode="before") + @classmethod + def _normalize_path(cls, v: object) -> str: + return coerce_remote_abs_path(str(v)) + class FileChmod(BaseModel): """Change file permissions on a remote server.""" server_id: int = Field(..., ge=1) path: str = Field(..., min_length=1, max_length=4096) mode: str = Field(..., min_length=3, max_length=4, pattern=r"^[0-7]{3,4}$") + owner: Optional[str] = Field(None, max_length=64, pattern=r"^[a-zA-Z0-9_.-]+$") + group: Optional[str] = Field(None, max_length=64, pattern=r"^[a-zA-Z0-9_.-]+$") + recursive: bool = False + + @field_validator("path", mode="before") + @classmethod + def _normalize_path(cls, v: object) -> str: + return coerce_remote_abs_path(str(v)) + + @model_validator(mode="after") + def _group_requires_owner(self): + if self.group and not self.owner: + raise ValueError("指定属组时必须同时指定属主") + return self class FileCompress(BaseModel): @@ -226,12 +397,32 @@ class FileCompress(BaseModel): dest: str = Field(..., min_length=1, max_length=4096) format: str = Field("tar.gz", pattern=r"^(tar\.gz|zip)$") + @field_validator("paths", mode="before") + @classmethod + def _normalize_paths(cls, v: object) -> list[str]: + if not isinstance(v, list): + raise ValueError("paths 必须为路径列表") + return coerce_remote_abs_path_list([str(x) for x in v]) + + @field_validator("dest", mode="before") + @classmethod + def _normalize_dest(cls, v: object) -> str: + return coerce_remote_abs_path(str(v)) + class FileDecompress(BaseModel): """Decompress an archive on a remote server.""" server_id: int = Field(..., ge=1) path: str = Field(..., min_length=1, max_length=4096) - dest: str = Field(".", min_length=1, max_length=4096) + dest: str = Field(..., min_length=1, max_length=4096) + + @field_validator("path", "dest", mode="before") + @classmethod + def _normalize_paths(cls, v: object) -> str: + text = str(v).strip() + if text == ".": + raise ValueError("解压目标必须为绝对路径(不可使用 '.')") + return coerce_remote_abs_path(text) # ── Script ── @@ -335,6 +526,16 @@ class ScheduleCreate(BaseModel): raise ValueError("push 类型调度必须提供 source_path") return self + @field_validator("source_path", mode="before") + @classmethod + def _normalize_source_path(cls, v: object) -> object: + if v is None: + return None + text = str(v).strip() + if not text: + return None + return coerce_nexus_host_abs_path(text) + class ScheduleUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=100) @@ -351,6 +552,16 @@ class ScheduleUpdate(BaseModel): exec_timeout: Optional[int] = Field(None, ge=10, le=600) long_task: Optional[bool] = None + @field_validator("source_path", mode="before") + @classmethod + def _normalize_source_path(cls, v: object) -> object: + if v is None: + return None + text = str(v).strip() + if not text: + return None + return coerce_nexus_host_abs_path(text) + # ── Preset ── diff --git a/server/api/servers.py b/server/api/servers.py index 8a8f7106..4841072d 100644 --- a/server/api/servers.py +++ b/server/api/servers.py @@ -29,6 +29,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession import shlex +from server.utils.posix_paths import posix_dirname + def _sudo_wrap(cmd: str, ssh_user: str) -> str: """Wrap a command with sudo setup/teardown for non-root SSH users. @@ -753,7 +755,6 @@ async def batch_detect_path( 作为服务器的 target_path 写入数据库。找到第一个即停止搜索。 """ import asyncio - import os from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command sem = asyncio.Semaphore(5) @@ -786,7 +787,7 @@ async def batch_detect_path( ) # 取目录路径(可能有多个结果,取第一个) first_match = found.split("\n")[0].strip() - target_dir = os.path.dirname(first_match) + target_dir = posix_dirname(first_match) # 更新服务器 target_path — lock to avoid concurrent session commits async with db_lock: server.target_path = target_dir @@ -935,6 +936,21 @@ async def get_server( return server_data +@router.get("/{id}/files-capability", response_model=dict) +async def get_files_capability( + id: int, + admin: Admin = Depends(get_current_admin), + service: ServerService = Depends(get_server_service), +): + """Probe remote sudo capability for file manager (chmod/chown/write).""" + from server.infrastructure.ssh.files_capability import probe_files_capability + + server = await service.get_server(id) + if not server: + raise HTTPException(status_code=404, detail="Server not found") + return await probe_files_capability(server) + + @router.post("/", response_model=dict, status_code=201) async def create_server( request: Request, @@ -948,7 +964,10 @@ async def create_server( from server.infrastructure.database.password_preset_repo import PasswordPresetRepositoryImpl from server.infrastructure.database.ssh_key_preset_repo import SshKeyPresetRepositoryImpl + from server.utils.files_elevation import apply_files_elevation_payload + data = payload.model_dump(exclude_none=True) + apply_files_elevation_payload(data) # If preset_id is provided, resolve the preset password (server-side, no re-auth needed) if data.get("preset_id") and data.get("auth_method") == "password" and not data.get("password"): @@ -1014,8 +1033,11 @@ async def update_server( if not server: raise HTTPException(status_code=404, detail="Server not found") + from server.utils.files_elevation import apply_files_elevation_payload + # If preset_id is provided (and not None), resolve the preset password update_data = payload.model_dump(exclude_unset=True) + apply_files_elevation_payload(update_data) if "preset_id" in update_data and update_data["preset_id"] is not None and not update_data.get("password"): preset_repo = PasswordPresetRepositoryImpl(db) preset = await preset_repo.get_by_id(update_data["preset_id"]) @@ -1553,6 +1575,8 @@ async def get_server_logs( def _server_to_dict(server: Server) -> dict: """Convert Server model to API response dict (hide sensitive fields)""" + from server.utils.files_elevation import get_files_elevation + return { "id": server.id, "name": server.name, @@ -1576,6 +1600,7 @@ def _server_to_dict(server: Server) -> dict: "node_id": server.node_id, "protocols": server.protocols, "extra_attrs": server.extra_attrs, + "files_elevation": get_files_elevation(server).value, "connectivity": server.connectivity, "ssh_key_configured": server.ssh_key_configured, "is_online": server.is_online, diff --git a/server/api/sync_v2.py b/server/api/sync_v2.py index 4b90dd44..388e5f46 100644 --- a/server/api/sync_v2.py +++ b/server/api/sync_v2.py @@ -4,12 +4,46 @@ S1: POST /api/sync/files — rsync file sync P1: POST /api/sync/preview — rsync dry-run (方案D: 智能提示预览) """ -import io import logging from fastapi import APIRouter, Depends, HTTPException, Request -from server.api.schemas import SyncFiles, SyncBrowse, SyncBrowseLocal, SyncPreview, FileOperation, LocalFileOperation, LocalFilePreview, SyncVerify, SyncCancel, ValidateSourcePath, SyncDiagnose, FileSyncDiff, FileDownload, FileRead, FileWrite, FileChmod, FileCompress, FileDecompress +from server.api.schemas import ( + SyncFiles, + SyncBrowse, + SyncBrowseLocal, + SyncPreview, + FileOperation, + FileClipboardApply, + LocalFileOperation, + LocalFilePreview, + SyncVerify, + SyncCancel, + ValidateSourcePath, + SyncDiagnose, + FileSyncDiff, + FileDownload, + FileRead, + FileWrite, + FileChmod, + FileCompress, + FileDecompress, +) +from server.api.remote_path_validation import RemotePathError, assert_clipboard_transfer_safe +from server.utils.posix_paths import ( + PosixPathError, + assert_zip_member_safe, + ensure_under_nexus_upload, + is_path_under_prefix, + normalize_remote_abs_path, + posix_basename, + posix_dirname, + posix_join, + normalize_remote_dest, + remote_join, + resolve_nexus_host_path, + to_posix, +) from server.application.services.sync_engine_v2 import SyncEngineV2 from server.infrastructure.database.server_repo import ServerRepositoryImpl from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl @@ -99,7 +133,7 @@ async def file_operation( """ import shlex from server.infrastructure.database.server_repo import ServerRepositoryImpl - from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command + from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback session = request.state.db server = await ServerRepositoryImpl(session).get_by_id(payload.server_id) @@ -107,7 +141,10 @@ async def file_operation( raise HTTPException(status_code=404, detail="Server not found") op = payload.operation - p = payload.path.rstrip("/") or "/" + try: + p = normalize_remote_abs_path(payload.path) + except PosixPathError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc if op == "delete": # rm -rf covers both files and directories @@ -116,7 +153,10 @@ async def file_operation( elif op == "rename": if not payload.new_path: raise HTTPException(status_code=400, detail="new_path required for rename") - np = payload.new_path.rstrip("/") or "/" + try: + np = normalize_remote_abs_path(payload.new_path) + except PosixPathError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc cmd = f"mv {shlex.quote(p)} {shlex.quote(np)}" audit_detail = f"重命名: {p} → {np}" elif op == "mkdir": @@ -125,11 +165,12 @@ async def file_operation( else: raise HTTPException(status_code=400, detail="Invalid operation") - result = await exec_ssh_command(server, cmd, timeout=30) + result = await exec_ssh_command_with_fallback(server, cmd, timeout=30) if result["exit_code"] != 0: + err = (result.get("stderr") or result.get("stdout") or "")[:300] raise HTTPException( - status_code=400, - detail=result["stderr"][:300] or f"Command failed (exit {result['exit_code']})" + status_code=403 if "permission denied" in err.lower() else 400, + detail=err or f"Command failed (exit {result['exit_code']})", ) await _audit_sync( @@ -139,6 +180,66 @@ async def file_operation( return {"success": True, "operation": op, "path": p} +@router.post("/file-clipboard") +async def apply_file_clipboard( + payload: FileClipboardApply, + request: Request, + admin: Admin = Depends(get_current_admin), +): + """Batch copy (cp -r) or move/cut (mv -t) remote paths into dest_dir.""" + import shlex + from server.infrastructure.database.server_repo import ServerRepositoryImpl + from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command + from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback + + try: + sources, dest_dir = assert_clipboard_transfer_safe(payload.sources, payload.dest_dir) + except RemotePathError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + session = request.state.db + server = await ServerRepositoryImpl(session).get_by_id(payload.server_id) + if not server: + raise HTTPException(status_code=404, detail="服务器不存在") + + dest_q = shlex.quote(dest_dir) + check = await exec_ssh_command(server, f"test -d {dest_q}", timeout=15) + if check["exit_code"] != 0: + raise HTTPException(status_code=400, detail="目标目录不存在") + + quoted_sources = " ".join(shlex.quote(s) for s in sources) + if payload.mode == "copy": + cmd = f"cp -r {quoted_sources} {dest_q}" + audit_action = "file_copy" + audit_detail = f"复制 {len(sources)} 项 → {dest_dir}" + else: + cmd = f"mv -t {dest_q} {quoted_sources}" + audit_action = "file_move" + audit_detail = f"移动 {len(sources)} 项 → {dest_dir}" + + result = await exec_ssh_command_with_fallback(server, cmd, timeout=120) + if result["exit_code"] != 0: + raise HTTPException( + status_code=502, + detail=(result["stderr"] or result["stdout"] or "传输失败")[:300], + ) + + await _audit_sync( + audit_action, + "server", + payload.server_id, + f"{audit_detail} 于 {server.name}", + admin.username, + request, + ) + return { + "success": True, + "mode": payload.mode, + "dest_dir": dest_dir, + "count": len(sources), + } + + @router.post("/browse") async def browse_directory( payload: SyncBrowse, @@ -161,42 +262,62 @@ async def browse_directory( if not server: raise HTTPException(status_code=404, detail="Server not found") - # Use ls -la for detailed listing, parse output - import shlex - result = await exec_ssh_command(server, f"ls -la {shlex.quote(path)}", timeout=10) + try: + path = normalize_remote_abs_path(path) + except PosixPathError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + import shlex + from server.utils.unix_ls import parse_ls_la_line, perms_to_mode_octal + + path_q = shlex.quote(path) + result = await exec_ssh_command( + server, + f"ls -la --time-style=long-iso {path_q}", + timeout=10, + ) + if result["exit_code"] != 0: + result = await exec_ssh_command(server, f"ls -la {path_q}", timeout=10) - # Audit await _audit_sync("browse_directory", "server", server_id, f"Browse {server.name}:{path}", admin.username, request) if result["exit_code"] != 0: - return {"path": path, "entries": [], "error": result["stderr"][:500]} + return {"path": path, "items": [], "error": result["stderr"][:500]} - entries = [] - for line in result["stdout"].split("\n")[1:]: # Skip "total" line - if not line.strip(): - continue - parts = line.split(None, 8) - if len(parts) >= 9: - perms = parts[0] - is_dir = perms.startswith("d") - is_link = perms.startswith("l") - name = parts[8] - link_target = None - # P2-1: Parse symlink: "name -> target" - if is_link and " -> " in name: - name, link_target = name.split(" -> ", 1) - entries.append({ - "name": name, - "is_dir": is_dir, - "is_link": is_link, - "link_target": link_target, - "size": parts[4], - "perms": perms, - "owner": parts[2], - "modified": " ".join(parts[5:8]), - }) + raw_entries = [] + for line in result["stdout"].split("\n"): + parsed = parse_ls_la_line(line) + if parsed: + raw_entries.append(parsed) - return {"path": path, "entries": entries} + items = [] + for e in raw_entries: + is_dir = bool(e["is_dir"]) + is_link = bool(e["is_link"]) + try: + size = int(e["size"]) if not is_dir and not is_link else 0 + except (TypeError, ValueError): + size = 0 + if is_dir: + ftype = "directory" + elif is_link: + ftype = "symlink" + else: + ftype = "file" + mode_octal = perms_to_mode_octal(e["perms"]) or "" + items.append({ + "name": e["name"], + "type": ftype, + "size": size, + "modified": e["modified"], + "permissions": e["perms"], + "owner": e.get("owner") or "", + "group": e.get("group") or "", + "mode_octal": mode_octal, + "link_target": e.get("link_target"), + }) + + return {"path": path, "items": items} @router.post("/browse-local") @@ -212,12 +333,10 @@ async def browse_local_directory( """ import os - path = payload.path.rstrip("/") or "/" - - # Security: only allow browsing under /tmp/nexus_upload_* - real_path = os.path.realpath(path) - if not real_path.startswith("/tmp/nexus_upload_"): - raise HTTPException(status_code=403, detail="仅允许浏览上传临时目录") + try: + real_path = ensure_under_nexus_upload(payload.path) + except PosixPathError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc if not os.path.isdir(real_path): raise HTTPException(status_code=404, detail="目录不存在") @@ -253,12 +372,12 @@ async def local_file_operation( import os import shutil - path = payload.path.rstrip("/") or "/" + try: + real_path = ensure_under_nexus_upload(payload.path) + except PosixPathError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc - # Security: only allow operations under /tmp/nexus_upload_* - real_path = os.path.realpath(path) - if not real_path.startswith("/tmp/nexus_upload_"): - raise HTTPException(status_code=403, detail="仅允许操作上传临时目录中的文件") + path = to_posix(real_path) if payload.operation == "delete": if not os.path.exists(real_path): @@ -288,12 +407,13 @@ async def local_file_operation( if not os.path.exists(real_path): raise HTTPException(status_code=404, detail="文件或目录不存在") - parent_dir = os.path.dirname(real_path) - new_path = os.path.join(parent_dir, safe_name) + new_path = posix_join(posix_dirname(real_path), safe_name) - # Security: new path must also be under upload dir - if not os.path.realpath(new_path).startswith("/tmp/nexus_upload_"): - raise HTTPException(status_code=403, detail="目标路径不在上传临时目录内") + try: + ensure_under_nexus_upload(new_path) + except PosixPathError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + new_path = resolve_nexus_host_path(new_path) if os.path.exists(new_path): raise HTTPException(status_code=400, detail="目标名称已存在") @@ -324,12 +444,12 @@ async def local_file_preview( import os import base64 - path = payload.path.rstrip("/") or "/" + try: + real_path = ensure_under_nexus_upload(payload.path) + except PosixPathError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc - # Security: only allow previewing files under /tmp/nexus_upload_* - real_path = os.path.realpath(path) - if not real_path.startswith("/tmp/nexus_upload_"): - raise HTTPException(status_code=403, detail="仅允许预览上传临时目录中的文件") + path = to_posix(real_path) if not os.path.isfile(real_path): raise HTTPException(status_code=404, detail="文件不存在或不是普通文件") @@ -357,7 +477,7 @@ async def local_file_preview( except (UnicodeDecodeError, ValueError): pass - name = os.path.basename(real_path) + name = posix_basename(real_path) await _audit_sync( "local_file_preview", "sync", 0, @@ -436,12 +556,13 @@ async def validate_source_path( path = payload.path.strip() - # Reject path traversal - if ".." in path.split("/"): + try: + real_path = resolve_nexus_host_path(path) + except PosixPathError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if ".." in to_posix(path).split("/"): raise HTTPException(status_code=400, detail="路径不允许包含 '..'") - real_path = os.path.realpath(path) - # Reject sensitive paths for prefix in _FORBIDDEN_PATH_PREFIXES: if real_path == prefix or real_path.startswith(prefix + "/"): @@ -460,7 +581,7 @@ async def validate_source_path( for fn in fnames: file_count += 1 try: - total_size += os.path.getsize(os.path.join(root, fn)) + total_size += os.path.getsize(posix_join(root, fn)) except OSError: pass if file_count >= 10000: @@ -532,7 +653,10 @@ async def diagnose_push( await _audit_sync("diagnose", "server", payload.server_id, "诊断: SSH失败", admin.username, request) return result - dest = payload.target_path or server.target_path or "/tmp/sync" + try: + dest = normalize_remote_dest(payload.target_path or server.target_path) + except PosixPathError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc safe_dest = shlex.quote(dest) # 2. Disk space @@ -564,7 +688,7 @@ async def diagnose_push( # 4. Write test if result["path_exists"]: try: - test_file = f"{dest.rstrip('/')}/.nexus_diag_test_$$" + test_file = remote_join(dest, ".nexus_diag_test_$$") r = await exec_ssh_command( server, f"touch {shlex.quote(test_file)} && rm -f {shlex.quote(test_file)}", @@ -610,19 +734,22 @@ async def file_diff( from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command from server.infrastructure.database.server_repo import ServerRepositoryImpl - # Security: source_path must be under /tmp/nexus_upload_* - real_source = os.path.realpath(payload.source_path) - if not real_source.startswith("/tmp/nexus_upload_"): - raise HTTPException(status_code=403, detail="仅允许对比上传临时目录中的文件") + try: + real_source = ensure_under_nexus_upload(payload.source_path) + except PosixPathError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc - # Resolve local file path - local_file = os.path.realpath(os.path.join(payload.source_path, payload.relative_path)) - if not local_file.startswith(real_source): + rel = to_posix(payload.relative_path).lstrip("/") + if not rel or ".." in rel.split("/"): + raise HTTPException(status_code=400, detail="相对路径无效") + + local_file = resolve_nexus_host_path(posix_join(real_source, rel)) + if not is_path_under_prefix(local_file, real_source): raise HTTPException(status_code=403, detail="文件路径越界") if not os.path.isfile(local_file): return { - "file_name": os.path.basename(payload.relative_path), + "file_name": posix_basename(rel), "local_exists": False, "remote_exists": None, "local_size": 0, @@ -649,8 +776,11 @@ async def file_diff( if not server: raise HTTPException(status_code=404, detail="服务器不存在") - dest = payload.target_path or server.target_path or "/tmp/sync" - remote_file = f"{dest.rstrip('/')}/{payload.relative_path}" + try: + dest = normalize_remote_dest(payload.target_path or server.target_path) + remote_file = remote_join(dest, rel) + except PosixPathError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc # Read remote file remote_exists = False @@ -710,7 +840,7 @@ async def file_diff( ) return { - "file_name": os.path.basename(payload.relative_path), + "file_name": posix_basename(rel), "local_exists": True, "remote_exists": remote_exists, "local_size": local_size, @@ -741,7 +871,7 @@ async def upload_file( If the file already exists, it will be overwritten. """ import asyncssh - from server.infrastructure.ssh.asyncssh_pool import ssh_pool + from server.infrastructure.ssh.asyncssh_pool import remote_write_bytes, ssh_pool form = await request.form() server_id_raw = form.get("server_id") @@ -766,9 +896,10 @@ async def upload_file( remote_path = str(remote_path_raw).strip() filename = file.filename - # Security: path traversal prevention - if ".." in remote_path.split("/") or ".." in filename.split("/"): - raise HTTPException(status_code=400, detail="路径不允许包含 '..'") + try: + remote_dir = normalize_remote_abs_path(remote_path) + except PosixPathError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc # Sanitize filename — strip slashes to prevent path injection safe_filename = filename.replace("/", "_").replace("\\", "_").strip() @@ -781,15 +912,22 @@ async def upload_file( if not server: raise HTTPException(status_code=404, detail="服务器不存在") - # Build full remote path - full_remote_path = f"{remote_path.rstrip('/')}/{safe_filename}" + try: + full_remote_path = remote_join(remote_dir, safe_filename) + except PosixPathError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + from server.utils.files_elevation import get_files_elevation - # Connect via asyncssh pool and use SFTP try: conn = await ssh_pool.acquire(server) try: - async with conn.create_sftp_client() as sftp: - await sftp.put(io.BytesIO(content), full_remote_path) + await remote_write_bytes( + conn, + full_remote_path, + content, + elevation=get_files_elevation(server), + ) finally: await ssh_pool.release(server.id) except asyncssh.PermissionDenied: @@ -833,6 +971,7 @@ async def upload_zip( Returns {"source_path": "/tmp/nexus_upload_xxx/", "file_count": N} """ import os + import posixpath import uuid import zipfile import shutil @@ -869,10 +1008,14 @@ async def upload_zip( # Validate and extract with zip slip protection file_count = 0 with zipfile.ZipFile(zip_path, "r") as zf: + extract_base = resolve_nexus_host_path(extract_dir) for info in zf.infolist(): - # Zip slip: ensure no path traversal - real_path = os.path.realpath(os.path.join(extract_dir, info.filename)) - if not real_path.startswith(os.path.realpath(extract_dir)): + try: + member_path = assert_zip_member_safe(extract_dir, info.filename) + except PosixPathError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + real_path = resolve_nexus_host_path(member_path) + if not is_path_under_prefix(real_path, extract_base): raise HTTPException(status_code=400, detail=f"ZIP 包含非法路径: {info.filename}") if info.is_dir(): continue @@ -891,7 +1034,7 @@ async def upload_zip( files = [] for root, _dirs, fnames in os.walk(extract_dir): for fn in fnames: - rel = os.path.relpath(os.path.join(root, fn), extract_dir) + rel = posixpath.relpath(posix_join(root, fn), extract_dir) files.append(rel) if len(files) >= 500: break @@ -956,6 +1099,7 @@ async def verify_sync( import asyncio import hashlib import os + import posixpath import shlex from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command from server.infrastructure.database.server_repo import ServerRepositoryImpl @@ -969,8 +1113,8 @@ async def verify_sync( local_files = {} for root, _dirs, fnames in os.walk(payload.source_path): for fn in fnames: - full = os.path.join(root, fn) - rel = os.path.relpath(full, payload.source_path) + full = posix_join(root, fn) + rel = posixpath.relpath(full, to_posix(payload.source_path)) if len(local_files) >= payload.max_files: break try: @@ -998,7 +1142,15 @@ async def verify_sync( results[sid] = {"server_id": sid, "server_name": "", "error": "服务器不存在"} return - dest = payload.target_path or server.target_path or "/tmp/sync" + try: + dest = normalize_remote_dest(payload.target_path or server.target_path) + except PosixPathError as exc: + results[sid] = { + "server_id": sid, + "server_name": server.name, + "error": str(exc), + } + return # Run sha256sum on remote server for the same relative paths # Build a file list for sha256sum to check file_list = " ".join(shlex.quote(f) for f in local_files.keys()) @@ -1066,22 +1218,26 @@ async def download_file( admin: Admin = Depends(get_current_admin), ): """P2-7: Download a file from a remote server via SFTP.""" - import os from fastapi.responses import StreamingResponse from server.infrastructure.database.server_repo import ServerRepositoryImpl - from server.infrastructure.ssh.asyncssh_pool import ssh_pool + from server.infrastructure.ssh.asyncssh_pool import sftp_session, ssh_pool session = request.state.db server = await ServerRepositoryImpl(session).get_by_id(payload.server_id) if not server: raise HTTPException(status_code=404, detail="服务器不存在") + try: + remote_path = normalize_remote_abs_path(payload.path) + except PosixPathError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + conn = await ssh_pool.acquire(server) try: - async with conn.create_sftp_client() as sftp: + async with sftp_session(conn) as sftp: # Check file exists and size try: - stat = await sftp.stat(payload.path) + stat = await sftp.stat(remote_path) except FileNotFoundError: raise HTTPException(status_code=404, detail="文件不存在") from None except Exception as e: @@ -1097,16 +1253,16 @@ async def download_file( # H-02: Sanitize filename for Content-Disposition (RFC 6266) import re as _re - filename = _re.sub(r'[^\w.\-]', '_', os.path.basename(payload.path)) or "download" + filename = _re.sub(r'[^\w.\-]', '_', posix_basename(remote_path)) or "download" async def file_generator(): - async with sftp.open(payload.path, "rb") as f: + async with sftp.open(remote_path, "rb") as f: while chunk := await f.read(65536): yield chunk await _audit_sync( "file_download", "server", payload.server_id, - f"下载文件: {payload.path}", + f"下载文件: {remote_path}", admin.username, request, ) @@ -1128,15 +1284,22 @@ async def read_file( """P2-8: Read text file content from a remote server.""" import shlex from server.infrastructure.database.server_repo import ServerRepositoryImpl - from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command + from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback session = request.state.db server = await ServerRepositoryImpl(session).get_by_id(payload.server_id) if not server: raise HTTPException(status_code=404, detail="服务器不存在") - # Check file size first - size_result = await exec_ssh_command(server, f"stat -c%s {shlex.quote(payload.path)}", timeout=5) + try: + remote_path = normalize_remote_abs_path(payload.path) + except PosixPathError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + path_q = shlex.quote(remote_path) + size_result = await exec_ssh_command_with_fallback( + server, f"stat -c%s {path_q}", timeout=5, + ) if size_result["exit_code"] != 0: raise HTTPException(status_code=404, detail="文件不存在或无法访问") @@ -1151,18 +1314,18 @@ async def read_file( detail=f"文件大小 {file_size} 字节超过限制 {payload.max_size} 字节", ) - # Read file content - result = await exec_ssh_command(server, f"cat {shlex.quote(payload.path)}", timeout=15) + result = await exec_ssh_command_with_fallback(server, f"cat {path_q}", timeout=15) if result["exit_code"] != 0: - raise HTTPException(status_code=502, detail=f"读取失败: {result['stderr'][:200]}") + err = ((result.get("stderr") or "") + (result.get("stdout") or ""))[:200] + raise HTTPException(status_code=502, detail=f"读取失败: {err}") await _audit_sync( "file_read", "server", payload.server_id, - f"读取文件: {payload.path} ({file_size} bytes)", + f"读取文件: {remote_path} ({file_size} bytes)", admin.username, request, ) - return {"content": result["stdout"], "size": file_size, "path": payload.path} + return {"content": result["stdout"], "size": file_size, "path": remote_path} @router.post("/write-file") @@ -1171,32 +1334,66 @@ async def write_file( request: Request, admin: Admin = Depends(get_current_admin), ): - """P2-8: Write text content to a file on a remote server.""" - import shlex - import base64 + """Write text file content to a remote server (SFTP + SSH tee/mv/sudo fallback).""" + import asyncssh from server.infrastructure.database.server_repo import ServerRepositoryImpl - from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command + from server.infrastructure.ssh.asyncssh_pool import remote_write_bytes, ssh_pool session = request.state.db server = await ServerRepositoryImpl(session).get_by_id(payload.server_id) if not server: raise HTTPException(status_code=404, detail="服务器不存在") - # Use base64 to avoid shell escaping issues with file content - encoded = base64.b64encode(payload.content.encode("utf-8")).decode("ascii") - cmd = f"echo '{encoded}' | base64 -d | tee {shlex.quote(payload.path)} > /dev/null" + try: + remote_path = normalize_remote_abs_path(payload.path) + except PosixPathError as e: + raise HTTPException(status_code=400, detail=str(e)) from e - result = await exec_ssh_command(server, cmd, timeout=30) - if result["exit_code"] != 0: - raise HTTPException(status_code=502, detail=f"写入失败: {result['stderr'][:200]}") + from server.utils.files_elevation import get_files_elevation + + content_bytes = payload.content.encode("utf-8") + if len(content_bytes) > 5_000_000: + raise HTTPException(status_code=413, detail="文件内容超过 5MB 限制") + + try: + conn = await ssh_pool.acquire(server) + try: + await remote_write_bytes( + conn, + remote_path, + content_bytes, + elevation=get_files_elevation(server), + ) + finally: + await ssh_pool.release(server.id) + except asyncssh.PermissionDenied: + raise HTTPException( + status_code=403, + detail=( + f"无写入权限: {remote_path}。" + "请确认 SSH 用户对目录有写权限,或已配置免密 sudo(tee/mv)。" + ), + ) from None + except FileNotFoundError: + raise HTTPException(status_code=404, detail="目标路径不存在") from None + except asyncssh.SFTPError as e: + raise HTTPException( + status_code=403, + detail=( + f"写入失败: {e}。" + f"路径 {remote_path} — 若文件属主为 root,请为 SSH 用户配置免密 sudo 或修改文件权限。" + ), + ) from e + except Exception as e: + raise HTTPException(status_code=502, detail=f"SSH 连接失败: {e}") from e await _audit_sync( "file_write", "server", payload.server_id, - f"写入文件: {payload.path} ({len(payload.content)} chars)", + f"写入文件: {remote_path} ({len(content_bytes)} bytes)", admin.username, request, ) - return {"success": True, "path": payload.path, "size": len(payload.content)} + return {"success": True, "path": remote_path, "size": len(content_bytes)} @router.post("/chmod") @@ -1205,28 +1402,79 @@ async def chmod_file( request: Request, admin: Admin = Depends(get_current_admin), ): - """Change file permissions on a remote server.""" + """Change file permissions (and optional owner) on a remote server.""" import shlex from server.infrastructure.database.server_repo import ServerRepositoryImpl - from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command + from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback session = request.state.db server = await ServerRepositoryImpl(session).get_by_id(payload.server_id) if not server: raise HTTPException(status_code=404, detail="服务器不存在") - cmd = f"chmod {shlex.quote(payload.mode)} {shlex.quote(payload.path)}" - result = await exec_ssh_command(server, cmd, timeout=10) - if result["exit_code"] != 0: - raise HTTPException(status_code=502, detail=f"chmod 失败: {result['stderr'][:200]}") + try: + remote_path = normalize_remote_abs_path(payload.path) + except PosixPathError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc - await _audit_sync( - "file_chmod", "server", payload.server_id, - f"chmod {payload.mode} {payload.path}", - admin.username, request, + from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command + from server.utils.files_chmod_policy import ( + RECURSIVE_CHMOD_TIMEOUT_SEC, + SINGLE_CHMOD_TIMEOUT_SEC, + assert_recursive_chmod_allowed, ) - return {"success": True, "path": payload.path, "mode": payload.mode} + path_q = shlex.quote(remote_path) + recursive = bool(payload.recursive) + if recursive: + try: + assert_recursive_chmod_allowed(remote_path) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + dir_check = await exec_ssh_command(server, f"test -d {path_q}", timeout=10) + if dir_check.get("exit_code") != 0: + raise HTTPException(status_code=400, detail="递归 chmod 仅适用于目录") + + chmod_prefix = "chmod -R " if recursive else "chmod " + chown_prefix = "chown -R " if recursive else "chown " + cmd_parts = [f"{chmod_prefix}{shlex.quote(payload.mode)} {path_q}"] + if payload.owner: + group = (payload.group or payload.owner).strip() + owner_spec = shlex.quote(f"{payload.owner}:{group}") + cmd_parts.append(f"{chown_prefix}{owner_spec} {path_q}") + + timeout = RECURSIVE_CHMOD_TIMEOUT_SEC if recursive else SINGLE_CHMOD_TIMEOUT_SEC + result = await exec_ssh_command_with_fallback(server, " && ".join(cmd_parts), timeout=timeout) + if result["exit_code"] != 0: + err = ((result.get("stderr") or "") + (result.get("stdout") or ""))[:300] + raise HTTPException( + status_code=403 if "permission denied" in err.lower() else 502, + detail=err or "chmod 失败", + ) + + audit_bits = [f"chmod {'-R ' if recursive else ''}{payload.mode}"] + if payload.owner: + audit_bits.append( + f"chown {'-R ' if recursive else ''}{payload.owner}:{payload.group or payload.owner}" + ) + await _audit_sync( + "file_permission_change", + "server", + payload.server_id, + f"{' '.join(audit_bits)} {remote_path}", + admin.username, + request, + ) + + return { + "success": True, + "path": remote_path, + "mode": payload.mode, + "owner": payload.owner, + "group": payload.group, + "recursive": recursive, + "elevated": bool(result.get("elevated")), + } @router.post("/compress") @@ -1236,34 +1484,41 @@ async def compress_files( admin: Admin = Depends(get_current_admin), ): """Compress files into tar.gz or zip archive.""" - import shlex from server.infrastructure.database.server_repo import ServerRepositoryImpl - from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command + from server.infrastructure.ssh.remote_archive import run_remote_compress session = request.state.db server = await ServerRepositoryImpl(session).get_by_id(payload.server_id) if not server: raise HTTPException(status_code=404, detail="服务器不存在") - quoted_paths = " ".join(shlex.quote(p) for p in payload.paths) - dest = shlex.quote(payload.dest) + try: + dest = normalize_remote_abs_path(payload.dest) + paths = [normalize_remote_abs_path(p) for p in payload.paths] + except PosixPathError as e: + raise HTTPException(status_code=400, detail=str(e)) from e - if payload.format == "tar.gz": - cmd = f"tar czf {dest} {quoted_paths}" - else: - cmd = f"zip -r {dest} {quoted_paths}" - - result = await exec_ssh_command(server, cmd, timeout=120) + result = await run_remote_compress( + server, paths, dest, payload.format, timeout=120, + ) if result["exit_code"] != 0: - raise HTTPException(status_code=502, detail=f"压缩失败: {result['stderr'][:300]}") + err = ((result.get("stderr") or "") + (result.get("stdout") or ""))[:400] + raise HTTPException( + status_code=403 if "permission denied" in err.lower() else 502, + detail=( + f"压缩失败: {err}" + if err.strip() + else "压缩失败。请确认对目标目录有写权限,或配置免密 sudo。" + ), + ) await _audit_sync( "file_compress", "server", payload.server_id, - f"压缩 {len(payload.paths)} 个文件 → {payload.dest}", + f"压缩 {len(payload.paths)} 个文件 → {dest}", admin.username, request, ) - return {"success": True, "dest": payload.dest, "format": payload.format} + return {"success": True, "dest": dest, "format": payload.format} @router.post("/decompress") @@ -1275,31 +1530,37 @@ async def decompress_file( """Decompress a tar.gz or zip archive.""" import shlex from server.infrastructure.database.server_repo import ServerRepositoryImpl - from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command + from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback session = request.state.db server = await ServerRepositoryImpl(session).get_by_id(payload.server_id) if not server: raise HTTPException(status_code=404, detail="服务器不存在") - path = shlex.quote(payload.path) - dest = shlex.quote(payload.dest) + try: + archive_path = normalize_remote_abs_path(payload.path) + dest_dir = normalize_remote_abs_path(payload.dest) + except PosixPathError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc - if payload.path.endswith(".tar.gz") or payload.path.endswith(".tgz"): + path = shlex.quote(archive_path) + dest = shlex.quote(dest_dir) + + if archive_path.endswith(".tar.gz") or archive_path.endswith(".tgz"): cmd = f"tar xzf {path} -C {dest}" - elif payload.path.endswith(".zip"): + elif archive_path.endswith(".zip"): cmd = f"unzip -o {path} -d {dest}" else: raise HTTPException(status_code=400, detail="仅支持 .tar.gz/.tgz 和 .zip 格式") - result = await exec_ssh_command(server, cmd, timeout=120) + result = await exec_ssh_command_with_fallback(server, cmd, timeout=120) if result["exit_code"] != 0: raise HTTPException(status_code=502, detail=f"解压失败: {result['stderr'][:300]}") await _audit_sync( "file_decompress", "server", payload.server_id, - f"解压 {payload.path} → {payload.dest}", + f"解压 {archive_path} → {dest_dir}", admin.username, request, ) - return {"success": True, "path": payload.path, "dest": payload.dest} + return {"success": True, "path": archive_path, "dest": dest_dir} diff --git a/server/application/services/sync_engine_v2.py b/server/application/services/sync_engine_v2.py index 92a7c124..e90cf780 100644 --- a/server/application/services/sync_engine_v2.py +++ b/server/application/services/sync_engine_v2.py @@ -20,6 +20,12 @@ from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl from server.infrastructure.database.crypto import decrypt_value +from server.utils.posix_paths import normalize_remote_abs_path, to_posix + + +def _nexus_source_path(path: str) -> str: + """Normalize push source path string (Nexus host, Linux deployment).""" + return to_posix(path.strip()) logger = logging.getLogger("nexus.sync_v2") @@ -56,6 +62,7 @@ class SyncEngineV2: trigger_type: str = "manual", ) -> dict: """Push files from Nexus to target servers via rsync over SSH""" + source_path = _nexus_source_path(source_path) if not os.path.isdir(source_path): return {"total": 0, "completed": 0, "failed": 0, "results": {}, "error": f"源路径不存在: {source_path}"} @@ -256,6 +263,7 @@ class SyncEngineV2: if not server: return {"error": "Server not found", "exit_code": -1} + source_path = _nexus_source_path(source_path) if not os.path.isdir(source_path): return {"error": f"源路径不存在: {source_path}", "exit_code": -1} @@ -338,7 +346,8 @@ async def _rsync_push( ssh_user = server.username or "root" ssh_host = server.domain ssh_port = server.port or 22 - dest = f"{ssh_user}@{ssh_host}:{target_path}" + remote_dest = normalize_remote_abs_path(to_posix(target_path)) + rsync_remote = f"{ssh_user}@{ssh_host}:{remote_dest}" # Build rsync args args = ["rsync", "-az"] @@ -384,8 +393,9 @@ async def _rsync_push( return {"exit_code": -1, "stdout": "", "stderr": f"服务器 {server.name} 无有效 SSH 凭据"} args.extend(["-e", ssh_opts]) + # source_path: Nexus 主机本地目录(生产环境为 Linux);保持原样供 rsync 读取 args.append(source_path.rstrip("/") + "/") - args.append(dest.rstrip("/") + "/") + args.append(rsync_remote.rstrip("/") + "/") # Execute rsync on Nexus host proc = await asyncio.create_subprocess_exec( diff --git a/server/infrastructure/ssh/asyncssh_pool.py b/server/infrastructure/ssh/asyncssh_pool.py index d1ffeaaa..3aab4956 100644 --- a/server/infrastructure/ssh/asyncssh_pool.py +++ b/server/infrastructure/ssh/asyncssh_pool.py @@ -9,12 +9,17 @@ W2: Replaces paramiko pool.py with asyncssh throughout the codebase. import asyncio import logging +import secrets +import shlex import time +from contextlib import asynccontextmanager from dataclasses import dataclass, field -from typing import Optional, Dict +from typing import AsyncIterator, Dict, Optional import asyncssh from server.domain.models import Server +from server.utils.files_elevation import FilesElevation +from server.utils.posix_paths import posix_dirname from server.infrastructure.database.crypto import decrypt_value logger = logging.getLogger("nexus.asyncssh_pool") @@ -256,6 +261,131 @@ class AsyncSSHPool: ssh_pool = AsyncSSHPool() +@asynccontextmanager +async def sftp_session(conn: asyncssh.SSHClientConnection) -> AsyncIterator[asyncssh.SFTPClient]: + """Open an SFTP client on a pooled SSH connection (asyncssh 2.x: start_sftp_client).""" + async with await conn.start_sftp_client() as sftp: + yield sftp + + +async def sftp_write_bytes(sftp: asyncssh.SFTPClient, remote_path: str, data: bytes) -> None: + """Write in-memory bytes to a remote file (asyncssh put() only accepts local paths).""" + async with sftp.open(remote_path, "wb") as remote_file: + await remote_file.write(data) + + +def _sftp_permission_denied(exc: Exception) -> bool: + if isinstance(exc, asyncssh.PermissionDenied): + return True + if isinstance(exc, asyncssh.SFTPError): + # SSH_FX_PERMISSION_DENIED = 3 + if getattr(exc, "code", None) == 3: + return True + return "permission denied" in str(exc).lower() + + +async def _run_tee_stdin( + conn: asyncssh.SSHClientConnection, + command: str, + data: bytes, +) -> asyncssh.SSHCompletedProcess: + """Feed raw bytes to stdin; default SSH session encoding expects str, not bytes.""" + return await conn.run(command, input=data, encoding=None, check=False) + + +async def _ensure_remote_parent_dir( + conn: asyncssh.SSHClientConnection, + remote_path: str, + *, + use_sudo: bool, +) -> None: + parent = posix_dirname(remote_path) + if not parent or parent == "/": + return + prefix = "sudo -n " if use_sudo else "" + await conn.run(f"{prefix}mkdir -p {shlex.quote(parent)}", check=False) + + +async def _ssh_write_bytes_via_tee( + conn: asyncssh.SSHClientConnection, + remote_path: str, + data: bytes, + *, + use_sudo: bool, +) -> None: + """Shell write fallback when SFTP cannot open the file (e.g. file not writable but directory is).""" + prefix = "sudo -n " if use_sudo else "" + await _ensure_remote_parent_dir(conn, remote_path, use_sudo=use_sudo) + + direct = await _run_tee_stdin( + conn, + f"{prefix}tee {shlex.quote(remote_path)}", + data, + ) + if direct.exit_status == 0: + return + + tmp_path = f"/tmp/nexus-write-{secrets.token_hex(12)}" + staged = await _run_tee_stdin( + conn, + f"{prefix}tee {shlex.quote(tmp_path)}", + data, + ) + if staged.exit_status != 0: + stderr = (staged.stderr or staged.stdout or direct.stderr or "")[:500] + raise PermissionError(stderr or "tee 写入失败") + + moved = await conn.run( + f"{prefix}mv -f {shlex.quote(tmp_path)} {shlex.quote(remote_path)}", + check=False, + ) + if moved.exit_status != 0: + await conn.run(f"{prefix}rm -f {shlex.quote(tmp_path)}", check=False) + stderr = (moved.stderr or "")[:500] + raise PermissionError(stderr or "无法覆盖目标文件(目录可能不可写)") + + +def _sudo_attempt_order(elevation: FilesElevation | None) -> tuple[bool, ...]: + mode = elevation if elevation is not None else FilesElevation.AUTO + if mode == FilesElevation.OFF: + return (False,) + if mode == FilesElevation.ALWAYS: + return (True,) + return (False, True) + + +async def remote_write_bytes( + conn: asyncssh.SSHClientConnection, + remote_path: str, + data: bytes, + *, + elevation: FilesElevation | None = None, +) -> None: + """Write bytes to a remote path: SFTP first, then SSH tee / tmp+mv (incl. optional sudo -n).""" + eff = elevation if elevation is not None else FilesElevation.AUTO + skip_sftp = eff == FilesElevation.ALWAYS + + if not skip_sftp: + try: + async with await conn.start_sftp_client() as sftp: + await sftp_write_bytes(sftp, remote_path, data) + return + except (asyncssh.PermissionDenied, asyncssh.SFTPError) as exc: + if not _sftp_permission_denied(exc): + raise + + last_error: Exception | None = None + for use_sudo in _sudo_attempt_order(eff): + try: + await _ssh_write_bytes_via_tee(conn, remote_path, data, use_sudo=use_sudo) + return + except PermissionError as exc: + last_error = exc + + reason = str(last_error) if last_error else "Permission denied" + raise asyncssh.SFTPError(3, reason) + + async def exec_ssh_command(server: Server, command: str, timeout: int = 30) -> dict: """Execute a command via SSH using the asyncssh connection pool. diff --git a/server/infrastructure/ssh/files_capability.py b/server/infrastructure/ssh/files_capability.py new file mode 100644 index 00000000..6fe1508a --- /dev/null +++ b/server/infrastructure/ssh/files_capability.py @@ -0,0 +1,62 @@ +"""Probe remote host for Nexus file-manager sudo capability.""" + +from __future__ import annotations + +from server.domain.models import Server +from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command +from server.utils.files_elevation import ( + SUDOERS_DOC_PATH, + FilesElevation, + get_files_elevation, +) + + +async def probe_files_capability(server: Server) -> dict: + """Run lightweight SSH checks for passwordless sudo and chmod/chown whitelist.""" + ssh_user = (server.username or "root").strip() or "root" + elevation: FilesElevation = get_files_elevation(server) + is_root = ssh_user == "root" + + if is_root: + return { + "ssh_user": ssh_user, + "is_root": True, + "files_elevation": elevation.value, + "can_sudo_nopasswd": True, + "whitelist_ok": True, + "message": "SSH 用户为 root,无需 sudo 提权", + "docs_path": SUDOERS_DOC_PATH, + } + + sudo_probe = await exec_ssh_command(server, "sudo -n true", timeout=15) + can_sudo_nopasswd = sudo_probe.get("exit_code") == 0 + + whitelist_probe = await exec_ssh_command( + server, + "sudo -n bash -c 'command -v chmod >/dev/null && command -v chown >/dev/null'", + timeout=15, + ) + whitelist_ok = can_sudo_nopasswd and whitelist_probe.get("exit_code") == 0 + + if can_sudo_nopasswd and whitelist_ok: + message = "已配置免密 sudo,文件管理可自动提权" + elif can_sudo_nopasswd: + message = ( + "可免密 sudo,但 chmod/chown 可能未列入白名单;" + "请参考仓库内 sudoers 示例补全" + ) + else: + message = ( + "无法免密 sudo;写权限、chmod 等可能失败。" + "请在目标机安装 nexus-files.sudoers(见文档路径)" + ) + + return { + "ssh_user": ssh_user, + "is_root": False, + "files_elevation": elevation.value, + "can_sudo_nopasswd": can_sudo_nopasswd, + "whitelist_ok": whitelist_ok, + "message": message, + "docs_path": SUDOERS_DOC_PATH, + } diff --git a/server/infrastructure/ssh/remote_archive.py b/server/infrastructure/ssh/remote_archive.py new file mode 100644 index 00000000..0ed8fde3 --- /dev/null +++ b/server/infrastructure/ssh/remote_archive.py @@ -0,0 +1,128 @@ +"""Remote tar/zip compress & decompress via SSH with permission/sudo fallbacks.""" + +from __future__ import annotations + +import posixpath +import secrets +import shlex + +from server.domain.models import Server +from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback + + +def _common_parent_dir(paths: list[str]) -> str: + normalized = [posixpath.normpath(p) for p in paths] + if len(normalized) == 1: + p = normalized[0] + return posixpath.dirname(p) if not p.endswith("/") else p.rstrip("/") + parts_list = [p.strip("/").split("/") for p in normalized if p.startswith("/")] + if not parts_list: + return "/" + common: list[str] = [] + for segment_lists in zip(*parts_list): + if len(set(segment_lists)) == 1: + common.append(segment_lists[0]) + else: + break + if not common: + return "/" + return "/" + "/".join(common) + + +def _tar_member_names(paths: list[str], work_dir: str) -> list[str]: + members: list[str] = [] + work = work_dir.rstrip("/") or "/" + for raw in paths: + p = posixpath.normpath(raw) + if p == work or p.rstrip("/") == work: + members.append(".") + continue + prefix = work + "/" + if p.startswith(prefix): + members.append(p[len(prefix) :]) + else: + members.append(p.lstrip("/")) + # unique preserve order + seen: set[str] = set() + out: list[str] = [] + for m in members: + if m not in seen: + seen.add(m) + out.append(m) + return out + + +def tar_compress_commands(paths: list[str], dest: str) -> list[str]: + """Build tar commands: direct write, then /tmp staging + mv.""" + dest_norm = posixpath.normpath(dest) + work_dir = _common_parent_dir(paths) + members = _tar_member_names(paths, work_dir) + if not members: + raise ValueError("没有可压缩的文件") + + work_q = shlex.quote(work_dir) + members_q = " ".join(shlex.quote(m) for m in members) + dest_q = shlex.quote(dest_norm) + dest_parent = posixpath.dirname(dest_norm) or "/" + dest_base = posixpath.basename(dest_norm) + + commands: list[str] = [] + if dest_parent == work_dir: + commands.append(f"tar -czf {shlex.quote(dest_base)} -C {work_q} {members_q}") + else: + commands.append(f"tar -czf {dest_q} -C {work_q} {members_q}") + + tmp = f"/tmp/nexus-compress-{secrets.token_hex(8)}.tar.gz" + commands.append( + f"tar -czf {shlex.quote(tmp)} -C {work_q} {members_q} && mv -f {shlex.quote(tmp)} {dest_q}" + ) + return commands + + +def zip_compress_commands(paths: list[str], dest: str) -> list[str]: + dest_norm = posixpath.normpath(dest) + work_dir = _common_parent_dir(paths) + members = _tar_member_names(paths, work_dir) + work_q = shlex.quote(work_dir) + members_q = " ".join(shlex.quote(m) for m in members) + dest_q = shlex.quote(dest_norm) + dest_parent = posixpath.dirname(dest_norm) or "/" + dest_base = posixpath.basename(dest_norm) + dest_arg = shlex.quote(dest_base) if dest_parent == work_dir else dest_q + + inner = f"cd {work_q} && zip -qr {dest_arg} {members_q}" + tmp = f"/tmp/nexus-compress-{secrets.token_hex(8)}.zip" + staged = ( + f"cd {work_q} && zip -qr {shlex.quote(tmp)} {members_q} " + f"&& mv -f {shlex.quote(tmp)} {dest_q}" + ) + return [inner, staged] + + +async def run_remote_compress( + server: Server, + paths: list[str], + dest: str, + fmt: str, + *, + timeout: int = 120, +) -> dict: + """Try compress commands until one succeeds (with sudo fallback per command).""" + if fmt == "tar.gz": + commands = tar_compress_commands(paths, dest) + elif fmt == "zip": + commands = zip_compress_commands(paths, dest) + else: + return { + "exit_code": -1, + "stderr": f"不支持的格式: {fmt}", + "stdout": "", + "status": "error", + } + + last = {"exit_code": -1, "stderr": "", "stdout": "", "status": "failed"} + for cmd in commands: + last = await exec_ssh_command_with_fallback(server, cmd, timeout=timeout) + if last.get("exit_code") == 0: + return last + return last diff --git a/server/infrastructure/ssh/remote_shell.py b/server/infrastructure/ssh/remote_shell.py new file mode 100644 index 00000000..f6ec2c43 --- /dev/null +++ b/server/infrastructure/ssh/remote_shell.py @@ -0,0 +1,63 @@ +"""Remote shell execution with optional passwordless sudo fallback.""" + +from __future__ import annotations + +import shlex + +from server.domain.models import Server +from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command +from server.utils.files_elevation import FilesElevation, get_files_elevation + + +def shell_output(result: dict) -> str: + return f"{result.get('stderr') or ''}{result.get('stdout') or ''}" + + +def is_permission_denied_result(result: dict) -> bool: + if result.get("exit_code") == 0: + return False + text = shell_output(result).lower() + return "permission denied" in text or "operation not permitted" in text + + +async def exec_ssh_command_with_fallback( + server: Server, + command: str, + *, + timeout: int = 120, +) -> dict: + """Run command; elevation follows server ``extra_attrs.files_elevation``.""" + elevation = get_files_elevation(server) + ssh_user = (server.username or "root").strip() or "root" + is_root_ssh = ssh_user == "root" + + if elevation == FilesElevation.ALWAYS and not is_root_ssh: + sudo_cmd = f"sudo -n bash -c {shlex.quote(command)}" + sudo_result = await exec_ssh_command(server, sudo_cmd, timeout=timeout) + sudo_result = dict(sudo_result) + sudo_result["elevated"] = True + return sudo_result + + result = await exec_ssh_command(server, command, timeout=timeout) + result = dict(result) + if result.get("exit_code") == 0: + result["elevated"] = False + return result + if elevation == FilesElevation.OFF or is_root_ssh: + result["elevated"] = False + return result + if not is_permission_denied_result(result): + result["elevated"] = False + return result + + sudo_cmd = f"sudo -n bash -c {shlex.quote(command)}" + sudo_result = await exec_ssh_command(server, sudo_cmd, timeout=timeout) + sudo_result = dict(sudo_result) + if sudo_result.get("exit_code") == 0: + sudo_result["elevated"] = True + return sudo_result + if is_permission_denied_result(sudo_result): + sudo_result["elevated"] = True + return sudo_result + sudo_result["elevated"] = False + return sudo_result diff --git a/server/utils/files_chmod_policy.py b/server/utils/files_chmod_policy.py new file mode 100644 index 00000000..126dab61 --- /dev/null +++ b/server/utils/files_chmod_policy.py @@ -0,0 +1,29 @@ +"""Safety policy for recursive remote chmod.""" + +from __future__ import annotations + +# Paths that must never receive chmod -R / chown -R via the UI. +FORBIDDEN_RECURSIVE_CHMOD_PATHS = frozenset({ + "/", + "/bin", + "/boot", + "/dev", + "/etc", + "/lib", + "/lib64", + "/proc", + "/run", + "/sbin", + "/sys", + "/usr", + "/var", +}) + +RECURSIVE_CHMOD_TIMEOUT_SEC = 300 +SINGLE_CHMOD_TIMEOUT_SEC = 15 + + +def assert_recursive_chmod_allowed(remote_path: str) -> None: + """Raise ValueError if recursive chmod must be rejected for this path.""" + if remote_path in FORBIDDEN_RECURSIVE_CHMOD_PATHS: + raise ValueError(f"禁止对系统路径递归修改权限: {remote_path}") diff --git a/server/utils/files_elevation.py b/server/utils/files_elevation.py new file mode 100644 index 00000000..dd1fb446 --- /dev/null +++ b/server/utils/files_elevation.py @@ -0,0 +1,55 @@ +"""Server-level file-management elevation policy (stored in ``servers.extra_attrs``).""" + +from __future__ import annotations + +from enum import StrEnum + +from server.domain.models import Server + +FILES_ELEVATION_ATTR = "files_elevation" +VALID_FILES_ELEVATIONS = frozenset({"off", "auto_sudo", "always_sudo"}) +DEFAULT_FILES_ELEVATION = "auto_sudo" +SUDOERS_DOC_PATH = "docs/deploy/nexus-files-sudoers.example" + + +class FilesElevation(StrEnum): + OFF = "off" + AUTO = "auto_sudo" + ALWAYS = "always_sudo" + + +def normalize_files_elevation(value: object | None) -> FilesElevation: + """Return elevation mode; unknown values fall back to AUTO.""" + if value is None or value == "": + return FilesElevation.AUTO + raw = str(value).strip().lower() + if raw in VALID_FILES_ELEVATIONS: + return FilesElevation(raw) + return FilesElevation.AUTO + + +def get_files_elevation(server: Server) -> FilesElevation: + attrs = server.extra_attrs if isinstance(server.extra_attrs, dict) else {} + return normalize_files_elevation(attrs.get(FILES_ELEVATION_ATTR)) + + +def merge_files_elevation_into_extra_attrs( + extra_attrs: dict | None, + files_elevation: str | None, +) -> dict | None: + if files_elevation is None: + return extra_attrs + out = dict(extra_attrs or {}) + mode = normalize_files_elevation(files_elevation) + out[FILES_ELEVATION_ATTR] = mode.value + return out + + +def apply_files_elevation_payload(data: dict) -> None: + """Pop ``files_elevation`` from API payload and merge into ``extra_attrs``.""" + fe = data.pop("files_elevation", None) + if fe is not None: + data["extra_attrs"] = merge_files_elevation_into_extra_attrs( + data.get("extra_attrs"), + fe, + ) diff --git a/server/utils/posix_paths.py b/server/utils/posix_paths.py new file mode 100644 index 00000000..b886b042 --- /dev/null +++ b/server/utils/posix_paths.py @@ -0,0 +1,122 @@ +"""Linux/POSIX path helpers for remote SSH paths and Nexus-host Unix paths. + +Use this module whenever manipulating path *strings* for Linux targets. +Do not use ``os.path`` for remote paths — on Windows dev hosts ``os.path.join`` +produces backslashes and breaks SSH/SFTP commands. + +For real filesystem I/O on the Nexus host (``open``, ``os.scandir``), keep +``os.path.realpath`` / ``os.walk`` after normalizing with :func:`to_posix`. +""" + +from __future__ import annotations + +import os +import posixpath + +UPLOAD_STAGING_PREFIX = "/tmp/nexus_upload_" + + +class PosixPathError(ValueError): + """Invalid or unsafe POSIX path.""" + + +def to_posix(path: str) -> str: + """Normalize slashes to forward slash (does not resolve ``..``).""" + return (path or "").strip().replace("\\", "/") + + +def reject_windows_separators(path: str, *, field: str = "path") -> None: + if "\\" in path or "\0" in path: + raise PosixPathError(f"{field} 必须使用 Linux 路径(不允许反斜杠)") + + +def normalize_remote_abs_path(path: str) -> str: + """Validate and normalize a remote absolute path (SSH/SFTP).""" + raw_in = (path or "").strip() + reject_windows_separators(raw_in, field="path") + raw = to_posix(raw_in) + if not raw.startswith("/"): + raise PosixPathError("路径必须为绝对路径") + parts = [p for p in raw.split("/") if p and p != "."] + if ".." in parts: + raise PosixPathError("路径不允许包含 '..'") + normalized = "/" + "/".join(parts) if parts else "/" + if len(normalized) > 1 and normalized.endswith("/"): + normalized = normalized.rstrip("/") + return normalized or "/" + + +def posix_join(*parts: str) -> str: + """Join path segments using POSIX rules (safe on any dev OS).""" + if not parts: + return "/" + acc = to_posix(parts[0]) + for part in parts[1:]: + seg = to_posix(part) + if not seg or seg == "/": + continue + acc = posixpath.join(acc, seg.lstrip("/")) + return posixpath.normpath(acc) or "/" + + +def posix_basename(path: str) -> str: + return posixpath.basename(to_posix(path.rstrip("/"))) + + +def posix_dirname(path: str) -> str: + parent = posixpath.dirname(to_posix(path.rstrip("/"))) + return parent or "/" + + +def remote_join(directory: str, name: str) -> str: + """Join remote directory + file name (both logical Linux paths).""" + base = normalize_remote_abs_path(directory) + segment = to_posix(name).strip("/") + reject_windows_separators(segment, field="name") + if ".." in segment.split("/"): + raise PosixPathError("名称不能包含 '..'") + if not segment: + return base + return posix_join(base, segment) + + +def resolve_nexus_host_path(path: str) -> str: + """Resolve a path on the Nexus host filesystem (Linux deployment).""" + posix = to_posix(path) + reject_windows_separators(posix, field="path") + return os.path.realpath(posix) + + +def ensure_under_nexus_upload(path: str) -> str: + """Resolve *path* and ensure it stays under ``/tmp/nexus_upload_*``.""" + real = resolve_nexus_host_path(path) + if not to_posix(real).startswith(UPLOAD_STAGING_PREFIX): + raise PosixPathError("仅允许访问上传临时目录") + return real + + +def assert_zip_member_safe(extract_dir: str, member_name: str) -> str: + """Zip-slip check; returns normalized member path under *extract_dir*.""" + base = normalize_remote_abs_path(extract_dir) + member = to_posix(member_name).lstrip("/") + if not member or member == ".": + return base + if member == ".." or member.startswith("../") or "/../" in f"/{member}/": + raise PosixPathError(f"ZIP 包含非法路径: {member_name}") + candidate = posixpath.normpath(posixpath.join(base, member)) + if candidate != base and not candidate.startswith(base + "/"): + raise PosixPathError(f"ZIP 包含非法路径: {member_name}") + return candidate + + +def is_path_under_prefix(resolved: str, prefix: str) -> bool: + """True if *resolved* is *prefix* or a child (POSIX string compare after norm).""" + r = normalize_remote_abs_path(to_posix(resolved)) + p = normalize_remote_abs_path(to_posix(prefix)) + return r == p or r.startswith(p + "/") + + +def normalize_remote_dest(path: str | None, *, default: str = "/tmp/sync") -> str: + """Normalize remote target directory (push/rsync/diagnose); uses *default* when empty.""" + raw = (path or "").strip() or default + return normalize_remote_abs_path(raw) diff --git a/server/utils/unix_ls.py b/server/utils/unix_ls.py new file mode 100644 index 00000000..be1f67cf --- /dev/null +++ b/server/utils/unix_ls.py @@ -0,0 +1,83 @@ +"""Parse GNU `ls -la` permission strings and listing lines.""" + +from __future__ import annotations + +import re + +_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def modified_from_ls_parts(parts: list[str]) -> str: + """Build display timestamp from ``ls`` fields (without filename).""" + if len(parts) < 6: + return "" + if len(parts) >= 7 and _ISO_DATE.match(parts[5]): + time_part = parts[6].split(".", 1)[0] if parts[6] else "" + return f"{parts[5]} {time_part}".strip() + if len(parts) >= 9: + return " ".join(parts[5:8]) + return " ".join(parts[5:]) + + +def parse_ls_la_line(line: str) -> dict | None: + """Parse one ``ls -la`` line; returns None for total/skip lines.""" + stripped = line.strip() + if not stripped or stripped.startswith("total "): + return None + parts = stripped.split(None, 8) + if len(parts) < 9: + return None + + perms = parts[0] + is_dir = perms.startswith("d") + is_link = perms.startswith("l") + name = parts[8] + link_target = None + if is_link and " -> " in name: + name, link_target = name.split(" -> ", 1) + if name in (".", ".."): + return None + + return { + "name": name, + "is_dir": is_dir, + "is_link": is_link, + "link_target": link_target, + "size": parts[4], + "perms": perms, + "owner": parts[2], + "group": parts[3] if len(parts) > 3 else "", + "modified": modified_from_ls_parts(parts), + } + + +def perms_to_mode_octal(perms: str) -> str | None: + """Convert ``drwxr-xr-x`` / ``-rw-r--r--`` to ``755`` / ``644``.""" + if not perms or len(perms) < 10: + return None + bits = perms[1:10] + if len(bits) != 9: + return None + + def _tri(chunk: str) -> int: + value = 0 + if chunk[0] == "r": + value += 4 + if chunk[1] == "w": + value += 2 + if chunk[2] in "xXsStT": + value += 1 + return value + + return f"{_tri(bits[0:3])}{_tri(bits[3:6])}{_tri(bits[6:9])}" + + +def format_owner_label(owner: str, group: str | None = None) -> str: + """Primary list label: ``www`` or ``www:nogroup`` when group differs.""" + o = (owner or "").strip() + g = (group or "").strip() + if not o: + return "—" + if not g or g == o: + return o + return f"{o}:{g}" diff --git a/tests/test_api.py b/tests/test_api.py index 2e2dce41..1fa6b107 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -63,7 +63,7 @@ def _get_auth_headers(): return {"Authorization": f"Bearer {ACCESS_TOKEN}"} if ACCESS_TOKEN else {} -def test(name: str, method: str, path: str, body=None, expect_code=200, headers=None): +def api_test(name: str, method: str, path: str, body=None, expect_code=200, headers=None): """Execute an API test and report results.""" global PASS, FAIL url = f"{BASE}{path}" @@ -120,7 +120,7 @@ def test(name: str, method: str, path: str, body=None, expect_code=200, headers= return None -def test_plain_text(name: str, method: str, path: str, expect_body: str, expect_code=200): +def api_test_plain_text(name: str, method: str, path: str, expect_body: str, expect_code=200): """Health and other probes that return plain text, not JSON.""" global PASS, FAIL url = f"{BASE}{path}" @@ -159,10 +159,10 @@ def run_tests() -> int: # --- Health (no auth) — plain text per health.py (Shell guardian) --- print("\n[1] Health") - test_plain_text("GET /health", "GET", "/health", expect_body="ok") + api_test_plain_text("GET /health", "GET", "/health", expect_body="ok") print("\n[2] Auth") - login_result = test("POST /api/auth/login", "POST", "/api/auth/login", body={ + login_result = api_test("POST /api/auth/login", "POST", "/api/auth/login", body={ "username": ADMIN_USER, "password": ADMIN_PASSWORD, }, expect_code=200) @@ -173,17 +173,17 @@ def run_tests() -> int: else: print(" → WARNING: Login failed, subsequent tests will fail without JWT") - test("GET /api/auth/me", "GET", "/api/auth/me") + api_test("GET /api/auth/me", "GET", "/api/auth/me") print("\n[3] Server CRUD") - existing_servers = test("GET /api/servers/ (pre-cleanup)", "GET", "/api/servers/?search=test-server-e2e", expect_code=200) + existing_servers = api_test("GET /api/servers/ (pre-cleanup)", "GET", "/api/servers/?search=test-server-e2e", expect_code=200) if existing_servers and isinstance(existing_servers, dict): for srv in existing_servers.get("items", existing_servers.get("servers", [])): if isinstance(srv, dict) and srv.get("name") == "test-server-e2e": - test("DELETE leftover test server", "DELETE", f"/api/servers/{srv['id']}", expect_code=200) + api_test("DELETE leftover test server", "DELETE", f"/api/servers/{srv['id']}", expect_code=200) break - created = test("POST /api/servers/ (create)", "POST", "/api/servers/", body={ + created = api_test("POST /api/servers/ (create)", "POST", "/api/servers/", body={ "name": "test-server-e2e", "domain": "192.168.1.100", "port": 22, @@ -195,30 +195,30 @@ def run_tests() -> int: }, expect_code=201) server_id = created.get("id") if created and isinstance(created, dict) else None - test("GET /api/servers/ (list)", "GET", "/api/servers/") - test("GET /api/servers/stats", "GET", "/api/servers/stats") + api_test("GET /api/servers/ (list)", "GET", "/api/servers/") + api_test("GET /api/servers/stats", "GET", "/api/servers/stats") if server_id: - test(f"GET /api/servers/{server_id}", "GET", f"/api/servers/{server_id}") - test(f"PUT /api/servers/{server_id} (update)", "PUT", f"/api/servers/{server_id}", body={ + api_test(f"GET /api/servers/{server_id}", "GET", f"/api/servers/{server_id}") + api_test(f"PUT /api/servers/{server_id} (update)", "PUT", f"/api/servers/{server_id}", body={ "description": "E2E test server", }) - test(f"DELETE /api/servers/{server_id}", "DELETE", f"/api/servers/{server_id}", expect_code=204) + api_test(f"DELETE /api/servers/{server_id}", "DELETE", f"/api/servers/{server_id}", expect_code=204) else: print(" → SKIP: Server CRUD detail tests (no server_id from create)") print("\n[4] Scripts") - script = test("POST /api/scripts/ (create)", "POST", "/api/scripts/", body={ + script = api_test("POST /api/scripts/ (create)", "POST", "/api/scripts/", body={ "name": "test-script", "category": "ops", "content": "echo hello", }, expect_code=201) script_id = script.get("id") if script and isinstance(script, dict) else None - test("GET /api/scripts/ (list)", "GET", "/api/scripts/") + api_test("GET /api/scripts/ (list)", "GET", "/api/scripts/") if script_id: - test(f"DELETE /api/scripts/{script_id}", "DELETE", f"/api/scripts/{script_id}", expect_code=204) + api_test(f"DELETE /api/scripts/{script_id}", "DELETE", f"/api/scripts/{script_id}", expect_code=204) print("\n[5] Schedules") - sched = test("POST /api/schedules/ (create)", "POST", "/api/schedules/", body={ + sched = api_test("POST /api/schedules/ (create)", "POST", "/api/schedules/", body={ "name": "test-schedule", "source_path": "/tmp/nexus-test", "run_mode": "cron", @@ -227,37 +227,37 @@ def run_tests() -> int: "enabled": True, }, expect_code=201) sched_id = sched.get("id") if sched and isinstance(sched, dict) else None - test("GET /api/schedules/ (list)", "GET", "/api/schedules/") + api_test("GET /api/schedules/ (list)", "GET", "/api/schedules/") if sched_id: - test(f"PUT /api/schedules/{sched_id} (disable)", "PUT", f"/api/schedules/{sched_id}", body={ + api_test(f"PUT /api/schedules/{sched_id} (disable)", "PUT", f"/api/schedules/{sched_id}", body={ "enabled": False, }) - test(f"DELETE /api/schedules/{sched_id}", "DELETE", f"/api/schedules/{sched_id}", expect_code=204) + api_test(f"DELETE /api/schedules/{sched_id}", "DELETE", f"/api/schedules/{sched_id}", expect_code=204) print("\n[6] Password Presets") - preset = test("POST /api/presets/ (create)", "POST", "/api/presets/", body={ + preset = api_test("POST /api/presets/ (create)", "POST", "/api/presets/", body={ "name": "test-preset", "encrypted_pw": "test-password-123", }, expect_code=201) preset_id = preset.get("id") if preset and isinstance(preset, dict) else None - test("GET /api/presets/ (list)", "GET", "/api/presets/") + api_test("GET /api/presets/ (list)", "GET", "/api/presets/") if preset_id: - test(f"DELETE /api/presets/{preset_id}", "DELETE", f"/api/presets/{preset_id}", expect_code=204) + api_test(f"DELETE /api/presets/{preset_id}", "DELETE", f"/api/presets/{preset_id}", expect_code=204) print("\n[7] Settings") - test("GET /api/settings/ (list)", "GET", "/api/settings/") - test("PUT /api/settings/system_name", "PUT", "/api/settings/system_name", body={ + api_test("GET /api/settings/ (list)", "GET", "/api/settings/") + api_test("PUT /api/settings/system_name", "PUT", "/api/settings/system_name", body={ "value": "Nexus", }) print("\n[8] Audit") - test("GET /api/audit/ (list)", "GET", "/api/audit/?limit=10") + api_test("GET /api/audit/ (list)", "GET", "/api/audit/?limit=10") print("\n[9] Retry Queue") - test("GET /api/retries/ (list)", "GET", "/api/retries/") + api_test("GET /api/retries/ (list)", "GET", "/api/retries/") print("\n[10] Sync") - test("POST /api/sync/browse (no server)", "POST", "/api/sync/browse", body={ + api_test("POST /api/sync/browse (no server)", "POST", "/api/sync/browse", body={ "server_id": 99999, "path": "/tmp", }, expect_code=404) @@ -268,7 +268,7 @@ def run_tests() -> int: if FAIL == 0: print("All tests passed!") else: - print(f"{FAIL} test(s) failed") + print(f"{FAIL} api_test(s) failed") return 0 if FAIL == 0 else 1 diff --git a/tests/test_file_permissions.py b/tests/test_file_permissions.py new file mode 100644 index 00000000..607bf0a5 --- /dev/null +++ b/tests/test_file_permissions.py @@ -0,0 +1,75 @@ +"""File manager: ls parsing and chmod schema validation.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from server.api.schemas import FileChmod +from server.utils.unix_ls import modified_from_ls_parts, parse_ls_la_line + + +def test_parse_ls_la_line_file_classic(): + line = "-rw-r--r-- 1 www www 1234 Jun 1 12:00 index.php" + row = parse_ls_la_line(line) + assert row is not None + assert row["name"] == "index.php" + assert row["owner"] == "www" + assert row["group"] == "www" + assert row["modified"] == "Jun 1 12:00" + + +def test_parse_ls_la_line_long_iso(): + line = "-rw-r--r-- 1 www www 99 2026-06-01 12:00:00.123456789 +0800 app.log" + row = parse_ls_la_line(line) + assert row is not None + assert row["name"] == "app.log" + assert row["modified"] == "2026-06-01 12:00:00" + + +def test_parse_ls_la_line_symlink(): + line = "lrwxrwxrwx 1 root root 8 2026-06-01 10:00:00 +0800 current -> release" + row = parse_ls_la_line(line) + assert row is not None + assert row["is_link"] is True + assert row["name"] == "current" + assert row["link_target"] == "release" + + +def test_parse_ls_la_line_name_with_spaces(): + line = '-rw-r--r-- 1 www www 1 2026-06-01 09:00:00 +0800 my file.txt' + row = parse_ls_la_line(line) + assert row is not None + assert row["name"] == "my file.txt" + + +def test_parse_ls_la_line_skips_dot_dirs(): + assert parse_ls_la_line("drwxr-xr-x 2 www www 4096 Jun 1 12:00 .") is None + assert parse_ls_la_line("total 12") is None + + +def test_modified_from_ls_parts_iso(): + parts = "-rw-r--r-- 1 www www 1 2026-06-01 15:30:45.999 +0800".split(None, 8)[:8] + assert modified_from_ls_parts(parts) == "2026-06-01 15:30:45" + + +def test_file_chmod_group_requires_owner(): + with pytest.raises(ValidationError): + FileChmod(server_id=1, path="/tmp/x", mode="644", group="www") + + +def test_file_chmod_valid_chown(): + m = FileChmod(server_id=1, path="/tmp/x", mode="755", owner="www", group="www") + assert m.owner == "www" + + +def test_file_chmod_recursive_flag(): + m = FileChmod(server_id=1, path="/var/www/html", mode="755", recursive=True) + assert m.recursive is True + + +def test_assert_recursive_chmod_forbidden_root(): + from server.utils.files_chmod_policy import assert_recursive_chmod_allowed + + with pytest.raises(ValueError, match="禁止"): + assert_recursive_chmod_allowed("/") diff --git a/tests/test_files_elevation.py b/tests/test_files_elevation.py new file mode 100644 index 00000000..91b221c5 --- /dev/null +++ b/tests/test_files_elevation.py @@ -0,0 +1,109 @@ +"""Tests for file-manager elevation policy helpers.""" + +from __future__ import annotations + +import pytest + +from server.domain.models import Server +from server.utils.files_elevation import ( + FilesElevation, + apply_files_elevation_payload, + get_files_elevation, + merge_files_elevation_into_extra_attrs, + normalize_files_elevation, +) + + +def test_normalize_defaults_to_auto(): + assert normalize_files_elevation(None) == FilesElevation.AUTO + assert normalize_files_elevation("bogus") == FilesElevation.AUTO + assert normalize_files_elevation("off") == FilesElevation.OFF + + +def test_get_files_elevation_from_extra_attrs(): + server = Server(name="t", domain="1.2.3.4", extra_attrs={"files_elevation": "always_sudo"}) + assert get_files_elevation(server) == FilesElevation.ALWAYS + + +def test_apply_files_elevation_payload_merges(): + data = {"name": "x", "files_elevation": "off"} + apply_files_elevation_payload(data) + assert "files_elevation" not in data + assert data["extra_attrs"]["files_elevation"] == "off" + + +def test_merge_preserves_other_extra_attrs(): + merged = merge_files_elevation_into_extra_attrs({"foo": 1}, "auto_sudo") + assert merged == {"foo": 1, "files_elevation": "auto_sudo"} + + +@pytest.mark.asyncio +async def test_probe_files_capability_root(monkeypatch): + from server.infrastructure.ssh import files_capability as fc + + server = Server(name="t", domain="1.2.3.4", username="root") + + async def fake_exec(*_a, **_k): + raise AssertionError("should not SSH when user is root") + + monkeypatch.setattr(fc, "exec_ssh_command", fake_exec) + result = await fc.probe_files_capability(server) + assert result["is_root"] is True + assert result["can_sudo_nopasswd"] is True + assert result["whitelist_ok"] is True + + +@pytest.mark.asyncio +async def test_probe_files_capability_deploy(monkeypatch): + from server.infrastructure.ssh import files_capability as fc + + server = Server(name="t", domain="1.2.3.4", username="deploy") + + calls: list[str] = [] + + async def fake_exec(_server, command, timeout=15): + calls.append(command) + if command == "sudo -n true": + return {"exit_code": 0, "stdout": "", "stderr": ""} + return {"exit_code": 0, "stdout": "", "stderr": ""} + + monkeypatch.setattr(fc, "exec_ssh_command", fake_exec) + result = await fc.probe_files_capability(server) + assert result["can_sudo_nopasswd"] is True + assert result["whitelist_ok"] is True + assert len(calls) == 2 + + +@pytest.mark.asyncio +async def test_remote_shell_always_sudo(monkeypatch): + from server.infrastructure.ssh import remote_shell + + server = Server(name="t", domain="1.2.3.4", username="deploy", extra_attrs={"files_elevation": "always_sudo"}) + seen: list[str] = [] + + async def fake_exec(_server, command, timeout=120): + seen.append(command) + return {"exit_code": 0, "stdout": "ok", "stderr": ""} + + monkeypatch.setattr(remote_shell, "exec_ssh_command", fake_exec) + result = await remote_shell.exec_ssh_command_with_fallback(server, "chmod 644 /tmp/x") + assert result["elevated"] is True + assert seen[0].startswith("sudo -n bash -c") + + +@pytest.mark.asyncio +async def test_remote_shell_off_skips_sudo(monkeypatch): + from server.infrastructure.ssh import remote_shell + + server = Server(name="t", domain="1.2.3.4", username="deploy", extra_attrs={"files_elevation": "off"}) + call_count = 0 + + async def fake_exec(_server, command, timeout=120): + nonlocal call_count + call_count += 1 + return {"exit_code": 1, "stdout": "", "stderr": "permission denied"} + + monkeypatch.setattr(remote_shell, "exec_ssh_command", fake_exec) + result = await remote_shell.exec_ssh_command_with_fallback(server, "chmod 644 /tmp/x") + assert call_count == 1 + assert result["elevated"] is False diff --git a/tests/test_posix_paths.py b/tests/test_posix_paths.py new file mode 100644 index 00000000..2cf1a6da --- /dev/null +++ b/tests/test_posix_paths.py @@ -0,0 +1,62 @@ +"""Tests for Linux/POSIX path helpers (remote + Nexus host paths).""" + +import pytest + +from server.utils.posix_paths import ( + PosixPathError, + assert_zip_member_safe, + normalize_remote_abs_path, + normalize_remote_dest, + posix_basename, + posix_dirname, + posix_join, + remote_join, + to_posix, +) + + +def test_to_posix_converts_backslashes(): + assert to_posix(r"\www\site") == "/www/site" + + +def test_normalize_remote_abs_path(): + assert normalize_remote_abs_path("/www/wwwroot//site") == "/www/wwwroot/site" + assert normalize_remote_abs_path("/") == "/" + + +def test_normalize_rejects_traversal(): + with pytest.raises(PosixPathError): + normalize_remote_abs_path("/www/../etc/passwd") + + +def test_normalize_rejects_backslash(): + with pytest.raises(PosixPathError): + normalize_remote_abs_path(r"\www\site") + + +def test_posix_join_on_windows_style_input(): + assert posix_join("/www/wwwroot", "m.example.com", "index.php") == ( + "/www/wwwroot/m.example.com/index.php" + ) + + +def test_posix_dirname_basename(): + assert posix_dirname("/www/wwwroot/app/index.php") == "/www/wwwroot/app" + assert posix_basename("/www/wwwroot/app/index.php") == "index.php" + + +def test_remote_join(): + assert remote_join("/www/wwwroot", "index.php") == "/www/wwwroot/index.php" + assert remote_join("/tmp", "a/b") == "/tmp/a/b" # segments sanitized via normpath + + +def test_normalize_remote_dest_default(): + assert normalize_remote_dest(None) == "/tmp/sync" + assert normalize_remote_dest("/www/wwwroot/site/") == "/www/wwwroot/site" + + +def test_assert_zip_member_safe(): + base = "/tmp/nexus_upload_abc" + assert assert_zip_member_safe(base, "foo/bar.txt").startswith(base) + with pytest.raises(PosixPathError): + assert_zip_member_safe(base, "../etc/passwd") diff --git a/tests/test_remote_path_validation.py b/tests/test_remote_path_validation.py new file mode 100644 index 00000000..539dad79 --- /dev/null +++ b/tests/test_remote_path_validation.py @@ -0,0 +1,48 @@ +import pytest + +from server.api.remote_path_validation import ( + RemotePathError, + assert_clipboard_transfer_safe, + normalize_remote_abs_path, +) + + +def test_normalize_ok(): + assert normalize_remote_abs_path("/www/wwwroot/foo/") == "/www/wwwroot/foo" + + +def test_normalize_rejects_relative(): + with pytest.raises(RemotePathError): + normalize_remote_abs_path("relative/path") + + +def test_normalize_rejects_dotdot(): + with pytest.raises(RemotePathError): + normalize_remote_abs_path("/www/../etc/passwd") + + +def test_transfer_blocks_dest_inside_source(): + with pytest.raises(RemotePathError): + assert_clipboard_transfer_safe( + ["/www/wwwroot/project"], + "/www/wwwroot/project/src", + ) + + +def test_transfer_allows_sibling_dirs(): + sources, dest = assert_clipboard_transfer_safe( + ["/www/wwwroot/a/file.txt"], + "/www/wwwroot/b", + ) + assert sources == ["/www/wwwroot/a/file.txt"] + assert dest == "/www/wwwroot/b" + + +def test_transfer_allows_parent_directory(): + """跨目录:粘贴到父级目录(从子目录剪切/复制到上级)""" + sources, dest = assert_clipboard_transfer_safe( + ["/www/wwwroot/project/src/main.py"], + "/www/wwwroot/project", + ) + assert dest == "/www/wwwroot/project" + assert sources[0].endswith("main.py") diff --git a/tests/test_schema_path_validators.py b/tests/test_schema_path_validators.py new file mode 100644 index 00000000..00e40b6f --- /dev/null +++ b/tests/test_schema_path_validators.py @@ -0,0 +1,61 @@ +"""Schema-level path validation (P2).""" + +import pytest +from pydantic import ValidationError + +from server.api.schemas import ( + FileDecompress, + FileOperation, + ServerCreate, + ServerUpdate, + SyncFiles, + SyncBrowse, +) + + +def test_server_create_target_path_normalizes(): + m = ServerCreate( + name="s1", + domain="1.2.3.4", + target_path="/www/wwwroot/foo/", + ) + assert m.target_path == "/www/wwwroot/foo" + + +def test_server_create_rejects_backslash(): + with pytest.raises(ValidationError): + ServerCreate(name="s1", domain="1.2.3.4", target_path=r"\www\site") + + +def test_server_update_optional_target_path(): + m = ServerUpdate(target_path=None) + assert m.target_path is None + + +def test_sync_browse_root(): + m = SyncBrowse(server_id=1, path="/") + assert m.path == "/" + + +def test_sync_files_source_nexus_path(): + m = SyncFiles( + server_ids=[1], + source_path="/tmp/nexus_upload_abc", + target_path="/www/wwwroot/site", + ) + assert m.source_path == "/tmp/nexus_upload_abc" + assert m.target_path == "/www/wwwroot/site" + + +def test_file_operation_paths(): + m = FileOperation( + server_id=1, + operation="mkdir", + path="/www/wwwroot/newdir", + ) + assert m.path == "/www/wwwroot/newdir" + + +def test_file_decompress_rejects_dot_dest(): + with pytest.raises(ValidationError): + FileDecompress(server_id=1, path="/tmp/a.tar.gz", dest=".") diff --git a/tests/test_unix_ls.py b/tests/test_unix_ls.py new file mode 100644 index 00000000..d02b26fd --- /dev/null +++ b/tests/test_unix_ls.py @@ -0,0 +1,21 @@ +from server.utils.unix_ls import format_owner_label, parse_ls_la_line, perms_to_mode_octal + + +def test_perms_to_mode_octal_file(): + assert perms_to_mode_octal("-rw-r--r--") == "644" + + +def test_perms_to_mode_octal_dir(): + assert perms_to_mode_octal("drwxr-xr-x") == "755" + + +def test_format_owner_label(): + assert format_owner_label("www", "www") == "www" + assert format_owner_label("www", "nogroup") == "www:nogroup" + + +def test_parse_ls_la_line_directory(): + row = parse_ls_la_line("drwxr-xr-x 3 www www 4096 Jun 1 2025 public") + assert row is not None + assert row["is_dir"] is True + assert row["name"] == "public"