feat(files): POSIX paths, elevation, recursive chmod, access hints and docs

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-06-01 19:54:18 +08:00
parent 1cfac23c01
commit d93c518a14
79 changed files with 7784 additions and 394 deletions
+20
View File
@@ -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`
@@ -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/`
+38
View File
@@ -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] 批次审查表更新
@@ -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 — 解析/策略在 utilsAPI 编排 |
| 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 passedposix+file 套件)
- [x] ruff server/ 全规则(Windows 开发机)
- [x] import server.main
- [x] bandit 0 HIGHMEDIUM 不阻断门控)
- [ ] 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 凭据)。
@@ -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/P10**
**P22026-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] 批次 17 均已执行并记录
- [x] 每批有明确通过/豁免/建议
- [x] P0/P1 为 0
- [x] P2 已实施(schemas + sync_engine
## 批次 9:文件管理归属 + 权限 UI2026-06-01
| 项 | 状态 |
|----|------|
| `remote_shell.py` | ✅ |
| browse owner/group/mode_octal | ✅ |
| chmod + chown + sudo 回退 | ✅ |
| 前端归属列 + FilePermissionDialog | ✅ |
| `files-capability` API | ✅ 批次 10 |
| `files_elevation` 服务器字段 | ✅ 批次 10 |
| Phase 5ls 解析单测 + long-iso + read-file 提权 | ✅ 批次 11 |
| 二期:递归 chmod + 不可读提示条 | ✅ 批次 12 |
@@ -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 目标机上回归(人工)
@@ -0,0 +1,25 @@
# 2026-06-01 修复 SFTP APIcreate_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。
@@ -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`,应成功或给出明确权限提示。
@@ -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 / 保存 → 提示「文件已保存」,刷新后内容一致。
@@ -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. 主题切换按钮 → 深色/浅色主题下对比度正常。
@@ -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 提示无法编辑/查看
@@ -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` 数组
@@ -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` → 右键修改权限 →「检测提权能力」。
@@ -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` 与后端一致,禁止粘贴到源目录内部
@@ -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. 最小化、全屏、关闭未保存确认
@@ -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;全屏按钮仍可铺满视口。
@@ -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. 关闭时未保存会提示确认
@@ -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`
@@ -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. 筛选框过滤当前目录文件名
@@ -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 勾选递归;打开无读权限文件触发提示更新。
@@ -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
```
@@ -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 刷新
@@ -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 完整、进度总表与已完成清单不矛盾。
@@ -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"
```
@@ -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`
@@ -0,0 +1,34 @@
# ChangelogAPI 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
```
+7
View File
@@ -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
@@ -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
@@ -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 属主文件显示锁图标与顶栏提示。
@@ -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`
@@ -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 变更。
@@ -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` | 绝对路径列表(150 |
| `dest_dir` | 目标目录(绝对路径) |
命令:`cp -r src… dest/``mv -t dest/ src…`GNU mv)。
## 安全
- 路径必须 `/` 开头,禁止 `..`
- 禁止粘贴到源路径自身或其子目录
- 仅 JWT 管理员;审计 `file_copy` / `file_move`
## 前端
- 模块级剪贴板状态(同服务器粘贴)
- 工具栏粘贴、右键与 Ctrl+C/X/V
- 剪切粘贴成功后清空剪贴板
## 验收
- [ ] 多选复制到另一目录,源仍在
- [ ] 剪切后粘贴,源消失
- [ ] 跨服务器粘贴提示不可用
- [ ] 粘贴到子目录被拒绝
@@ -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] 目录树、新建文件、压缩解压、排序筛选、快捷键
@@ -0,0 +1,403 @@
# 文件管理:自动提权、归属显示、权限修改 — 设计文档
| 项 | 内容 |
|----|------|
| 日期 | 2026-06-01 |
| 状态 | 已实现 Phase 15 + 二期递归 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` 约 270302 行)。
---
## 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 ] (验证 34 位) │
│ 属主 [ 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`
@@ -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-01A-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-0106 | 备注 |
|------|--------|------------|------|
| | | | |
---
## 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 完成定义**C1C4 全部 `[x]`
---
## Phase D — 正式审计(8 步 · 部署门控)
> 模板:`docs/audit/TEMPLATE.md` · 规则:`.cursor/rules/audit-line-review.mdc`
> 现状:POSIX 分批审查 ✅;files-phase5 简表 ✅;**下列为改动的完整 8 步审计(待补)**。
| ID | 审计主题 | 状态 | 输出文件 |
|----|----------|------|----------|
| AUD-01 | 文件管理 Phase 13remote_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 路径校验 P2schema_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 规范化 | 批次 17 | `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 项 |
+1
View File
@@ -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
@@ -0,0 +1,69 @@
# 文件管理(#/files)— AI 接续备忘
> **日期**: 2026-06-01
> **用户确认**: 「好 你自己记住」— 本地改动**尚未 git commit**;生产已用 SCP/tar 部署,与仓库可能不一致。
---
## 1. 生产状态(api.synaglobal.vip
| 项 | 状态 |
|----|------|
| 前端 | `web/app/` 已 tar+scpprune 后 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`
+84 -9
View File
@@ -44,13 +44,7 @@ async function tryRefreshSession(): Promise<boolean> {
return refreshInFlight
}
/** Raw fetch wrapper with JSON + JWT + refresh retry */
export async function api<T = any>(
path: string,
opts: RequestInit & { params?: Record<string, any>; _retry?: boolean } = {},
): Promise<T> {
const { params, _retry, ...init } = opts
function buildApiUrl(path: string, params?: Record<string, unknown>): string {
let url = `${BASE_URL}${path}`
if (params) {
const sp = new URLSearchParams()
@@ -60,7 +54,10 @@ export async function api<T = any>(
const qs = sp.toString()
if (qs) url += `?${qs}`
}
return url
}
function buildAuthHeaders(init: RequestInit): Record<string, string> {
const auth = useAuthStore()
const headers: Record<string, string> = {
...(init.headers as Record<string, string> || {}),
@@ -71,14 +68,28 @@ export async function api<T = any>(
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<string, unknown>; _retry?: boolean } = {},
): Promise<Response> {
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<T>(path, { ...opts, _retry: true })
return fetchAuthed(path, { ...opts, _retry: true })
}
auth.forceLogout()
throw new ApiError(401, '登录已过期,请重新登录')
@@ -93,6 +104,68 @@ export async function api<T = any>(
throw new ApiError(401, msg)
}
return res
}
async function throwApiErrorFromResponse(res: Response): Promise<never> {
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<T = any>(
path: string,
opts: RequestInit & { params?: Record<string, any>; _retry?: boolean } = {},
): Promise<T> {
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: <T = any>(path: string, formData: FormData) =>
api<T>(path, { method: 'POST', body: formData }),
download: (path: string, body?: unknown) => downloadPost(path, body),
}
export class ApiError extends Error {
@@ -0,0 +1,266 @@
<template>
<v-card
border
rounded="lg"
class="file-dir-tree d-flex flex-column"
:class="{ 'file-dir-tree--embedded': embedded }"
>
<v-card-title v-if="!embedded" class="text-subtitle-2 py-2 px-3 d-flex align-center">
<v-icon size="18" class="mr-1">mdi-file-tree</v-icon>
目录
<v-spacer />
<v-btn icon="mdi-refresh" size="x-small" variant="text" :disabled="!serverId" @click="reloadRoot" />
</v-card-title>
<div v-else class="file-dir-tree-embedded-bar d-flex align-center px-2 py-1">
<v-icon size="16" class="mr-1 text-medium-emphasis">mdi-file-tree</v-icon>
<span class="text-caption text-medium-emphasis">目录</span>
<v-spacer />
<v-btn icon="mdi-refresh" size="x-small" variant="text" :disabled="!serverId" @click="reloadRoot" />
</div>
<v-divider />
<v-card-text class="file-dir-tree-body pa-0 flex-grow-1">
<div v-if="!serverId" class="text-caption text-medium-emphasis pa-3">请先选择服务器</div>
<v-progress-linear v-else-if="rootLoading" indeterminate />
<v-list v-else density="compact" nav class="py-0">
<div
v-for="node in visibleNodes"
:key="node.path"
class="file-dir-tree-row d-flex align-center"
:style="{ paddingLeft: `${8 + node.depth * 14}px` }"
>
<button
type="button"
class="file-dir-tree-toggle"
@click.stop="toggleNode(node)"
>
<v-progress-circular v-if="loadingPath === node.path" indeterminate size="14" width="2" />
<v-icon v-else size="16">
{{ expandedPaths.has(node.path) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
</v-icon>
</button>
<button
type="button"
class="file-dir-tree-label text-body-2 text-truncate"
:class="{ 'file-dir-tree-label--active': normalizeRemotePath(currentPath) === node.path }"
@click="emit('navigate', node.path)"
@contextmenu.prevent="emit('paste-target', node.path)"
>
<v-icon size="14" class="mr-1">mdi-folder-outline</v-icon>
{{ node.name }}
</button>
</div>
</v-list>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { http } from '@/api'
import { parseBrowseResponse } from '@/utils/fileBrowse'
import { DEFAULT_FILES_ROOT, joinRemotePath, normalizeRemotePath } from '@/utils/remotePath'
import type { FileEntry } from '@/types/api'
export interface DirTreeNode {
path: string
name: string
depth: number
hasChildren: boolean
loaded: boolean
}
const props = defineProps<{
serverId: number | null
currentPath: string
/** 嵌入浮动编辑器侧栏:紧凑样式、填满高度 */
embedded?: boolean
}>()
const emit = defineEmits<{
navigate: [path: string]
'paste-target': [path: string]
}>()
const rootLoading = ref(false)
const loadingPath = ref<string | null>(null)
const expandedPaths = ref<Set<string>>(new Set([DEFAULT_FILES_ROOT]))
const childrenByParent = ref<Map<string, DirTreeNode[]>>(new Map())
const visibleNodes = computed(() => {
const out: DirTreeNode[] = []
const walk = (parentPath: string, depth: number) => {
const kids = childrenByParent.value.get(parentPath) ?? []
for (const node of kids) {
out.push({ ...node, depth })
if (expandedPaths.value.has(node.path)) {
walk(node.path, depth + 1)
}
}
}
walk(DEFAULT_FILES_ROOT, 0)
return out
})
async function fetchDirectories(path: string): Promise<DirTreeNode[]> {
if (!props.serverId) return []
const res = await http.post('/sync/browse', {
server_id: props.serverId,
path: normalizeRemotePath(path),
})
const { items } = parseBrowseResponse(res)
return items
.filter((e: FileEntry) => e.type === 'directory')
.sort((a, b) => a.name.localeCompare(b.name))
.map((e) => ({
path: joinRemotePath(path, e.name),
name: e.name,
depth: 0,
hasChildren: true,
loaded: false,
}))
}
async function loadChildren(parentPath: string) {
if (!props.serverId) return
loadingPath.value = parentPath
try {
const children = await fetchDirectories(parentPath)
childrenByParent.value.set(parentPath, children)
const existing = childrenByParent.value
children.forEach((c) => {
if (!existing.has(c.path)) existing.set(c.path, [])
})
} finally {
loadingPath.value = null
}
}
async function reloadRoot() {
if (!props.serverId) return
rootLoading.value = true
childrenByParent.value = new Map()
expandedPaths.value = new Set([DEFAULT_FILES_ROOT])
try {
await loadChildren(DEFAULT_FILES_ROOT)
childrenByParent.value.get(DEFAULT_FILES_ROOT)?.forEach((n) => {
childrenByParent.value.set(n.path, [])
})
} finally {
rootLoading.value = false
}
}
async function toggleNode(node: DirTreeNode) {
if (expandedPaths.value.has(node.path)) {
expandedPaths.value.delete(node.path)
return
}
if (!childrenByParent.value.has(node.path)) {
await loadChildren(node.path)
}
expandedPaths.value.add(node.path)
}
function expandAncestors(path: string) {
const norm = normalizeRemotePath(path)
if (norm === DEFAULT_FILES_ROOT) return
const parts = norm.split('/').filter(Boolean)
let acc = ''
for (const part of parts) {
acc = acc ? `${acc}/${part}` : `/${part}`
expandedPaths.value.add(acc)
}
expandedPaths.value.add(DEFAULT_FILES_ROOT)
}
watch(
() => props.serverId,
(id) => {
if (id) reloadRoot()
else {
childrenByParent.value = new Map()
expandedPaths.value = new Set()
}
},
{ immediate: true },
)
async function ensurePathVisible(targetPath: string) {
if (!props.serverId) return
const norm = normalizeRemotePath(targetPath)
expandAncestors(norm)
if (!childrenByParent.value.has(DEFAULT_FILES_ROOT)) {
await loadChildren(DEFAULT_FILES_ROOT)
}
if (norm === DEFAULT_FILES_ROOT) return
const prefix = DEFAULT_FILES_ROOT.endsWith('/') ? DEFAULT_FILES_ROOT : DEFAULT_FILES_ROOT
const rel = norm.startsWith(prefix) ? norm.slice(prefix.length) : norm
const parts = rel.split('/').filter(Boolean)
let acc = DEFAULT_FILES_ROOT
for (const part of parts) {
if (!childrenByParent.value.has(acc)) await loadChildren(acc)
acc = joinRemotePath(acc, part)
}
}
watch(
() => props.currentPath,
(path) => {
if (path) void ensurePathVisible(path)
},
)
</script>
<style scoped>
.file-dir-tree {
min-height: 320px;
max-height: min(56vh, 560px);
}
.file-dir-tree-body {
overflow: auto;
}
.file-dir-tree-toggle {
width: 22px;
height: 22px;
border: none;
background: transparent;
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.file-dir-tree-label {
flex: 1;
min-width: 0;
text-align: left;
border: none;
background: transparent;
cursor: pointer;
padding: 4px 6px;
border-radius: 4px;
}
.file-dir-tree-label:hover {
background: rgba(var(--v-theme-on-surface), 0.06);
}
.file-dir-tree-label--active {
color: rgb(var(--v-theme-primary));
font-weight: 600;
background: rgba(var(--v-theme-primary), 0.1);
}
.file-dir-tree--embedded {
min-height: 0;
max-height: none;
height: 100%;
border: none;
border-radius: 0;
}
.file-dir-tree--embedded .file-dir-tree-body {
flex: 1;
min-height: 0;
}
.file-dir-tree-embedded-bar {
flex-shrink: 0;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
</style>
@@ -0,0 +1,721 @@
<template>
<Teleport to="body">
<div
v-if="tabs.length > 0 && !minimized"
class="file-editor-float"
:class="{ 'file-editor-float--maximized': maximized }"
:style="floatContainerStyle"
>
<v-card border rounded="lg" class="file-editor-panel d-flex flex-column h-100">
<v-toolbar
density="compact"
color="surface"
class="flex-grow-0 file-editor-toolbar"
:class="{ 'file-editor-toolbar--draggable': !maximized }"
@mousedown="onToolbarMouseDown"
>
<v-icon v-if="!maximized" size="18" class="mr-1 text-medium-emphasis file-editor-drag-hint">
mdi-drag
</v-icon>
<v-tabs
v-model="activeTabId"
density="compact"
show-arrows
class="file-editor-tabs flex-grow-1 min-w-0"
@update:model-value="onTabSelected"
@mousedown.stop
>
<v-tab
v-for="tab in tabs"
:key="tab.id"
:value="tab.id"
class="text-none"
>
<span class="text-truncate" style="max-width: 140px;">{{ tab.name }}</span>
<span v-if="tab.modified" class="text-error ml-1"></span>
<v-btn
icon="mdi-close"
size="x-small"
variant="text"
class="ml-1"
@click.stop="requestCloseTab(tab.id)"
/>
</v-tab>
</v-tabs>
<v-select
v-model="activeLanguageChoice"
:items="EDITOR_LANGUAGE_OPTIONS"
item-title="title"
item-value="value"
density="compact"
variant="outlined"
hide-details
class="file-editor-lang-select flex-grow-0"
label="语法"
@mousedown.stop
@update:model-value="onLanguageChoiceChange"
/>
<v-spacer />
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-content-save"
:loading="saving"
:disabled="!activeTab?.modified || !ready"
@mousedown.stop
@click="saveActive"
>
保存
</v-btn>
<v-btn
size="small"
variant="text"
:icon="maximized ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
:title="maximized ? '退出全屏' : '全屏'"
@mousedown.stop
@click="maximized = !maximized"
/>
<v-btn
size="small"
variant="text"
icon="mdi-window-minimize"
title="最小化"
@mousedown.stop
@click="minimized = true"
/>
<v-btn
size="small"
variant="text"
icon="mdi-close"
title="关闭编辑器"
@mousedown.stop
@click="requestClosePanel"
/>
</v-toolbar>
<div class="file-editor-main d-flex flex-grow-1 min-h-0">
<aside class="file-editor-sidebar flex-shrink-0" @mousedown.stop>
<FileDirectoryTree
embedded
:server-id="serverId || null"
:current-path="treeBrowsePath"
@navigate="onTreeNavigate"
@paste-target="(p) => emit('paste-target', p)"
/>
</aside>
<div class="file-editor-content d-flex flex-column flex-grow-1 min-w-0 min-h-0">
<div class="file-editor-body flex-grow-1 position-relative min-h-0">
<v-progress-linear v-if="!ready" indeterminate absolute class="file-editor-progress" />
<div ref="editorHost" class="file-editor-host" />
</div>
<div class="file-editor-status text-caption px-3 py-1 d-flex flex-wrap ga-3">
<span>行 {{ cursorLine }}, 列 {{ cursorColumn }}</span>
<span>{{ activeLanguage }}</span>
<span class="text-truncate flex-grow-1">{{ activeTab?.path ?? '' }}</span>
</div>
</div>
</div>
<div
v-if="!maximized"
class="file-editor-resize"
title="拖动调整大小"
@mousedown.stop="startResize"
/>
</v-card>
</div>
<v-card
v-if="tabs.length > 0 && minimized"
border
rounded="lg"
class="file-editor-dock"
>
<span class="text-caption text-medium-emphasis mr-2">编辑器</span>
<v-chip
v-for="tab in tabs"
:key="tab.id"
size="small"
:color="tab.id === activeTabId ? 'primary' : undefined"
variant="tonal"
class="cursor-pointer"
@click="restoreTab(tab.id)"
>
{{ tab.name }}
<span v-if="tab.modified" class="text-error ml-1">●</span>
</v-chip>
<v-spacer />
<v-btn size="x-small" variant="text" icon="mdi-dock-window" title="展开" @click="minimized = false" />
<v-btn size="x-small" variant="text" icon="mdi-close" title="关闭全部" @click="requestClosePanel" />
</v-card>
</Teleport>
<v-dialog v-model="showCloseConfirm" max-width="400">
<v-card border>
<v-card-title>{{ closeConfirmTitle }}</v-card-title>
<v-card-text>{{ closeConfirmText }}</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="cancelClose">取消</v-btn>
<v-btn color="error" variant="flat" @click="confirmDiscardClose">不保存</v-btn>
<v-btn color="primary" variant="flat" @click="confirmSaveClose">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount, defineAsyncComponent } from 'vue'
import { ApiError, http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { useFloatingPanel } from '@/composables/useFloatingPanel'
import { applyNexusEditorTheme, buildMonacoEditorOptions, initMonaco } from '@/monaco/initMonaco'
import {
EDITOR_LANGUAGE_OPTIONS,
detectEditorLanguage,
resolveTabLanguage,
} from '@/utils/editorLanguage'
import { normalizeRemotePath, parentRemotePath } from '@/utils/remotePath'
const FileDirectoryTree = defineAsyncComponent(() => import('@/components/FileDirectoryTree.vue'))
import type * as Monaco from 'monaco-editor'
const monaco = initMonaco()
export interface EditorTabState {
id: string
path: string
name: string
content: string
originalContent: string
modified: boolean
/** `auto` 或 null 表示按文件名检测 */
languageId: string | null
}
const props = defineProps<{
serverId: number
currentPath?: string
}>()
const emit = defineEmits<{
saved: [path: string]
closed: []
'path-change': [path: string]
'paste-target': [path: string]
}>()
const snackbar = useSnackbar()
const FLOAT_KEY = 'nexus_file_editor_float'
const defaultW = Math.min(1120, Math.round(window.innerWidth * 0.88))
const defaultH = Math.min(760, Math.round(window.innerHeight * 0.82))
const { panelStyle, startDrag, startResize, clampToViewport } = useFloatingPanel(FLOAT_KEY, {
x: Math.max(24, (window.innerWidth - defaultW) / 2),
y: Math.max(24, (window.innerHeight - defaultH) / 2),
w: defaultW,
h: defaultH,
})
const tabs = ref<EditorTabState[]>([])
const activeTabId = ref<string | null>(null)
const minimized = ref(false)
const maximized = ref(false)
const saving = ref(false)
const ready = ref(false)
const showCloseConfirm = ref(false)
const cursorLine = ref(1)
const cursorColumn = ref(1)
const treeBrowsePath = ref(props.currentPath || '/')
const editorHost = ref<HTMLElement>()
let editor: Monaco.editor.IStandaloneCodeEditor | null = null
let model: Monaco.editor.ITextModel | null = null
let layoutObserver: ResizeObserver | null = null
const pendingCloseTabId = ref<string | null>(null)
const pendingClosePanel = ref(false)
const activeTab = computed(() => tabs.value.find((t) => t.id === activeTabId.value) ?? null)
const activeLanguage = computed(() => {
if (!activeTab.value) return 'plaintext'
return resolveTabLanguage(activeTab.value.name, activeTab.value.path, activeTab.value.languageId)
})
const activeLanguageChoice = computed({
get: () => activeTab.value?.languageId ?? 'auto',
set: (v: string | null) => {
if (!activeTab.value) return
activeTab.value.languageId = v === 'auto' ? null : v
},
})
const floatContainerStyle = computed(() => (maximized.value ? undefined : panelStyle.value))
const closeConfirmTitle = computed(() =>
pendingClosePanel.value ? '关闭编辑器' : '关闭文件',
)
const closeConfirmText = computed(() => {
const tab = pendingCloseTabId.value
? tabs.value.find((t) => t.id === pendingCloseTabId.value)
: tabs.value.find((t) => t.modified)
const name = tab?.name ?? '文件'
return pendingClosePanel.value
? `${name}」等未保存的更改将丢失,确定吗?`
: `${name}」已修改但未保存,确定要关闭吗?`
})
function normalizeEol(text: string): string {
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
}
function tabLanguageId(tab: EditorTabState): string {
return resolveTabLanguage(tab.name, tab.path, tab.languageId)
}
function onLanguageChoiceChange(choice: string | null) {
if (!activeTab.value || !model) return
activeTab.value.languageId = choice === 'auto' || !choice ? null : choice
const lang = tabLanguageId(activeTab.value)
monaco.editor.setModelLanguage(model, lang)
}
function tabId(path: string): string {
return `${props.serverId}:${normalizeRemotePath(path)}`
}
function syncTreePathFromFile(filePath: string) {
const parent = parentRemotePath(normalizeRemotePath(filePath))
if (parent) treeBrowsePath.value = parent
}
function onTreeNavigate(path: string) {
treeBrowsePath.value = normalizeRemotePath(path)
emit('path-change', treeBrowsePath.value)
}
function onToolbarMouseDown(e: MouseEvent) {
if (maximized.value) return
const target = e.target as HTMLElement
if (target.closest('button, .v-tab, .v-btn, input, a')) return
startDrag(e)
}
function persistEditorToActiveTab() {
if (!editor || !activeTab.value) return
const value = normalizeEol(editor.getValue())
activeTab.value.content = value
activeTab.value.modified = value !== activeTab.value.originalContent
}
function updateCursorStatus() {
if (!editor) return
const pos = editor.getPosition()
cursorLine.value = pos?.lineNumber ?? 1
cursorColumn.value = pos?.column ?? 1
}
function destroyEditor() {
layoutObserver?.disconnect()
layoutObserver = null
editor?.dispose()
editor = null
model?.dispose()
model = null
ready.value = false
}
function applyTabToEditor(tab: EditorTabState) {
if (!editor) return
const lang = tabLanguageId(tab)
const content = normalizeEol(tab.content)
if (model) {
model.setValue(content)
monaco.editor.setModelLanguage(model, lang)
} else {
model = monaco.editor.createModel(content, lang, monaco.Uri.file(tab.path))
editor.setModel(model)
}
syncTreePathFromFile(tab.path)
updateCursorStatus()
}
function mountEditor() {
if (!editorHost.value || !activeTab.value) return
destroyEditor()
ready.value = false
const tab = activeTab.value
const lang = tabLanguageId(tab)
const content = normalizeEol(tab.content)
model = monaco.editor.createModel(content, lang, monaco.Uri.file(tab.path))
editor = monaco.editor.create(editorHost.value, {
...buildMonacoEditorOptions(),
model,
})
editor.onDidChangeModelContent(() => {
persistEditorToActiveTab()
updateCursorStatus()
})
editor.onDidChangeCursorPosition(() => {
updateCursorStatus()
})
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
saveActive()
})
editor.addCommand(monaco.KeyCode.Escape, () => {
if (maximized.value) maximized.value = false
else requestClosePanel()
})
layoutObserver = new ResizeObserver(() => {
editor?.layout()
})
layoutObserver.observe(editorHost.value)
syncTreePathFromFile(tab.path)
applyNexusEditorTheme(monaco)
ready.value = true
updateCursorStatus()
requestAnimationFrame(() => editor?.layout())
}
function onTabSelected(id: unknown) {
if (typeof id !== 'string' || !id) return
persistEditorToActiveTab()
activeTabId.value = id
const tab = tabs.value.find((t) => t.id === id)
if (tab && editor) {
applyTabToEditor(tab)
} else if (tab) {
nextTick(() => mountEditor())
}
}
function restoreTab(id: string) {
activeTabId.value = id
minimized.value = false
nextTick(() => {
clampToViewport()
if (!editor) mountEditor()
else {
const tab = tabs.value.find((t) => t.id === id)
if (tab) applyTabToEditor(tab)
}
})
}
function openFile(payload: { path: string; name: string; content: string }) {
const id = tabId(payload.path)
const normalized = normalizeEol(payload.content)
const existing = tabs.value.find((t) => t.id === id)
if (existing) {
activeTabId.value = id
minimized.value = false
nextTick(() => {
if (!editor) mountEditor()
else applyTabToEditor(existing)
})
return
}
tabs.value.push({
id,
path: payload.path,
name: payload.name,
content: normalized,
originalContent: normalized,
modified: false,
languageId: null,
})
activeTabId.value = id
minimized.value = false
syncTreePathFromFile(payload.path)
nextTick(() => mountEditor())
}
function removeTab(id: string) {
const idx = tabs.value.findIndex((t) => t.id === id)
if (idx < 0) return
tabs.value.splice(idx, 1)
if (tabs.value.length === 0) {
activeTabId.value = null
destroyEditor()
maximized.value = false
minimized.value = false
emit('closed')
return
}
if (activeTabId.value === id) {
const next = tabs.value[Math.min(idx, tabs.value.length - 1)]
activeTabId.value = next.id
nextTick(() => applyTabToEditor(next))
}
}
function requestCloseTab(id: string) {
const tab = tabs.value.find((t) => t.id === id)
if (!tab) return
if (activeTabId.value === id) persistEditorToActiveTab()
if (tab.modified) {
pendingCloseTabId.value = id
pendingClosePanel.value = false
showCloseConfirm.value = true
return
}
const wasActive = activeTabId.value === id
if (wasActive) destroyEditor()
removeTab(id)
if (wasActive && activeTabId.value) {
nextTick(() => mountEditor())
}
}
function requestClosePanel() {
persistEditorToActiveTab()
if (tabs.value.some((t) => t.modified)) {
pendingCloseTabId.value = null
pendingClosePanel.value = true
showCloseConfirm.value = true
return
}
tabs.value = []
activeTabId.value = null
destroyEditor()
maximized.value = false
minimized.value = false
emit('closed')
}
function cancelClose() {
showCloseConfirm.value = false
pendingCloseTabId.value = null
pendingClosePanel.value = false
}
function confirmDiscardClose() {
showCloseConfirm.value = false
if (pendingClosePanel.value) {
tabs.value = []
activeTabId.value = null
destroyEditor()
maximized.value = false
minimized.value = false
emit('closed')
} else if (pendingCloseTabId.value) {
const id = pendingCloseTabId.value
destroyEditor()
removeTab(id)
nextTick(() => {
if (activeTabId.value) mountEditor()
})
}
pendingCloseTabId.value = null
pendingClosePanel.value = false
}
async function confirmSaveClose() {
const closePanel = pendingClosePanel.value
const closeTabId = pendingCloseTabId.value
await saveActive()
showCloseConfirm.value = false
pendingClosePanel.value = false
pendingCloseTabId.value = null
if (closePanel) {
if (!tabs.value.some((t) => t.modified)) {
tabs.value = []
activeTabId.value = null
destroyEditor()
maximized.value = false
minimized.value = false
emit('closed')
}
return
}
if (closeTabId) {
const tab = tabs.value.find((t) => t.id === closeTabId)
if (tab && !tab.modified) {
const wasActive = activeTabId.value === closeTabId
if (wasActive) destroyEditor()
removeTab(closeTabId)
if (wasActive && activeTabId.value) {
nextTick(() => mountEditor())
}
}
}
}
async function saveActive() {
if (!editor || !activeTab.value) return
if (!props.serverId) {
snackbar('请先选择服务器', 'error')
return
}
persistEditorToActiveTab()
const tab = activeTab.value
if (!tab.modified) return
saving.value = true
try {
await http.post('/sync/write-file', {
server_id: props.serverId,
path: tab.path,
content: editor.getValue(),
})
const saved = normalizeEol(editor.getValue())
tab.content = saved
tab.originalContent = saved
tab.modified = false
snackbar('文件已保存')
emit('saved', tab.path)
} catch (e: unknown) {
const msg =
e instanceof ApiError ? e.message : e instanceof Error ? e.message : '保存失败'
snackbar(msg, 'error')
} finally {
saving.value = false
}
}
watch(
() => props.currentPath,
(path) => {
if (path) treeBrowsePath.value = normalizeRemotePath(path)
},
)
watch(maximized, () => {
nextTick(() => editor?.layout())
})
watch(minimized, (v) => {
if (!v) nextTick(() => editor?.layout())
})
function hasOpenTabs(): boolean {
return tabs.value.length > 0
}
defineExpose({
openFile,
hasOpenTabs,
})
onBeforeUnmount(() => {
destroyEditor()
})
</script>
<style scoped>
.file-editor-float {
position: fixed;
z-index: 2400;
display: flex;
flex-direction: column;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
}
.file-editor-float--maximized {
inset: 0 !important;
width: 100vw !important;
height: 100vh !important;
left: 0 !important;
top: 0 !important;
border-radius: 0;
}
.file-editor-panel {
overflow: hidden;
position: relative;
height: 100%;
background: rgb(var(--v-theme-surface));
}
.file-editor-toolbar--draggable {
cursor: grab;
user-select: none;
}
.file-editor-toolbar--draggable:active {
cursor: grabbing;
}
.file-editor-main {
min-height: 0;
}
.file-editor-sidebar {
width: 220px;
border-right: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
overflow: hidden;
display: flex;
flex-direction: column;
}
.file-editor-sidebar :deep(.file-dir-tree) {
min-height: 0;
max-height: none;
height: 100%;
border: none;
border-radius: 0;
box-shadow: none;
}
.file-editor-body {
min-height: 120px;
background: #1e1e1e;
}
.file-editor-host {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.file-editor-status {
background: #252526;
color: #cccccc;
border-top: 1px solid #3c3c3c;
flex-shrink: 0;
}
.file-editor-lang-select {
max-width: 132px;
min-width: 108px;
}
.file-editor-progress {
z-index: 2;
}
.file-editor-tabs :deep(.v-tab) {
min-width: 0;
padding-inline: 8px;
}
.file-editor-resize {
position: absolute;
right: 0;
bottom: 0;
width: 18px;
height: 18px;
cursor: nwse-resize;
z-index: 3;
background: linear-gradient(
135deg,
transparent 50%,
rgba(var(--v-theme-on-surface), 0.25) 50%
);
}
.file-editor-dock {
position: fixed;
left: 16px;
right: 16px;
bottom: 16px;
z-index: 2390;
max-width: 960px;
margin: 0 auto;
padding: 8px 12px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
}
</style>
@@ -0,0 +1,453 @@
<template>
<v-dialog :model-value="modelValue" max-width="520" @update:model-value="emit('update:modelValue', $event)">
<v-card border>
<v-card-title>修改权限</v-card-title>
<v-card-text>
<p v-if="fileName" class="text-body-2 mb-3 text-medium-emphasis">
{{ fileName }}
</p>
<p v-if="currentSummary" class="text-caption mb-4">
当前{{ currentSummary }}
</p>
<div class="d-flex flex-wrap ga-2 mb-4">
<v-chip
v-for="preset in visiblePresets"
:key="preset.mode + preset.label"
size="small"
variant="tonal"
color="primary"
@click="applyPreset(preset.mode)"
>
{{ preset.label }}
</v-chip>
</div>
<v-text-field
v-model="mode"
label="八进制权限"
variant="outlined"
density="compact"
placeholder="644 / 755"
:rules="[required('权限'), octalRule]"
class="mb-2"
@keydown.enter="submit"
/>
<v-text-field
v-model="owner"
label="属主(可选)"
variant="outlined"
density="compact"
placeholder="www"
:rules="[ownerRule]"
class="mb-2"
/>
<v-text-field
v-model="group"
label="属组(可选)"
variant="outlined"
density="compact"
placeholder="www"
:rules="[groupRule]"
class="mb-2"
/>
<v-checkbox
v-if="isDirectory"
v-model="recursive"
label="同时修改子项(递归 chmod/chown"
density="compact"
hide-details
color="warning"
class="mb-2"
/>
<v-divider class="my-4" />
<div class="d-flex align-center flex-wrap ga-2 mb-2">
<span class="text-caption text-medium-emphasis">提权能力</span>
<v-btn
size="small"
variant="tonal"
:loading="capabilityLoading"
:disabled="!serverId"
@click="runCapabilityCheck"
>
检测提权能力
</v-btn>
</div>
<v-alert
v-if="capability"
:type="capabilityAlertType"
variant="tonal"
density="compact"
class="mb-2"
>
<div class="text-body-2">{{ capability.message }}</div>
<div v-if="capability.ssh_user" class="text-caption mt-1">
SSH 用户{{ capability.ssh_user }}
· 策略{{ elevationLabel(capability.files_elevation) }}
</div>
</v-alert>
<p class="text-caption text-medium-emphasis mb-0">
root 用户写/chmod 失败时将按服务器策略尝试 <code>sudo -n</code>
示例配置见仓库 <code>{{ docsPath }}</code>由运维审阅后安装到目标机
</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="emit('update:modelValue', false)">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="loading" @click="submit">应用</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { FileEntry, FilesCapabilityResult, FilesElevationMode } from '@/types/api'
import { http } from '@/api'
import { CHMOD_PRESETS, permissionTooltip } from '@/utils/fileMode'
const props = defineProps<{
modelValue: boolean
file: FileEntry | null
serverId?: number | null
loading?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [boolean]
apply: [payload: { mode: string; owner?: string; group?: string; recursive?: boolean }]
}>()
const mode = ref('')
const owner = ref('')
const group = ref('')
const capability = ref<FilesCapabilityResult | null>(null)
const capabilityLoading = ref(false)
const recursive = ref(false)
const isDirectory = computed(() => props.file?.type === 'directory')
const docsPath = computed(() => capability.value?.docs_path ?? 'docs/deploy/nexus-files-sudoers.example')
const fileName = computed(() => props.file?.name ?? '')
const currentSummary = computed(() => (props.file ? permissionTooltip(props.file) : ''))
const visiblePresets = computed(() => {
const t = props.file?.type === 'directory' ? 'directory' : 'file'
return CHMOD_PRESETS.filter((p) => p.for === t || p.for === 'file')
})
const capabilityAlertType = computed(() => {
if (!capability.value) return 'info'
if (capability.value.is_root || (capability.value.can_sudo_nopasswd && capability.value.whitelist_ok)) {
return 'success'
}
if (capability.value.can_sudo_nopasswd) return 'warning'
return 'error'
})
watch(
() => [props.modelValue, props.file] as const,
([open, file]) => {
if (!open || !file) return
mode.value = file.mode_octal || ''
owner.value = file.owner || ''
group.value = file.group && file.group !== file.owner ? file.group : ''
recursive.value = false
capability.value = null
if (props.serverId) {
void runCapabilityCheck()
}
},
)
function elevationLabel(mode: FilesElevationMode | undefined) {
if (mode === 'off') return '关闭提权'
if (mode === 'always_sudo') return '始终 sudo'
return '自动 sudo'
}
async function runCapabilityCheck() {
if (!props.serverId) return
capabilityLoading.value = true
try {
capability.value = await http.get<FilesCapabilityResult>(
`/servers/${props.serverId}/files-capability`,
)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '检测失败'
capability.value = {
ssh_user: '',
is_root: false,
files_elevation: 'auto_sudo',
can_sudo_nopasswd: false,
whitelist_ok: false,
message: msg,
docs_path: 'docs/deploy/nexus-files-sudoers.example',
}
} finally {
capabilityLoading.value = false
}
}
function required(label: string) {
return (v: string) => !!String(v || '').trim() || `${label}不能为空`
}
function octalRule(v: string) {
return /^[0-7]{3,4}$/.test(String(v || '').trim()) || '格式: 34 位八进制'
}
const IDENT = /^[a-zA-Z0-9_.-]+$/
function ownerRule(v: string) {
const s = String(v || '').trim()
if (!s) return true
return IDENT.test(s) || '仅允许字母、数字、._-'
}
function groupRule(v: string) {
const s = String(v || '').trim()
if (!s) return true
if (!owner.value.trim()) return '请先填写属主'
return IDENT.test(s) || '仅允许字母、数字、._-'
}
function applyPreset(m: string) {
mode.value = m
}
function submit() {
const m = mode.value.trim()
if (!/^[0-7]{3,4}$/.test(m)) return
const o = owner.value.trim()
const g = group.value.trim()
const rec = isDirectory.value && recursive.value
if (rec) {
const name = props.file?.name ?? '该目录'
const ok = window.confirm(
`确认对目录「${name}」及其全部子项递归应用权限 ${m}?此操作影响范围大且不可自动撤销。`,
)
if (!ok) return
}
emit('apply', {
mode: m,
owner: o || undefined,
group: g || undefined,
recursive: rec || undefined,
})
}
</script>
+127 -76
View File
@@ -1,25 +1,34 @@
<template>
<v-dialog v-model="visible" fullscreen transition="dialog-bottom-transition">
<v-card>
<!-- Toolbar -->
<v-toolbar density="compact" color="surface">
<v-dialog v-model="visible" fullscreen transition="dialog-bottom-transition" @after-enter="onDialogShown">
<v-card class="monaco-editor-card d-flex flex-column">
<v-toolbar density="compact" color="surface" class="flex-grow-0">
<v-btn icon="mdi-close" @click="requestClose" />
<v-toolbar-title class="text-body-2">
<v-toolbar-title class="text-body-2 text-truncate" style="max-width: 50vw;">
{{ fileName }}
<span class="text-medium-emphasis ml-1">{{ shortPath }}</span>
<span v-if="modified" class="text-error ml-1"></span>
</v-toolbar-title>
<v-spacer />
<v-chip size="x-small" variant="tonal" label class="mr-2">{{ language }}</v-chip>
<v-btn size="small" variant="tonal" prepend-icon="mdi-content-save" @click="save" :loading="saving" :disabled="!modified">
<span v-if="lineCount" class="text-caption text-medium-emphasis mr-3">{{ lineCount }} </span>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-content-save"
:loading="saving"
:disabled="!modified || !ready"
@click="save"
>
保存
</v-btn>
<v-btn size="small" variant="text" prepend-icon="mdi-theme-light-dark" @click="toggleTheme" />
<v-btn size="small" variant="text" icon="mdi-theme-light-dark" @click="toggleTheme" />
</v-toolbar>
<!-- Editor container -->
<div ref="editorContainer" style="height: calc(100vh - 48px); width: 100%;" />
<div class="monaco-editor-body flex-grow-1 position-relative">
<v-progress-linear v-if="!ready" indeterminate absolute class="monaco-editor-progress" />
<div ref="editorContainer" class="monaco-editor-host" />
</div>
<!-- Unsaved changes dialog -->
<v-dialog v-model="showCloseConfirm" max-width="400">
<v-card border>
<v-card-title>未保存的更改</v-card-title>
@@ -38,9 +47,14 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import * as monaco from 'monaco-editor'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { buildMonacoEditorOptions, initMonaco, toggleNexusEditorTheme } from '@/monaco/initMonaco'
import { detectEditorLanguage } from '@/utils/editorLanguage'
import type * as Monaco from 'monaco-editor'
const monaco = initMonaco()
const snackbar = useSnackbar()
@@ -65,78 +79,94 @@ const visible = computed({
const editorContainer = ref<HTMLElement>()
const saving = ref(false)
const modified = ref(false)
const ready = ref(false)
const showCloseConfirm = ref(false)
const language = ref('plaintext')
const lineCount = ref(0)
let editor: monaco.editor.IStandaloneCodeEditor | null = null
let editor: Monaco.editor.IStandaloneCodeEditor | null = null
let model: Monaco.editor.ITextModel | null = null
let originalContent = ''
let layoutObserver: ResizeObserver | null = null
// Language detection
const LANG_MAP: Record<string, string> = {
js: 'javascript', mjs: 'javascript', cjs: 'javascript',
ts: 'typescript', mts: 'typescript', cts: 'typescript',
jsx: 'javascript', tsx: 'typescript',
py: 'python', rb: 'ruby', sh: 'shell', bash: 'shell', zsh: 'shell',
php: 'php', java: 'java', go: 'go', rs: 'rust', c: 'c', cpp: 'cpp',
h: 'c', hpp: 'cpp', cs: 'csharp', swift: 'swift', kt: 'kotlin',
html: 'html', htm: 'html', css: 'css', scss: 'scss', less: 'less',
json: 'json', xml: 'xml', yaml: 'yaml', yml: 'yaml', toml: 'toml',
md: 'markdown', sql: 'sql', graphql: 'graphql',
dockerfile: 'dockerfile', makefile: 'makefile',
conf: 'ini', ini: 'ini', env: 'ini',
const shortPath = computed(() => {
const p = props.filePath || ''
return p.length > 48 ? `${p.slice(-44)}` : p
})
function normalizeEol(text: string): string {
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
}
function detectLanguage(fileName: string): string {
const ext = fileName.split('.').pop()?.toLowerCase() || ''
if (fileName === 'Dockerfile') return 'dockerfile'
if (fileName === 'Makefile') return 'makefile'
return LANG_MAP[ext] || 'plaintext'
function updateLineCount() {
if (!editor) {
lineCount.value = 0
return
}
lineCount.value = editor.getModel()?.getLineCount() ?? 0
}
function initEditor() {
if (!editorContainer.value) return
function destroyEditor() {
layoutObserver?.disconnect()
layoutObserver = null
editor?.dispose()
editor = null
model?.dispose()
model = null
ready.value = false
}
language.value = detectLanguage(props.fileName)
originalContent = props.fileContent
function mountEditor() {
if (!editorContainer.value || !visible.value) return
destroyEditor()
ready.value = false
language.value = detectEditorLanguage(props.fileName, props.filePath)
originalContent = normalizeEol(props.fileContent ?? '')
modified.value = false
model = monaco.editor.createModel(originalContent, language.value, monaco.Uri.file(props.filePath || props.fileName))
editor = monaco.editor.create(editorContainer.value, {
value: props.fileContent,
language: language.value,
theme: localStorage.getItem('nexus_editor_theme') === 'light' ? 'vs' : 'vs-dark',
automaticLayout: true,
lineNumbers: 'on',
folding: true,
bracketPairColorization: { enabled: true },
matchBrackets: 'always',
fontSize: 14,
minimap: { enabled: true },
tabSize: 4,
wordWrap: 'off',
scrollBeyondLastLine: false,
suggestOnTriggerCharacters: true,
...buildMonacoEditorOptions(),
model,
})
// Track modifications
editor.onDidChangeModelContent(() => {
modified.value = editor!.getValue() !== originalContent
if (!editor) return
modified.value = normalizeEol(editor.getValue()) !== originalContent
updateLineCount()
})
// Ctrl+S to save
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
save()
})
editor.addCommand(monaco.KeyCode.Escape, () => {
requestClose()
})
layoutObserver = new ResizeObserver(() => {
editor?.layout()
})
layoutObserver.observe(editorContainer.value)
ready.value = true
updateLineCount()
requestAnimationFrame(() => editor?.layout())
}
function onDialogShown() {
mountEditor()
}
function toggleTheme() {
const current = localStorage.getItem('nexus_editor_theme') || 'dark'
const next = current === 'dark' ? 'light' : 'dark'
localStorage.setItem('nexus_editor_theme', next)
monaco.editor.setTheme(next === 'light' ? 'vs' : 'vs-dark')
toggleNexusEditorTheme(monaco)
}
async function save() {
if (!editor || !modified.value) return
if (!editor || !modified.value || !props.serverId) return
saving.value = true
try {
await http.post('/sync/write-file', {
@@ -144,7 +174,7 @@ async function save() {
path: props.filePath,
content: editor.getValue(),
})
originalContent = editor.getValue()
originalContent = normalizeEol(editor.getValue())
modified.value = false
snackbar('文件已保存')
emit('saved')
@@ -171,33 +201,54 @@ function forceClose() {
async function saveThenClose() {
await save()
showCloseConfirm.value = false
close()
if (!modified.value) {
showCloseConfirm.value = false
close()
}
}
function close() {
if (editor) {
editor.dispose()
editor = null
}
destroyEditor()
visible.value = false
}
// Initialize editor when dialog opens
watch(visible, async (v) => {
if (v) {
watch(
() => [props.modelValue, props.filePath, props.fileContent] as const,
async ([open]) => {
if (!open) {
destroyEditor()
return
}
await nextTick()
initEditor()
} else if (editor) {
editor.dispose()
editor = null
}
})
if (editorContainer.value?.offsetParent !== null) {
mountEditor()
}
},
)
onBeforeUnmount(() => {
if (editor) {
editor.dispose()
editor = null
}
destroyEditor()
})
</script>
<style scoped>
.monaco-editor-card {
height: 100vh;
overflow: hidden;
}
.monaco-editor-body {
min-height: 0;
}
.monaco-editor-host {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.monaco-editor-host :deep(.monaco-editor .view-line span) {
font-family: inherit;
}
.monaco-editor-progress {
z-index: 2;
}
</style>
@@ -0,0 +1,91 @@
import { computed, ref } from 'vue'
import { http } from '@/api'
import { normalizeRemotePath } from '@/utils/remotePath'
export type FilesClipboardMode = 'copy' | 'cut'
export interface FilesClipboardState {
mode: FilesClipboardMode
serverId: number
paths: string[]
label: string
}
const clipboard = ref<FilesClipboardState | null>(null)
export function useFilesClipboard() {
const hasClipboard = computed(() => clipboard.value !== null)
const clipboardSummary = computed(() => {
if (!clipboard.value) return ''
const verb = clipboard.value.mode === 'cut' ? '已剪切' : '已复制'
return `${verb} ${clipboard.value.paths.length}`
})
function setFromSelection(
mode: FilesClipboardMode,
serverId: number,
paths: string[],
) {
if (!paths.length) return false
const normalized = [...new Set(paths.map((p) => normalizeRemotePath(p)))]
clipboard.value = {
mode,
serverId,
paths: normalized,
label: normalized.map((p) => p.split('/').pop() || p).join(', '),
}
return true
}
function clear() {
clipboard.value = null
}
function canPasteOnServer(serverId: number | null): boolean {
return Boolean(clipboard.value && serverId && clipboard.value.serverId === serverId)
}
/** 粘贴前校验(与后端 remote_path_validation 一致,含跨目录到父级) */
function validatePasteTarget(sources: string[], destDir: string): string | null {
const dest = normalizeRemotePath(destDir)
for (const raw of sources) {
const src = normalizeRemotePath(raw)
if (dest === src) {
return `${src.split('/').pop() || src}」与目标路径相同,无需粘贴`
}
if (dest.startsWith(`${src}/`)) {
return `不能粘贴到源目录「${src}」的内部`
}
}
return null
}
async function pasteToDirectory(destDir: string): Promise<void> {
const clip = clipboard.value
if (!clip) throw new Error('剪贴板为空')
const dest = normalizeRemotePath(destDir)
const conflict = validatePasteTarget(clip.paths, dest)
if (conflict) throw new Error(conflict)
const mode = clip.mode === 'cut' ? 'move' : 'copy'
await http.post('/sync/file-clipboard', {
server_id: clip.serverId,
mode,
sources: clip.paths,
dest_dir: dest,
})
if (clip.mode === 'cut') {
clear()
}
}
return {
clipboard,
hasClipboard,
clipboardSummary,
setFromSelection,
clear,
canPasteOnServer,
validatePasteTarget,
pasteToDirectory,
}
}
@@ -0,0 +1,70 @@
import { onMounted, onBeforeUnmount, type Ref } from 'vue'
import type { FileEntry } from '@/types/api'
export interface FilesHotkeyHandlers {
refresh: () => void
goUp: () => void
renameSelection: () => void
deleteSelection: () => void
selectAll: () => void
copySelection: () => void
cutSelection: () => void
pasteClipboard: () => void
}
function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
const tag = target.tagName
return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable
}
export function useFilesHotkeys(
handlers: FilesHotkeyHandlers,
selectedFiles: Ref<FileEntry[]>,
) {
function onKeydown(e: KeyboardEvent) {
if (isTypingTarget(e.target)) return
if (e.key === 'F5') {
e.preventDefault()
handlers.refresh()
return
}
if (e.key === 'Backspace') {
e.preventDefault()
handlers.goUp()
return
}
if (e.key === 'F2' && selectedFiles.value.length === 1) {
e.preventDefault()
handlers.renameSelection()
return
}
if (e.key === 'Delete' && selectedFiles.value.length > 0) {
e.preventDefault()
handlers.deleteSelection()
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
e.preventDefault()
handlers.selectAll()
return
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
e.preventDefault()
handlers.copySelection()
return
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'x') {
e.preventDefault()
handlers.cutSelection()
return
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
e.preventDefault()
handlers.pasteClipboard()
}
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
}
@@ -0,0 +1,116 @@
import { computed, onBeforeUnmount, ref, type CSSProperties } from 'vue'
export interface FloatRect {
x: number
y: number
w: number
h: number
}
function loadRect(key: string, fallback: FloatRect): FloatRect {
try {
const raw = localStorage.getItem(key)
if (!raw) return fallback
const parsed = JSON.parse(raw) as FloatRect
if (
typeof parsed.x === 'number' &&
typeof parsed.y === 'number' &&
typeof parsed.w === 'number' &&
typeof parsed.h === 'number'
) {
return parsed
}
} catch {
/* ignore */
}
return fallback
}
function saveRect(key: string, rect: FloatRect) {
try {
localStorage.setItem(key, JSON.stringify(rect))
} catch {
/* ignore */
}
}
/** 可拖动浮动面板位置与尺寸(持久化到 localStorage */
export function useFloatingPanel(storageKey: string, fallback: FloatRect) {
const rect = ref<FloatRect>(loadRect(storageKey, fallback))
let dragOrigin = { px: 0, py: 0, rx: 0, ry: 0 }
let resizing = false
let resizeOrigin = { px: 0, py: 0, rw: 0, rh: 0 }
const panelStyle = computed((): CSSProperties => ({
left: `${rect.value.x}px`,
top: `${rect.value.y}px`,
width: `${rect.value.w}px`,
height: `${rect.value.h}px`,
}))
function clampToViewport() {
const margin = 8
const maxW = Math.min(window.innerWidth - margin * 2, 1400)
const maxH = Math.min(window.innerHeight - margin * 2, 900)
rect.value.w = Math.max(480, Math.min(rect.value.w, maxW))
rect.value.h = Math.max(320, Math.min(rect.value.h, maxH))
rect.value.x = Math.max(margin, Math.min(rect.value.x, window.innerWidth - rect.value.w - margin))
rect.value.y = Math.max(margin, Math.min(rect.value.y, window.innerHeight - rect.value.h - margin))
}
function onDragMove(e: MouseEvent) {
if (resizing) {
rect.value.w = Math.max(480, resizeOrigin.rw + e.clientX - resizeOrigin.px)
rect.value.h = Math.max(320, resizeOrigin.rh + e.clientY - resizeOrigin.py)
clampToViewport()
return
}
rect.value.x = dragOrigin.rx + e.clientX - dragOrigin.px
rect.value.y = dragOrigin.ry + e.clientY - dragOrigin.py
clampToViewport()
}
function onDragEnd() {
resizing = false
window.removeEventListener('mousemove', onDragMove)
window.removeEventListener('mouseup', onDragEnd)
saveRect(storageKey, rect.value)
}
function startDrag(e: MouseEvent) {
if (e.button !== 0) return
dragOrigin = { px: e.clientX, py: e.clientY, rx: rect.value.x, ry: rect.value.y }
window.addEventListener('mousemove', onDragMove)
window.addEventListener('mouseup', onDragEnd)
}
function startResize(e: MouseEvent) {
if (e.button !== 0) return
e.stopPropagation()
resizing = true
resizeOrigin = { px: e.clientX, py: e.clientY, rw: rect.value.w, rh: rect.value.h }
window.addEventListener('mousemove', onDragMove)
window.addEventListener('mouseup', onDragEnd)
}
function centerOnScreen() {
rect.value.x = Math.max(16, (window.innerWidth - rect.value.w) / 2)
rect.value.y = Math.max(16, (window.innerHeight - rect.value.h) / 2)
saveRect(storageKey, rect.value)
}
onBeforeUnmount(() => {
window.removeEventListener('mousemove', onDragMove)
window.removeEventListener('mouseup', onDragEnd)
})
return {
rect,
panelStyle,
startDrag,
startResize,
centerOnScreen,
clampToViewport,
}
}
+85
View File
@@ -0,0 +1,85 @@
import * as monaco from 'monaco-editor'
import { setupMonacoEnvironment } from '@/monaco/setupMonacoEnv'
import {
applyNexusEditorTheme,
nexusEditorThemeId,
registerNexusThemes,
toggleNexusEditorTheme,
} from '@/monaco/nexusThemes'
let initialized = false
function registerNginxLanguage(): void {
if (monaco.languages.getLanguages().some((l) => l.id === 'nginx')) return
monaco.languages.register({ id: 'nginx', extensions: ['.conf'], aliases: ['Nginx', 'nginx'] })
monaco.languages.setMonarchTokensProvider('nginx', {
defaultToken: '',
ignoreCase: true,
tokenizer: {
root: [
[/\s+#.*$/, 'comment'],
[/#.*$/, 'comment'],
[
/\b(server|listen|location|root|index|include|proxy_pass|proxy_set_header|fastcgi_pass|try_files|return|rewrite|if|set|limit_req|upstream|map|geo|ssl_certificate|access_log|error_log|gzip|types|default_type)\b/,
'keyword',
],
[/\$[a-zA-Z_][\w]*/, 'variable.predefined'],
[/"[^"]*"/, 'string'],
[/'[^']*'/, 'string'],
[/\d+\.\d+\.\d+\.\d+/, 'number'],
[/\d+[kmg]?\b/, 'number'],
[/[{};]/, 'delimiter'],
],
},
})
}
export function initMonaco(): typeof monaco {
if (!initialized) {
setupMonacoEnvironment()
registerNexusThemes(monaco)
registerNginxLanguage()
applyNexusEditorTheme(monaco)
initialized = true
}
return monaco
}
export function buildMonacoEditorOptions(
overrides?: monaco.editor.IStandaloneEditorConstructionOptions,
): monaco.editor.IStandaloneEditorConstructionOptions {
return {
theme: nexusEditorThemeId(),
automaticLayout: true,
lineNumbers: 'on',
folding: true,
bracketPairColorization: { enabled: true, independentColorPoolPerBracketType: true },
matchBrackets: 'always',
fontSize: 14,
fontLigatures: true,
fontFamily:
"ui-monospace, SFMono-Regular, 'Cascadia Code', Menlo, Monaco, Consolas, 'Courier New', monospace",
minimap: { enabled: true },
tabSize: 4,
insertSpaces: true,
wordWrap: 'off',
scrollBeyondLastLine: false,
suggestOnTriggerCharacters: true,
smoothScrolling: true,
renderWhitespace: 'selection',
colorDecorators: true,
occurrencesHighlight: 'singleFile',
selectionHighlight: true,
renderLineHighlight: 'line',
guides: {
bracketPairs: true,
bracketPairsHorizontal: true,
indentation: true,
highlightActiveIndentation: true,
},
...overrides,
}
}
export { monaco, applyNexusEditorTheme, toggleNexusEditorTheme }
+102
View File
@@ -0,0 +1,102 @@
import type * as Monaco from 'monaco-editor'
const TOKEN_RULES: Monaco.editor.ITokenThemeRule[] = [
{ token: '', foreground: 'D4D4D4' },
{ token: 'comment', foreground: '6A9955', fontStyle: 'italic' },
{ token: 'comment.line', foreground: '6A9955', fontStyle: 'italic' },
{ token: 'comment.block', foreground: '6A9955', fontStyle: 'italic' },
{ token: 'keyword', foreground: '569CD6', fontStyle: 'bold' },
{ token: 'keyword.control', foreground: 'C586C0' },
{ token: 'string', foreground: 'CE9178' },
{ token: 'string.escape', foreground: 'D7BA7D' },
{ token: 'number', foreground: 'B5CEA8' },
{ token: 'regexp', foreground: 'D16969' },
{ token: 'type', foreground: '4EC9B0' },
{ token: 'type.identifier', foreground: '4EC9B0' },
{ token: 'class', foreground: '4EC9B0' },
{ token: 'interface', foreground: '4EC9B0' },
{ token: 'function', foreground: 'DCDCAA' },
{ token: 'method', foreground: 'DCDCAA' },
{ token: 'variable', foreground: '9CDCFE' },
{ token: 'variable.predefined', foreground: '4FC1FF' },
{ token: 'constant', foreground: '4FC1FF' },
{ token: 'tag', foreground: '569CD6' },
{ token: 'attribute.name', foreground: '9CDCFE' },
{ token: 'attribute.value', foreground: 'CE9178' },
{ token: 'delimiter', foreground: 'D4D4D4' },
{ token: 'operator', foreground: 'D4D4D4' },
{ token: 'metatag', foreground: '569CD6' },
{ token: 'metatag.content.html', foreground: 'CE9178' },
{ token: 'key', foreground: '9CDCFE' },
{ token: 'string.key', foreground: '9CDCFE' },
{ token: 'string.value', foreground: 'CE9178' },
]
const LIGHT_TOKEN_RULES: Monaco.editor.ITokenThemeRule[] = [
{ token: '', foreground: '24292E' },
{ token: 'comment', foreground: '6A737D', fontStyle: 'italic' },
{ token: 'keyword', foreground: 'D73A49', fontStyle: 'bold' },
{ token: 'string', foreground: '032F62' },
{ token: 'number', foreground: '005CC5' },
{ token: 'type', foreground: '22863A' },
{ token: 'function', foreground: '6F42C1' },
{ token: 'variable', foreground: '24292E' },
{ token: 'tag', foreground: '22863A' },
{ token: 'attribute.name', foreground: '6F42C1' },
{ token: 'attribute.value', foreground: '032F62' },
]
export function registerNexusThemes(monaco: typeof Monaco): void {
monaco.editor.defineTheme('nexus-dark', {
base: 'vs-dark',
inherit: true,
rules: TOKEN_RULES,
colors: {
'editor.background': '#1E1E1E',
'editor.foreground': '#D4D4D4',
'editorLineNumber.foreground': '#858585',
'editorLineNumber.activeForeground': '#C6C6C6',
'editor.selectionBackground': '#264F78',
'editor.inactiveSelectionBackground': '#3A3D41',
'editor.lineHighlightBackground': '#2A2D2E',
'editorCursor.foreground': '#AEAFAD',
'editorWhitespace.foreground': '#3B3B3B',
'editorIndentGuide.background': '#404040',
'editorIndentGuide.activeBackground': '#707070',
'editorBracketMatch.background': '#0064001a',
'editorBracketMatch.border': '#888888',
},
})
monaco.editor.defineTheme('nexus-light', {
base: 'vs',
inherit: true,
rules: LIGHT_TOKEN_RULES,
colors: {
'editor.background': '#FFFFFF',
'editor.foreground': '#24292E',
'editorLineNumber.foreground': '#959DA5',
'editorLineNumber.activeForeground': '#24292E',
'editor.selectionBackground': '#C8E1FF',
'editor.lineHighlightBackground': '#F6F8FA',
'editorCursor.foreground': '#24292E',
'editorIndentGuide.background': '#EFF2F5',
'editorIndentGuide.activeBackground': '#D0D7DE',
},
})
}
/** 编辑器固定深色主题(避免浅色底与 Vuetify 卡片冲突导致看不清) */
export function nexusEditorThemeId(): 'nexus-dark' {
return 'nexus-dark'
}
export function applyNexusEditorTheme(monaco: typeof Monaco): void {
monaco.editor.setTheme(nexusEditorThemeId())
}
export function toggleNexusEditorTheme(monaco: typeof Monaco): 'dark' {
localStorage.setItem('nexus_editor_theme', 'dark')
applyNexusEditorTheme(monaco)
return 'dark'
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Monaco Editor worker setup for Vite (required for syntax highlighting & validation).
* Import once before monaco.editor.create().
*/
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
let configured = false
export function setupMonacoEnvironment(): void {
if (configured) return
configured = true
self.MonacoEnvironment = {
getWorker(_workerId: string, label: string) {
switch (label) {
case 'json':
return new jsonWorker()
case 'css':
case 'scss':
case 'less':
return new cssWorker()
case 'html':
case 'handlebars':
case 'razor':
return new htmlWorker()
case 'typescript':
case 'javascript':
return new tsWorker()
default:
return new editorWorker()
}
},
}
}
File diff suppressed because it is too large Load Diff
+24 -1
View File
@@ -184,6 +184,17 @@
<v-text-field v-model="form.ssh_user" label="SSH 用户" variant="outlined" density="compact" class="mb-2" :rules="[required('用户名')]" />
<v-text-field v-model="form.ssh_port" label="SSH 端口" variant="outlined" density="compact" type="number" class="mb-2" />
<v-text-field v-model="form.password" label="密码" variant="outlined" density="compact" type="password" class="mb-2" />
<v-select
v-model="form.files_elevation"
:items="filesElevationItems"
item-title="title"
item-value="value"
label="文件管理提权"
variant="outlined"
density="compact"
hint="非 root SSH 用户需目标机配置免密 sudo;示例见 docs/deploy/nexus-files-sudoers.example"
persistent-hint
/>
</v-card-text>
<v-card-actions>
<v-spacer />
@@ -271,9 +282,16 @@ const deleting = ref(false)
const deletingServer = ref<ServerApiItem | null>(null)
const editingId = ref<number | null>(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 ──
+22 -2
View File
@@ -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
+196
View File
@@ -0,0 +1,196 @@
/** Monaco 语言 ID 与文件名/路径映射(运维场景优先) */
const EXT_TO_LANG: Record<string, string> = {
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<string, string> = {
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)
}
+61
View File
@@ -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 }
}
+12
View File
@@ -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)
}
+101
View File
@@ -0,0 +1,101 @@
import type { FileEntry } from '@/types/api'
/** List chip label: owner, or owner:group when different. */
export function formatOwnerChip(item: Pick<FileEntry, 'owner' | 'group' | 'type'>): 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<FileEntry, 'type' | 'permissions' | 'owner' | 'group'>,
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 }
+111
View File
@@ -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<string, string>()
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<FileEntry, 'type' | 'size'>): 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<string> {
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()))
}
+57
View File
@@ -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<FileEntry['type'], number> = {
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')
}
+82
View File
@@ -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
}
+33
View File
@@ -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
+59
View File
@@ -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
+213 -2
View File
@@ -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 ──
+27 -2
View File
@@ -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,
+414 -153
View File
@@ -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}
+12 -2
View File
@@ -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(
+131 -1
View File
@@ -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.
@@ -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,
}
+128
View File
@@ -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
+63
View File
@@ -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
+29
View File
@@ -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}")
+55
View File
@@ -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,
)
+122
View File
@@ -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)
+83
View File
@@ -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}"
+29 -29
View File
@@ -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
+75
View File
@@ -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("/")
+109
View File
@@ -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
+62
View File
@@ -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")
+48
View File
@@ -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")
+61
View File
@@ -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=".")
+21
View File
@@ -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"