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:
@@ -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 引擎升级 — ✅ 已完成
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>')}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user