feat(files): POSIX paths, elevation, recursive chmod, access hints and docs
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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/`。
|
||||
@@ -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 — 解析/策略在 utils;API 编排 |
|
||||
| H5 | 跨层直连 | PASS — API→infra SSH,无 ORM 内 SQL |
|
||||
| H6 | 静默失败 | PASS — chmod/browse 返回明确 error |
|
||||
| H7 | 路径 Windows | PASS — `posix_paths` SSOT;前端 `remotePath.ts` |
|
||||
| H8 | 批量 chmod | PASS — 递归需目录+确认+系统路径黑名单 |
|
||||
|
||||
## Step 4 Closure 表
|
||||
|
||||
| H | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1 | SAFE | sync_v2.py chmod/browse 均 quote |
|
||||
| H2 | SAFE | remote_shell.py:34-61 elevation 分支 |
|
||||
| H3 | SAFE | files_capability.py 仅返回布尔与 message |
|
||||
| H4 | SAFE | 无 Service 层绕过 |
|
||||
| H5 | SAFE | — |
|
||||
| H6 | SAFE | sync_v2 browse 返回 error 字段 |
|
||||
| H7 | SAFE | rg server 无远程 os.path.join |
|
||||
| H8 | SAFE | files_chmod_policy.py FORBIDDEN_* |
|
||||
|
||||
## Step 5 入口表
|
||||
|
||||
| 入口 | 认证 | 说明 |
|
||||
|------|------|------|
|
||||
| POST /api/sync/browse | JWT | ls 只读 |
|
||||
| POST /api/sync/chmod | JWT | mode/owner/recursive |
|
||||
| POST /api/sync/read-file | JWT | cat+sudo 回退 |
|
||||
| GET /api/servers/{id}/files-capability | JWT | SSH 探测 |
|
||||
| PUT /api/servers/{id} | JWT | files_elevation→extra_attrs |
|
||||
|
||||
## Step 6 输入 → Sink
|
||||
|
||||
| 输入 | 校验 | Sink |
|
||||
|------|------|------|
|
||||
| path | coerce_remote_abs_path | SSH 命令 |
|
||||
| mode | `^[0-7]{3,4}$` | chmod |
|
||||
| owner/group | `[a-zA-Z0-9_.-]+` | chown |
|
||||
| recursive | bool + test -d + policy | chmod -R |
|
||||
|
||||
## Step 7 归类
|
||||
|
||||
```
|
||||
remote_shell.py 64行 8H 0 FINDING
|
||||
files_capability.py 63行 8H 0 FINDING
|
||||
files_chmod_policy.py 30行 8H 0 FINDING
|
||||
sync_v2.py (chmod) ~80行 8H 0 FINDING
|
||||
────────────────────────────────────────
|
||||
总计 8H 0 FINDING
|
||||
```
|
||||
|
||||
## Step 8 DoD
|
||||
|
||||
- [x] Phase A 单测 44 passed(posix+file 套件)
|
||||
- [x] ruff server/ 全规则(Windows 开发机)
|
||||
- [x] import server.main
|
||||
- [x] bandit 0 HIGH(MEDIUM 不阻断门控)
|
||||
- [ ] Gate 3 test_api — 需运行中后端(本地 503 = 安装模式/未启动)
|
||||
- [ ] Gate 7 Review — 本地 git HEAD~1 跨多历史提交,生产部署后以服务器仓库为准
|
||||
- [ ] Phase B 生产部署 — 待 push + ssh
|
||||
- [ ] Phase C 浏览器 L1-L4 — 待人工
|
||||
|
||||
## FINDING 列表
|
||||
|
||||
无(0 FINDING)。
|
||||
|
||||
## 批次 POSIX-13 结论
|
||||
|
||||
| 文件 | 结论 |
|
||||
|------|------|
|
||||
| remote_shell.py | ✅ sudo 经 `shlex.quote(command)` 包装 |
|
||||
| files_capability.py | ✅ 固定探测命令,无用户输入拼接 |
|
||||
| files_chmod_policy.py | ✅ 路径白名单集合 |
|
||||
|
||||
## 结论
|
||||
|
||||
☑ 代码审计通过,0 FINDING。
|
||||
☑ 可合并;**部署前**在生产机执行 `pre_deploy_check.sh`(需 Nexus 进程 + `.env` + test 凭据)。
|
||||
@@ -0,0 +1,163 @@
|
||||
# POSIX 路径分批审查记录
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 日期 | 2026-06-01 |
|
||||
| 方法 | 按模块分批 `rg os.path` / `rg posix` / 人工读 SSH 拼接点 |
|
||||
| SSOT | `server/utils/posix_paths.py` |
|
||||
| 总览 | [posix-path-full-audit.md](./2026-06-01-posix-path-full-audit.md) |
|
||||
|
||||
---
|
||||
|
||||
## 批次 1:基础设施层
|
||||
|
||||
| 文件 | 结论 | 证据 |
|
||||
|------|------|------|
|
||||
| `server/utils/posix_paths.py` | ✅ 通过 | 唯一允许 `os.path.realpath`(本机 FS) |
|
||||
| `server/api/remote_path_validation.py` | ✅ 通过 | 委托 posix_paths |
|
||||
| `server/infrastructure/ssh/asyncssh_pool.py` | ✅ 已修 | `posix_dirname(remote_path)` |
|
||||
| `server/infrastructure/ssh/remote_archive.py` | ✅ 通过 | 全程 `posixpath` |
|
||||
| `server/infrastructure/ssh/remote_probe.py` | ✅ 无路径拼接 | — |
|
||||
| `server/infrastructure/ssh/__init__.py` | ✅ 无业务路径 | — |
|
||||
|
||||
**批次 1 结论:无遗留问题。**
|
||||
|
||||
---
|
||||
|
||||
## 批次 2:`server/api/sync_v2.py`(文件/同步 API)
|
||||
|
||||
| 端点/区域 | 结论 |
|
||||
|-----------|------|
|
||||
| browse / file-ops / clipboard | ✅ `normalize_remote_abs_path` |
|
||||
| browse-local / local-file-ops / preview | ✅ `ensure_under_nexus_upload` + `posix_join`;`os.path.*` 仅本机 I/O |
|
||||
| validate-source-path | ✅ `resolve_nexus_host_path` + walk 用 `posix_join` |
|
||||
| diagnose / file-diff / verify | ✅ `normalize_remote_dest` + `remote_join` |
|
||||
| upload / upload-zip | ✅ `remote_join` + `assert_zip_member_safe` |
|
||||
| download / read / write / chmod / compress / decompress | ✅ 远程 path 均规范化 |
|
||||
|
||||
**仍见 `os.path`:** 仅 `isdir`/`exists`/`getsize`/`walk`(Nexus 主机文件系统)— **合法**。
|
||||
|
||||
**批次 2 结论:无遗留问题。**
|
||||
|
||||
---
|
||||
|
||||
## 批次 3:应用服务层
|
||||
|
||||
| 文件 | 结论 | 说明 |
|
||||
|------|------|------|
|
||||
| `sync_engine_v2.py` | ✅ 已修 | `_rsync_push` 内 `normalize_remote_abs_path(to_posix(target_path))` + `user@host:path` |
|
||||
| | ⚠️ 注意 | `os.path.isdir(source_path)` — Nexus **本机**源目录,生产为 Linux |
|
||||
| `sync_service.py` | ✅ 无路径逻辑 | 注释/委托 v2 |
|
||||
| `script_service.py` | ✅ 通过 | 远程日志路径固定 `/var/log/nexus-job/` + `shlex.quote` |
|
||||
| `script_jobs.py` | ✅ 无文件路径 | shell 转义 only |
|
||||
| `server_service.py` | ✅ 无 SSH 路径拼接 | — |
|
||||
|
||||
**批次 3 结论:无遗留问题。**
|
||||
|
||||
---
|
||||
|
||||
## 批次 4:其他 API + 后台任务
|
||||
|
||||
| 文件 | 结论 |
|
||||
|------|------|
|
||||
| `servers.py` | ✅ workerman `posix_dirname`;Agent `install_dir="/opt/nexus-agent"` 常量拼接 |
|
||||
| `agent.py` / `webssh.py` / `terminal.py` | ✅ 无远程文件路径 join |
|
||||
| `install.py` | ➖ 豁免 | `Path("/www/...")` 在**安装目标 Linux 机**执行 |
|
||||
| `settings.py` / `auth_jwt.py` / `main.py` | ➖ 豁免 | `Path(__file__)` 仓库/静态资源 |
|
||||
| `schemas.py` | ⚠️ 建议 | `target_path` 无 Pydantic 校验反斜杠(见下方 P2) |
|
||||
| `background/schedule_runner.py` | ✅ 可接受 | `source_path` 进 engine,远程 dest 在 `_rsync_push` 规范化 |
|
||||
| `background/retry_runner.py` | ✅ 透传 DB 字段,同上 |
|
||||
|
||||
**批次 4 结论:无 P0/P1;1 项 P2 建议。**
|
||||
|
||||
---
|
||||
|
||||
## 批次 5:领域 / 数据库 / Redis
|
||||
|
||||
| 范围 | 结论 |
|
||||
|------|------|
|
||||
| `server/domain/**` | ✅ ORM 字段存字符串,不做 path join |
|
||||
| `server/infrastructure/database/**` | ✅ 无路径拼接 |
|
||||
| `server/infrastructure/redis/**` | ✅ 无文件路径 |
|
||||
|
||||
**批次 5 结论:无问题。**
|
||||
|
||||
---
|
||||
|
||||
## 批次 6:前端
|
||||
|
||||
| 文件 | 结论 |
|
||||
|------|------|
|
||||
| `utils/remotePath.ts` | ✅ `/` 拼接;`\` → `/` |
|
||||
| `FilesPage.vue` / `FileDirectoryTree.vue` / `filePreload.ts` / `useFilesClipboard.ts` | ✅ 均 `joinRemotePath` / `normalizeRemotePath` |
|
||||
| `PushPage.vue` / `SchedulesPage.vue` | ✅ 路径交 API,由后端规范化 |
|
||||
| `TerminalPage.vue` | ➖ | `cd ${dir}` 为用户终端输入,非文件管理器 |
|
||||
|
||||
**批次 6 结论:无遗留问题。**
|
||||
|
||||
---
|
||||
|
||||
## 批次 7:测试 / 工具 / MCP
|
||||
|
||||
| 文件 | 结论 |
|
||||
|------|------|
|
||||
| `tests/test_posix_paths.py` | ✅ |
|
||||
| `tests/test_remote_path_validation.py` | ✅ |
|
||||
| `tests/test_api.py` 等 | ➖ 本地 `.env` 定位 |
|
||||
| `mcp/Nexus_server.py` | ➖ 部署目录 `DEPLOY_DIR` |
|
||||
| `deploy/gate_log.py` | ➖ |
|
||||
| `docs/build_html.py` / `audit_scan.py` | ➖ 仓库内扫描 |
|
||||
|
||||
**批次 7 结论:无问题。**
|
||||
|
||||
---
|
||||
|
||||
## 汇总
|
||||
|
||||
| 批次 | 范围 | 状态 |
|
||||
|------|------|------|
|
||||
| 1 | SSH 基础设施 | ✅ 完成 |
|
||||
| 2 | sync_v2 API | ✅ 完成 |
|
||||
| 3 | application services | ✅ 完成 |
|
||||
| 4 | 其他 API + background | ✅ 完成(P2×1) |
|
||||
| 5 | domain / DB | ✅ 完成 |
|
||||
| 6 | frontend | ✅ 完成 |
|
||||
| 7 | tests / tooling | ✅ 完成 |
|
||||
|
||||
**P0/P1:0**
|
||||
**P2(2026-06-01 已完成):**
|
||||
|
||||
1. ✅ **`schemas.py` + `schema_path_validators.py`**:远程/本机路径字段入口校验与规范化
|
||||
2. ✅ **`sync_engine_v2`**:`source_path` 在 `isdir` 前 `_nexus_source_path()`(`to_posix`)
|
||||
|
||||
---
|
||||
|
||||
## 回归命令
|
||||
|
||||
```bash
|
||||
pytest tests/test_posix_paths.py tests/test_remote_path_validation.py -q
|
||||
rg "os\.path\.(join|dirname|basename)" server/ --glob "*.py"
|
||||
# 期望:仅 posix_paths 注释 + sync_v2/sync_engine 的 isdir/exists/getsize
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DoD(分批审查)
|
||||
|
||||
- [x] 批次 1~7 均已执行并记录
|
||||
- [x] 每批有明确通过/豁免/建议
|
||||
- [x] P0/P1 为 0
|
||||
- [x] P2 已实施(schemas + sync_engine)
|
||||
|
||||
## 批次 9:文件管理归属 + 权限 UI(2026-06-01)
|
||||
|
||||
| 项 | 状态 |
|
||||
|----|------|
|
||||
| `remote_shell.py` | ✅ |
|
||||
| browse owner/group/mode_octal | ✅ |
|
||||
| chmod + chown + sudo 回退 | ✅ |
|
||||
| 前端归属列 + FilePermissionDialog | ✅ |
|
||||
| `files-capability` API | ✅ 批次 10 |
|
||||
| `files_elevation` 服务器字段 | ✅ 批次 10 |
|
||||
| Phase 5:ls 解析单测 + long-iso + read-file 提权 | ✅ 批次 11 |
|
||||
| 二期:递归 chmod + 不可读提示条 | ✅ 批次 12 |
|
||||
@@ -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 API:create_sftp_client → start_sftp_client
|
||||
|
||||
## 摘要
|
||||
|
||||
修复文件上传/下载/写入时 `SSH 连接失败: 'SSHClientConnection' object has no attribute 'create_sftp_client'`。
|
||||
|
||||
## 动机
|
||||
|
||||
asyncssh 2.x 的 `SSHClientConnection` 仅提供 `start_sftp_client()`,不存在 `create_sftp_client()`。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `server/infrastructure/ssh/asyncssh_pool.py` | 新增 `sftp_session()` 上下文管理器 |
|
||||
| `server/api/sync_v2.py` | upload / download / write-file 改用 `sftp_session` |
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
需重启 `nexus` Supervisor 进程。
|
||||
|
||||
## 验证方式
|
||||
|
||||
1. 文件管理 → 编辑并保存 → 应成功(`/api/sync/write-file`)。
|
||||
2. 上传、下载文件不再返回 502 与上述 AttributeError。
|
||||
@@ -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 @@
|
||||
# Changelog:API Schema 层 POSIX 路径校验(P2)
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 日期 | 2026-06-01 |
|
||||
| 类型 | 增强 / 安全 |
|
||||
| 动机 | 分批审查 P2:在 Pydantic 入口拒绝 Windows 反斜杠并统一 Linux 路径形态 |
|
||||
|
||||
## 变更摘要
|
||||
|
||||
- 新增 `server/api/schema_path_validators.py`:远程绝对路径、Nexus 本机绝对路径、相对路径校验
|
||||
- `server/api/schemas.py`:服务器 `target_path`、同步/文件/调度等相关字段增加 `field_validator`
|
||||
- `FileDecompress.dest` 不再默认 `"."`,必须为绝对路径(与解压 API 一致)
|
||||
- `sync_engine_v2`:推送前对 `source_path` 做 `to_posix`
|
||||
- 测试:`tests/test_schema_path_validators.py`
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/api/schema_path_validators.py`(新)
|
||||
- `server/api/schemas.py`
|
||||
- `server/application/services/sync_engine_v2.py`
|
||||
- `tests/test_schema_path_validators.py`(新)
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 需重启后端
|
||||
- 若客户端曾传 `dest: "."` 解压,将收到 422,应改为当前目录绝对路径(如 `/www/wwwroot/...`)
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
pytest tests/test_schema_path_validators.py tests/test_posix_paths.py tests/test_remote_path_validation.py -q
|
||||
ruff check server/api/schemas.py server/api/schema_path_validators.py
|
||||
```
|
||||
@@ -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` | 绝对路径列表(1~50) |
|
||||
| `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 1–5 + 二期递归 chmod/不可读提示(2026-06-01) |
|
||||
| 范围 | `#/files` 远程文件管理(浏览、写入、压缩、chmod 等) |
|
||||
| 关联 | `sync_v2.py` · `asyncssh_pool.py` · `remote_archive.py` · `FilesPage.vue` |
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
当前文件管理已具备浏览、编辑、上传、压缩、右键 chmod 等能力,但生产环境普遍存在:
|
||||
|
||||
- SSH 登录用户为 **deploy/www** 等非 root 账号;
|
||||
- 站点目录与文件属主为 **root** 或 **www**,普通用户 **读得到、写不进**;
|
||||
- 列表展示 `drwxr-xr-x` 等原始 `ls` 权限串,运维难以一眼看出「这是谁家的文件」;
|
||||
- chmod 仅支持八进制输入,无预设、无 chown,且无 sudo 回退,与保存/压缩已做的提权能力不一致。
|
||||
|
||||
用户诉求(原文):
|
||||
|
||||
1. **自动获取 root 权限**(文件操作应能自动提权,而不是每次失败再手工改服务器);
|
||||
2. **列表显示权限**,且 **简化为用户维度**(直接显示 `www` / `root` 等,而非冗长权限串);
|
||||
3. **右键修改权限**,弹出 **结构化对话框**(而非仅一个 chmod 输入框)。
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
| 目标 | 可验收标准 |
|
||||
|------|------------|
|
||||
| G1 自动提权 | 写、删、压缩、chmod/chown 等在配置允许时自动 `sudo -n` 重试,成功率与 root 登录接近 |
|
||||
| G2 归属可读 | 列表默认列显示 **属主**(如 `www`),悬停可看完整 `权限 + 用户:组` |
|
||||
| G3 权限可改 | 右键「修改权限」打开对话框:预设 + 自定义八进制 + 可选改属主/属组 |
|
||||
| G4 安全可审计 | 提权范围可配置、命令白名单、所有 chmod/chown 记审计日志 |
|
||||
| G5 不破坏架构 | API → Service/SSH 基础设施层,不在路由里散落 sudo 逻辑 |
|
||||
|
||||
### 1.3 非目标(本期不做)
|
||||
|
||||
- 不要求 Nexus 在目标机上 **永久改写 sudoers**(类似 Agent 安装的临时注入)作为默认路径——仅文档 + 可选一键脚本;
|
||||
- 不实现交互式 `sudo` 密码输入(违反无人值守运维);
|
||||
- 不把文件管理改成「必须 root SSH 才能用」。
|
||||
|
||||
---
|
||||
|
||||
## 2. 现状摘要
|
||||
|
||||
| 能力 | 现状 | 缺口 |
|
||||
|------|------|------|
|
||||
| 浏览 | `ls -la`,解析 `perms`,**未返回 owner/group** | 前端无法显示 www/root |
|
||||
| 保存/上传 | SFTP → SSH tee/mv,`sudo -n` 部分已做 | 未统一、chmod 未接入 |
|
||||
| 压缩 | `remote_archive.py` 已 sudo 回退 | — |
|
||||
| chmod | 仅 `chmod mode path`,无 sudo | 非 root 常失败 |
|
||||
| 提权模型 | Agent 安装用 `_sudo_wrap` 临时 sudoers | 文件管理未复用同一策略 |
|
||||
|
||||
后端 `browse` 已解析 `parts[2]`(owner)、`parts[3]`(group),但未写入 API 响应(见 `sync_v2.py` 约 270–302 行)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 方案对比
|
||||
|
||||
### 3.1 自动提权
|
||||
|
||||
| 方案 | 说明 | 优点 | 缺点 |
|
||||
|------|------|------|------|
|
||||
| **A. 仅 SSH 用户** | 不 sudo,失败即报错 | 最简单 | 无法满足用户「自动 root」 |
|
||||
| **B. 统一 sudo -n 回退** | 先普通执行,Permission denied 再 `sudo -n bash -c` | 与现网 partial 实现一致;可审计 | 需目标机配置 NOPASSWD 白名单 |
|
||||
| **C. 每次临时写 sudoers** | 复用 `servers._sudo_wrap` | 无预先配置 | 安全风险高、慢、需 root 写 sudoers |
|
||||
| **D. 文件管理强制 root SSH** | 凭据层只用 root | 权限足 | 违背最小权限;很多站点不给 root |
|
||||
|
||||
**选定:B(主) + 安装向导/文档配置 NOPASSWD(辅)**
|
||||
|
||||
- 业务命令统一走 `exec_ssh_command_with_fallback()`(从 `remote_archive` 提升到 `remote_shell.py`)。
|
||||
- 对 **非 root** SSH 用户:失败且 stderr 含 permission denied → 自动 `sudo -n bash -c '...'`。
|
||||
- **root** SSH 用户:不包 sudo。
|
||||
- 提供 **服务器级开关**(见 §5.2),默认 `auto_sudo`。
|
||||
|
||||
不推荐 C 作为默认:与 Agent 安装场景不同,文件管理高频、面过大。
|
||||
|
||||
### 3.2 权限展示
|
||||
|
||||
| 方案 | 列表主列 | 说明 |
|
||||
|------|----------|------|
|
||||
| **S1** | `drwxr-xr-x` | 现状 |
|
||||
| **S2** | `www` | 仅属主 |
|
||||
| **S3** | `www · 755` | 属主 + 八进制简写(从 perms 解析) |
|
||||
| **S4** | Chip `www` + tooltip 全文 | **推荐**:主信息属主,详情悬停 |
|
||||
|
||||
**选定:S4**
|
||||
|
||||
- 列名:**归属**(`owner` 字段,展示 `owner` 或 `owner:group` 若不同)。
|
||||
- 辅助解析 `mode_octal`:`755` / `644` 供排序或二级展示(可选列,默认隐藏或放在 tooltip)。
|
||||
- `symlink` 仍显示 `→ target`,归属列显示 `-` 或链接目标属主。
|
||||
|
||||
### 3.3 修改权限对话框
|
||||
|
||||
| 方案 | 内容 |
|
||||
|------|------|
|
||||
| **P1** | 仅八进制 chmod(现状) |
|
||||
| **P2** | 预设按钮 + 八进制 + chown(**推荐**) |
|
||||
| **P3** | 完整 Unix 权限矩阵(rwx 勾选) |
|
||||
|
||||
**选定:P2** — 满足运维习惯,实现成本低于 P3。
|
||||
|
||||
---
|
||||
|
||||
## 4. 选定方案详设
|
||||
|
||||
### 4.1 统一远程 Shell 执行层
|
||||
|
||||
新增模块(建议路径):
|
||||
|
||||
```
|
||||
server/infrastructure/ssh/remote_shell.py
|
||||
```
|
||||
|
||||
职责:
|
||||
|
||||
```python
|
||||
# 概念接口(实现时写完整类型)
|
||||
|
||||
class FilesElevation(str, Enum):
|
||||
OFF = "off" # 从不 sudo
|
||||
AUTO = "auto_sudo" # 默认:失败且权限错误时 sudo -n
|
||||
ALWAYS = "always_sudo" # 所有命令直接 sudo -n(可选,仅排障)
|
||||
|
||||
async def exec_remote_command(
|
||||
server: Server,
|
||||
command: str,
|
||||
*,
|
||||
timeout: int = 30,
|
||||
elevation: FilesElevation | None = None, # None → 读服务器/全局配置
|
||||
) -> CommandResult: ...
|
||||
|
||||
def wrap_sudo(command: str) -> str:
|
||||
return f"sudo -n bash -c {shlex.quote(command)}"
|
||||
```
|
||||
|
||||
**接入范围(须全部走统一层)**
|
||||
|
||||
| 操作 | 当前入口 | 改造 |
|
||||
|------|----------|------|
|
||||
| browse | `exec_ssh_command(ls)` | 保持直接执行(只读,一般不需 sudo) |
|
||||
| write-file / upload | `remote_write_bytes` | 已有多级回退,并入 elevation 策略 |
|
||||
| delete / mkdir / rename | `file-ops` | 加 fallback |
|
||||
| compress / decompress | `remote_archive` | 已有 fallback,迁到 remote_shell |
|
||||
| chmod / chown | `chmod` 端点 | 扩展 payload + fallback |
|
||||
| read-file | `cat` | 一般只读;可选对不可读文件 sudo cat(二期) |
|
||||
|
||||
**提权判定**
|
||||
|
||||
```text
|
||||
exit_code != 0
|
||||
且 (
|
||||
"permission denied" in stderr.lower()
|
||||
或 "operation not permitted" in stderr.lower()
|
||||
)
|
||||
→ 若 elevation == AUTO,重试 wrap_sudo(command)
|
||||
```
|
||||
|
||||
### 4.2 目标机 sudo 配置(运维一次性)
|
||||
|
||||
在 `docs/deploy/` 增加模板 **`nexus-files.sudoers`**(示例):
|
||||
|
||||
```sudoers
|
||||
# /etc/sudoers.d/nexus-files — 由运维审阅后安装
|
||||
deploy ALL=(ALL) NOPASSWD: /bin/chmod, /bin/chown, /usr/bin/chown,
|
||||
/bin/mv, /bin/cp, /bin/mkdir, /bin/rm, /bin/tar, /usr/bin/zip,
|
||||
/usr/bin/unzip, /usr/bin/tee, /usr/bin/cat
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- Nexus **不会**默认自动创建该文件(避免未授权扩权);
|
||||
- 安装向导或「服务器详情 → 文件管理」页提供 **检测** API:`GET /api/servers/{id}/files-capability`
|
||||
- 检测项:`ssh_user`、`can_sudo_nopasswd`、`whitelist_ok`;
|
||||
- 检测失败时,前端 Toast 链到文档锚点。
|
||||
|
||||
若 SSH 用户已是 **root**:`can_sudo_nopasswd=true`,无需 sudoers。
|
||||
|
||||
### 4.3 浏览 API 扩展
|
||||
|
||||
**`POST /api/sync/browse` 响应项扩展**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "index.php",
|
||||
"type": "file",
|
||||
"size": 1234,
|
||||
"modified": "Jun 1 12:00",
|
||||
"permissions": "rw-r--r--",
|
||||
"mode_octal": "644",
|
||||
"owner": "www",
|
||||
"group": "www",
|
||||
"link_target": null
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 来源 | 说明 |
|
||||
|------|------|------|
|
||||
| `permissions` | `ls` 第 1 列 | 保留,供 tooltip / 高级用户 |
|
||||
| `mode_octal` | 从 perms 解析 | `rwxr-xr-x` → `755`(含 setuid 等用 4 位,少见则原样) |
|
||||
| `owner` | `ls` 第 3 列 | **列表主展示** |
|
||||
| `group` | `ls` 第 4 列 | 与 owner 相同时 UI 只显示 owner |
|
||||
|
||||
解析工具:`frontend/src/utils/fileMode.ts` + 后端 `server/utils/unix_ls.py`(避免前后端不一致,**以后端为准**)。
|
||||
|
||||
**browse 可选增强(二期)**:`ls -la --time-style=long-iso` 统一时间格式。
|
||||
|
||||
### 4.4 前端列表 UI
|
||||
|
||||
**列调整(`FilesPage.vue`)**
|
||||
|
||||
| 列 | 默认 | 展示 |
|
||||
|----|------|------|
|
||||
| 名称 | 显示 | 不变 |
|
||||
| 归属 | **显示**(新) | `v-chip`:`www` / `root`;`www:nogroup` 简化为 `www` |
|
||||
| 大小 | 显示 | 不变 |
|
||||
| 修改时间 | 显示 | 不变 |
|
||||
| 权限 | **隐藏**(可选展开) | tooltip:`drwxr-xr-x · 755` |
|
||||
|
||||
**样式约定**
|
||||
|
||||
- `owner === 'root'` → chip 颜色 `warning`(提醒系统文件)
|
||||
- 当前 SSH 用户无写权限且非 root 属主 → 行尾可选 icon `mdi-shield-lock`(仅提示,不阻断只读)
|
||||
|
||||
### 4.5 右键「修改权限」对话框
|
||||
|
||||
**入口**:已有右键 `chmod` 菜单项,改为打开新组件 `FilePermissionDialog.vue`。
|
||||
|
||||
**对话框结构**
|
||||
|
||||
```
|
||||
┌─ 修改权限 ─────────────────────────────┐
|
||||
│ 文件:index.php │
|
||||
│ 当前:www · 644 (rw-r--r--) │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 常用预设(按 type 过滤) │
|
||||
│ [ 644 文件 ] [ 755 目录 ] [ 600 私密 ] │
|
||||
│ [ 775 协作目录 ] [ 自定义… ] │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 八进制权限 [ 644 ] (验证 3–4 位) │
|
||||
│ 属主 [ www ] (可选) │
|
||||
│ 属组 [ www ] (可选) │
|
||||
│ □ 同时修改子项(仅目录,二期) │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 提权:将使用 sudo 执行(SSH 用户 deploy) │
|
||||
│ [ 检测提权能力 ] 链接文档 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ [ 取消 ] [ 应用 ] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**预设表(前端常量,后端可校验)**
|
||||
|
||||
| 标签 | mode | 适用 |
|
||||
|------|------|------|
|
||||
| 文件默认 | 644 | file |
|
||||
| 目录默认 | 755 | directory |
|
||||
| 仅本人 | 600 | file |
|
||||
| 协作目录 | 775 | directory |
|
||||
| 可执行 | 755 | file(脚本) |
|
||||
|
||||
**API:`POST /api/sync/chmod` 扩展**
|
||||
|
||||
```python
|
||||
class FileChmod(BaseModel):
|
||||
server_id: int
|
||||
path: str = Field(..., min_length=1, max_length=4096)
|
||||
mode: str = Field(..., pattern=r"^[0-7]{3,4}$")
|
||||
owner: str | None = Field(None, max_length=64) # 可选 chown
|
||||
group: str | None = Field(None, max_length=64)
|
||||
recursive: bool = False # 二期:chmod -R
|
||||
```
|
||||
|
||||
**远程命令(经 `exec_remote_command` + AUTO elevation)**
|
||||
|
||||
```bash
|
||||
# 单文件
|
||||
chmod 644 '/path/file' && chown www:www '/path/file'
|
||||
|
||||
# 仅 chmod
|
||||
chmod 644 '/path/file'
|
||||
```
|
||||
|
||||
顺序:先 `chmod` 再 `chown`(或反之,实现时固定一种并写测试)。
|
||||
|
||||
**响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"path": "/www/.../index.php",
|
||||
"mode": "644",
|
||||
"owner": "www",
|
||||
"group": "www",
|
||||
"elevated": true
|
||||
}
|
||||
```
|
||||
|
||||
前端成功后 `browseCurrent()` 刷新列表。
|
||||
|
||||
### 4.6 服务器级配置(三写之一扩展)
|
||||
|
||||
在 **服务器编辑** 或 **settings** 增加(MySQL `servers` 表或 `settings` JSON):
|
||||
|
||||
| 字段 | 类型 | 默认 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `files_elevation` | enum | `auto_sudo` | `off` / `auto_sudo` / `always_sudo` |
|
||||
|
||||
不在安装向导强制,避免与 Agent sudoers 混淆;在服务器表单「高级 → 文件管理」折叠区配置。
|
||||
|
||||
---
|
||||
|
||||
## 5. 安全与性能
|
||||
|
||||
### 5.1 安全
|
||||
|
||||
- **最小权限**:`sudo -n` 仅允许白名单命令,禁止 `ALL=(ALL) NOPASSWD: ALL`;
|
||||
- **禁止**在 API 中拼接未校验的 owner/group(仅 `[a-zA-Z0-9_-.]+`);
|
||||
- **路径**继续 `normalize_remote_abs_path`,禁止 `..`;
|
||||
- **审计**:`file_chmod` / `file_chown` 合并为 `file_permission_change`,记录 mode、owner、group、elevated;
|
||||
- **失败信息**不泄露 sudoers 路径内容。
|
||||
|
||||
### 5.2 性能
|
||||
|
||||
- browse 仍单次 `ls -la`,不增加 SSH 往返;
|
||||
- sudo 回退最多 **2 倍** 命令耗时(仅失败路径),可接受;
|
||||
- 批量 chmod(二期 `-R`)需限制路径深度或要求确认框。
|
||||
|
||||
---
|
||||
|
||||
## 6. 实现步骤(供技术文档拆分)
|
||||
|
||||
| 阶段 | 内容 | 预估 |
|
||||
|------|------|------|
|
||||
| **Phase 1** | `remote_shell.py` + 迁移 write/compress/chmod/file-ops | 1d |
|
||||
| **Phase 2** | browse 返回 owner/group/mode_octal;前端归属列 | 0.5d |
|
||||
| **Phase 3** | `FilePermissionDialog` + chmod API 扩展 chown | 1d |
|
||||
| **Phase 4** | `files-capability` 检测 + 文档 + 设置项 `files_elevation` | 0.5d |
|
||||
| **Phase 5** | 测试 + changelog + 审计 | 0.5d |
|
||||
|
||||
依赖顺序:Phase 1 → 2/3 可并行 → 4 → 5。
|
||||
|
||||
---
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
### L1 提权
|
||||
|
||||
- [ ] SSH 用户 `deploy` + 已配置 `nexus-files.sudoers`:编辑、保存、压缩、chmod 均成功,`elevated: true` 出现在审计(若执行了 sudo)。
|
||||
- [ ] SSH 用户 `root`:不调用 sudo,功能正常。
|
||||
- [ ] 无 sudo 且非 root:操作失败,提示含「配置免密 sudo」链接,不返回误导性「SSH 连接失败」。
|
||||
|
||||
### L2 展示
|
||||
|
||||
- [ ] 列表「归属」列显示 `www` / `root`,悬停可见完整权限串。
|
||||
- [ ] 不再默认展示 `drwxr-xr-x` 宽列(可列设置打开)。
|
||||
|
||||
### L3 修改权限
|
||||
|
||||
- [ ] 右键单文件/目录 → 对话框预设 644/755 一键填充。
|
||||
- [ ] 修改属主为 `www` 后,`ls` 刷新显示更新。
|
||||
- [ ] 非法 mode/owner 前端校验 + 后端 400。
|
||||
|
||||
### L4 回归
|
||||
|
||||
- [ ] 文件树、编辑器、剪贴板粘贴、下载不受回归影响。
|
||||
|
||||
---
|
||||
|
||||
## 8. 风险与对策
|
||||
|
||||
| 风险 | 对策 |
|
||||
|------|------|
|
||||
| 用户以为「自动 root」= Nexus 破解权限 | UI 文案写清:**在服务器已授权 sudo 前提下自动提权** |
|
||||
| sudo 白名单遗漏命令 | capability 检测 + 失败时记录完整 stderr |
|
||||
| `ls` 解析在不同 OS 上列错位 | 优先 GNU coreutils;异常行跳过并记日志 |
|
||||
| chown 误操作 | 对话框展示当前值;危险路径(/etc、/usr)二次确认(二期) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 相关文档(实现后补齐)
|
||||
|
||||
- 技术实施:`docs/design/plans/2026-06-01-files-root-ownership-ui.md`(待写)
|
||||
- Changelog:`docs/changelog/2026-06-01-files-root-ownership-ui.md`(待写)
|
||||
- 运维:`docs/deploy/nexus-files-sudoers.example`
|
||||
|
||||
---
|
||||
|
||||
## 10. 决策记录
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|------|------|------|
|
||||
| 自动 root 实现 | `sudo -n` 回退,非临时写 sudoers | 安全、可审计、与现有 partial 实现一致 |
|
||||
| 列表权限 | 主显 **属主**,权限进 tooltip | 符合用户「www/root」认知 |
|
||||
| chmod UI | 预设 + 八进制 + 可选 chown | 平衡能力与复杂度 |
|
||||
| browse 是否 sudo | 否(默认) | 列目录极少需要 root;减少噪音 |
|
||||
|
||||
---
|
||||
|
||||
**请评审确认后进入 Phase 1 实现。** 若需「始终 sudo」或「强制 root SSH」,可在服务器级 `files_elevation` 中开启 `always_sudo`。
|
||||
@@ -0,0 +1,263 @@
|
||||
# Nexus 主待办长计划(文件管理 · POSIX · 部署 · 审计)
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 创建日期 | 2026-06-01 |
|
||||
| 状态 | **活跃** — 新会话接续本文任务 ID |
|
||||
| 范围 | 2026-06-01 会话内已实现代码的验证/部署/审计 + 全站遗留验收 |
|
||||
| 关联 SSOT | [development-acceptance-standard.md](./development-acceptance-standard.md) · [AI-HANDOFF-2026-05-23.md](./AI-HANDOFF-2026-05-23.md) |
|
||||
|
||||
---
|
||||
|
||||
## 如何使用
|
||||
|
||||
1. 按 **阶段(Phase)** 顺序执行;阶段内任务可并行标 `∥`。
|
||||
2. 每完成一项:将 `[ ]` 改为 `[x]`,并在对应 changelog/audit 留痕。
|
||||
3. 宣称「可部署/可上线」前:至少完成 **Phase D(门控)** + 相关 **Phase C/E** 验证任务。
|
||||
4. 任务 ID 格式:`{域}-{序号}`,便于 PR/commit 引用(例:`DEP-03`)。
|
||||
|
||||
**图例**
|
||||
|
||||
| 标记 | 含义 |
|
||||
|------|------|
|
||||
| `[x]` | 代码/文档已完成(2026-06-01 会话) |
|
||||
| `[ ]` | 待做 |
|
||||
| `🔍` | 需人工/生产环境 |
|
||||
| `⚠` | 阻塞部署或上线 |
|
||||
|
||||
---
|
||||
|
||||
## 总览路线图
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph done [已实现代码]
|
||||
A1[POSIX 批次 1-7]
|
||||
A2[文件管理 P1-P5]
|
||||
A3[二期递归+提示]
|
||||
end
|
||||
subgraph next [待执行]
|
||||
B[Phase A 单测与门控]
|
||||
C[Phase B 部署]
|
||||
D[Phase C 浏览器与 L1-L4]
|
||||
E[Phase D 正式审计 8 步]
|
||||
F[Phase E POSIX 批次 13+]
|
||||
G[Phase F 全站回归 T1-T5]
|
||||
H[Phase G 增强与风险]
|
||||
end
|
||||
done --> B --> C --> D
|
||||
D --> E
|
||||
E --> F
|
||||
F --> G
|
||||
G --> H
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase A — 本地验证与程序门控(未部署前必做)
|
||||
|
||||
| ID | 任务 | 状态 | 验收标准 |
|
||||
|----|------|------|----------|
|
||||
| A-01 | 跑 POSIX 单测套件 | [ ] | `pytest tests/test_posix_paths.py tests/test_remote_path_validation.py tests/test_schema_path_validators.py -q` 全绿 |
|
||||
| A-02 | 跑文件管理单测套件 | [ ] | `pytest tests/test_unix_ls.py tests/test_file_permissions.py tests/test_files_elevation.py -q` 全绿 |
|
||||
| A-03 | 跑全量 API 测试(有 `.env`) | [ ] | `pytest tests/test_api.py -q` 通过;401 时核对 `NEXUS_TEST_ADMIN_PASSWORD` |
|
||||
| A-04 | ruff `server/` 零错误 | [ ] | `ruff check server/` |
|
||||
| A-05 | import 冒烟 | [ ] | `python -c "import server.main"` |
|
||||
| A-06 | bandit 无 HIGH/MEDIUM | [ ] | `bandit -r server/ -ll` |
|
||||
| A-07 | 7 门预检脚本 | [ ] | `bash deploy/pre_deploy_check.sh` 全过;`deploy/gate_log.jsonl` 有记录 |
|
||||
| A-08 | 合并 changelog(本次主题) | [x] | 见 `docs/changelog/2026-06-01-*.md` 系列(posix / files / phase5 / phase2 / capability) |
|
||||
| A-09 | WSL 集成 smoke(若环境可用) | [ ] | `wsl_integration_smoke.sh` 或项目等价脚本 |
|
||||
|
||||
**Phase A 完成定义**:A-01~A-07 全 `[x]`(A-09 可选但推荐)。
|
||||
|
||||
---
|
||||
|
||||
## Phase B — 生产部署(后端 + 前端)
|
||||
|
||||
| ID | 任务 | 状态 | 验收标准 |
|
||||
|----|------|------|----------|
|
||||
| DEP-01 | `git push origin main` | [ ] | 远程与本地一致 |
|
||||
| DEP-02 | 生产 `git fetch && reset --hard origin/main` | [ ] | `ssh nexus` 部署目录无冲突 |
|
||||
| DEP-03 | `supervisorctl restart nexus` | [ ] | 进程 running |
|
||||
| DEP-04 | 健康检查 | [ ] | `curl -s http://127.0.0.1:8600/health` → `ok` |
|
||||
| DEP-05 | 前端 `deploy/deploy-frontend.sh` | [ ] | `web/app/index.html` + `assets/` 已更新 |
|
||||
| DEP-06 | 浏览器 `/app/` HTTP 200 | [ ] | 非旧 Tailwind 静态页 |
|
||||
| DEP-07 | 记录部署日期到本文 | [ ] | 在 §部署记录 填一行 |
|
||||
|
||||
**依赖**:Phase A 的 A-07 通过。
|
||||
**路径 SSOT**:`/www/wwwroot/api.synaglobal.vip/` · SSH 别名 `nexus` · 端口 `8600`。
|
||||
|
||||
### 部署记录
|
||||
|
||||
| 日期 | 操作人 | DEP-01~06 | 备注 |
|
||||
|------|--------|------------|------|
|
||||
| | | | |
|
||||
|
||||
---
|
||||
|
||||
## Phase C — 文件管理功能验收(设计文档 L1~L4)
|
||||
|
||||
> 设计:[2026-06-01-files-root-ownership-ui-design.md](../design/specs/2026-06-01-files-root-ownership-ui-design.md) §7
|
||||
> 代码状态:Phase 1~5 + 二期 **已实现**;本节全部为 **🔍 生产/浏览器验证**。
|
||||
|
||||
### C1 — L1 提权
|
||||
|
||||
| ID | 任务 | 状态 | 步骤 / 预期 |
|
||||
|----|------|------|-------------|
|
||||
| FM-L1-01 | deploy + `nexus-files.sudoers` | [ ] 🔍 | 目标机安装 `docs/deploy/nexus-files-sudoers.example`;`sudo -n true` 成功 |
|
||||
| FM-L1-02 | 写/保存/压缩/chmod 成功 | [ ] 🔍 | 审计含 `elevated: true`(若走了 sudo) |
|
||||
| FM-L1-03 | SSH 用户 root | [ ] 🔍 | 不触发多余 sudo;功能正常 |
|
||||
| FM-L1-04 | 无 sudo 非 root | [ ] 🔍 | 失败提示含免密 sudo / 文档路径,非「SSH 连接失败」 |
|
||||
| FM-L1-05 | 顶栏不可读提示 | [ ] 🔍 | 选 deploy 服务器见提示条;读 root 属主文件后提示更新 |
|
||||
| FM-L1-06 | `files-capability` API | [ ] 🔍 | `GET /api/servers/{id}/files-capability` 与 UI 检测一致 |
|
||||
| FM-L1-07 | 服务器 `files_elevation` 三档 | [ ] 🔍 | off / auto_sudo / always_sudo 行为符合预期 |
|
||||
|
||||
### C2 — L2 展示
|
||||
|
||||
| ID | 任务 | 状态 | 预期 |
|
||||
|----|------|------|------|
|
||||
| FM-L2-01 | 归属列 www/root | [ ] 🔍 | chip 正确 |
|
||||
| FM-L2-02 | tooltip 完整权限 | [ ] 🔍 | 悬停见 `drwx… · 755 · www` |
|
||||
| FM-L2-03 | 无默认宽「权限」列 | [ ] 🔍 | 主表不挤占 |
|
||||
| FM-L2-04 | 不可读行锁图标 | [ ] 🔍 | `mdi-shield-lock` 与 ls 一致 |
|
||||
| FM-L2-05 | 修改时间为 long-iso | [ ] 🔍 | 形如 `2026-06-01 12:00:00`,排序合理 |
|
||||
|
||||
### C3 — L3 修改权限
|
||||
|
||||
| ID | 任务 | 状态 | 预期 |
|
||||
|----|------|------|------|
|
||||
| FM-L3-01 | 预设 644/755 一键 | [ ] 🔍 | |
|
||||
| FM-L3-02 | chown www 后刷新 | [ ] 🔍 | 列表归属更新 |
|
||||
| FM-L3-03 | 非法 mode/owner | [ ] 🔍 | 前端拦截 + API 400 |
|
||||
| FM-L3-04 | 目录递归 chmod | [ ] 🔍 | 勾选 → confirm → 子项权限变化;`/` 等被拒绝 |
|
||||
| FM-L3-05 | 权限对话框检测按钮 | [ ] 🔍 | 与顶栏 capability 一致 |
|
||||
|
||||
### C4 — L4 回归
|
||||
|
||||
| ID | 任务 | 状态 | 预期 |
|
||||
|----|------|------|------|
|
||||
| FM-L4-01 | 文件树 / 编辑器 | [ ] 🔍 | 打开保存无回归 |
|
||||
| FM-L4-02 | 剪贴板复制/粘贴/移动 | [ ] 🔍 | |
|
||||
| FM-L4-03 | 上传 / 下载 | [ ] 🔍 | |
|
||||
| FM-L4-04 | 压缩 / 解压 | [ ] 🔍 | |
|
||||
| FM-L4-05 | Windows 开发机 + Linux 目标路径 | [ ] 🔍 | 路径无 `\` 进入远程命令 |
|
||||
|
||||
**Phase C 完成定义**:C1~C4 全部 `[x]`。
|
||||
|
||||
---
|
||||
|
||||
## Phase D — 正式审计(8 步 · 部署门控)
|
||||
|
||||
> 模板:`docs/audit/TEMPLATE.md` · 规则:`.cursor/rules/audit-line-review.mdc`
|
||||
> 现状:POSIX 分批审查 ✅;files-phase5 简表 ✅;**下列为改动的完整 8 步审计(待补)**。
|
||||
|
||||
| ID | 审计主题 | 状态 | 输出文件 |
|
||||
|----|----------|------|----------|
|
||||
| AUD-01 | 文件管理 Phase 1~3(remote_shell / browse / chmod UI) | [ ] | `docs/audit/2026-06-01-files-phase1-3.md` |
|
||||
| AUD-02 | files-capability + files_elevation(批次 10) | [ ] | `docs/audit/2026-06-01-files-capability.md` |
|
||||
| AUD-03 | Phase 5 加固(long-iso / read-file / remote_write) | [ ] | 扩写 `docs/audit/2026-06-01-files-phase5.md` 至完整 8 步 |
|
||||
| AUD-04 | 二期递归 chmod + 不可读提示(批次 12) | [ ] | `docs/audit/2026-06-01-files-phase2.md` |
|
||||
| AUD-05 | Schema 路径校验 P2(schema_path_validators) | [ ] | `docs/audit/2026-06-01-schema-posix-path.md` |
|
||||
| AUD-06 | 部署前 Review 门:审计含**实际改动文件清单** | [ ] | 与 DEP 同次提交 |
|
||||
|
||||
**每份审计必须含**:Step3 规则扫描 · Closure 全表 · 入口表 · 输入→Sink · DoD · 0 FINDING 或已修 FINDING(`文件:行号`)。
|
||||
|
||||
---
|
||||
|
||||
## Phase E — POSIX 路径审计(延续)
|
||||
|
||||
> 总览:[posix-path-full-audit.md](../audit/2026-06-01-posix-path-full-audit.md)
|
||||
> 分批记录:[posix-path-batch-review.md](../audit/2026-06-01-posix-path-batch-review.md)
|
||||
|
||||
| ID | 任务 | 状态 | 说明 |
|
||||
|----|------|------|------|
|
||||
| POSIX-13 | 批次 13:`remote_shell` + `files_capability` + `files_chmod_policy` | [ ] | 命令注入 / elevation 误用 |
|
||||
| POSIX-14 | 批次 14:`unix_ls.py` 解析边界(空格文件名、异常行) | [ ] | 对照 `test_file_permissions.py` 补案例 |
|
||||
| POSIX-15 | 批次 15:前端 `remotePath.ts` 全页面扫尾 | [ ] | `rg` 无裸 `+` 路径拼接 |
|
||||
| POSIX-16 | 批次 16:`sync_engine_v2` rsync 目标再回归 | [ ] | `user@host:path` 形态 |
|
||||
| POSIX-17 | 生产回归:Windows 主机开发 + Linux SSH 目标 | [ ] 🔍 | full-audit DoD 最后一项 |
|
||||
| POSIX-18 | 规则固化:`.cursor/rules/nexus-posix-paths.mdc` 与 CI 可选检查 | [ ] | 如在 CI 加 `rg` 门禁 |
|
||||
|
||||
---
|
||||
|
||||
## Phase F — 全站遗留测试(CLAUDE / HANDOFF)
|
||||
|
||||
| ID | 任务 | 状态 | 说明 |
|
||||
|----|------|------|------|
|
||||
| T-01 | 心跳 Redis 写入验证 | [ ] 🔍 | Agent 60s → `heartbeat:{id}` |
|
||||
| T-02 | WebSocket 连接与告警推送 | [ ] 🔍 | 浏览器 + Telegram |
|
||||
| T-03 | 三层守护(杀 Python → Supervisor 重启) | [ ] 🔍 | |
|
||||
| T-04 | Telegram 推送 | [ ] 🔍 | |
|
||||
| T-05 | install.html 五步安装向导 | [ ] 🔍 | |
|
||||
| T-06 | L5 生产验收清单 | [ ] | `production-verification-checklist.md` |
|
||||
| T-07 | WebSSH 断线重连(HANDOFF P1) | [ ] | 独立设计与实现 |
|
||||
| T-08 | 服务器 CSV 批量导入(HANDOFF P1) | [ ] | |
|
||||
|
||||
---
|
||||
|
||||
## Phase G — 增强与已知风险(未实现 / 低优先级)
|
||||
|
||||
| ID | 任务 | 优先级 | 说明 |
|
||||
|----|------|--------|------|
|
||||
| ENH-01 | 危险路径 chmod 二次确认(`/etc`、`/usr` 下单文件) | P2 | 设计 §8 风险表 |
|
||||
| ENH-02 | `find -maxdepth` 替代大范围 `chmod -R`(可选) | P3 | 降低误操作面 |
|
||||
| ENH-03 | 预加载失败聚合提示(非静默) | P3 | `filePreload.ts` 现静默 catch |
|
||||
| ENH-04 | `NEXUS_FILES_ELEVATION_DEFAULT` 全局 env 默认 | P3 | plans 二期提及 |
|
||||
| ENH-05 | 设计文档 §7 勾选同步到本文 | P1 | FM-L* 完成后回写 spec |
|
||||
| ENH-06 | 更新 `AI-HANDOFF` 指针到本文 | P1 | 避免多 SSOT 分叉 |
|
||||
| ENH-07 | `CLAUDE.md` 实现表:文件管理条目标为已完成 | P2 | 与代码一致 |
|
||||
| ENH-08 | 可选列「权限」默认隐藏 + 列设置 | P3 | L2-03 细化 |
|
||||
|
||||
---
|
||||
|
||||
## 已完成代码清单(勿重复实现)
|
||||
|
||||
| 主题 | 批次/阶段 | 关键路径 |
|
||||
|------|-----------|----------|
|
||||
| POSIX SSOT + sync_v2 规范化 | 批次 1~7 | `server/utils/posix_paths.py` |
|
||||
| Schema 路径 P2 | P2 | `schema_path_validators.py` |
|
||||
| remote_shell + sudo 回退 | Phase 1 | `remote_shell.py` |
|
||||
| browse owner/group/mode_octal | Phase 2 | `sync_v2.py` |
|
||||
| FilePermissionDialog + chown | Phase 3 | 前端组件 |
|
||||
| files-capability + files_elevation | 批次 10 / Phase 4 | `servers.py` |
|
||||
| long-iso / read-file 提权 / write elevation | 批次 11 / Phase 5 | `unix_ls.py` |
|
||||
| 递归 chmod + 不可读顶栏/锁图标 | 批次 12 / 二期 | `files_chmod_policy.py` |
|
||||
|
||||
---
|
||||
|
||||
## 进度总表(给用户一眼看跳步)
|
||||
|
||||
```
|
||||
代码实现 [x] POSIX 1-7 + 文件管理 P1-P5 + 二期
|
||||
本地单测门控 [ ] Phase A
|
||||
正式审计 8 步 [ ] Phase D
|
||||
生产部署 [ ] Phase B
|
||||
浏览器 L1-L4 [ ] Phase C
|
||||
POSIX 13-18 [ ] Phase E
|
||||
全站 T1-T8 [ ] Phase F
|
||||
增强 ENH [ ] Phase G(按需)
|
||||
```
|
||||
|
||||
**当前建议下一项**:`A-01` → `A-07` → `DEP-01`~`DEP-06` → `FM-L1-01` 起。
|
||||
|
||||
---
|
||||
|
||||
## 文档索引
|
||||
|
||||
| 类型 | 路径 |
|
||||
|------|------|
|
||||
| 文件管理设计 | `docs/design/specs/2026-06-01-files-root-ownership-ui-design.md` |
|
||||
| 文件管理计划 | `docs/design/plans/2026-06-01-files-root-ownership-ui.md` |
|
||||
| 二期计划 | `docs/design/plans/2026-06-01-files-phase2-recursive-read-hint.md` |
|
||||
| POSIX 全审计 | `docs/audit/2026-06-01-posix-path-full-audit.md` |
|
||||
| POSIX 分批 | `docs/audit/2026-06-01-posix-path-batch-review.md` |
|
||||
| Changelog 目录 | `docs/changelog/2026-06-01-*.md` |
|
||||
| sudoers 示例 | `docs/deploy/nexus-files-sudoers.example` |
|
||||
|
||||
---
|
||||
|
||||
## 变更记录
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 2026-06-01 | 初版:合并 POSIX 分批、文件管理 Phase/二期、部署门控、审计、全站 T 项 |
|
||||
@@ -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+scp,prune 后 restart nexus |
|
||||
| 后端 | `sync_v2.py`、`schemas.py`、`remote_path_validation.py` 曾 SCP 到服务器 |
|
||||
| 健康 | `/health` → `ok` |
|
||||
| 验证 | 用户需 Ctrl+Shift+R 强刷 `#/files` |
|
||||
|
||||
**下一任部署优先**:`git push` + 服务器 `git pull` + `supervisorctl restart nexus`,避免仅 SCP 漂移。
|
||||
|
||||
---
|
||||
|
||||
## 2. 本会话已完成功能
|
||||
|
||||
| 模块 | 说明 |
|
||||
|------|------|
|
||||
| browse 契约 | `items` + `fileBrowse.ts` |
|
||||
| 页面迭代 | 下载、预览、路由 query、拖拽上传、symlink |
|
||||
| 300KB 预加载 | `filePreload.ts` |
|
||||
| 编辑器 | `FileEditorWorkbench.vue` 分屏+多标签+状态栏(非默认全屏) |
|
||||
| v2 补齐 | 目录树、新建文件、压缩/解压、排序、扩展名筛选、快捷键 |
|
||||
| 剪贴板 | `POST /sync/file-clipboard` + Ctrl+C/X/V |
|
||||
|
||||
---
|
||||
|
||||
## 3. 待入库(git status 快照 2026-06-01)
|
||||
|
||||
**修改**:`FilesPage.vue`、`sync_v2.py`、`schemas.py`、`MonacoEditor.vue`、`api/index.ts`、`types/api.ts` 等
|
||||
|
||||
**新增**:`FileEditorWorkbench.vue`、`FileDirectoryTree.vue`、`useFilesClipboard.ts`、`useFilesHotkeys.ts`、`filePreload.ts`、`fileSort.ts`、`remote_path_validation.py`、`tests/test_remote_path_validation.py`、多篇 `docs/changelog/2026-06-01-files-*.md` 与设计文档
|
||||
|
||||
**用户未要求 commit 时**:不要擅自 `git commit`;用户说「好」= 同意记住此状态,下次可代其提交。
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键 API
|
||||
|
||||
- `POST /api/sync/browse`
|
||||
- `POST /api/sync/read-file`(`max_size` 建议 307200)
|
||||
- `POST /api/sync/write-file`
|
||||
- `POST /api/sync/file-clipboard`(`mode`: `copy` \| `move`)
|
||||
- `POST /api/sync/compress` / `decompress`
|
||||
|
||||
---
|
||||
|
||||
## 5. 未做 / 否决
|
||||
|
||||
- v2 设计中的跨目录 **剪贴板到不同服务器**(前端已拦)
|
||||
- 公网 **8601**、嵌入式 Monaco 全站预加载(见项目 SSOT 否决项)
|
||||
|
||||
---
|
||||
|
||||
## 6. 相关 changelog(按主题)
|
||||
|
||||
- `2026-06-01-files-browse-fix.md`
|
||||
- `2026-06-01-files-page-iteration.md`
|
||||
- `2026-06-01-files-300k-preload.md`
|
||||
- `2026-06-01-files-monaco-editor-fix.md`
|
||||
- `2026-06-01-files-editor-panel.md`
|
||||
- `2026-06-01-files-v2-complete.md`
|
||||
- `2026-06-01-files-clipboard-transfer.md`
|
||||
@@ -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()) || '格式: 3–4 位八进制'
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
+931
-117
File diff suppressed because it is too large
Load Diff
@@ -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 ──
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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("/")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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=".")
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user