Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94fbdf7b2d | |||
| e65c9993a2 | |||
| ae47eb42de | |||
| c570801119 | |||
| cba8b3d8f6 | |||
| caef4a216b | |||
| 507b957b4e | |||
| c70424a224 | |||
| e8c1acba9f | |||
| 34786737b4 | |||
| be633f3e58 | |||
| 00a3309005 | |||
| 3d15200cfa | |||
| d8aca5bf18 | |||
| cb60af4483 | |||
| 5a5adafb39 | |||
| c0296b9d8e | |||
| ca4aceba3e | |||
| b5dfa8a870 | |||
| 17cdc96bb0 | |||
| 6012af0b4d | |||
| 9bdf7e420f | |||
| 40d672fdbb | |||
| 557c8d28c1 | |||
| 9f7be66356 | |||
| ac38442383 | |||
| 14131f98f0 | |||
| 64503c0260 | |||
| d19f5af807 | |||
| 463841a740 |
@@ -0,0 +1,93 @@
|
||||
# 审计记录 — files.html + sync_v2.py 全面迭代
|
||||
|
||||
## 审计信息
|
||||
|
||||
- **日期**: 2026-05-30
|
||||
- **审计人**: Claude (AI) + 用户确认
|
||||
- **触发原因**: 文件管理器全面迭代(15项改进)
|
||||
|
||||
## 审计范围
|
||||
|
||||
| 文件 | 行数 | 状态 |
|
||||
|------|------|------|
|
||||
| web/app/files.html | 439 | ☑ 已审 |
|
||||
| server/api/sync_v2.py | 1200 | ☑ 已审 |
|
||||
| server/api/schemas.py | 215 | ☑ 已审 |
|
||||
|
||||
## 审计8步结果
|
||||
|
||||
### Step 1: 登记 ✅
|
||||
### Step 2: 全文Read ✅
|
||||
### Step 3: 规则扫描H — 30 项
|
||||
### Step 4: Closure表
|
||||
|
||||
| H-xx | 严重度 | Closure | 说明 |
|
||||
|------|--------|---------|------|
|
||||
| H-01 | HIGH | **ACCEPTED_RISK** | 远程路径无限制 — 用户明确决策"不加限制" |
|
||||
| H-02 | LOW | **FINDING→已修** | Content-Disposition filename sanitize |
|
||||
| H-03 | LOW | ACCEPTED_RISK | base64 echo 单引号,RFC 4648 字母表安全 |
|
||||
| H-04 | LOW | FALSE_POSITIVE | upload 路径穿越检查充分 |
|
||||
| H-05 | N/A | FALSE_POSITIVE | 无 SQL 操作 |
|
||||
| H-06 | INFO | FALSE_POSITIVE | esc() 覆盖完整,无 XSS |
|
||||
| H-07 | N/A | FALSE_POSITIVE | 无 HTTP 客户端 |
|
||||
| H-08 | N/A | FALSE_POSITIVE | 无明文密码 |
|
||||
| H-09 | N/A | FALSE_POSITIVE | 无硬编码密钥 |
|
||||
| H-10 | INFO | FALSE_POSITIVE | 审计失败降级合理 |
|
||||
| H-11 | N/A | FALSE_POSITIVE | JWT 全覆盖 |
|
||||
| H-12 | LOW | **FINDING→已修** | path 字段加 max_length=4096 |
|
||||
| H-13 | N/A | FALSE_POSITIVE | 全部有审计日志 |
|
||||
| H-14 | INFO | FALSE_POSITIVE | Bearer token 免疫 CSRF |
|
||||
| H-15 | MEDIUM | **FINDING→已修** | download 加 100MB 大小限制 |
|
||||
| H-16 | LOW | ACCEPTED_RISK | batchDelete is_dir 不影响结果 |
|
||||
| H-17 | LOW | ACCEPTED_RISK | 竞态概率极低 |
|
||||
| H-18 | INFO | FALSE_POSITIVE | parseInt 源数据安全 |
|
||||
| H-19 | N/A | FALSE_POSITIVE | Pydantic 自动校验 |
|
||||
| H-20 | N/A | FALSE_POSITIVE | JS pop() 安全 |
|
||||
| H-21 | N/A | FALSE_POSITIVE | 线性遍历无循环风险 |
|
||||
| H-22 | INFO | FALSE_POSITIVE | async with + finally 双重保护 |
|
||||
| H-23 | LOW | **FINDING→已修** | 同 H-15,合并修复 |
|
||||
| H-24 | INFO | FALSE_POSITIVE | async 架构 |
|
||||
| H-25 | N/A | FALSE_POSITIVE | 无 DB 密集操作 |
|
||||
| H-26 | INFO | ACCEPTED_RISK | ls 截断 10000 字符足够 |
|
||||
| H-27 | LOW | **FINDING→已修** | doPreview 保存失败解析错误详情 |
|
||||
| H-28 | INFO | ACCEPTED_RISK | 错误处理整体一致 |
|
||||
| H-29 | INFO | **FINDING→已修** | esc() 加注释 |
|
||||
| H-30 | INFO | ACCEPTED_RISK | ls -la 解析器正确 |
|
||||
|
||||
### Step 5: 入口表 ✅
|
||||
全部 API 端点已检查(download/read-file/write-file/browse/file-ops/upload)。
|
||||
|
||||
### Step 6: 输入→Sink ✅
|
||||
所有用户输入路径已追踪:
|
||||
- download: path → sftp.stat → StreamingResponse
|
||||
- read-file: path → stat -c%s → cat
|
||||
- write-file: path + content → base64 + tee
|
||||
- file-ops: path → shlex.quote → SSH exec
|
||||
- upload: remote_path + filename → SFTP put
|
||||
|
||||
### Step 7: 归类
|
||||
|
||||
```
|
||||
web/app/files.html 439行 10H 2FINDING → 0FINDING
|
||||
server/api/sync_v2.py 1200行 12H 3FINDING → 0FINDING (1 ACCEPTED_RISK)
|
||||
server/api/schemas.py 215行 8H 1FINDING → 0FINDING
|
||||
──────────────────────────────────────────────────────────────────
|
||||
总计 6 FINDING → 0FINDING (全部已修或 ACCEPTED_RISK)
|
||||
```
|
||||
|
||||
### Step 8: DoD ✅
|
||||
|
||||
## FINDING 列表
|
||||
|
||||
| 编号 | 严重度 | 描述 | 修复方式 |
|
||||
|------|--------|------|----------|
|
||||
| H-01 | HIGH | 远程端点无路径限制 | ACCEPTED_RISK — 用户决策 |
|
||||
| H-15 | MEDIUM | download 无大小限制 | 加 100MB stat.size 检查 → 413 |
|
||||
| H-02 | LOW | Content-Disposition 头注入 | re.sub 替换特殊字符 |
|
||||
| H-12 | LOW | path 无 max_length | 三个 schema 加 max_length=4096 |
|
||||
| H-27 | LOW | doPreview 保存错误丢失详情 | 解析 wr.json() 显示 detail |
|
||||
| H-29 | INFO | esc() 缺注释 | 加 /** HTML-escape */ 注释 |
|
||||
|
||||
## 结论
|
||||
|
||||
☑ 审计通过,6 FINDING 全部已处理(5 修复 + 1 ACCEPTED_RISK),0 遗留,可部署
|
||||
@@ -0,0 +1,86 @@
|
||||
# 审计记录 — Monaco IDE 编辑器 + 文件管理增强
|
||||
|
||||
## 审计信息
|
||||
|
||||
- **日期**: 2026-05-30
|
||||
- **审计人**: Claude (AI) + 用户确认
|
||||
- **触发原因**: Monaco 编辑器 + IDE 重构 + 文件管理增强
|
||||
|
||||
## 审计范围
|
||||
|
||||
| 文件 | 行数 | 状态 |
|
||||
|------|------|------|
|
||||
| web/app/files.html | 870 | ☑ 已审 |
|
||||
| web/app/terminal.html | 831 | ☑ 已审 |
|
||||
| web/app/servers.html | 1202 | ☑ 已审 |
|
||||
|
||||
## 审计8步结果
|
||||
|
||||
### Step 1: 登记 ✅
|
||||
### Step 2: 全文Read ✅
|
||||
### Step 3: 规则扫描H — 30 项
|
||||
### Step 4: Closure表
|
||||
|
||||
| H-xx | 严重度 | Closure | 说明 |
|
||||
|------|--------|---------|------|
|
||||
| H-01 | MEDIUM | **FINDING→已修** | esc() 加引号转义 |
|
||||
| H-02 | MEDIUM | **FINDING→已修** | initialPath 路径白名单 |
|
||||
| H-03 | LOW | **FINDING→已修** | doNewFile 文件名白名单 |
|
||||
| H-04 | N/A | FALSE_POSITIVE | WebSocket 认证完整 |
|
||||
| H-05 | N/A | FALSE_POSITIVE | JWT 保护完备 |
|
||||
| H-06 | N/A | FALSE_POSITIVE | innerHTML XSS 已防护 |
|
||||
| H-07 | - | ACCEPTED_RISK | 路径穿越(管理员上下文) |
|
||||
| H-08 | LOW | **FINDING** | Monaco CDN 无 SRI(已知限制) |
|
||||
| H-09 | N/A | FALSE_POSITIVE | 敏感数据脱敏正确 |
|
||||
| H-10 | N/A | FALSE_POSITIVE | URL 参数 parseInt 安全 |
|
||||
| H-11 | N/A | FALSE_POSITIVE | JWT 天然 CSRF 免疫 |
|
||||
| H-12 | - | ACCEPTED_RISK | CSP 需 Nginx 层配置 |
|
||||
| H-13 | MEDIUM | **FINDING→已修** | 移除 modalTextarea 引用 |
|
||||
| H-14 | - | FALSE_POSITIVE | 事件委托竞态合理 |
|
||||
| H-15 | N/A | FALSE_POSITIVE | 选择状态一致 |
|
||||
| H-16 | N/A | FALSE_POSITIVE | 边界检查完备 |
|
||||
| H-17 | LOW | ACCEPTED_RISK | Ctrl+S 并发保存 |
|
||||
| H-18 | - | ACCEPTED_RISK | 预加载空 catch 合理 |
|
||||
| H-19 | N/A | FALSE_POSITIVE | Monaco 防重复加载正确 |
|
||||
| H-20 | LOW | **FINDING→已修** | initialPath 仅首次连接 cd |
|
||||
| H-21 | LOW | ACCEPTED_RISK | esc() DOM 操作频繁 |
|
||||
| H-22 | N/A | FALSE_POSITIVE | 预加载有上限保护 |
|
||||
| H-23 | LOW | ACCEPTED_RISK | Monaco 内存 |
|
||||
| H-24 | LOW | ACCEPTED_RISK | 全量重渲染 |
|
||||
| H-25 | N/A | FALSE_POSITIVE | innerHTML 批量赋值正确 |
|
||||
| H-26 | MEDIUM | **FINDING→已修** | esc() 跨文件一致性 |
|
||||
| H-27 | N/A | FALSE_POSITIVE | _modalResolve 正确 |
|
||||
| H-28 | LOW | ACCEPTED_RISK | modal 关闭状态清理 |
|
||||
| H-29 | N/A | FALSE_POSITIVE | esc() HTML 上下文安全 |
|
||||
| H-30 | LOW | **FINDING→已修** | esc() 注释更新 |
|
||||
|
||||
### Step 5: 入口表 ✅
|
||||
### Step 6: 输入→Sink ✅
|
||||
### Step 7: 归类
|
||||
|
||||
```
|
||||
web/app/files.html 870行 12H 5FINDING → 0FINDING
|
||||
web/app/terminal.html 831行 4H 2FINDING → 0FINDING
|
||||
web/app/servers.html 1202行 1H 0FINDING
|
||||
──────────────────────────────────────────────────────────────
|
||||
总计 8 FINDING → 0FINDING (1 ACCEPTED_RISK H-08)
|
||||
```
|
||||
|
||||
### Step 8: DoD ✅
|
||||
|
||||
## FINDING 列表
|
||||
|
||||
| 编号 | 严重度 | 描述 | 修复方式 |
|
||||
|------|--------|------|----------|
|
||||
| H-01 | MEDIUM | esc() onclick XSS | 加 `'` `"` 转义 |
|
||||
| H-02 | MEDIUM | initialPath 命令注入 | 路径白名单正则 |
|
||||
| H-03 | LOW | doNewFile 文件名 | 白名单正则 |
|
||||
| H-08 | LOW | Monaco CDN 无 SRI | ACCEPTED_RISK(版本锁定) |
|
||||
| H-13 | MEDIUM | modalTextarea null 引用 | 移除 dead code |
|
||||
| H-20 | LOW | initialPath 全标签 cd | _initialCdSent 标志 |
|
||||
| H-26 | MEDIUM | esc() 不一致 | 统一为含引号转义版本 |
|
||||
| H-30 | LOW | esc() 注释 | 更新注释 |
|
||||
|
||||
## 结论
|
||||
|
||||
☑ 审计通过,8 FINDING 全部已处理(7 修复 + 1 ACCEPTED_RISK),0 遗留
|
||||
@@ -0,0 +1,115 @@
|
||||
# 审计记录 — Settings 安全加固 + UX 迭代
|
||||
|
||||
## 审计信息
|
||||
|
||||
- **日期**: 2026-05-30
|
||||
- **审计人**: Claude (AI) + 用户确认
|
||||
- **触发原因**: 安全加固 + UX 迭代
|
||||
|
||||
## 审计范围
|
||||
|
||||
| 文件 | 行数 | 状态 |
|
||||
|------|------|------|
|
||||
| server/api/settings.py | 1278 | ☑ 已审 |
|
||||
| server/api/auth.py | 372 | ☑ 已审 |
|
||||
| server/application/services/auth_service.py | 576 | ☑ 已审 |
|
||||
| web/app/settings.html | 938 | ☑ 已审 |
|
||||
| web/app/login.html | 347 | ☑ 已审 |
|
||||
|
||||
## 审计8步结果
|
||||
|
||||
### Step 1: 登记 ✅
|
||||
|
||||
### Step 2: 全文Read ✅
|
||||
|
||||
### Step 3: 规则扫描H
|
||||
|
||||
30 项基础检查 + 1 项额外检查 = 31 项
|
||||
|
||||
### Step 4: Closure表
|
||||
|
||||
| 编号 | 项目 | 文件 | 严重度 | 判定 | 依据 |
|
||||
|------|------|------|--------|------|------|
|
||||
| H-01 | SQL注入 | 全部 | - | 未命中 | SQLAlchemy ORM,无f-string拼SQL |
|
||||
| H-02 | 命令注入 | 全部 | - | 未命中 | 无os.system/subprocess |
|
||||
| H-03 | XSS | settings.html | - | 未命中 | esc()/escAttr()标准转义 |
|
||||
| H-04 | SSRF TOCTOU | settings.py | LOW | ACCEPTED_RISK | 管理员接口、窗口极短、需控制DNS |
|
||||
| H-05 | 路径穿越 | 全部 | - | 未命中 | 无文件系统操作 |
|
||||
| H-06 | 明文密码 | settings.py | - | 未命中 | SENSITIVE_KEYS脱敏+reveal需密码 |
|
||||
| H-07 | 硬编码密钥 | 全部 | - | 未命中 | 来自settings对象(.env+MySQL) |
|
||||
| H-08 | 静默吞错 | auth.py | - | FALSE_POSITIVE | logger.warning+exc_info=True |
|
||||
| H-09 | 静默吞错(hot-reload) | settings.py | LOW | FINDING→已修 | 改为logger.warning+跳过setattr |
|
||||
| H-10 | 权限检查 | 全部 | - | 未命中 | JWT+Depends(get_current_admin) |
|
||||
| H-11 | IP格式校验 | settings.py | MEDIUM | FINDING→已修 | IpAllowlistSaveRequest加model_validator |
|
||||
| H-12 | 竞态条件 | settings.py | LOW | ACCEPTED_RISK | 管理员单人操作,概率极低 |
|
||||
| H-13 | CSRF | 全部 | - | 未命中 | JWT Bearer不自动附加+SameSite cookie |
|
||||
| H-14 | 边界条件 | settings.py | - | 未命中 | min_length/max_length保护 |
|
||||
| H-15 | 类型转换 | auth_service.py | - | 未命中 | try/except包裹int() |
|
||||
| H-16 | None检查 | 全部 | - | 未命中 | 关键None检查均已覆盖 |
|
||||
| H-17 | 索引越界 | auth_service.py | - | 未命中 | HMAC-SHA1 digest长度足够 |
|
||||
| H-18 | 递归/循环 | 全部 | - | 未命中 | MAX_REDIRECTS=3+range(-1,2) |
|
||||
| H-19 | 资源泄漏 | 全部 | - | 未命中 | async with上下文管理 |
|
||||
| H-20 | 时序问题 | settings.py | - | 未命中 | 毫秒级窗口,实际不可利用 |
|
||||
| H-21 | N+1查询 | 全部 | - | 未命中 | 最多2-3次DB查询/请求 |
|
||||
| H-22 | 同步阻塞DNS | settings.py | MEDIUM | FINDING→已修 | 改为asyncio.get_event_loop().getaddrinfo() |
|
||||
| H-23 | 大数据 | 全部 | - | 未命中 | 全部分页+27行settings表 |
|
||||
| H-24 | 内存使用 | 全部 | - | 未命中 | 无大对象常驻 |
|
||||
| H-25 | 缓存 | 全部 | - | N/A | 内存热更新,无需额外缓存 |
|
||||
| H-26 | 错误处理 | 全部 | - | 未命中 | HTTP状态码+detail消息一致 |
|
||||
| H-27 | 日志 | 全部 | - | 未命中 | AuditLog+logger.error/warning |
|
||||
| H-28 | 文档 | 全部 | - | 未命中 | 模块/类/函数级docstring |
|
||||
| H-29 | 重复代码 | settings.py | LOW | FINDING→已修 | 提取_verify_reauth辅助函数 |
|
||||
| H-30 | 一致性 | settings.py/html | LOW | FINDING→已修 | 统一catch→console.error+toast |
|
||||
| H-31 | TOTP格式不一致 | auth.py | LOW | FINDING→已修 | totp_code加min_length=6 |
|
||||
|
||||
### Step 5: 入口表 ✅
|
||||
|
||||
全部API端点已检查(GET/PUT/POST/DELETE路由)。
|
||||
|
||||
### Step 6: 输入→Sink ✅
|
||||
|
||||
所有用户输入路径已追踪:
|
||||
- PUT /{key} → IMMUTABLE_KEYS → MUTABLE_KEYS → SETTING_VALIDATORS → repo.set
|
||||
- POST /manual → IpAllowlistRequest model_validator → repo.set
|
||||
- DELETE /ip → query param ip → string comparison
|
||||
- PUT /password → bcrypt.checkpw → TOTP verify → bcrypt.hashpw → DB
|
||||
|
||||
### Step 7: 归类
|
||||
|
||||
```
|
||||
server/api/settings.py 1278行 13H 4FINDING → 0FINDING (全部已修)
|
||||
server/api/auth.py 372行 8H 1FINDING → 0FINDING (已修)
|
||||
server/application/services/auth_service.py 576行 0H 0FINDING
|
||||
web/app/settings.html 938行 10H 2FINDING → 0FINDING (全部已修)
|
||||
web/app/login.html 347行 0H 0FINDING
|
||||
────────────────────────────────────────────────────────────
|
||||
总计 7 FINDING → 0FINDING (全部已修)
|
||||
```
|
||||
|
||||
### Step 8: DoD ✅
|
||||
|
||||
## FINDING 列表
|
||||
|
||||
| 编号 | 严重度 | 描述 | 修复方式 |
|
||||
|------|--------|------|----------|
|
||||
| F-1 | MEDIUM | IpAllowlistSaveRequest.manual_ips 无IP格式校验 | 加model_validator复用_IP_RE+_DOMAIN_RE |
|
||||
| F-2 | MEDIUM | socket.getaddrinfo同步阻塞事件循环 | 改为await asyncio.get_event_loop().getaddrinfo() |
|
||||
| F-3 | LOW | hot-reload int转换失败静默pass | 改为logger.warning+跳过setattr |
|
||||
| F-4 | LOW | re-auth模式4处完全重复 | 提取_verify_reauth()辅助函数 |
|
||||
| F-5 | LOW | totp_code无长度约束 | 加min_length=6,max_length=6 |
|
||||
| F-6 | LOW | revealTelegramToken空catch | 改为console.error+toast |
|
||||
| F-7 | LOW | toggleApiKeyVisibility空catch | 改为console.error+toast |
|
||||
|
||||
## 结论
|
||||
|
||||
☑ 审计通过,7 FINDING 全部已修复,0 遗留,可部署
|
||||
|
||||
## 归类统计
|
||||
|
||||
| 严重度 | 数量 | 处理 |
|
||||
|--------|------|------|
|
||||
| MEDIUM | 2 | 全部FINDING,已修复 |
|
||||
| LOW | 5 | 全部FINDING,已修复 |
|
||||
| ACCEPTED_RISK | 2 | H-04 SSRF TOCTOU + H-12 竞态条件 |
|
||||
| FALSE_POSITIVE | 1 | H-08 auth.py Redis降级 |
|
||||
| 未命中 | 22 | 安全基线良好 |
|
||||
@@ -0,0 +1,101 @@
|
||||
# Changelog — files.html + sync_v2.py 全面迭代
|
||||
|
||||
**日期**: 2026-05-30
|
||||
**Commit**: 14131f9
|
||||
**类型**: 安全加固 + 功能补全 + UX 全面改进
|
||||
**涉及文件**:
|
||||
- `server/api/sync_v2.py` — 符号链接解析 + 新增 3 个端点
|
||||
- `server/api/schemas.py` — 新增 FileDownload/FileRead/FileWrite schema
|
||||
- `web/app/files.html` — 前端全面重写
|
||||
|
||||
## 后端变更 (sync_v2.py)
|
||||
|
||||
### P2-1/P3-2: 符号链接解析
|
||||
- `ls -la` 解析逻辑新增 `is_link` 字段(检测 `l` 权限)
|
||||
- 符号链接文件名 `name -> target` 分离为 `name` + `link_target`
|
||||
- 响应新增 `is_link` 和 `link_target` 字段
|
||||
|
||||
### P2-7: 新增 `/api/sync/download` 端点
|
||||
- 通过 SFTP 流式下载远程文件
|
||||
- 使用 `StreamingResponse` + 64KB chunk 避免内存膨胀
|
||||
- 审计日志记录
|
||||
|
||||
### P2-8: 新增 `/api/sync/read-file` + `/write-file` 端点
|
||||
- `read-file`: `stat` 检查大小 → `cat` 读取内容,限制 1MB
|
||||
- `write-file`: base64 编码内容避免 shell 注入,`echo | base64 -d | tee` 写入
|
||||
- 两个端点均有审计日志
|
||||
|
||||
## 前端全面重写 (files.html)
|
||||
|
||||
### P1-1: 目录删除二次确认
|
||||
- 目录删除:自定义模态框 + 要求输入目录名确认
|
||||
- 文件删除:自定义确认模态框
|
||||
|
||||
### P2-2/P2-3: 事件委托
|
||||
- 移除所有内联 `onclick="..."` 字符串拼接
|
||||
- 改用 `data-action` + `data-path` + `data-name` 属性
|
||||
- 统一事件委托 `fileListEl.addEventListener('click', ...)`
|
||||
- 彻底消除 XSS 向量
|
||||
|
||||
### P2-4: 上传进度条
|
||||
- `XMLHttpRequest.upload.onprogress` 实时进度
|
||||
- 顶部进度条显示当前文件名 + 百分比 + 进度条动画
|
||||
|
||||
### P2-5: 文件大小可读化
|
||||
- `formatSize()` 函数:字节 → B/KB/MB/GB
|
||||
- 保留 1 位小数
|
||||
|
||||
### P2-6: 文件类型图标
|
||||
- 30+ 文件扩展名映射
|
||||
- `.py`→🐍 `.jpg`→🖼️ `.tar.gz`→📦 `.log`→📊 `.env`→🔒 等
|
||||
|
||||
### P2-7: 文件下载
|
||||
- 每行 hover 显示 ⬇ 下载按钮
|
||||
- 点击触发 SFTP 下载 → 浏览器保存文件
|
||||
|
||||
### P2-8: 文件预览/编辑
|
||||
- 每行 hover 显示 👁 预览按钮
|
||||
- 点击读取文件内容 → 模态框显示 + textarea 编辑 + 保存
|
||||
|
||||
### P2-9: 自定义模态框
|
||||
- 完全替代原生 `prompt()` / `confirm()`
|
||||
- Tailwind 样式:暗色主题 + backdrop blur + 圆角
|
||||
- 支持:文本提示 / 输入框 / 多行编辑 / 危险操作红色样式
|
||||
|
||||
### P2-10: 批量操作
|
||||
- 每行开头 checkbox + 表头全选
|
||||
- 选中时底部出现批量操作栏(计数 + 批量删除 + 取消)
|
||||
|
||||
### P3-1: 文件搜索
|
||||
- 面包屑右侧搜索框
|
||||
- 前端实时过滤文件名
|
||||
|
||||
### P3-2: 符号链接显示
|
||||
- 🔗 图标 + cyan 颜色
|
||||
- hover 显示 `→ target` tooltip
|
||||
|
||||
### P3-3: 空目录状态
|
||||
- 📂 大图标 + 提示文字 + 「上传文件」引导按钮
|
||||
|
||||
### P3-4: 服务器刷新
|
||||
- 🔄 按钮在服务器下拉旁,点击重新加载列表
|
||||
|
||||
### P3-5: 操作 loading
|
||||
- 上传期间顶部进度条持续显示
|
||||
- 浏览期间文件列表显示 ⏳ 加载动画
|
||||
|
||||
## 验证结果
|
||||
|
||||
| 测试 | 结果 |
|
||||
|------|------|
|
||||
| 健康检查 | ✅ ok |
|
||||
| 页面加载 | ✅ 0 errors |
|
||||
| 浏览 /tmp | ✅ 文件列表+图标+大小正确 |
|
||||
| 符号链接 mysql.sock | ✅ 🔗 图标 |
|
||||
| 面包屑 | ✅ /tmp 路径 |
|
||||
| 搜索框 | ✅ 存在 |
|
||||
| 全选 checkbox | ✅ 存在 |
|
||||
| 操作按钮 | ✅ 👁 ⬏ ✏ 🗑 |
|
||||
|
||||
## 是否需迁移/重启
|
||||
- 需要重启后端服务(新增 3 个 API 端点)
|
||||
@@ -0,0 +1,61 @@
|
||||
# Changelog — Monaco 编辑器 + IDE 重构 + 文件管理增强
|
||||
|
||||
**日期**: 2026-05-30
|
||||
**Commit**: e8c1acb (含 15+ commits)
|
||||
**类型**: 功能增强 + 安全修复
|
||||
**涉及文件**:
|
||||
- `web/app/files.html` — IDE 编辑器全面重构
|
||||
- `web/app/api.js` — Monaco 预加载(后移入 files.html)
|
||||
- `web/app/terminal.html` — path 参数自动 cd
|
||||
- `web/app/servers.html` — 「文件」按钮
|
||||
- `server/api/sync_v2.py` — chmod/compress/decompress 端点
|
||||
- `server/api/schemas.py` — FileChmod/FileCompress/FileDecompress schema
|
||||
|
||||
## Monaco 编辑器
|
||||
|
||||
- 全局预加载:页面加载后 CDN 后台下载 Monaco (~2MB)
|
||||
- IDE 面板:全屏替代模态框,支持多文件标签页
|
||||
- 每个标签独立 Monaco 实例,切换保持状态
|
||||
- 语法高亮:30+ 语言自动检测(conf→ini, sh→shell, py→python 等)
|
||||
- Ctrl+S 保存 + 未保存指示器
|
||||
- 状态栏:光标位置 + 语言 + 文件路径
|
||||
|
||||
## 文件管理增强
|
||||
|
||||
- 右键菜单:预览/下载/重命名/权限/复制路径/终端/压缩/解压/删除
|
||||
- 文件树:IDE 左侧面板,点击文件打开/目录导航
|
||||
- 最小化/恢复:底部标签栏
|
||||
- 新建文件按钮(📄+)
|
||||
- chmod 权限修改
|
||||
- 压缩 tar.gz / 解压 tar.gz+zip
|
||||
- 排序切换(名称/大小/时间)
|
||||
- 文件类型筛选(.conf/.log/.sh/.py/.json/.yml/.txt)
|
||||
- 文件预加载 + 缓存(≤1MB 文本文件,双击秒开)
|
||||
|
||||
## SSH 终端集成
|
||||
|
||||
- 工具栏「🖥 终端」按钮
|
||||
- 新标签页打开 SSH + 自动 cd 到当前目录
|
||||
- terminal.html 支持 path URL 参数
|
||||
|
||||
## 服务器列表
|
||||
|
||||
- 每行操作栏新增「文件」按钮(琥珀色)
|
||||
- 点击直达文件管理,自动选中服务器
|
||||
|
||||
## 安全修复(审计 FINDING)
|
||||
|
||||
- H-01: esc() 加引号转义防 onclick XSS
|
||||
- H-02: initialPath 路径白名单防命令注入
|
||||
- H-03: 新建文件文件名白名单
|
||||
- H-13: 移除 showModal 中已删除的 textarea 引用
|
||||
- H-20: initialPath 仅首次连接 cd
|
||||
- H-26: esc() 跨文件一致性
|
||||
|
||||
## 已知限制
|
||||
|
||||
- Monaco CDN 无 SRI(版本锁定 @0.45.0 降低风险)
|
||||
- Nginx 静态文件缓存可能导致更新延迟(需 nginx -s reload)
|
||||
|
||||
## 是否需迁移/重启
|
||||
- 需要重启后端服务(新增 chmod/compress/decompress 端点)
|
||||
@@ -192,6 +192,26 @@ class FileOperation(BaseModel):
|
||||
new_path: Optional[str] = None # required for rename
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class FileRead(BaseModel):
|
||||
"""Read text file content from a remote server."""
|
||||
server_id: int = Field(..., ge=1)
|
||||
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
|
||||
|
||||
|
||||
class FileWrite(BaseModel):
|
||||
"""Write text content to a file on a remote server."""
|
||||
server_id: int = Field(..., ge=1)
|
||||
path: str = Field(..., min_length=1, max_length=4096)
|
||||
content: str = Field(..., max_length=5_000_000)
|
||||
|
||||
|
||||
# ── Script ──
|
||||
|
||||
class ScriptCreate(BaseModel):
|
||||
|
||||
+15
-21
@@ -14,7 +14,7 @@ import socket
|
||||
|
||||
import bcrypt
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from server.api.dependencies import get_db
|
||||
from server.api.auth_jwt import get_current_admin
|
||||
@@ -45,6 +45,7 @@ MUTABLE_KEYS: set[str] = {
|
||||
"notify_alert_cpu", "notify_alert_mem", "notify_alert_disk",
|
||||
"notify_recovery", "notify_time_drift",
|
||||
"notify_system_redis", "notify_system_mysql", "notify_restart",
|
||||
"editor_theme", # Monaco editor theme preference
|
||||
}
|
||||
|
||||
# S-02: Per-key validation rules: (type, min, max) for int settings
|
||||
@@ -93,6 +94,16 @@ async def _verify_reauth(db: AsyncSession, admin: Admin, password: str) -> None:
|
||||
raise HTTPException(status_code=400, detail="当前密码错误")
|
||||
|
||||
|
||||
def _validate_ip_list(ips: list[str]) -> None:
|
||||
"""Validate IP/CIDR/domain list. Raises 400 on invalid entry."""
|
||||
for raw in ips:
|
||||
ip = raw.strip()
|
||||
if not ip:
|
||||
continue
|
||||
if not (_IP_RE.match(ip) or _DOMAIN_RE.match(ip)):
|
||||
raise HTTPException(status_code=400, detail=f"无效的 IP/CIDR/域名: {ip}")
|
||||
|
||||
|
||||
# ── System Settings ──
|
||||
|
||||
@router.get("/", response_model=list)
|
||||
@@ -841,16 +852,6 @@ _DOMAIN_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-
|
||||
class IpAllowlistRequest(BaseModel):
|
||||
ips: list[str] = Field(..., min_length=1, max_length=100, description="IP or hostname list")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_ips(self):
|
||||
for raw in self.ips:
|
||||
ip = raw.strip()
|
||||
if not ip:
|
||||
continue
|
||||
if not (_IP_RE.match(ip) or _DOMAIN_RE.match(ip)):
|
||||
raise ValueError(f"无效的 IP/CIDR/域名: {ip}")
|
||||
return self
|
||||
|
||||
|
||||
class SubscriptionParseRequest(BaseModel):
|
||||
url: str = Field(..., min_length=1, description="Proxy subscription URL to fetch and parse")
|
||||
@@ -938,16 +939,6 @@ class IpAllowlistSaveRequest(BaseModel):
|
||||
subscription_url: Optional[str] = None # None = don't change; "" = clear
|
||||
enabled: Optional[bool] = None # None = don't change
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_manual_ips(self):
|
||||
for raw in self.manual_ips:
|
||||
ip = raw.strip()
|
||||
if not ip:
|
||||
continue
|
||||
if not (_IP_RE.match(ip) or _DOMAIN_RE.match(ip)):
|
||||
raise ValueError(f"无效的 IP/CIDR/域名: {ip}")
|
||||
return self
|
||||
|
||||
|
||||
@router.post("/ip-allowlist", response_model=dict)
|
||||
async def set_ip_allowlist(
|
||||
@@ -958,6 +949,8 @@ async def set_ip_allowlist(
|
||||
):
|
||||
"""Save manual IPs, subscription URL, and/or enabled toggle."""
|
||||
from server.config import settings as _settings
|
||||
if payload.manual_ips:
|
||||
_validate_ip_list(payload.manual_ips)
|
||||
|
||||
repo = SettingRepositoryImpl(db)
|
||||
|
||||
@@ -1044,6 +1037,7 @@ async def add_manual_ips(
|
||||
):
|
||||
"""Append IPs to the manual list and trigger allowlist rebuild."""
|
||||
from server.config import settings as _settings
|
||||
_validate_ip_list(payload.ips)
|
||||
|
||||
existing = [ip.strip() for ip in (_settings.LOGIN_MANUAL_IPS or "").split(",") if ip.strip()]
|
||||
new_ips = [ip.strip() for ip in payload.ips if ip.strip()]
|
||||
|
||||
+158
-4
@@ -9,7 +9,7 @@ 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
|
||||
from server.api.schemas import SyncFiles, SyncBrowse, SyncBrowseLocal, SyncPreview, FileOperation, LocalFileOperation, LocalFilePreview, SyncVerify, SyncCancel, ValidateSourcePath, SyncDiagnose, FileSyncDiff, FileDownload, FileRead, FileWrite
|
||||
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
|
||||
@@ -177,12 +177,21 @@ async def browse_directory(
|
||||
continue
|
||||
parts = line.split(None, 8)
|
||||
if len(parts) >= 9:
|
||||
is_dir = parts[0].startswith("d")
|
||||
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": parts[8], # Last field after split(None, 8) — handles spaces in names
|
||||
"name": name,
|
||||
"is_dir": is_dir,
|
||||
"is_link": is_link,
|
||||
"link_target": link_target,
|
||||
"size": parts[4],
|
||||
"perms": parts[0],
|
||||
"perms": perms,
|
||||
"owner": parts[2],
|
||||
"modified": " ".join(parts[5:8]),
|
||||
})
|
||||
@@ -1043,3 +1052,148 @@ async def verify_sync(
|
||||
)
|
||||
|
||||
return {"results": results, "total_local_files": len(local_files)}
|
||||
|
||||
|
||||
# ── File Download / Read / Write ──
|
||||
|
||||
MAX_READ_FILE_SIZE = 1_000_000 # 1MB
|
||||
|
||||
|
||||
@router.post("/download")
|
||||
async def download_file(
|
||||
payload: FileDownload,
|
||||
request: Request,
|
||||
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
|
||||
|
||||
session = request.state.db
|
||||
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="服务器不存在")
|
||||
|
||||
conn = await ssh_pool.acquire(server)
|
||||
try:
|
||||
async with conn.create_sftp_client() as sftp:
|
||||
# Check file exists and size
|
||||
try:
|
||||
stat = await sftp.stat(payload.path)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="文件不存在") from None
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"无法访问文件: {e}") from e
|
||||
|
||||
if stat.type not in ("regular file", "unknown"):
|
||||
raise HTTPException(status_code=400, detail="只能下载文件,不能下载目录")
|
||||
|
||||
# H-15: Download file size limit (100MB)
|
||||
MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024
|
||||
if hasattr(stat, "size") and stat.size and stat.size > MAX_DOWNLOAD_SIZE:
|
||||
raise HTTPException(status_code=413, detail=f"文件大小 {stat.size} 字节超过下载限制 100MB")
|
||||
|
||||
# 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"
|
||||
|
||||
async def file_generator():
|
||||
async with sftp.open(payload.path, "rb") as f:
|
||||
while chunk := await f.read(65536):
|
||||
yield chunk
|
||||
|
||||
await _audit_sync(
|
||||
"file_download", "server", payload.server_id,
|
||||
f"下载文件: {payload.path}",
|
||||
admin.username, request,
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
file_generator(),
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
finally:
|
||||
await ssh_pool.release(server.id)
|
||||
|
||||
|
||||
@router.post("/read-file")
|
||||
async def read_file(
|
||||
payload: FileRead,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""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
|
||||
|
||||
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)
|
||||
if size_result["exit_code"] != 0:
|
||||
raise HTTPException(status_code=404, detail="文件不存在或无法访问")
|
||||
|
||||
try:
|
||||
file_size = int(size_result["stdout"].strip())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=502, detail="无法获取文件大小") from None
|
||||
|
||||
if file_size > payload.max_size:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"文件大小 {file_size} 字节超过限制 {payload.max_size} 字节",
|
||||
)
|
||||
|
||||
# Read file content
|
||||
result = await exec_ssh_command(server, f"cat {shlex.quote(payload.path)}", timeout=15)
|
||||
if result["exit_code"] != 0:
|
||||
raise HTTPException(status_code=502, detail=f"读取失败: {result['stderr'][:200]}")
|
||||
|
||||
await _audit_sync(
|
||||
"file_read", "server", payload.server_id,
|
||||
f"读取文件: {payload.path} ({file_size} bytes)",
|
||||
admin.username, request,
|
||||
)
|
||||
|
||||
return {"content": result["stdout"], "size": file_size, "path": payload.path}
|
||||
|
||||
|
||||
@router.post("/write-file")
|
||||
async def write_file(
|
||||
payload: FileWrite,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""P2-8: Write text content to a file on a remote server."""
|
||||
import shlex
|
||||
import base64
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||||
|
||||
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"
|
||||
|
||||
result = await exec_ssh_command(server, cmd, timeout=30)
|
||||
if result["exit_code"] != 0:
|
||||
raise HTTPException(status_code=502, detail=f"写入失败: {result['stderr'][:200]}")
|
||||
|
||||
await _audit_sync(
|
||||
"file_write", "server", payload.server_id,
|
||||
f"写入文件: {payload.path} ({len(payload.content)} chars)",
|
||||
admin.username, request,
|
||||
)
|
||||
|
||||
return {"success": True, "path": payload.path, "size": len(payload.content)}
|
||||
|
||||
@@ -132,6 +132,22 @@ if (!token) {
|
||||
_recordActivity();
|
||||
}
|
||||
|
||||
// ── Monaco Editor 预加载(后台,不阻塞页面) ──
|
||||
(function preloadMonaco(){
|
||||
if(window._monacoLoading||window._monacoReady)return;
|
||||
window._monacoLoading=true;
|
||||
const s=document.createElement('script');
|
||||
s.src='https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js';
|
||||
s.onload=function(){
|
||||
require.config({paths:{vs:'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs'}});
|
||||
require(['vs/editor/editor.main'],function(){
|
||||
window.monaco=monaco;
|
||||
window._monacoReady=true;
|
||||
});
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
})();
|
||||
|
||||
// ── Global Toast Notification System ──
|
||||
// Call toast('success', '操作成功') or toast('error', '操作失败') from any page.
|
||||
|
||||
|
||||
+914
-102
File diff suppressed because it is too large
Load Diff
@@ -359,6 +359,7 @@
|
||||
|
||||
function renderActions(s){
|
||||
let html=`<a href="/app/terminal.html?server_id=${s.id}" onclick="event.stopPropagation()" class="text-brand-light hover:underline text-xs mr-2">终端</a>`;
|
||||
html+=`<a href="/app/files.html?server_id=${s.id}" onclick="event.stopPropagation()" class="text-amber-400 hover:underline text-xs mr-2">文件</a>`;
|
||||
if(s.agent_version) html+=`<button onclick="event.stopPropagation();_quickCheck(${s.id})" class="text-emerald-400 hover:underline text-xs mr-2">检查</button>`;
|
||||
html+=`<button onclick="event.stopPropagation();showEditServer(${s.id})" class="text-slate-400 hover:underline text-xs mr-2">编辑</button>`;
|
||||
html+=`<button onclick="event.stopPropagation();deleteServer(${s.id})" class="text-red-400 hover:underline text-xs">删除</button>`;
|
||||
|
||||
@@ -119,6 +119,10 @@ initLayout('servers');
|
||||
// ── Parse params ──
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const initialServerId = parseInt(params.get('server_id'));
|
||||
const _rawPath = params.get('path') || '';
|
||||
// H-02: Strict path whitelist — only allow safe characters
|
||||
const initialPath = _rawPath && /^[a-zA-Z0-9/._\-]+$/.test(_rawPath) ? _rawPath : '';
|
||||
let _initialCdSent = false; // H-20: Only cd on first connection, not restored tabs
|
||||
if (!initialServerId) window.location.href = '/app/servers.html';
|
||||
|
||||
// ── localStorage keys ──
|
||||
@@ -357,6 +361,13 @@ async function connectSession(idx) {
|
||||
if (d && s.ws && s.ws.readyState === WebSocket.OPEN) {
|
||||
s.ws.send(JSON.stringify({ type: 'RESIZE', cols: d.cols, rows: d.rows }));
|
||||
}
|
||||
// H-20: Auto-cd to initial path only on first connection (not restored tabs)
|
||||
if (initialPath && !_initialCdSent && s.ws && s.ws.readyState === WebSocket.OPEN) {
|
||||
_initialCdSent = true;
|
||||
setTimeout(() => {
|
||||
s.ws.send(JSON.stringify({ type: 'DATA', data: `cd ${initialPath}\r` }));
|
||||
}, 200);
|
||||
}
|
||||
break;
|
||||
case 'DATA':
|
||||
s.term.write(msg.data);
|
||||
|
||||
Reference in New Issue
Block a user