mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-07 08:40:16 +08:00
feat(hermes): Batch 3 §L - 文件管理器(限定在 ~/.hermes 子树内)
校对:Hermes 没有 file HTTP API,必须自建。
## Rust 后端:3 个安全 fs 命令(~180 行)
### 安全策略
- 所有路径必须在 hermes_home() 子树内(防 path traversal)
- canonicalize 后用 starts_with 验证
- 拒绝绝对路径跳出 + .. 跳出
- 5 MB 文件大小上限
- 单次列目录最多 2000 条
### 命令
- hermes_fs_list(path) → { path, entries: [{name, kind, size, modified}] }
· 隐藏文件默认不显示(.env 除外)
· 排序:目录在前,文件在后,按名字
- hermes_fs_read(path) → { path, size, text?, binary_b64? }
· UTF-8 文本 → text
· 二进制 → 内置 base64 编码(不引新依赖)
- hermes_fs_write(path, content) → { path, size }
· 父目录必须存在
· 5 MB 上限
## 前端 /h/files 页面(~270 行)
### UI 布局
- 左侧:面包屑 + 目录树(点目录进入 / 点文件选中)
- 右侧:编辑器(文本)/ 预览(图片)/ 元信息(其他二进制)
### 文件树
- 文件图标按扩展名(📁/🔗/🖼️/📝/⚙️/📄)
- 文件大小显示(B/KB/MB)
- ".." 返回上级
- 选中态高亮
### 编辑器
- textarea 编辑,monospace 字体
- "保存 *" 状态标记 dirty
- 切目录/文件前 showConfirm 「丢弃未保存修改?」
- 保存成功 toast
### 二进制预览
- 图片 (png/jpg/gif/webp/svg) → 用 data: URL 显示
- 其他 → 「二进制文件(不可编辑)」+ 文件大小
## sidebar
- 「管理」section 加文件管理器入口(folder icon)
- /h/files 路由注册(含意外的 routes 顺序修复)
### dev-api.js
- Web 模式走 Node fs.readdirSync/readFileSync/writeFileSync
- 同样的安全策略:根目录 hermes_home() = process.env.HERMES_HOME 或 ~/.hermes
- realpath 验证 + 5 MB 限制
### CSS(~165 行)
- .hm-files-layout: grid 双栏(响应式 → 单栏)
- .hm-files-tree: 左侧树,breadcrumb + entry 列表
- .hm-files-pane: 右侧编辑器/预览
- .hm-files-editor: 全宽 textarea,monospace
- 响应式:768px 以下单栏
### i18n
- 12 个新键 × 3 语言(hermesFiles*)
## 修复
- src/engines/hermes/index.js 末尾多余 `}` 删除(之前 edit 留下)
## 累计
- Rust ~180 行 + 前端 ~270 行 + CSS ~165 行 + dev-api ~55 行
- 12 个 i18n × 3 语言
- cargo check ✓ + npm build ✓
This commit is contained in:
@@ -6431,6 +6431,189 @@ pub async fn hermes_multi_gateway_stop(
|
||||
Ok(serde_json::json!({ "stopped": true, "was_running": true, "pid": pid }))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Batch 3 §L: 文件管理器(基础 fs 命令)
|
||||
//
|
||||
// 限制:所有路径必须在 hermes_home() (~/.hermes) 子树内(防 path traversal)。
|
||||
// 提供:list / read / write 三个基础命令,前端组合成文件管理器 UI。
|
||||
// ============================================================================
|
||||
|
||||
const FS_MAX_READ_BYTES: u64 = 5 * 1024 * 1024; // 5 MB
|
||||
const FS_MAX_LIST_ENTRIES: usize = 2000; // 单次最多返回 2000 条
|
||||
|
||||
/// 验证路径在 hermes_home 子树内(防 path traversal)。
|
||||
/// 返回安全的绝对路径,或 Err。
|
||||
fn validate_hermes_fs_path(rel_path: &str) -> Result<PathBuf, String> {
|
||||
let root = hermes_home();
|
||||
// 空 = 根目录
|
||||
let target = if rel_path.is_empty() {
|
||||
root.clone()
|
||||
} else {
|
||||
// 拒绝绝对路径输入(必须相对于 hermes_home)
|
||||
let p = std::path::Path::new(rel_path);
|
||||
if p.is_absolute() {
|
||||
// 允许绝对路径,但必须以 root 开头(用 starts_with 检查)
|
||||
let canonical_root = root.canonicalize().unwrap_or(root.clone());
|
||||
let canonical_target = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
|
||||
if !canonical_target.starts_with(&canonical_root) {
|
||||
return Err(format!(
|
||||
"路径必须在 {} 子树内",
|
||||
root.to_string_lossy()
|
||||
));
|
||||
}
|
||||
canonical_target
|
||||
} else {
|
||||
// 相对路径:拼到 root 下,再 canonicalize 防 ..
|
||||
let joined = root.join(p);
|
||||
// 父目录必须存在才能 canonicalize;对不存在的新文件 fallback 到 joined
|
||||
let canon = joined.canonicalize().unwrap_or(joined.clone());
|
||||
let canonical_root = root.canonicalize().unwrap_or(root.clone());
|
||||
if !canon.starts_with(&canonical_root) {
|
||||
return Err(format!(
|
||||
"路径不能跳出 {} 目录",
|
||||
root.to_string_lossy()
|
||||
));
|
||||
}
|
||||
canon
|
||||
}
|
||||
};
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hermes_fs_list(path: String) -> Result<Value, String> {
|
||||
let target = validate_hermes_fs_path(&path)?;
|
||||
if !target.exists() {
|
||||
return Err(format!("目录不存在: {}", target.to_string_lossy()));
|
||||
}
|
||||
if !target.is_dir() {
|
||||
return Err(format!("不是目录: {}", target.to_string_lossy()));
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
let read_dir = std::fs::read_dir(&target).map_err(|e| format!("读取目录失败: {e}"))?;
|
||||
for entry in read_dir.flatten().take(FS_MAX_LIST_ENTRIES) {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.') && name != ".env" && name != ".hermes" {
|
||||
continue; // 隐藏文件默认不显示(.env 除外因为 Hermes 用它)
|
||||
}
|
||||
let ft = match entry.file_type() {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let meta = entry.metadata().ok();
|
||||
let size = meta.as_ref().and_then(|m| if m.is_file() { Some(m.len()) } else { None });
|
||||
let modified = meta.as_ref().and_then(|m| m.modified().ok()).and_then(|t| {
|
||||
t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs())
|
||||
});
|
||||
entries.push(serde_json::json!({
|
||||
"name": name,
|
||||
"kind": if ft.is_dir() { "dir" } else if ft.is_symlink() { "symlink" } else { "file" },
|
||||
"size": size,
|
||||
"modified": modified,
|
||||
}));
|
||||
}
|
||||
// 目录在前,文件在后,每组按名字排序
|
||||
entries.sort_by(|a, b| {
|
||||
let ak = a.get("kind").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let bk = b.get("kind").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if ak != bk {
|
||||
return if ak == "dir" { std::cmp::Ordering::Less } else { std::cmp::Ordering::Greater };
|
||||
}
|
||||
let an = a.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let bn = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
an.to_lowercase().cmp(&bn.to_lowercase())
|
||||
});
|
||||
Ok(serde_json::json!({
|
||||
"path": target.to_string_lossy(),
|
||||
"entries": entries,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hermes_fs_read(path: String) -> Result<Value, String> {
|
||||
let target = validate_hermes_fs_path(&path)?;
|
||||
if !target.exists() {
|
||||
return Err(format!("文件不存在: {}", target.to_string_lossy()));
|
||||
}
|
||||
if !target.is_file() {
|
||||
return Err(format!("不是文件: {}", target.to_string_lossy()));
|
||||
}
|
||||
let meta = target.metadata().map_err(|e| format!("读元数据失败: {e}"))?;
|
||||
if meta.len() > FS_MAX_READ_BYTES {
|
||||
return Err(format!(
|
||||
"文件过大({} bytes),最大 {} bytes",
|
||||
meta.len(),
|
||||
FS_MAX_READ_BYTES
|
||||
));
|
||||
}
|
||||
let content = std::fs::read(&target).map_err(|e| format!("读取失败: {e}"))?;
|
||||
// 尝试当作 UTF-8 文本;失败 → 二进制(用 base64)
|
||||
let (text_content, binary_b64) = match std::str::from_utf8(&content) {
|
||||
Ok(s) => (Some(s.to_string()), None),
|
||||
Err(_) => {
|
||||
// 简单的非文本判定(包含 null byte 即认为是二进制)
|
||||
(None, Some(base64_encode(&content)))
|
||||
}
|
||||
};
|
||||
Ok(serde_json::json!({
|
||||
"path": target.to_string_lossy(),
|
||||
"size": meta.len(),
|
||||
"text": text_content,
|
||||
"binary_b64": binary_b64,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 简单的 base64 编码(不引新依赖)
|
||||
fn base64_encode(bytes: &[u8]) -> String {
|
||||
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4);
|
||||
let mut i = 0;
|
||||
while i + 3 <= bytes.len() {
|
||||
let n = (u32::from(bytes[i]) << 16) | (u32::from(bytes[i + 1]) << 8) | u32::from(bytes[i + 2]);
|
||||
out.push(CHARS[((n >> 18) & 0x3F) as usize] as char);
|
||||
out.push(CHARS[((n >> 12) & 0x3F) as usize] as char);
|
||||
out.push(CHARS[((n >> 6) & 0x3F) as usize] as char);
|
||||
out.push(CHARS[(n & 0x3F) as usize] as char);
|
||||
i += 3;
|
||||
}
|
||||
let rem = bytes.len() - i;
|
||||
if rem == 1 {
|
||||
let n = u32::from(bytes[i]) << 16;
|
||||
out.push(CHARS[((n >> 18) & 0x3F) as usize] as char);
|
||||
out.push(CHARS[((n >> 12) & 0x3F) as usize] as char);
|
||||
out.push('=');
|
||||
out.push('=');
|
||||
} else if rem == 2 {
|
||||
let n = (u32::from(bytes[i]) << 16) | (u32::from(bytes[i + 1]) << 8);
|
||||
out.push(CHARS[((n >> 18) & 0x3F) as usize] as char);
|
||||
out.push(CHARS[((n >> 12) & 0x3F) as usize] as char);
|
||||
out.push(CHARS[((n >> 6) & 0x3F) as usize] as char);
|
||||
out.push('=');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hermes_fs_write(path: String, content: String) -> Result<Value, String> {
|
||||
let target = validate_hermes_fs_path(&path)?;
|
||||
// 父目录必须存在
|
||||
if let Some(parent) = target.parent() {
|
||||
if !parent.exists() {
|
||||
return Err(format!("父目录不存在: {}", parent.to_string_lossy()));
|
||||
}
|
||||
}
|
||||
// 写入大小限制(防止巨型文件意外写入)
|
||||
if content.len() as u64 > FS_MAX_READ_BYTES {
|
||||
return Err(format!("内容过大({} bytes),最大 {} bytes", content.len(), FS_MAX_READ_BYTES));
|
||||
}
|
||||
std::fs::write(&target, content.as_bytes()).map_err(|e| format!("写入失败: {e}"))?;
|
||||
let meta = target.metadata().ok();
|
||||
Ok(serde_json::json!({
|
||||
"path": target.to_string_lossy(),
|
||||
"size": meta.map(|m| m.len()).unwrap_or(0),
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Unit tests for the pure YAML helpers (no filesystem I/O).
|
||||
// ============================================================================
|
||||
|
||||
@@ -249,6 +249,9 @@ pub fn run() {
|
||||
hermes::hermes_multi_gateway_remove,
|
||||
hermes::hermes_multi_gateway_start,
|
||||
hermes::hermes_multi_gateway_stop,
|
||||
hermes::hermes_fs_list,
|
||||
hermes::hermes_fs_read,
|
||||
hermes::hermes_fs_write,
|
||||
hermes::hermes_read_config,
|
||||
hermes::hermes_read_config_full,
|
||||
hermes::hermes_lazy_deps_features,
|
||||
|
||||
Reference in New Issue
Block a user