Compare commits

...

30 Commits

Author SHA1 Message Date
Your Name 94fbdf7b2d refactor: 主题切换移到⚙️设置下拉菜单
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run
- 移除标题栏独立主题按钮(🌙/☀️)
- 新增⚙️设置按钮 → 下拉菜单
- 菜单项: 「☀️ 亮色主题」/「🌙 暗色主题」动态文字
- 点击菜单外自动关闭
- 菜单样式: bg-slate-800, rounded, shadow, 最小宽度200px
2026-05-31 02:42:24 +08:00
Your Name e65c9993a2 feat: 编辑器亮色/暗色主题切换 + 保存到MySQL
- 标题栏🌙/☀️按钮切换 Monaco 主题
- 'vs'(亮色) / 'vs-dark'(暗色)
- 切换后立即应用到所有打开的标签页
- 通过 PUT /api/settings/editor_theme 保存到MySQL
- 页面加载时自动读取保存的主题偏好
- settings.py MUTABLE_KEYS 白名单加 editor_theme
2026-05-31 02:39:21 +08:00
Your Name ae47eb42de fix: 双击标题栏最大化 + Monaco用官方默认主题
1. 双击编辑器标题栏空白区域 → 切换最大化/还原
2. Monaco移除theme:'vs-dark'和fontFamily覆盖, 使用官方默认配置
3. 编辑器外观与 https://microsoft.github.io/monaco-editor 一致
2026-05-31 02:35:52 +08:00
Your Name c570801119 fix: 编辑器拖拽修复 — 扩大拖拽区域+退出最大化+边界限制
问题:
- 标签栏整体被排除导致可拖拽区域几乎为零
- 最大化/全屏后拖拽不工作
- 没有拖拽视觉提示

修复:
- 添加⠿拖拽把手图标(cursor-grab)
- 只排除button/input/select, 标签栏空白处可拖拽
- 拖拽时自动退出最大化/全屏模式
- 边界限制(不拖出屏幕)
- 拖拽结束触发Monaco重新layout
2026-05-31 02:31:56 +08:00
Your Name cba8b3d8f6 feat: 浮动编辑器 — 拖拽+缩放+全屏+文件树
功能:
- 拖拽: 按住标题栏拖动编辑器面板
- 缩放: 右下角拖拽调整大小
- 最大化(): 占满屏幕留边距
- 全屏(⤢): 完全覆盖页面
- 最小化(—): 收到底部标签栏
- 关闭(✕): 关闭全部标签
- 文件树(🌳): 左侧可折叠文件树, 点击文件打开/目录导航
- 文件树同步: 浏览新目录时自动更新

布局:
┌──────────────────────────────────┐
│ [🌳] [📄a.conf×][📄b.py×]  [][⤢][—][✕] │ ← 可拖拽
├────────┬─────────────────────────┤
│ 📂 ..  │                         │
│ 📁 dir │    Monaco Editor        │
│ 📄 a   │                         │
│ 📄 b   │                         │
│        │ Ln1,Col1|ini|UTF-8|file │
└────────┴─────────────────────────┘  ← 右下角可缩放
2026-05-31 02:26:25 +08:00
Your Name caef4a216b fix: Monaco配置完全对齐官方默认值
对齐项目:
- cursorBlinking: smooth→blink(官方默认)
- 移除: cursorSmoothCaretAnimation, cursorWidth(非官方默认)
- 移除: lineHeight, padding(非官方默认)
- 移除: renderLineHighlight, smoothScrolling(非官方默认)
- 移除: scrollbar自定义尺寸(使用官方默认)
- scrollBeyondLastLine: false→true(官方默认)
- renderWhitespace: selection→none(官方默认)
- 添加: fontFamily(Cascadia Code/Fira Code/JetBrains Mono)
- 移除: quickSuggestions, insertSpaces, foldingHighlight, minimap side/scale
- 保留vs-dark主题(匹配Nexus暗色UI)
2026-05-31 02:20:04 +08:00
Your Name 507b957b4e refactor: 编辑器改为浮动面板 + Monaco配置对齐官方
布局:
- 全屏覆盖 → 右下角浮动面板(70vw×65vh)
- 文件列表始终可见, 编辑器不遮挡
- 最大化/还原按钮()
- 最小化到底部标签栏
- 移除文件树(文件列表已在背景可见)

