2026-05-21 22:11:38 +08:00
|
|
|
|
"""Nexus — Sync Engine v2 API Routes (Step 4)
|
|
|
|
|
|
|
2026-05-23 16:41:47 +08:00
|
|
|
|
S1: POST /api/sync/files — rsync file sync
|
|
|
|
|
|
P1: POST /api/sync/preview — rsync dry-run (方案D: 智能提示预览)
|
2026-05-21 22:11:38 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
|
import logging
|
|
|
|
|
|
|
2026-05-21 22:11:38 +08:00
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
from server.api.schemas import (
|
|
|
|
|
|
SyncFiles,
|
|
|
|
|
|
SyncBrowse,
|
|
|
|
|
|
SyncBrowseLocal,
|
|
|
|
|
|
SyncPreview,
|
|
|
|
|
|
FileOperation,
|
|
|
|
|
|
FileClipboardApply,
|
|
|
|
|
|
LocalFileOperation,
|
|
|
|
|
|
LocalFilePreview,
|
|
|
|
|
|
SyncVerify,
|
|
|
|
|
|
SyncCancel,
|
|
|
|
|
|
ValidateSourcePath,
|
|
|
|
|
|
SyncDiagnose,
|
|
|
|
|
|
FileSyncDiff,
|
|
|
|
|
|
FileDownload,
|
|
|
|
|
|
FileRead,
|
|
|
|
|
|
FileWrite,
|
|
|
|
|
|
FileChmod,
|
|
|
|
|
|
FileCompress,
|
|
|
|
|
|
FileDecompress,
|
|
|
|
|
|
)
|
|
|
|
|
|
from server.api.remote_path_validation import RemotePathError, assert_clipboard_transfer_safe
|
|
|
|
|
|
from server.utils.posix_paths import (
|
|
|
|
|
|
PosixPathError,
|
|
|
|
|
|
assert_zip_member_safe,
|
|
|
|
|
|
ensure_under_nexus_upload,
|
|
|
|
|
|
is_path_under_prefix,
|
|
|
|
|
|
normalize_remote_abs_path,
|
|
|
|
|
|
posix_basename,
|
|
|
|
|
|
posix_dirname,
|
|
|
|
|
|
posix_join,
|
|
|
|
|
|
normalize_remote_dest,
|
|
|
|
|
|
remote_join,
|
|
|
|
|
|
resolve_nexus_host_path,
|
|
|
|
|
|
to_posix,
|
|
|
|
|
|
)
|
2026-05-21 22:11:38 +08:00
|
|
|
|
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
|
|
|
|
|
|
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
|
|
|
|
|
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
|
|
|
|
|
|
from server.api.auth_jwt import get_current_admin
|
2026-05-22 22:29:35 +08:00
|
|
|
|
from server.domain.models import Admin, AuditLog
|
2026-05-21 22:11:38 +08:00
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
|
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
|
logger = logging.getLogger("nexus.sync_v2")
|
|
|
|
|
|
|
2026-05-21 22:11:38 +08:00
|
|
|
|
|
|
|
|
|
|
async def _get_sync_engine(request: Request) -> SyncEngineV2:
|
|
|
|
|
|
"""DI factory for SyncEngineV2"""
|
|
|
|
|
|
session = request.state.db
|
|
|
|
|
|
return SyncEngineV2(
|
|
|
|
|
|
server_repo=ServerRepositoryImpl(session),
|
|
|
|
|
|
sync_log_repo=SyncLogRepositoryImpl(session),
|
|
|
|
|
|
audit_repo=AuditLogRepositoryImpl(session),
|
|
|
|
|
|
retry_repo=PushRetryJobRepositoryImpl(session),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/files")
|
|
|
|
|
|
async def sync_files(
|
2026-05-21 23:18:49 +08:00
|
|
|
|
payload: SyncFiles,
|
2026-05-21 22:11:38 +08:00
|
|
|
|
request: Request,
|
|
|
|
|
|
admin: Admin = Depends(get_current_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""S1: File sync via rsync over SSH (with parallel execution)"""
|
|
|
|
|
|
engine = await _get_sync_engine(request)
|
|
|
|
|
|
return await engine.sync_files(
|
2026-05-21 23:18:49 +08:00
|
|
|
|
server_ids=payload.server_ids,
|
|
|
|
|
|
source_path=payload.source_path,
|
|
|
|
|
|
target_path=payload.target_path,
|
|
|
|
|
|
sync_mode=payload.sync_mode,
|
2026-05-21 22:11:38 +08:00
|
|
|
|
operator=admin.username,
|
2026-05-21 23:18:49 +08:00
|
|
|
|
batch_size=payload.batch_size,
|
|
|
|
|
|
concurrency=payload.concurrency,
|
2026-05-21 22:11:38 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 16:41:47 +08:00
|
|
|
|
@router.post("/preview")
|
|
|
|
|
|
async def preview_sync(
|
|
|
|
|
|
payload: SyncPreview,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
admin: Admin = Depends(get_current_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""P1: Dry-run rsync --stats against one representative server.
|
|
|
|
|
|
|
|
|
|
|
|
No data is transferred. Returns file-change statistics and optional
|
|
|
|
|
|
per-file list (verbose=true, capped at 200 lines).
|
|
|
|
|
|
|
|
|
|
|
|
Full-sync mode (--delete) shows files_deleted — use this to confirm
|
|
|
|
|
|
before running a destructive full sync.
|
|
|
|
|
|
"""
|
|
|
|
|
|
engine = await _get_sync_engine(request)
|
|
|
|
|
|
result = await engine.preview_sync(
|
|
|
|
|
|
server_id=payload.server_id,
|
|
|
|
|
|
source_path=payload.source_path,
|
|
|
|
|
|
target_path=payload.target_path,
|
|
|
|
|
|
sync_mode=payload.sync_mode,
|
|
|
|
|
|
verbose=payload.verbose,
|
|
|
|
|
|
)
|
|
|
|
|
|
if "error" in result:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=result["error"])
|
|
|
|
|
|
|
|
|
|
|
|
await _audit_sync(
|
|
|
|
|
|
"sync_preview", "server", payload.server_id,
|
|
|
|
|
|
f"Dry-run preview: {payload.source_path} → server #{payload.server_id} ({payload.sync_mode})",
|
|
|
|
|
|
admin.username, request,
|
|
|
|
|
|
)
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 16:46:00 +08:00
|
|
|
|
@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
|
2026-06-01 19:54:18 +08:00
|
|
|
|
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
|
2026-05-23 16:46:00 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-06-01 19:54:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
p = normalize_remote_abs_path(payload.path)
|
|
|
|
|
|
except PosixPathError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
2026-05-23 16:46:00 +08:00
|
|
|
|
|
|
|
|
|
|
if op == "delete":
|
|
|
|
|
|
# rm -rf covers both files and directories
|
|
|
|
|
|
cmd = f"rm -rf {shlex.quote(p)}"
|
2026-05-27 02:17:35 +08:00
|
|
|
|
audit_detail = f"删除: {p}"
|
2026-05-23 16:46:00 +08:00
|
|
|
|
elif op == "rename":
|
|
|
|
|
|
if not payload.new_path:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="new_path required for rename")
|
2026-06-01 19:54:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
np = normalize_remote_abs_path(payload.new_path)
|
|
|
|
|
|
except PosixPathError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
2026-05-23 16:46:00 +08:00
|
|
|
|
cmd = f"mv {shlex.quote(p)} {shlex.quote(np)}"
|
2026-05-27 02:17:35 +08:00
|
|
|
|
audit_detail = f"重命名: {p} → {np}"
|
2026-05-23 16:46:00 +08:00
|
|
|
|
elif op == "mkdir":
|
|
|
|
|
|
cmd = f"mkdir -p {shlex.quote(p)}"
|
2026-05-27 02:17:35 +08:00
|
|
|
|
audit_detail = f"新建目录: {p}"
|
2026-05-23 16:46:00 +08:00
|
|
|
|
else:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Invalid operation")
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
result = await exec_ssh_command_with_fallback(server, cmd, timeout=30)
|
2026-05-23 16:46:00 +08:00
|
|
|
|
if result["exit_code"] != 0:
|
2026-06-01 19:54:18 +08:00
|
|
|
|
err = (result.get("stderr") or result.get("stdout") or "")[:300]
|
2026-05-23 16:46:00 +08:00
|
|
|
|
raise HTTPException(
|
2026-06-01 19:54:18 +08:00
|
|
|
|
status_code=403 if "permission denied" in err.lower() else 400,
|
|
|
|
|
|
detail=err or f"Command failed (exit {result['exit_code']})",
|
2026-05-23 16:46:00 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
await _audit_sync(
|
|
|
|
|
|
f"file_{op}", "server", payload.server_id,
|
2026-05-27 02:17:35 +08:00
|
|
|
|
f"{audit_detail} 于 {server.name}", admin.username, request,
|
2026-05-23 16:46:00 +08:00
|
|
|
|
)
|
|
|
|
|
|
return {"success": True, "operation": op, "path": p}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
@router.post("/file-clipboard")
|
|
|
|
|
|
async def apply_file_clipboard(
|
|
|
|
|
|
payload: FileClipboardApply,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
admin: Admin = Depends(get_current_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Batch copy (cp -r) or move/cut (mv -t) remote paths into dest_dir."""
|
|
|
|
|
|
import shlex
|
|
|
|
|
|
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
|
|
|
|
|
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
|
|
|
|
|
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
sources, dest_dir = assert_clipboard_transfer_safe(payload.sources, payload.dest_dir)
|
|
|
|
|
|
except RemotePathError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
|
|
session = request.state.db
|
|
|
|
|
|
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
|
|
|
|
|
if not server:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="服务器不存在")
|
|
|
|
|
|
|
|
|
|
|
|
dest_q = shlex.quote(dest_dir)
|
|
|
|
|
|
check = await exec_ssh_command(server, f"test -d {dest_q}", timeout=15)
|
|
|
|
|
|
if check["exit_code"] != 0:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="目标目录不存在")
|
|
|
|
|
|
|
|
|
|
|
|
quoted_sources = " ".join(shlex.quote(s) for s in sources)
|
|
|
|
|
|
if payload.mode == "copy":
|
|
|
|
|
|
cmd = f"cp -r {quoted_sources} {dest_q}"
|
|
|
|
|
|
audit_action = "file_copy"
|
|
|
|
|
|
audit_detail = f"复制 {len(sources)} 项 → {dest_dir}"
|
|
|
|
|
|
else:
|
|
|
|
|
|
cmd = f"mv -t {dest_q} {quoted_sources}"
|
|
|
|
|
|
audit_action = "file_move"
|
|
|
|
|
|
audit_detail = f"移动 {len(sources)} 项 → {dest_dir}"
|
|
|
|
|
|
|
|
|
|
|
|
result = await exec_ssh_command_with_fallback(server, cmd, timeout=120)
|
|
|
|
|
|
if result["exit_code"] != 0:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=502,
|
|
|
|
|
|
detail=(result["stderr"] or result["stdout"] or "传输失败")[:300],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
await _audit_sync(
|
|
|
|
|
|
audit_action,
|
|
|
|
|
|
"server",
|
|
|
|
|
|
payload.server_id,
|
|
|
|
|
|
f"{audit_detail} 于 {server.name}",
|
|
|
|
|
|
admin.username,
|
|
|
|
|
|
request,
|
|
|
|
|
|
)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"success": True,
|
|
|
|
|
|
"mode": payload.mode,
|
|
|
|
|
|
"dest_dir": dest_dir,
|
|
|
|
|
|
"count": len(sources),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 22:54:35 +08:00
|
|
|
|
@router.post("/browse")
|
|
|
|
|
|
async def browse_directory(
|
2026-05-21 23:18:49 +08:00
|
|
|
|
payload: SyncBrowse,
|
2026-05-21 22:54:35 +08:00
|
|
|
|
request: Request,
|
|
|
|
|
|
admin: Admin = Depends(get_current_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Browse remote directory listing via SSH"""
|
|
|
|
|
|
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
|
|
|
|
|
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
|
|
|
|
|
|
|
|
|
|
|
session = request.state.db
|
2026-05-21 23:18:49 +08:00
|
|
|
|
server_id = payload.server_id
|
|
|
|
|
|
path = payload.path
|
2026-05-21 22:54:35 +08:00
|
|
|
|
|
|
|
|
|
|
if not server_id:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="server_id required")
|
|
|
|
|
|
|
|
|
|
|
|
repo = ServerRepositoryImpl(session)
|
|
|
|
|
|
server = await repo.get_by_id(server_id)
|
|
|
|
|
|
if not server:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
path = normalize_remote_abs_path(path)
|
|
|
|
|
|
except PosixPathError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
|
2026-05-22 08:19:56 +08:00
|
|
|
|
import shlex
|
2026-06-01 19:54:18 +08:00
|
|
|
|
from server.utils.unix_ls import parse_ls_la_line, perms_to_mode_octal
|
|
|
|
|
|
|
|
|
|
|
|
path_q = shlex.quote(path)
|
|
|
|
|
|
result = await exec_ssh_command(
|
|
|
|
|
|
server,
|
|
|
|
|
|
f"ls -la --time-style=long-iso {path_q}",
|
|
|
|
|
|
timeout=10,
|
|
|
|
|
|
)
|
|
|
|
|
|
if result["exit_code"] != 0:
|
|
|
|
|
|
result = await exec_ssh_command(server, f"ls -la {path_q}", timeout=10)
|
2026-05-22 22:29:35 +08:00
|
|
|
|
|
|
|
|
|
|
await _audit_sync("browse_directory", "server", server_id, f"Browse {server.name}:{path}", admin.username, request)
|
|
|
|
|
|
|
2026-05-21 22:54:35 +08:00
|
|
|
|
if result["exit_code"] != 0:
|
2026-06-01 19:54:18 +08:00
|
|
|
|
return {"path": path, "items": [], "error": result["stderr"][:500]}
|
|
|
|
|
|
|
|
|
|
|
|
raw_entries = []
|
|
|
|
|
|
for line in result["stdout"].split("\n"):
|
|
|
|
|
|
parsed = parse_ls_la_line(line)
|
|
|
|
|
|
if parsed:
|
|
|
|
|
|
raw_entries.append(parsed)
|
|
|
|
|
|
|
|
|
|
|
|
items = []
|
|
|
|
|
|
for e in raw_entries:
|
|
|
|
|
|
is_dir = bool(e["is_dir"])
|
|
|
|
|
|
is_link = bool(e["is_link"])
|
|
|
|
|
|
try:
|
|
|
|
|
|
size = int(e["size"]) if not is_dir and not is_link else 0
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
size = 0
|
|
|
|
|
|
if is_dir:
|
|
|
|
|
|
ftype = "directory"
|
|
|
|
|
|
elif is_link:
|
|
|
|
|
|
ftype = "symlink"
|
|
|
|
|
|
else:
|
|
|
|
|
|
ftype = "file"
|
|
|
|
|
|
mode_octal = perms_to_mode_octal(e["perms"]) or ""
|
|
|
|
|
|
items.append({
|
|
|
|
|
|
"name": e["name"],
|
|
|
|
|
|
"type": ftype,
|
|
|
|
|
|
"size": size,
|
|
|
|
|
|
"modified": e["modified"],
|
|
|
|
|
|
"permissions": e["perms"],
|
|
|
|
|
|
"owner": e.get("owner") or "",
|
|
|
|
|
|
"group": e.get("group") or "",
|
|
|
|
|
|
"mode_octal": mode_octal,
|
|
|
|
|
|
"link_target": e.get("link_target"),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return {"path": path, "items": items}
|
2026-05-22 22:29:35 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-28 21:01:27 +08:00
|
|
|
|
@router.post("/browse-local")
|
|
|
|
|
|
async def browse_local_directory(
|
|
|
|
|
|
payload: SyncBrowseLocal,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
admin: Admin = Depends(get_current_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Browse a local directory on the Nexus server (for upload file manager).
|
|
|
|
|
|
|
|
|
|
|
|
Only allows browsing under /tmp/nexus_upload_* directories.
|
|
|
|
|
|
Returns entries with name, is_dir, size.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import os
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
real_path = ensure_under_nexus_upload(payload.path)
|
|
|
|
|
|
except PosixPathError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
2026-05-28 21:01:27 +08:00
|
|
|
|
|
|
|
|
|
|
if not os.path.isdir(real_path):
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="目录不存在")
|
|
|
|
|
|
|
|
|
|
|
|
entries = []
|
|
|
|
|
|
try:
|
|
|
|
|
|
for entry in sorted(os.scandir(real_path), key=lambda e: (not e.is_dir(), e.name.lower())):
|
|
|
|
|
|
try:
|
|
|
|
|
|
stat = entry.stat()
|
|
|
|
|
|
entries.append({
|
|
|
|
|
|
"name": entry.name,
|
|
|
|
|
|
"is_dir": entry.is_dir(),
|
|
|
|
|
|
"size": stat.st_size if not entry.is_dir() else 0,
|
|
|
|
|
|
})
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
continue
|
|
|
|
|
|
except PermissionError:
|
2026-05-30 20:07:45 +08:00
|
|
|
|
raise HTTPException(status_code=403, detail="无权限访问该目录") from None
|
2026-05-28 21:01:27 +08:00
|
|
|
|
|
|
|
|
|
|
return {"path": real_path, "entries": entries}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/local-file-ops")
|
|
|
|
|
|
async def local_file_operation(
|
|
|
|
|
|
payload: LocalFileOperation,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
admin: Admin = Depends(get_current_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Delete or rename a file/directory in the local upload staging area.
|
|
|
|
|
|
|
|
|
|
|
|
Only allows operations under /tmp/nexus_upload_* directories.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import os
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
real_path = ensure_under_nexus_upload(payload.path)
|
|
|
|
|
|
except PosixPathError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
2026-05-28 21:01:27 +08:00
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
path = to_posix(real_path)
|
2026-05-28 21:01:27 +08:00
|
|
|
|
|
|
|
|
|
|
if payload.operation == "delete":
|
|
|
|
|
|
if not os.path.exists(real_path):
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="文件或目录不存在")
|
|
|
|
|
|
try:
|
|
|
|
|
|
if os.path.isdir(real_path):
|
|
|
|
|
|
shutil.rmtree(real_path)
|
|
|
|
|
|
else:
|
|
|
|
|
|
os.unlink(real_path)
|
|
|
|
|
|
except OSError as e:
|
2026-05-30 20:07:45 +08:00
|
|
|
|
raise HTTPException(status_code=500, detail=f"删除失败: {e}") from e
|
2026-05-28 21:01:27 +08:00
|
|
|
|
|
|
|
|
|
|
await _audit_sync(
|
|
|
|
|
|
"local_file_delete", "sync", 0,
|
|
|
|
|
|
f"删除: {path}", admin.username, request,
|
|
|
|
|
|
)
|
|
|
|
|
|
return {"success": True, "operation": "delete", "path": path}
|
|
|
|
|
|
|
|
|
|
|
|
elif payload.operation == "rename":
|
|
|
|
|
|
if not payload.new_name:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="new_name 必填")
|
|
|
|
|
|
# Sanitize: strip slashes and path traversal
|
|
|
|
|
|
safe_name = payload.new_name.replace("/", "_").replace("\\", "_").strip()
|
|
|
|
|
|
if not safe_name or safe_name == "." or safe_name == "..":
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="无效的新名称")
|
|
|
|
|
|
|
|
|
|
|
|
if not os.path.exists(real_path):
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="文件或目录不存在")
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
new_path = posix_join(posix_dirname(real_path), safe_name)
|
2026-05-28 21:01:27 +08:00
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
ensure_under_nexus_upload(new_path)
|
|
|
|
|
|
except PosixPathError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
|
|
|
|
new_path = resolve_nexus_host_path(new_path)
|
2026-05-28 21:01:27 +08:00
|
|
|
|
|
|
|
|
|
|
if os.path.exists(new_path):
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="目标名称已存在")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.rename(real_path, new_path)
|
|
|
|
|
|
except OSError as e:
|
2026-05-30 20:07:45 +08:00
|
|
|
|
raise HTTPException(status_code=500, detail=f"重命名失败: {e}") from e
|
2026-05-28 21:01:27 +08:00
|
|
|
|
|
|
|
|
|
|
await _audit_sync(
|
|
|
|
|
|
"local_file_rename", "sync", 0,
|
|
|
|
|
|
f"重命名: {path} → {safe_name}", admin.username, request,
|
|
|
|
|
|
)
|
|
|
|
|
|
return {"success": True, "operation": "rename", "path": path, "new_name": safe_name}
|
2026-05-22 22:29:35 +08:00
|
|
|
|
|
2026-05-29 15:59:30 +08:00
|
|
|
|
|
|
|
|
|
|
@router.post("/local-file-preview")
|
|
|
|
|
|
async def local_file_preview(
|
|
|
|
|
|
payload: LocalFilePreview,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
admin: Admin = Depends(get_current_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Preview file content in the local upload staging area.
|
|
|
|
|
|
|
|
|
|
|
|
Only allows files under /tmp/nexus_upload_* directories.
|
|
|
|
|
|
Returns up to 4KB of file content, base64-encoded.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import os
|
|
|
|
|
|
import base64
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
real_path = ensure_under_nexus_upload(payload.path)
|
|
|
|
|
|
except PosixPathError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
2026-05-29 15:59:30 +08:00
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
path = to_posix(real_path)
|
2026-05-29 15:59:30 +08:00
|
|
|
|
|
|
|
|
|
|
if not os.path.isfile(real_path):
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="文件不存在或不是普通文件")
|
|
|
|
|
|
|
|
|
|
|
|
# Size check: skip files over 1MB
|
|
|
|
|
|
file_size = os.path.getsize(real_path)
|
|
|
|
|
|
if file_size > 1_048_576:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="文件过大,不支持预览(>1MB)")
|
|
|
|
|
|
|
|
|
|
|
|
# Read up to 4KB
|
|
|
|
|
|
max_read = 4096
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(real_path, "rb") as f:
|
|
|
|
|
|
raw = f.read(max_read)
|
|
|
|
|
|
except OSError as e:
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"读取失败: {e}") from None
|
|
|
|
|
|
|
|
|
|
|
|
truncated = file_size > max_read
|
|
|
|
|
|
|
|
|
|
|
|
# Detect encoding: try UTF-8 decode
|
|
|
|
|
|
encoding_hint = "binary"
|
|
|
|
|
|
try:
|
|
|
|
|
|
raw.decode("utf-8")
|
|
|
|
|
|
encoding_hint = "text"
|
|
|
|
|
|
except (UnicodeDecodeError, ValueError):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
name = posix_basename(real_path)
|
2026-05-29 15:59:30 +08:00
|
|
|
|
|
|
|
|
|
|
await _audit_sync(
|
|
|
|
|
|
"local_file_preview", "sync", 0,
|
|
|
|
|
|
f"预览: {path}", admin.username, request,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"name": name,
|
|
|
|
|
|
"size": file_size,
|
|
|
|
|
|
"content_base64": base64.b64encode(raw).decode("ascii"),
|
|
|
|
|
|
"truncated": truncated,
|
|
|
|
|
|
"encoding_hint": encoding_hint,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 22:29:35 +08:00
|
|
|
|
async def _audit_sync(action: str, target_type: str, target_id: int, detail: str, operator: str, request: Request):
|
|
|
|
|
|
"""Create audit log entry for sync operations"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
session = request.state.db
|
|
|
|
|
|
audit_repo = AuditLogRepositoryImpl(session)
|
|
|
|
|
|
await audit_repo.create(AuditLog(
|
|
|
|
|
|
admin_username=operator,
|
|
|
|
|
|
action=action,
|
|
|
|
|
|
target_type=target_type,
|
|
|
|
|
|
target_id=target_id,
|
|
|
|
|
|
detail=detail,
|
2026-05-23 15:26:56 +08:00
|
|
|
|
ip_address=request.client.host if request.client else "",
|
2026-05-22 22:29:35 +08:00
|
|
|
|
))
|
|
|
|
|
|
except Exception:
|
2026-05-24 16:26:40 +08:00
|
|
|
|
logger.warning(f"Audit log write failed for {action} on {target_type}/{target_id}", exc_info=True)
|
2026-05-26 00:17:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-29 21:29:41 +08:00
|
|
|
|
@router.post("/cancel")
|
|
|
|
|
|
async def cancel_sync(
|
|
|
|
|
|
payload: SyncCancel,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
admin: Admin = Depends(get_current_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Cancel an in-progress push by setting a Redis flag.
|
|
|
|
|
|
|
|
|
|
|
|
The sync engine checks this flag before each rsync execution.
|
|
|
|
|
|
Already-completed servers retain their results; pending ones are skipped.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from server.infrastructure.redis.client import get_redis
|
|
|
|
|
|
redis = get_redis()
|
|
|
|
|
|
await redis.set(f"sync:cancel:{payload.batch_id}", "1", ex=3600)
|
|
|
|
|
|
except Exception as e:
|
2026-05-30 20:07:45 +08:00
|
|
|
|
raise HTTPException(status_code=500, detail=f"取消操作失败: {e}") from e
|
2026-05-29 21:29:41 +08:00
|
|
|
|
|
|
|
|
|
|
await _audit_sync(
|
|
|
|
|
|
"sync_cancel", "sync", 0,
|
|
|
|
|
|
f"取消推送: batch_id={payload.batch_id}", admin.username, request,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {"cancelled": True, "batch_id": payload.batch_id}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── H5: Validate Source Path ──
|
|
|
|
|
|
|
|
|
|
|
|
_FORBIDDEN_PATH_PREFIXES = ("/etc", "/root", "/home", "/var", "/boot", "/dev", "/proc", "/sys", "/run", "/srv")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/validate-source-path")
|
|
|
|
|
|
async def validate_source_path(
|
|
|
|
|
|
payload: ValidateSourcePath,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
admin: Admin = Depends(get_current_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Validate a local directory path on the Nexus server as a push source.
|
|
|
|
|
|
|
|
|
|
|
|
Security: rejects sensitive system paths (/etc, /root, /home, etc.)
|
|
|
|
|
|
and paths with traversal components.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
|
|
path = payload.path.strip()
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
real_path = resolve_nexus_host_path(path)
|
|
|
|
|
|
except PosixPathError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
if ".." in to_posix(path).split("/"):
|
2026-05-29 21:29:41 +08:00
|
|
|
|
raise HTTPException(status_code=400, detail="路径不允许包含 '..'")
|
|
|
|
|
|
|
|
|
|
|
|
# Reject sensitive paths
|
|
|
|
|
|
for prefix in _FORBIDDEN_PATH_PREFIXES:
|
|
|
|
|
|
if real_path == prefix or real_path.startswith(prefix + "/"):
|
|
|
|
|
|
raise HTTPException(status_code=403, detail=f"不允许使用系统路径: {prefix}")
|
|
|
|
|
|
|
|
|
|
|
|
if not os.path.exists(real_path):
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="路径不存在")
|
|
|
|
|
|
|
|
|
|
|
|
if not os.path.isdir(real_path):
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="路径不是目录")
|
|
|
|
|
|
|
|
|
|
|
|
# Count files and total size
|
|
|
|
|
|
file_count = 0
|
|
|
|
|
|
total_size = 0
|
|
|
|
|
|
for root, _dirs, fnames in os.walk(real_path):
|
|
|
|
|
|
for fn in fnames:
|
|
|
|
|
|
file_count += 1
|
|
|
|
|
|
try:
|
2026-06-01 19:54:18 +08:00
|
|
|
|
total_size += os.path.getsize(posix_join(root, fn))
|
2026-05-29 21:29:41 +08:00
|
|
|
|
except OSError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if file_count >= 10000:
|
|
|
|
|
|
break
|
|
|
|
|
|
if file_count >= 10000:
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
await _audit_sync(
|
|
|
|
|
|
"validate_source_path", "sync", 0,
|
|
|
|
|
|
f"验证源路径: {path} ({file_count} 文件)",
|
|
|
|
|
|
admin.username, request,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"valid": True,
|
|
|
|
|
|
"is_dir": True,
|
|
|
|
|
|
"path": real_path,
|
|
|
|
|
|
"file_count": file_count,
|
|
|
|
|
|
"size_bytes": total_size,
|
|
|
|
|
|
"file_count_truncated": file_count >= 10000,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── H4: Diagnose Push Failure ──
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/diagnose")
|
|
|
|
|
|
async def diagnose_push(
|
|
|
|
|
|
payload: SyncDiagnose,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
admin: Admin = Depends(get_current_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Diagnose why a push failed for a specific server.
|
|
|
|
|
|
|
|
|
|
|
|
Checks SSH connectivity, disk space, target path permissions, and write access.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import shlex
|
|
|
|
|
|
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
|
|
|
|
|
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
|
|
|
|
|
|
|
|
|
|
|
session = request.state.db
|
|
|
|
|
|
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
|
|
|
|
|
if not server:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="服务器不存在")
|
|
|
|
|
|
|
|
|
|
|
|
result = {
|
|
|
|
|
|
"server_id": payload.server_id,
|
|
|
|
|
|
"server_name": server.name,
|
|
|
|
|
|
"ssh_ok": False,
|
|
|
|
|
|
"disk_avail": None,
|
|
|
|
|
|
"disk_used_pct": None,
|
|
|
|
|
|
"path_exists": None,
|
|
|
|
|
|
"path_perms": None,
|
|
|
|
|
|
"path_writable": None,
|
|
|
|
|
|
"errors": [],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 1. SSH connectivity
|
|
|
|
|
|
try:
|
|
|
|
|
|
r = await exec_ssh_command(server, "echo ok", timeout=10)
|
|
|
|
|
|
if r["exit_code"] == 0 and "ok" in r["stdout"]:
|
|
|
|
|
|
result["ssh_ok"] = True
|
|
|
|
|
|
else:
|
|
|
|
|
|
result["errors"].append(f"SSH 连接异常: {r['stderr'][:200] or 'exit code ' + str(r['exit_code'])}")
|
|
|
|
|
|
# Cannot continue if SSH fails
|
|
|
|
|
|
await _audit_sync("diagnose", "server", payload.server_id, "诊断: SSH不通", admin.username, request)
|
|
|
|
|
|
return result
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
result["errors"].append(f"SSH 连接失败: {e}")
|
|
|
|
|
|
await _audit_sync("diagnose", "server", payload.server_id, "诊断: SSH失败", admin.username, request)
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
dest = normalize_remote_dest(payload.target_path or server.target_path)
|
|
|
|
|
|
except PosixPathError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
2026-05-29 21:29:41 +08:00
|
|
|
|
safe_dest = shlex.quote(dest)
|
|
|
|
|
|
|
|
|
|
|
|
# 2. Disk space
|
|
|
|
|
|
try:
|
|
|
|
|
|
r = await exec_ssh_command(server, f"df -h {safe_dest} | tail -1", timeout=10)
|
|
|
|
|
|
if r["exit_code"] == 0:
|
|
|
|
|
|
parts = r["stdout"].strip().split()
|
|
|
|
|
|
if len(parts) >= 6:
|
|
|
|
|
|
result["disk_avail"] = parts[3] # e.g. "50G"
|
|
|
|
|
|
try:
|
|
|
|
|
|
result["disk_used_pct"] = int(parts[4].replace("%", "")) # e.g. 72
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass
|
2026-05-29 22:11:06 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.debug(f"Diagnose disk check failed for server {payload.server_id}: {e}")
|
2026-05-29 21:29:41 +08:00
|
|
|
|
try:
|
|
|
|
|
|
r = await exec_ssh_command(server, f"ls -ld {safe_dest} 2>&1", timeout=10)
|
|
|
|
|
|
if r["exit_code"] == 0:
|
|
|
|
|
|
result["path_exists"] = True
|
|
|
|
|
|
parts = r["stdout"].strip().split()
|
|
|
|
|
|
if parts:
|
|
|
|
|
|
result["path_perms"] = parts[0] # e.g. "drwxr-xr-x"
|
|
|
|
|
|
else:
|
|
|
|
|
|
result["path_exists"] = False
|
|
|
|
|
|
result["errors"].append(f"目标路径不存在: {dest}")
|
2026-05-29 22:11:06 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.debug(f"Diagnose path check failed for server {payload.server_id}: {e}")
|
2026-05-29 21:29:41 +08:00
|
|
|
|
|
|
|
|
|
|
# 4. Write test
|
|
|
|
|
|
if result["path_exists"]:
|
|
|
|
|
|
try:
|
2026-06-01 19:54:18 +08:00
|
|
|
|
test_file = remote_join(dest, ".nexus_diag_test_$$")
|
2026-05-29 21:29:41 +08:00
|
|
|
|
r = await exec_ssh_command(
|
|
|
|
|
|
server,
|
|
|
|
|
|
f"touch {shlex.quote(test_file)} && rm -f {shlex.quote(test_file)}",
|
|
|
|
|
|
timeout=10,
|
|
|
|
|
|
)
|
|
|
|
|
|
result["path_writable"] = r["exit_code"] == 0
|
|
|
|
|
|
if r["exit_code"] != 0:
|
|
|
|
|
|
result["errors"].append(f"目标路径不可写: {r['stderr'][:200]}")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
result["errors"].append(f"写入测试失败: {e}")
|
|
|
|
|
|
result["path_writable"] = False
|
|
|
|
|
|
else:
|
|
|
|
|
|
result["path_writable"] = False
|
|
|
|
|
|
|
|
|
|
|
|
await _audit_sync(
|
|
|
|
|
|
"diagnose", "server", payload.server_id,
|
|
|
|
|
|
f"诊断: SSH={'✓' if result['ssh_ok'] else '✗'} 路径={'✓' if result['path_exists'] else '✗'} 写入={'✓' if result['path_writable'] else '✗'}",
|
|
|
|
|
|
admin.username, request,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── H3: File Diff View ──
|
|
|
|
|
|
|
|
|
|
|
|
_MAX_DIFF_SIZE = 102_400 # 100 KB
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/file-diff")
|
|
|
|
|
|
async def file_diff(
|
|
|
|
|
|
payload: FileSyncDiff,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
admin: Admin = Depends(get_current_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Compare a local file with its remote counterpart to show push diff.
|
|
|
|
|
|
|
|
|
|
|
|
Security: only allows diffing files under /tmp/nexus_upload_* source paths.
|
|
|
|
|
|
Reads up to 100KB from each side. Returns unified diff lines.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import os
|
|
|
|
|
|
import difflib
|
|
|
|
|
|
import shlex
|
|
|
|
|
|
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
|
|
|
|
|
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
real_source = ensure_under_nexus_upload(payload.source_path)
|
|
|
|
|
|
except PosixPathError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
|
|
rel = to_posix(payload.relative_path).lstrip("/")
|
|
|
|
|
|
if not rel or ".." in rel.split("/"):
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="相对路径无效")
|
2026-05-29 21:29:41 +08:00
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
local_file = resolve_nexus_host_path(posix_join(real_source, rel))
|
|
|
|
|
|
if not is_path_under_prefix(local_file, real_source):
|
2026-05-29 21:29:41 +08:00
|
|
|
|
raise HTTPException(status_code=403, detail="文件路径越界")
|
|
|
|
|
|
|
|
|
|
|
|
if not os.path.isfile(local_file):
|
|
|
|
|
|
return {
|
2026-06-01 19:54:18 +08:00
|
|
|
|
"file_name": posix_basename(rel),
|
2026-05-29 21:29:41 +08:00
|
|
|
|
"local_exists": False,
|
|
|
|
|
|
"remote_exists": None,
|
|
|
|
|
|
"local_size": 0,
|
|
|
|
|
|
"remote_size": None,
|
|
|
|
|
|
"diff_lines": [],
|
|
|
|
|
|
"truncated": False,
|
|
|
|
|
|
"error": "本地文件不存在",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Read local file
|
|
|
|
|
|
local_size = os.path.getsize(local_file)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(local_file, "r", encoding="utf-8", errors="replace") as f:
|
|
|
|
|
|
local_text = f.read(_MAX_DIFF_SIZE)
|
|
|
|
|
|
except OSError as e:
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"读取本地文件失败: {e}") from None
|
|
|
|
|
|
|
|
|
|
|
|
local_lines = local_text.splitlines(keepends=True)
|
|
|
|
|
|
local_truncated = local_size > _MAX_DIFF_SIZE
|
|
|
|
|
|
|
|
|
|
|
|
# Get server + target path
|
|
|
|
|
|
session = request.state.db
|
|
|
|
|
|
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
|
|
|
|
|
if not server:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="服务器不存在")
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
dest = normalize_remote_dest(payload.target_path or server.target_path)
|
|
|
|
|
|
remote_file = remote_join(dest, rel)
|
|
|
|
|
|
except PosixPathError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
2026-05-29 21:29:41 +08:00
|
|
|
|
|
|
|
|
|
|
# Read remote file
|
|
|
|
|
|
remote_exists = False
|
|
|
|
|
|
remote_size = None
|
|
|
|
|
|
remote_lines = []
|
|
|
|
|
|
remote_truncated = False
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
r = await exec_ssh_command(
|
|
|
|
|
|
server,
|
|
|
|
|
|
f"wc -c {shlex.quote(remote_file)} 2>/dev/null && cat {shlex.quote(remote_file)}",
|
|
|
|
|
|
timeout=15,
|
|
|
|
|
|
)
|
|
|
|
|
|
if r["exit_code"] == 0:
|
|
|
|
|
|
remote_exists = True
|
|
|
|
|
|
output = r["stdout"]
|
|
|
|
|
|
# First line is wc -c output: " 12345 /path/file"
|
|
|
|
|
|
first_newline = output.index("\n") if "\n" in output else 0
|
|
|
|
|
|
wc_line = output[:first_newline].strip()
|
|
|
|
|
|
try:
|
|
|
|
|
|
remote_size = int(wc_line.split()[0])
|
|
|
|
|
|
except (ValueError, IndexError):
|
|
|
|
|
|
remote_size = None
|
|
|
|
|
|
remote_text = output[first_newline + 1:]
|
|
|
|
|
|
if remote_size is not None and remote_size > _MAX_DIFF_SIZE:
|
|
|
|
|
|
remote_truncated = True
|
|
|
|
|
|
remote_lines = remote_text.splitlines(keepends=True)
|
|
|
|
|
|
else:
|
|
|
|
|
|
# File doesn't exist on remote — treat as empty (all local lines are additions)
|
|
|
|
|
|
remote_exists = False
|
2026-05-29 22:11:06 +08:00
|
|
|
|
except Exception as e:
|
2026-05-29 21:29:41 +08:00
|
|
|
|
# SSH failed — report error
|
2026-05-29 22:11:06 +08:00
|
|
|
|
logger.warning(f"File diff remote read failed for server {payload.server_id}: {e}")
|
2026-05-29 21:29:41 +08:00
|
|
|
|
remote_exists = None
|
|
|
|
|
|
|
|
|
|
|
|
# Compute unified diff
|
|
|
|
|
|
diff_lines = []
|
|
|
|
|
|
for line in difflib.unified_diff(
|
|
|
|
|
|
remote_lines, local_lines,
|
|
|
|
|
|
fromfile=f"remote/{payload.relative_path}",
|
|
|
|
|
|
tofile=f"local/{payload.relative_path}",
|
|
|
|
|
|
lineterm="",
|
|
|
|
|
|
):
|
|
|
|
|
|
if line.startswith("+") and not line.startswith("+++"):
|
|
|
|
|
|
diff_lines.append({"type": "add", "content": line[1:]})
|
|
|
|
|
|
elif line.startswith("-") and not line.startswith("---"):
|
|
|
|
|
|
diff_lines.append({"type": "del", "content": line[1:]})
|
|
|
|
|
|
elif line.startswith("@@"):
|
|
|
|
|
|
diff_lines.append({"type": "header", "content": line})
|
|
|
|
|
|
elif line.startswith(" "):
|
|
|
|
|
|
diff_lines.append({"type": "ctx", "content": line[1:]})
|
|
|
|
|
|
|
|
|
|
|
|
await _audit_sync(
|
|
|
|
|
|
"file_diff", "server", payload.server_id,
|
|
|
|
|
|
f"文件对比: {payload.relative_path}",
|
|
|
|
|
|
admin.username, request,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
2026-06-01 19:54:18 +08:00
|
|
|
|
"file_name": posix_basename(rel),
|
2026-05-29 21:29:41 +08:00
|
|
|
|
"local_exists": True,
|
|
|
|
|
|
"remote_exists": remote_exists,
|
|
|
|
|
|
"local_size": local_size,
|
|
|
|
|
|
"remote_size": remote_size,
|
|
|
|
|
|
"diff_lines": diff_lines,
|
|
|
|
|
|
"truncated": local_truncated or remote_truncated,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-26 00:17:43 +08:00
|
|
|
|
# ── File Upload via SFTP ──
|
|
|
|
|
|
|
|
|
|
|
|
MAX_UPLOAD_FILE_SIZE = 104_857_600 # 100 MB
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/upload")
|
|
|
|
|
|
async def upload_file(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
admin: Admin = Depends(get_current_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Upload a file to a remote server via SFTP.
|
|
|
|
|
|
|
|
|
|
|
|
Multipart form fields:
|
|
|
|
|
|
- server_id: int (required)
|
|
|
|
|
|
- remote_path: str (target directory, required)
|
|
|
|
|
|
- file: UploadFile (required)
|
|
|
|
|
|
|
|
|
|
|
|
The file is uploaded to {remote_path}/{filename} on the target server.
|
|
|
|
|
|
If the file already exists, it will be overwritten.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import asyncssh
|
2026-06-01 19:54:18 +08:00
|
|
|
|
from server.infrastructure.ssh.asyncssh_pool import remote_write_bytes, ssh_pool
|
2026-05-26 00:17:43 +08:00
|
|
|
|
|
|
|
|
|
|
form = await request.form()
|
|
|
|
|
|
server_id_raw = form.get("server_id")
|
|
|
|
|
|
remote_path_raw = form.get("remote_path", "/tmp")
|
|
|
|
|
|
file = form.get("file")
|
|
|
|
|
|
|
|
|
|
|
|
if not server_id_raw:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="server_id 必填")
|
|
|
|
|
|
try:
|
|
|
|
|
|
server_id = int(server_id_raw)
|
|
|
|
|
|
except (ValueError, TypeError):
|
2026-05-30 20:07:45 +08:00
|
|
|
|
raise HTTPException(status_code=400, detail="server_id 必须为数字") from None
|
2026-05-26 00:17:43 +08:00
|
|
|
|
|
|
|
|
|
|
if not file or not hasattr(file, "filename") or not file.filename:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="请选择要上传的文件")
|
|
|
|
|
|
|
|
|
|
|
|
# Read file content with size check
|
|
|
|
|
|
content = await file.read()
|
|
|
|
|
|
if len(content) > MAX_UPLOAD_FILE_SIZE:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"文件大小超过限制 ({MAX_UPLOAD_FILE_SIZE // (1024*1024)}MB)")
|
|
|
|
|
|
|
|
|
|
|
|
remote_path = str(remote_path_raw).strip()
|
|
|
|
|
|
filename = file.filename
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
remote_dir = normalize_remote_abs_path(remote_path)
|
|
|
|
|
|
except PosixPathError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
2026-05-26 00:17:43 +08:00
|
|
|
|
|
|
|
|
|
|
# Sanitize filename — strip slashes to prevent path injection
|
|
|
|
|
|
safe_filename = filename.replace("/", "_").replace("\\", "_").strip()
|
|
|
|
|
|
if not safe_filename:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="文件名无效")
|
|
|
|
|
|
|
|
|
|
|
|
# Get server from DB
|
|
|
|
|
|
session = request.state.db
|
|
|
|
|
|
server = await ServerRepositoryImpl(session).get_by_id(server_id)
|
|
|
|
|
|
if not server:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="服务器不存在")
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
full_remote_path = remote_join(remote_dir, safe_filename)
|
|
|
|
|
|
except PosixPathError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
|
|
from server.utils.files_elevation import get_files_elevation
|
2026-05-26 00:17:43 +08:00
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
conn = await ssh_pool.acquire(server)
|
|
|
|
|
|
try:
|
2026-06-01 19:54:18 +08:00
|
|
|
|
await remote_write_bytes(
|
|
|
|
|
|
conn,
|
|
|
|
|
|
full_remote_path,
|
|
|
|
|
|
content,
|
|
|
|
|
|
elevation=get_files_elevation(server),
|
|
|
|
|
|
)
|
2026-05-26 00:17:43 +08:00
|
|
|
|
finally:
|
|
|
|
|
|
await ssh_pool.release(server.id)
|
|
|
|
|
|
except asyncssh.PermissionDenied:
|
2026-05-30 20:07:45 +08:00
|
|
|
|
raise HTTPException(status_code=403, detail=f"SSH 权限不足,无法写入 {full_remote_path}") from None
|
2026-05-26 00:17:43 +08:00
|
|
|
|
except asyncssh.SFTPError as e:
|
2026-05-30 20:07:45 +08:00
|
|
|
|
raise HTTPException(status_code=400, detail=f"SFTP 上传失败: {e}") from e
|
2026-05-26 00:17:43 +08:00
|
|
|
|
except Exception as e:
|
2026-05-30 20:07:45 +08:00
|
|
|
|
raise HTTPException(status_code=502, detail=f"SSH 连接失败: {e}") from e
|
2026-05-26 00:17:43 +08:00
|
|
|
|
|
|
|
|
|
|
# Audit
|
|
|
|
|
|
await _audit_sync(
|
|
|
|
|
|
"file_upload", "server", server_id,
|
|
|
|
|
|
f"上传 {safe_filename} ({len(content)} bytes) → {server.name}:{full_remote_path}",
|
|
|
|
|
|
admin.username, request,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"success": True,
|
|
|
|
|
|
"server_id": server_id,
|
|
|
|
|
|
"remote_path": full_remote_path,
|
|
|
|
|
|
"filename": safe_filename,
|
|
|
|
|
|
"size": len(content),
|
|
|
|
|
|
}
|
2026-05-28 21:01:27 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── ZIP Upload & Extract (for push workflow) ──
|
|
|
|
|
|
|
|
|
|
|
|
MAX_ZIP_FILE_SIZE = 524_288_000 # 500 MB
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/upload-zip")
|
|
|
|
|
|
async def upload_zip(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
admin: Admin = Depends(get_current_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Upload a ZIP file to Nexus, extract it, return the source path for rsync push.
|
|
|
|
|
|
|
|
|
|
|
|
Multipart form fields:
|
|
|
|
|
|
- file: UploadFile (required, must be .zip)
|
|
|
|
|
|
|
|
|
|
|
|
Returns {"source_path": "/tmp/nexus_upload_xxx/", "file_count": N}
|
|
|
|
|
|
"""
|
|
|
|
|
|
import os
|
2026-06-01 19:54:18 +08:00
|
|
|
|
import posixpath
|
2026-05-28 21:01:27 +08:00
|
|
|
|
import uuid
|
|
|
|
|
|
import zipfile
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
|
|
|
|
|
|
form = await request.form()
|
|
|
|
|
|
file = form.get("file")
|
|
|
|
|
|
|
|
|
|
|
|
if not file or not hasattr(file, "filename") or not file.filename:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="请选择 ZIP 文件")
|
|
|
|
|
|
|
|
|
|
|
|
filename = file.filename
|
|
|
|
|
|
if not filename.lower().endswith(".zip"):
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="仅支持 .zip 格式")
|
|
|
|
|
|
|
|
|
|
|
|
# Read with size check
|
|
|
|
|
|
content = await file.read()
|
|
|
|
|
|
if len(content) > MAX_ZIP_FILE_SIZE:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"文件大小超过限制 ({MAX_ZIP_FILE_SIZE // (1024*1024)}MB)")
|
|
|
|
|
|
if len(content) == 0:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="文件为空")
|
|
|
|
|
|
|
|
|
|
|
|
# Create temp directory
|
|
|
|
|
|
upload_id = uuid.uuid4().hex[:12]
|
|
|
|
|
|
extract_dir = f"/tmp/nexus_upload_{upload_id}"
|
|
|
|
|
|
zip_path = f"{extract_dir}.zip"
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.makedirs(extract_dir, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
# Save ZIP
|
|
|
|
|
|
with open(zip_path, "wb") as f:
|
|
|
|
|
|
f.write(content)
|
|
|
|
|
|
|
|
|
|
|
|
# Validate and extract with zip slip protection
|
|
|
|
|
|
file_count = 0
|
|
|
|
|
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
2026-06-01 19:54:18 +08:00
|
|
|
|
extract_base = resolve_nexus_host_path(extract_dir)
|
2026-05-28 21:01:27 +08:00
|
|
|
|
for info in zf.infolist():
|
2026-06-01 19:54:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
member_path = assert_zip_member_safe(extract_dir, info.filename)
|
|
|
|
|
|
except PosixPathError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
real_path = resolve_nexus_host_path(member_path)
|
|
|
|
|
|
if not is_path_under_prefix(real_path, extract_base):
|
2026-05-28 21:01:27 +08:00
|
|
|
|
raise HTTPException(status_code=400, detail=f"ZIP 包含非法路径: {info.filename}")
|
|
|
|
|
|
if info.is_dir():
|
|
|
|
|
|
continue
|
|
|
|
|
|
file_count += 1
|
|
|
|
|
|
|
|
|
|
|
|
zf.extractall(extract_dir)
|
|
|
|
|
|
|
|
|
|
|
|
# Clean up ZIP file
|
|
|
|
|
|
os.unlink(zip_path)
|
|
|
|
|
|
|
|
|
|
|
|
if file_count == 0:
|
|
|
|
|
|
shutil.rmtree(extract_dir, ignore_errors=True)
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="ZIP 文件为空(无文件)")
|
|
|
|
|
|
|
|
|
|
|
|
# Build file listing (relative paths, capped at 500)
|
|
|
|
|
|
files = []
|
2026-05-30 20:07:45 +08:00
|
|
|
|
for root, _dirs, fnames in os.walk(extract_dir):
|
2026-05-28 21:01:27 +08:00
|
|
|
|
for fn in fnames:
|
2026-06-01 19:54:18 +08:00
|
|
|
|
rel = posixpath.relpath(posix_join(root, fn), extract_dir)
|
2026-05-28 21:01:27 +08:00
|
|
|
|
files.append(rel)
|
|
|
|
|
|
if len(files) >= 500:
|
|
|
|
|
|
break
|
|
|
|
|
|
if len(files) >= 500:
|
|
|
|
|
|
break
|
|
|
|
|
|
files_truncated = file_count > len(files)
|
|
|
|
|
|
|
|
|
|
|
|
# Audit
|
|
|
|
|
|
await _audit_sync(
|
|
|
|
|
|
"upload_zip", "sync", 0,
|
|
|
|
|
|
f"上传 ZIP: {filename} ({len(content)} bytes) → {extract_dir} ({file_count} 个文件)",
|
|
|
|
|
|
admin.username, request,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"success": True,
|
|
|
|
|
|
"source_path": extract_dir,
|
|
|
|
|
|
"file_count": file_count,
|
|
|
|
|
|
"files": files,
|
|
|
|
|
|
"files_truncated": files_truncated,
|
|
|
|
|
|
"filename": filename,
|
|
|
|
|
|
"size": len(content),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
shutil.rmtree(extract_dir, ignore_errors=True)
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.unlink(zip_path)
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
raise
|
|
|
|
|
|
except zipfile.BadZipFile:
|
|
|
|
|
|
shutil.rmtree(extract_dir, ignore_errors=True)
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.unlink(zip_path)
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
pass
|
2026-05-30 20:07:45 +08:00
|
|
|
|
raise HTTPException(status_code=400, detail="无效的 ZIP 文件") from None
|
2026-05-28 21:01:27 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
shutil.rmtree(extract_dir, ignore_errors=True)
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.unlink(zip_path)
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
pass
|
2026-05-30 20:07:45 +08:00
|
|
|
|
raise HTTPException(status_code=500, detail=f"解压失败: {e}") from e
|
2026-05-28 21:01:27 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-30 02:27:18 +08:00
|
|
|
|
# ── Post-Push Verification (sha256sum comparison) ──
|
2026-05-28 21:01:27 +08:00
|
|
|
|
|
|
|
|
|
|
@router.post("/verify")
|
|
|
|
|
|
async def verify_sync(
|
|
|
|
|
|
payload: SyncVerify,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
admin: Admin = Depends(get_current_admin),
|
|
|
|
|
|
):
|
2026-05-30 02:27:18 +08:00
|
|
|
|
"""Verify pushed files by comparing sha256sums between source and target servers.
|
2026-05-28 21:01:27 +08:00
|
|
|
|
|
2026-05-30 02:27:18 +08:00
|
|
|
|
Walks the local source directory and computes sha256 for each file,
|
|
|
|
|
|
then runs sha256sum on each target server via SSH and compares.
|
2026-05-28 21:01:27 +08:00
|
|
|
|
Returns per-server results: matched / missing / mismatched file lists.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import hashlib
|
2026-05-30 20:07:45 +08:00
|
|
|
|
import os
|
2026-06-01 19:54:18 +08:00
|
|
|
|
import posixpath
|
2026-05-28 21:01:27 +08:00
|
|
|
|
import shlex
|
|
|
|
|
|
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
|
|
|
|
|
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
|
|
|
|
|
|
|
|
|
|
|
session = request.state.db
|
|
|
|
|
|
|
|
|
|
|
|
if not os.path.isdir(payload.source_path):
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"源路径不存在: {payload.source_path}")
|
|
|
|
|
|
|
2026-05-30 02:27:18 +08:00
|
|
|
|
# Build local file manifest: {relative_path: sha256hex}
|
2026-05-28 21:01:27 +08:00
|
|
|
|
local_files = {}
|
2026-05-30 20:07:45 +08:00
|
|
|
|
for root, _dirs, fnames in os.walk(payload.source_path):
|
2026-05-28 21:01:27 +08:00
|
|
|
|
for fn in fnames:
|
2026-06-01 19:54:18 +08:00
|
|
|
|
full = posix_join(root, fn)
|
|
|
|
|
|
rel = posixpath.relpath(full, to_posix(payload.source_path))
|
2026-05-28 21:01:27 +08:00
|
|
|
|
if len(local_files) >= payload.max_files:
|
|
|
|
|
|
break
|
|
|
|
|
|
try:
|
2026-05-30 02:27:18 +08:00
|
|
|
|
h = hashlib.sha256()
|
2026-05-28 21:01:27 +08:00
|
|
|
|
with open(full, "rb") as f:
|
|
|
|
|
|
for chunk in iter(lambda: f.read(8192), b""):
|
|
|
|
|
|
h.update(chunk)
|
|
|
|
|
|
local_files[rel] = h.hexdigest()
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if len(local_files) >= payload.max_files:
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
if not local_files:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="源目录无文件可校验")
|
|
|
|
|
|
|
|
|
|
|
|
# Verify against each server concurrently
|
|
|
|
|
|
sem = asyncio.Semaphore(5)
|
|
|
|
|
|
results = {}
|
|
|
|
|
|
|
|
|
|
|
|
async def _verify_one(sid: int):
|
|
|
|
|
|
async with sem:
|
|
|
|
|
|
server = await ServerRepositoryImpl(session).get_by_id(sid)
|
|
|
|
|
|
if not server:
|
|
|
|
|
|
results[sid] = {"server_id": sid, "server_name": "", "error": "服务器不存在"}
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
dest = normalize_remote_dest(payload.target_path or server.target_path)
|
|
|
|
|
|
except PosixPathError as exc:
|
|
|
|
|
|
results[sid] = {
|
|
|
|
|
|
"server_id": sid,
|
|
|
|
|
|
"server_name": server.name,
|
|
|
|
|
|
"error": str(exc),
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
2026-05-30 02:27:18 +08:00
|
|
|
|
# Run sha256sum on remote server for the same relative paths
|
|
|
|
|
|
# Build a file list for sha256sum to check
|
2026-05-28 21:01:27 +08:00
|
|
|
|
file_list = " ".join(shlex.quote(f) for f in local_files.keys())
|
2026-05-30 02:27:18 +08:00
|
|
|
|
cmd = f"cd {shlex.quote(dest)} && sha256sum {file_list} 2>/dev/null"
|
2026-05-28 21:01:27 +08:00
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
r = await exec_ssh_command(server, cmd, timeout=60)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
results[sid] = {
|
|
|
|
|
|
"server_id": sid, "server_name": server.name,
|
|
|
|
|
|
"error": f"SSH 连接失败: {e}",
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-05-30 02:27:18 +08:00
|
|
|
|
# Parse sha256sum output: "hash filename"
|
2026-05-28 21:01:27 +08:00
|
|
|
|
remote_files = {}
|
|
|
|
|
|
if r["exit_code"] == 0:
|
|
|
|
|
|
for line in r["stdout"].strip().split("\n"):
|
|
|
|
|
|
parts = line.split(None, 1)
|
|
|
|
|
|
if len(parts) == 2:
|
|
|
|
|
|
remote_files[parts[1]] = parts[0]
|
|
|
|
|
|
|
|
|
|
|
|
matched = []
|
|
|
|
|
|
missing = []
|
|
|
|
|
|
mismatched = []
|
|
|
|
|
|
for rel, local_hash in local_files.items():
|
|
|
|
|
|
if rel not in remote_files:
|
|
|
|
|
|
missing.append(rel)
|
|
|
|
|
|
elif remote_files[rel] != local_hash:
|
|
|
|
|
|
mismatched.append(rel)
|
|
|
|
|
|
else:
|
|
|
|
|
|
matched.append(rel)
|
|
|
|
|
|
|
|
|
|
|
|
results[sid] = {
|
|
|
|
|
|
"server_id": sid,
|
|
|
|
|
|
"server_name": server.name,
|
|
|
|
|
|
"matched": len(matched),
|
|
|
|
|
|
"missing": len(missing),
|
|
|
|
|
|
"mismatched": len(mismatched),
|
|
|
|
|
|
"missing_files": missing[:50],
|
|
|
|
|
|
"mismatched_files": mismatched[:50],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await asyncio.gather(*[_verify_one(sid) for sid in payload.server_ids])
|
|
|
|
|
|
|
|
|
|
|
|
# Audit
|
|
|
|
|
|
await _audit_sync(
|
|
|
|
|
|
"sync_verify", "sync", 0,
|
|
|
|
|
|
f"推送校验: {payload.source_path} → {len(payload.server_ids)} 台服务器",
|
|
|
|
|
|
admin.username, request,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {"results": results, "total_local_files": len(local_files)}
|
2026-05-30 23:34:24 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 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."""
|
|
|
|
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
|
|
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
2026-06-01 19:54:18 +08:00
|
|
|
|
from server.infrastructure.ssh.asyncssh_pool import sftp_session, ssh_pool
|
2026-05-30 23:34:24 +08:00
|
|
|
|
|
|
|
|
|
|
session = request.state.db
|
|
|
|
|
|
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
|
|
|
|
|
if not server:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="服务器不存在")
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
remote_path = normalize_remote_abs_path(payload.path)
|
|
|
|
|
|
except PosixPathError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
|
2026-05-30 23:34:24 +08:00
|
|
|
|
conn = await ssh_pool.acquire(server)
|
|
|
|
|
|
try:
|
2026-06-01 19:54:18 +08:00
|
|
|
|
async with sftp_session(conn) as sftp:
|
2026-05-30 23:34:24 +08:00
|
|
|
|
# Check file exists and size
|
|
|
|
|
|
try:
|
2026-06-01 19:54:18 +08:00
|
|
|
|
stat = await sftp.stat(remote_path)
|
2026-05-30 23:34:24 +08:00
|
|
|
|
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="只能下载文件,不能下载目录")
|
|
|
|
|
|
|
2026-05-30 23:44:35 +08:00
|
|
|
|
# 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
|
2026-06-01 19:54:18 +08:00
|
|
|
|
filename = _re.sub(r'[^\w.\-]', '_', posix_basename(remote_path)) or "download"
|
2026-05-30 23:34:24 +08:00
|
|
|
|
|
|
|
|
|
|
async def file_generator():
|
2026-06-01 19:54:18 +08:00
|
|
|
|
async with sftp.open(remote_path, "rb") as f:
|
2026-05-30 23:34:24 +08:00
|
|
|
|
while chunk := await f.read(65536):
|
|
|
|
|
|
yield chunk
|
|
|
|
|
|
|
|
|
|
|
|
await _audit_sync(
|
|
|
|
|
|
"file_download", "server", payload.server_id,
|
2026-06-01 19:54:18 +08:00
|
|
|
|
f"下载文件: {remote_path}",
|
2026-05-30 23:34:24 +08:00
|
|
|
|
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
|
2026-06-01 19:54:18 +08:00
|
|
|
|
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
|
2026-05-30 23:34:24 +08:00
|
|
|
|
|
|
|
|
|
|
session = request.state.db
|
|
|
|
|
|
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
|
|
|
|
|
if not server:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="服务器不存在")
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
remote_path = normalize_remote_abs_path(payload.path)
|
|
|
|
|
|
except PosixPathError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
|
|
path_q = shlex.quote(remote_path)
|
|
|
|
|
|
size_result = await exec_ssh_command_with_fallback(
|
|
|
|
|
|
server, f"stat -c%s {path_q}", timeout=5,
|
|
|
|
|
|
)
|
2026-05-30 23:34:24 +08:00
|
|
|
|
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} 字节",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
result = await exec_ssh_command_with_fallback(server, f"cat {path_q}", timeout=15)
|
2026-05-30 23:34:24 +08:00
|
|
|
|
if result["exit_code"] != 0:
|
2026-06-01 19:54:18 +08:00
|
|
|
|
err = ((result.get("stderr") or "") + (result.get("stdout") or ""))[:200]
|
|
|
|
|
|
raise HTTPException(status_code=502, detail=f"读取失败: {err}")
|
2026-05-30 23:34:24 +08:00
|
|
|
|
|
|
|
|
|
|
await _audit_sync(
|
|
|
|
|
|
"file_read", "server", payload.server_id,
|
2026-06-01 19:54:18 +08:00
|
|
|
|
f"读取文件: {remote_path} ({file_size} bytes)",
|
2026-05-30 23:34:24 +08:00
|
|
|
|
admin.username, request,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
return {"content": result["stdout"], "size": file_size, "path": remote_path}
|
2026-05-30 23:34:24 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/write-file")
|
|
|
|
|
|
async def write_file(
|
|
|
|
|
|
payload: FileWrite,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
admin: Admin = Depends(get_current_admin),
|
|
|
|
|
|
):
|
2026-06-01 19:54:18 +08:00
|
|
|
|
"""Write text file content to a remote server (SFTP + SSH tee/mv/sudo fallback)."""
|
|
|
|
|
|
import asyncssh
|
2026-05-30 23:34:24 +08:00
|
|
|
|
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
2026-06-01 19:54:18 +08:00
|
|
|
|
from server.infrastructure.ssh.asyncssh_pool import remote_write_bytes, ssh_pool
|
2026-05-30 23:34:24 +08:00
|
|
|
|
|
|
|
|
|
|
session = request.state.db
|
|
|
|
|
|
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
|
|
|
|
|
if not server:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="服务器不存在")
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
remote_path = normalize_remote_abs_path(payload.path)
|
|
|
|
|
|
except PosixPathError as e:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
|
|
|
|
|
|
|
|
|
|
from server.utils.files_elevation import get_files_elevation
|
2026-05-30 23:34:24 +08:00
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
content_bytes = payload.content.encode("utf-8")
|
|
|
|
|
|
if len(content_bytes) > 5_000_000:
|
|
|
|
|
|
raise HTTPException(status_code=413, detail="文件内容超过 5MB 限制")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
conn = await ssh_pool.acquire(server)
|
|
|
|
|
|
try:
|
|
|
|
|
|
await remote_write_bytes(
|
|
|
|
|
|
conn,
|
|
|
|
|
|
remote_path,
|
|
|
|
|
|
content_bytes,
|
|
|
|
|
|
elevation=get_files_elevation(server),
|
|
|
|
|
|
)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await ssh_pool.release(server.id)
|
|
|
|
|
|
except asyncssh.PermissionDenied:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=403,
|
|
|
|
|
|
detail=(
|
|
|
|
|
|
f"无写入权限: {remote_path}。"
|
|
|
|
|
|
"请确认 SSH 用户对目录有写权限,或已配置免密 sudo(tee/mv)。"
|
|
|
|
|
|
),
|
|
|
|
|
|
) from None
|
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="目标路径不存在") from None
|
|
|
|
|
|
except asyncssh.SFTPError as e:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=403,
|
|
|
|
|
|
detail=(
|
|
|
|
|
|
f"写入失败: {e}。"
|
|
|
|
|
|
f"路径 {remote_path} — 若文件属主为 root,请为 SSH 用户配置免密 sudo 或修改文件权限。"
|
|
|
|
|
|
),
|
|
|
|
|
|
) from e
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
raise HTTPException(status_code=502, detail=f"SSH 连接失败: {e}") from e
|
2026-05-30 23:34:24 +08:00
|
|
|
|
|
|
|
|
|
|
await _audit_sync(
|
|
|
|
|
|
"file_write", "server", payload.server_id,
|
2026-06-01 19:54:18 +08:00
|
|
|
|
f"写入文件: {remote_path} ({len(content_bytes)} bytes)",
|
2026-05-30 23:34:24 +08:00
|
|
|
|
admin.username, request,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
return {"success": True, "path": remote_path, "size": len(content_bytes)}
|
2026-05-31 02:55:45 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/chmod")
|
|
|
|
|
|
async def chmod_file(
|
|
|
|
|
|
payload: FileChmod,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
admin: Admin = Depends(get_current_admin),
|
|
|
|
|
|
):
|
2026-06-01 19:54:18 +08:00
|
|
|
|
"""Change file permissions (and optional owner) on a remote server."""
|
2026-05-31 02:55:45 +08:00
|
|
|
|
import shlex
|
|
|
|
|
|
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
2026-06-01 19:54:18 +08:00
|
|
|
|
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
|
2026-05-31 02:55:45 +08:00
|
|
|
|
|
|
|
|
|
|
session = request.state.db
|
|
|
|
|
|
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
|
|
|
|
|
if not server:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="服务器不存在")
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
remote_path = normalize_remote_abs_path(payload.path)
|
|
|
|
|
|
except PosixPathError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
|
|
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
|
|
|
|
|
from server.utils.files_chmod_policy import (
|
|
|
|
|
|
RECURSIVE_CHMOD_TIMEOUT_SEC,
|
|
|
|
|
|
SINGLE_CHMOD_TIMEOUT_SEC,
|
|
|
|
|
|
assert_recursive_chmod_allowed,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
path_q = shlex.quote(remote_path)
|
|
|
|
|
|
recursive = bool(payload.recursive)
|
|
|
|
|
|
if recursive:
|
|
|
|
|
|
try:
|
|
|
|
|
|
assert_recursive_chmod_allowed(remote_path)
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
dir_check = await exec_ssh_command(server, f"test -d {path_q}", timeout=10)
|
|
|
|
|
|
if dir_check.get("exit_code") != 0:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="递归 chmod 仅适用于目录")
|
|
|
|
|
|
|
|
|
|
|
|
chmod_prefix = "chmod -R " if recursive else "chmod "
|
|
|
|
|
|
chown_prefix = "chown -R " if recursive else "chown "
|
|
|
|
|
|
cmd_parts = [f"{chmod_prefix}{shlex.quote(payload.mode)} {path_q}"]
|
|
|
|
|
|
if payload.owner:
|
|
|
|
|
|
group = (payload.group or payload.owner).strip()
|
|
|
|
|
|
owner_spec = shlex.quote(f"{payload.owner}:{group}")
|
|
|
|
|
|
cmd_parts.append(f"{chown_prefix}{owner_spec} {path_q}")
|
|
|
|
|
|
|
|
|
|
|
|
timeout = RECURSIVE_CHMOD_TIMEOUT_SEC if recursive else SINGLE_CHMOD_TIMEOUT_SEC
|
|
|
|
|
|
result = await exec_ssh_command_with_fallback(server, " && ".join(cmd_parts), timeout=timeout)
|
2026-05-31 02:55:45 +08:00
|
|
|
|
if result["exit_code"] != 0:
|
2026-06-01 19:54:18 +08:00
|
|
|
|
err = ((result.get("stderr") or "") + (result.get("stdout") or ""))[:300]
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=403 if "permission denied" in err.lower() else 502,
|
|
|
|
|
|
detail=err or "chmod 失败",
|
|
|
|
|
|
)
|
2026-05-31 02:55:45 +08:00
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
audit_bits = [f"chmod {'-R ' if recursive else ''}{payload.mode}"]
|
|
|
|
|
|
if payload.owner:
|
|
|
|
|
|
audit_bits.append(
|
|
|
|
|
|
f"chown {'-R ' if recursive else ''}{payload.owner}:{payload.group or payload.owner}"
|
|
|
|
|
|
)
|
2026-05-31 02:55:45 +08:00
|
|
|
|
await _audit_sync(
|
2026-06-01 19:54:18 +08:00
|
|
|
|
"file_permission_change",
|
|
|
|
|
|
"server",
|
|
|
|
|
|
payload.server_id,
|
|
|
|
|
|
f"{' '.join(audit_bits)} {remote_path}",
|
|
|
|
|
|
admin.username,
|
|
|
|
|
|
request,
|
2026-05-31 02:55:45 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"success": True,
|
|
|
|
|
|
"path": remote_path,
|
|
|
|
|
|
"mode": payload.mode,
|
|
|
|
|
|
"owner": payload.owner,
|
|
|
|
|
|
"group": payload.group,
|
|
|
|
|
|
"recursive": recursive,
|
|
|
|
|
|
"elevated": bool(result.get("elevated")),
|
|
|
|
|
|
}
|
2026-05-31 02:55:45 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/compress")
|
|
|
|
|
|
async def compress_files(
|
|
|
|
|
|
payload: FileCompress,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
admin: Admin = Depends(get_current_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Compress files into tar.gz or zip archive."""
|
|
|
|
|
|
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
2026-06-01 19:54:18 +08:00
|
|
|
|
from server.infrastructure.ssh.remote_archive import run_remote_compress
|
2026-05-31 02:55:45 +08:00
|
|
|
|
|
|
|
|
|
|
session = request.state.db
|
|
|
|
|
|
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
|
|
|
|
|
if not server:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="服务器不存在")
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
dest = normalize_remote_abs_path(payload.dest)
|
|
|
|
|
|
paths = [normalize_remote_abs_path(p) for p in payload.paths]
|
|
|
|
|
|
except PosixPathError as e:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
2026-05-31 02:55:45 +08:00
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
result = await run_remote_compress(
|
|
|
|
|
|
server, paths, dest, payload.format, timeout=120,
|
|
|
|
|
|
)
|
2026-05-31 02:55:45 +08:00
|
|
|
|
if result["exit_code"] != 0:
|
2026-06-01 19:54:18 +08:00
|
|
|
|
err = ((result.get("stderr") or "") + (result.get("stdout") or ""))[:400]
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=403 if "permission denied" in err.lower() else 502,
|
|
|
|
|
|
detail=(
|
|
|
|
|
|
f"压缩失败: {err}"
|
|
|
|
|
|
if err.strip()
|
|
|
|
|
|
else "压缩失败。请确认对目标目录有写权限,或配置免密 sudo。"
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2026-05-31 02:55:45 +08:00
|
|
|
|
|
|
|
|
|
|
await _audit_sync(
|
|
|
|
|
|
"file_compress", "server", payload.server_id,
|
2026-06-01 19:54:18 +08:00
|
|
|
|
f"压缩 {len(payload.paths)} 个文件 → {dest}",
|
2026-05-31 02:55:45 +08:00
|
|
|
|
admin.username, request,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
return {"success": True, "dest": dest, "format": payload.format}
|
2026-05-31 02:55:45 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/decompress")
|
|
|
|
|
|
async def decompress_file(
|
|
|
|
|
|
payload: FileDecompress,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
admin: Admin = Depends(get_current_admin),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Decompress a tar.gz or zip archive."""
|
|
|
|
|
|
import shlex
|
|
|
|
|
|
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
2026-06-01 19:54:18 +08:00
|
|
|
|
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
|
2026-05-31 02:55:45 +08:00
|
|
|
|
|
|
|
|
|
|
session = request.state.db
|
|
|
|
|
|
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
|
|
|
|
|
if not server:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="服务器不存在")
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
try:
|
|
|
|
|
|
archive_path = normalize_remote_abs_path(payload.path)
|
|
|
|
|
|
dest_dir = normalize_remote_abs_path(payload.dest)
|
|
|
|
|
|
except PosixPathError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
|
|
path = shlex.quote(archive_path)
|
|
|
|
|
|
dest = shlex.quote(dest_dir)
|
2026-05-31 02:55:45 +08:00
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
if archive_path.endswith(".tar.gz") or archive_path.endswith(".tgz"):
|
2026-05-31 02:55:45 +08:00
|
|
|
|
cmd = f"tar xzf {path} -C {dest}"
|
2026-06-01 19:54:18 +08:00
|
|
|
|
elif archive_path.endswith(".zip"):
|
2026-05-31 02:55:45 +08:00
|
|
|
|
cmd = f"unzip -o {path} -d {dest}"
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="仅支持 .tar.gz/.tgz 和 .zip 格式")
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
result = await exec_ssh_command_with_fallback(server, cmd, timeout=120)
|
2026-05-31 02:55:45 +08:00
|
|
|
|
if result["exit_code"] != 0:
|
|
|
|
|
|
raise HTTPException(status_code=502, detail=f"解压失败: {result['stderr'][:300]}")
|
|
|
|
|
|
|
|
|
|
|
|
await _audit_sync(
|
|
|
|
|
|
"file_decompress", "server", payload.server_id,
|
2026-06-01 19:54:18 +08:00
|
|
|
|
f"解压 {archive_path} → {dest_dir}",
|
2026-05-31 02:55:45 +08:00
|
|
|
|
admin.username, request,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-01 19:54:18 +08:00
|
|
|
|
return {"success": True, "path": archive_path, "dest": dest_dir}
|