feat: file operations (delete/rename/mkdir) + fix roadmap Step3 status

Backend:
- schemas.py: FileOperation model (server_id, operation, path, new_path)
- sync_v2.py: POST /api/sync/file-ops endpoint
  - delete: rm -rf with shlex.quote
  - rename: mv src -> dst with shlex.quote on both paths
  - mkdir: mkdir -p with shlex.quote
  - all operations audit-logged, 30s timeout

Frontend (files.html):
- Header: add 📁+ button (doMkdir via prompt)
- File rows: action buttons (rename + delete) appear on hover
  - rename: prompt for new name, inline mv
  - delete: confirm dialog with warning for dirs (rm -rf)
  - all ops refresh current directory after success
- fileOp() shared helper for POST /api/sync/file-ops

Docs:
- roadmap.md: Step 3 Web SSH updated from 70% to completed

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-05-23 16:46:00 +08:00
parent ac0eb79707
commit 6c5e6a2daa
4 changed files with 122 additions and 9 deletions
+2 -2
View File
@@ -130,13 +130,13 @@ Web SSH 表:
- [x] Pydantic 请求模型 (`server/api/schemas.py`)
- [x] 所有 API 路由 JWT 保护
### 第三步:Web SSH 终端 — ⚠️ 70% (后端已完成)
### 第三步:Web SSH 终端 — 已完成
- [x] asyncssh 连接池模块 (`server/infrastructure/ssh/asyncssh_pool.py`)
- [x] WebSocket 端点 `/ws/terminal/{server_id}` (`server/api/webssh.py`)
- [x] Koko 消息协议 (TERMINAL_INIT/DATA/RESIZE/CLOSE)
- [x] SSH 会话生命周期追踪 (SshSession + CommandLog)
- [ ] xterm.js 前端页面
- [x] xterm.js 前端页面 (`web/app/terminal.html`,含 webssh-token + 全屏 + resize)
### 第四步:Sync 引擎升级 — ✅ 已完成
+8
View File
@@ -124,6 +124,14 @@ class SyncBrowse(BaseModel):
path: str = Field("/", min_length=1)
class FileOperation(BaseModel):
"""Remote file operation via SSH exec (delete / rename / mkdir)."""
server_id: int = Field(..., ge=1)
operation: str = Field(..., pattern="^(delete|rename|mkdir)$")
path: str = Field(..., min_length=1) # source path (or new dir path for mkdir)
new_path: Optional[str] = None # required for rename
# ── Script ──
class ScriptCreate(BaseModel):
+54 -1
View File
@@ -11,7 +11,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from server.api.schemas import SyncFiles, SyncCommands, SyncConfig, SyncSftp, SyncBrowse, SyncPreview
from server.api.schemas import SyncFiles, SyncCommands, SyncConfig, SyncSftp, SyncBrowse, SyncPreview, FileOperation
from server.api.dependencies import get_server_service
from server.application.services.sync_engine_v2 import SyncEngineV2
from server.infrastructure.database.server_repo import ServerRepositoryImpl
@@ -174,6 +174,59 @@ async def preview_sync(
return result
@router.post("/file-ops")
async def file_operation(
payload: FileOperation,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""Remote file operation (delete / rename / mkdir) via SSH exec.
All paths are shlex-quoted server-side. Dirs use rm -rf; confirm
client-side before sending delete on a directory. Audit-logged.
"""
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="Server not found")
op = payload.operation
p = payload.path.rstrip("/") or "/"
if op == "delete":
# rm -rf covers both files and directories
cmd = f"rm -rf {shlex.quote(p)}"
audit_detail = f"Delete: {p}"
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 "/"
cmd = f"mv {shlex.quote(p)} {shlex.quote(np)}"
audit_detail = f"Rename: {p}{np}"
elif op == "mkdir":
cmd = f"mkdir -p {shlex.quote(p)}"
audit_detail = f"Mkdir: {p}"
else:
raise HTTPException(status_code=400, detail="Invalid operation")
result = await exec_ssh_command(server, cmd, timeout=30)
if result["exit_code"] != 0:
raise HTTPException(
status_code=400,
detail=result["stderr"][:300] or f"Command failed (exit {result['exit_code']})"
)
await _audit_sync(
f"file_{op}", "server", payload.server_id,
f"{audit_detail} on {server.name}", admin.username, request,
)
return {"success": True, "operation": op, "path": p}
@router.post("/browse")
async def browse_directory(
payload: SyncBrowse,
+58 -6
View File
@@ -13,6 +13,7 @@
</select>
<input id="dirPath" type="text" value="/" placeholder="/ 路径" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48">
<button onclick="browseDir()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">浏览</button>
<button onclick="doMkdir()" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="新建目录">📁+</button>
</div>
</header>
<!-- Breadcrumb -->
@@ -70,12 +71,17 @@
const sorted=[...d.entries].sort((a,b)=>(b.is_dir-a.is_dir)||a.name.localeCompare(b.name));
html+=sorted.map(e=>{
const fullPath=_currentPath.replace(/\/$/,'')+'/'+e.name;
return `<div class="flex items-center gap-3 px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50 ${e.is_dir?'cursor-pointer':''}" ${e.is_dir?`onclick="browseDir('${escAttr(fullPath)}')"`:''}>
<span class="${e.is_dir?'text-yellow-400':'text-slate-400'} text-sm">${e.is_dir?'📁':'📄'}</span>
<span class="text-white text-sm flex-1 ${e.is_dir?'hover:text-brand-light':''}">${esc(e.name)}</span>
<span class="text-slate-500 text-xs tabular-nums">${e.is_dir?'--':e.size}</span>
<span class="text-slate-600 text-xs">${esc(e.perms)}</span>
<span class="text-slate-600 text-xs">${esc(e.owner||'')}</span>
return `<div class="group flex items-center gap-3 px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50">
<span class="${e.is_dir?'text-yellow-400':'text-slate-400'} text-sm shrink-0">${e.is_dir?'📁':'📄'}</span>
<span class="text-white text-sm flex-1 ${e.is_dir?'cursor-pointer hover:text-brand-light':''}" ${e.is_dir?`onclick="browseDir('${escAttr(fullPath)}')"`:''} id="name_${escAttr(fullPath)}">${esc(e.name)}</span>
<span class="text-slate-500 text-xs tabular-nums w-16 text-right shrink-0">${e.is_dir?'--':e.size}</span>
<span class="text-slate-600 text-xs shrink-0 hidden sm:block">${esc(e.perms)}</span>
<span class="text-slate-600 text-xs shrink-0 hidden md:block">${esc(e.owner||'')}</span>
<!-- Action buttons (appear on row hover) -->
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button onclick="doRename('${escAttr(fullPath)}','${escAttr(e.name)}')" class="px-2 py-1 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded transition" title="重命名">✏</button>
<button onclick="doDelete('${escAttr(fullPath)}',${e.is_dir})" class="px-2 py-1 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/30 rounded transition" title="${e.is_dir?'删除目录':'删除文件'}">🗑</button>
</div>
</div>`;
}).join('');
}else if(_currentPath==='/'){
@@ -109,6 +115,52 @@
bcPath.innerHTML=html;
}
// ── File Operations ──
async function fileOp(operation, path, newPath){
if(!_currentServerId){toast('warning','请先选择服务器');return false}
const body={server_id:_currentServerId, operation, path};
if(newPath)body.new_path=newPath;
const r=await apiFetch(API+'/sync/file-ops',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
if(!r){toast('error','请求失败');return false}
if(!r.ok){const e=await r.json().catch(()=>({detail:'未知错误'}));toast('error',(e.detail||'操作失败').substring(0,80));return false}
return true;
}
async function doDelete(fullPath, isDir){
const name=fullPath.split('/').pop();
const warn=isDir?`确定要删除目录 "${name}" 及其所有内容吗?此操作不可恢复!`:`确定要删除文件 "${name}" 吗?`;
if(!confirm(warn))return;
if(await fileOp('delete', fullPath)){
toast('success', `已删除: ${name}`);
browseDir(_currentPath);
}
}
async function doRename(fullPath, oldName){
const dir=fullPath.slice(0, fullPath.lastIndexOf('/')) || '/';
const newName=prompt(`重命名「${oldName}」为:`, oldName);
if(!newName||newName===oldName)return;
if(newName.includes('/')){toast('error','文件名不能包含 /');return}
const newPath=dir.replace(/\/$/,'')+'/'+newName;
if(await fileOp('rename', fullPath, newPath)){
toast('success', `已重命名: ${oldName}${newName}`);
browseDir(_currentPath);
}
}
async function doMkdir(){
if(!_currentServerId){toast('warning','请先选择服务器');return}
const name=prompt('新建目录名称:', '');
if(!name)return;
if(name.includes('/')){toast('error','目录名不能包含 /');return}
const fullPath=_currentPath.replace(/\/$/,'')+'/'+name;
if(await fileOp('mkdir', fullPath)){
toast('success', `已创建目录: ${name}`);
browseDir(_currentPath);
}
}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function escAttr(s){if(!s)return'';return s.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}