Monaco配置对齐官方demo:
- fontSize 13→14, lineHeight 20
- wordWrap on→off(水平滚动)
- 新增: folding, matchBrackets, cursorBlinking smooth
- 新增: cursorSmoothCaretAnimation, smoothScrolling
- 新增: padding, renderLineHighlight all, renderWhitespace selection
- 新增: suggestOnTriggerCharacters, quickSuggestions
- 新增: links, colorDecorators
- 新增: tabSize 4
2026-05-31 02:14:56 +08:00
Your Name c70424a224 docs: Monaco IDE编辑器 changelog + 审计报告 2026-05-31 02:08:31 +08:00
Your Name e8c1acba9f fix: 审计8个FINDING修复 (IDE编辑器+终端)
- H-01 [MEDIUM] esc() 加引号转义('"\') 防止onclick XSS
- H-02 [MEDIUM] initialPath 路径白名单校验(仅允许字母数字/.-_)
- H-03 [LOW] doNewFile 文件名白名单正则(替代简单的includes('/'))
- H-08 [LOW] Monaco CDN SRI — 已知限制, 版本锁定@0.45.0
- H-13 [MEDIUM] showModal 移除已删除的modalTextarea引用
- H-20 [LOW] initialPath仅首次连接cd, 不影响恢复的标签
- H-26 [MEDIUM] esc()跨文件一致性 — files.html对齐terminal.html
- H-30 [LOW] esc()注释更新
2026-05-31 02:06:42 +08:00
Your Name 34786737b4 fix: IDE编辑器语法错误 — 多余花括号 2026-05-31 01:56:15 +08:00
Your Name be633f3e58 feat: IDE编辑器重构 — 多标签+文件树+最小化+状态栏
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run
- 全屏IDE面板替代模态框编辑器
- 多文件标签页: 独立Monaco实例, 切换保持状态
- 左侧文件树: 基于当前目录, 点击文件打开, 点击目录导航
- 最小化/恢复: 底部最小化栏显示所有打开的文件标签
- 状态栏: 光标位置+语言+编码+未保存指示器
- Ctrl+S保存 + 未保存关闭确认
- 标签栏: 点击切换, ×关闭, amber色未保存标记
- 文件树同步: 浏览新目录时自动更新文件树
- 模态框保留用于非编辑操作(重命名/删除/新建等)
2026-05-31 01:50:34 +08:00
Your Name 00a3309005 feat: 服务器列表加「文件」按钮直达文件管理
- servers.html: renderActions 加「文件」链接(琥珀色) → /app/files.html?server_id=X
- files.html: 支持 server_id URL参数, 自动选中服务器并浏览 /www/wwwroot
2026-05-31 01:33:52 +08:00
Your Name 3d15200cfa chore: 移除Monaco debug日志 2026-05-31 01:28:57 +08:00
Your Name d8aca5bf18 fix: Monaco预加载移入files.html(解决api.js缓存问题) 2026-05-31 01:26:59 +08:00
Your Name cb60af4483 debug: Monaco preload 加日志诊断 2026-05-31 01:22:45 +08:00
Your Name 5a5adafb39 feat: Monaco编辑器+右键菜单+排序筛选+chmod+压缩解压+新建文件
编辑器增强:
- E-2 Monaco集成: 70vh全屏编辑器, 暗色主题, bracket着色
- E-3 Ctrl+S保存快捷键 + 未保存指示器
- E-4 语言自动检测(30+扩展名映射)
- Monaco预加载: 登录后CDN后台加载, 打开编辑器秒开
- fallback: Monaco未就绪时退回textarea

文件管理增强:
- F-1 右键菜单: 预览/下载/重命名/权限/复制路径/终端/压缩/解压/删除
- F-2 新建文件按钮(📄+)
- F-3 chmod权限修改(右键→输入权限如755)
- F-4 排序切换(名称/大小/时间)
- F-5 文件类型筛选下拉(.conf/.log/.sh/.py等)
- F-6 压缩(tar.gz)
- F-7 解压(tar.gz/zip)
2026-05-31 01:10:22 +08:00
Your Name c0296b9d8e fix: 预加载文件大小 30KB → 1MB 2026-05-31 01:04:44 +08:00
Your Name ca4aceba3e fix: 面包屑点击事件委托 — 作用域不在fileListEl内
面包屑(#breadcrumbPath)和文件列表(#fileList)是兄弟节点, 不是父子关系
事件委托绑在fileListEl上无法捕获面包屑点击
修复: 给#breadcrumbPath加独立click事件委托
2026-05-31 00:57:58 +08:00
Your Name b5dfa8a870 feat: 文件管理器集成SSH终端
- files.html: 工具栏加「🖥 终端」按钮(绿色),点击在新标签页打开SSH终端
- files.html: openTerminal() 传递 server_id + 当前路径到 terminal.html
- terminal.html: 支持 path URL参数,连接成功后自动 cd 到指定目录
- 打开方式: /app/terminal.html?server_id=8&path=/www/wwwroot
- 右键菜单也会集成「打开终端」选项
2026-05-31 00:53:00 +08:00
Your Name 17cdc96bb0 feat: 文件预加载+缓存 — 双击秒开编辑器
- api.js: Monaco编辑器CDN预加载(登录后后台加载)
- files.html: 文件内容预加载(目录浏览后自动预读≤30KB文本文件)
- files.html: 文件缓存(_fileCache), 双击已缓存文件直接打开
- files.html: 切换服务器时清空缓存
- 预加载策略: 最多10个文件, 仅文本类型, ≤30KB
2026-05-31 00:46:04 +08:00
Your Name 6012af0b4d fix: 双击编辑器冲突 — 延迟区分单击/双击 2026-05-31 00:25:02 +08:00
Your Name 9bdf7e420f feat: 文件管理器4项改进
1. 切换服务器自动重置路径为 /www/wwwroot
2. 默认目录 /www/wwwroot(而非根目录 /)
3. 双击文件打开编辑器(doPreview textarea模态框)
4. 双击目录进入目录(browseDir)
2026-05-31 00:17:22 +08:00
Your Name 40d672fdbb fix: 面包屑双斜杠bug (/tmp显示为//tmp) 2026-05-31 00:11:47 +08:00
Your Name 557c8d28c1 docs: files迭代审计报告 2026-05-30 23:46:12 +08:00
Your Name 9f7be66356 fix: 审计5个FINDING修复 (files迭代)
- H-15 [MEDIUM] download端点加100MB大小限制
- H-02 [LOW] Content-Disposition filename sanitize(替换特殊字符)
- H-12 [LOW] FileDownload/FileRead/FileWrite path加max_length=4096
- H-27 [LOW] doPreview保存失败解析错误详情显示detail
- H-29 [INFO] esc()函数加注释说明用途
- H-01 [HIGH] 远程路径限制 — ACCEPTED_RISK(用户明确决策)
2026-05-30 23:44:35 +08:00
Your Name ac38442383 docs: files.html 全面迭代 changelog 2026-05-30 23:36:49 +08:00
Your Name 14131f98f0 feat: files.html 全面迭代 — 15项改进
安全+后端:
- P2-1 符号链接解析(l权限检测+name/target分离)
- P2-7 新增 /api/sync/download 端点(SFTP流式下载)
- P2-8 新增 /api/sync/read-file + /write-file 端点(base64编码避免shell注入)

前端全面重写:
- P1-1 目录删除二次确认(自定义模态框+输入目录名)
- P2-2/2-3 事件委托替代内联onclick(data-action+data-path)
- P2-4 上传进度条(XHR.upload.onprogress)
- P2-5 文件大小可读化(B/KB/MB/GB)
- P2-6 文件类型图标(30+扩展名映射)
- P2-7 下载按钮+逻辑
- P2-8 文件预览/编辑弹窗(读取+修改+保存)
- P2-9 自定义模态框替代prompt/confirm
- P2-10 批量选择+批量删除(checkbox+全选+底部操作栏)
- P3-1 文件名搜索框(前端实时过滤)
- P3-2 符号链接🔗图标+target tooltip
- P3-3 空目录状态(图标+上传引导按钮)
- P3-4 服务器刷新按钮
- P3-5 操作loading状态(进度条) 2>&1
2026-05-30 23:34:24 +08:00
Your Name 64503c0260 fix: IP校验改用endpoint级HTTPException替代Pydantic validator
model_validator/field_validator在自定义RequestValidationError handler下
无法正确序列化为JSON,导致500。改用endpoint级_validate_ip_list()辅助函数
抛HTTPException(400),避免Pydantic→FastAPI序列化链路问题。
2026-05-30 22:48:41 +08:00
Your Name d19f5af807 fix: IP格式校验用field_validator替代model_validator
model_validator(mode='after')抛出ValueError时FastAPI不能正确序列化为422
→ 导致500 TypeError: Object of type ValueError is not JSON serializable
改用field_validator(mode='before')由Pydantic正常捕获返回422
2026-05-30 22:44:26 +08:00
Your Name 463841a740 docs: settings安全加固审计报告 2026-05-30 22:35:40 +08:00
12 changed files with 1591 additions and 127 deletions
+93
View File
@@ -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 端点)
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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)}
+16
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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>`;
+11
View File
@@ -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);