"""Nexus — Sync Engine v2 API Routes (Step 4) S1: POST /api/sync/files — rsync file sync P1: POST /api/sync/preview — rsync dry-run (方案D: 智能提示预览) """ import logging import uuid from fastapi import APIRouter, Depends, HTTPException, Request from server.api.schemas import ( SyncFiles, SyncBrowse, SyncBrowseLocal, SyncPreview, FileOperation, FileClipboardApply, LocalFileOperation, LocalFilePreview, SyncVerify, SyncCancel, ReconcileStaleLogs, ValidateSourcePath, SyncDiagnose, FileSyncDiff, FileDownload, FileRead, FileWrite, FileChmod, FileCompress, FileDecompress, ServerFileTransferRelay, ServerFileTransferPackage, ServerFileTransferPull, ServerFileTransferDeliver, ) from server.api.remote_path_validation import RemotePathError, assert_clipboard_transfer_safe from server.utils.text_io import detect_text_eol 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, resolve_nexus_push_source_path, to_posix, ) 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 from server.domain.models import Admin, AuditLog router = APIRouter(prefix="/api/sync", tags=["sync"]) logger = logging.getLogger("nexus.sync_v2") 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( payload: SyncFiles, request: Request, admin: Admin = Depends(get_current_admin), ): """S1: Accept file push and run rsync in background (progress via /ws/sync).""" from server.application.services.sync_engine_v2 import _nexus_source_path from server.background.sync_push_runner import schedule_sync_files engine = await _get_sync_engine(request) try: source_path = _nexus_source_path(payload.source_path) except PosixPathError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc servers = await engine.server_repo.get_by_ids(payload.server_ids) if not servers: raise HTTPException(status_code=400, detail="未选择有效服务器") found_ids = {s.id for s in servers} missing = [sid for sid in payload.server_ids if sid not in found_ids] if missing: preview = ", ".join(str(x) for x in missing[:5]) suffix = f" 等 {len(missing)} 台" if len(missing) > 5 else "" raise HTTPException(status_code=400, detail=f"服务器不存在: {preview}{suffix}") batch_id = (payload.batch_id or uuid.uuid4().hex[:12])[:12] schedule_sync_files( server_ids=list(payload.server_ids), source_path=source_path, target_path=payload.target_path, sync_mode=payload.sync_mode, operator=admin.username, batch_size=payload.batch_size, concurrency=payload.concurrency, batch_id=batch_id, ) return { "accepted": True, "batch_id": batch_id, "total": len(servers), "completed": 0, "failed": 0, "cancelled": 0, "results": {}, } @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 @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.remote_shell import exec_ssh_command_with_fallback 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 try: p = normalize_remote_abs_path(payload.path) except PosixPathError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc rename_dest: str | None = None if op == "delete": # rm -rf covers both files and directories cmd = f"rm -rf -- {shlex.quote(p)}" audit_detail = f"删除: {p}" elif op == "rename": if not payload.new_path: raise HTTPException(status_code=400, detail="new_path required for rename") try: rename_dest = normalize_remote_abs_path(payload.new_path) except PosixPathError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc cmd = f"mv -- {shlex.quote(p)} {shlex.quote(rename_dest)}" audit_detail = f"重命名: {p} → {rename_dest}" elif op == "mkdir": cmd = f"mkdir -p -- {shlex.quote(p)}" audit_detail = f"新建目录: {p}" else: raise HTTPException(status_code=400, detail="Invalid operation") result = await exec_ssh_command_with_fallback(server, cmd, timeout=30) if result["exit_code"] != 0: err = (result.get("stderr") or result.get("stdout") or "")[:300] raise HTTPException( status_code=403 if "permission denied" in err.lower() else 400, detail=err or f"Command failed (exit {result['exit_code']})", ) await _audit_sync( f"file_{op}", "server", payload.server_id, f"{audit_detail} 于 {server.name}", admin.username, request, ) from server.application.services.files_browse_service import invalidate_browse_cache from server.utils.posix_paths import posix_dirname invalidate_browse_cache(payload.server_id, posix_dirname(p)) if op == "rename" and rename_dest: invalidate_browse_cache(payload.server_id, posix_dirname(rename_dest)) return {"success": True, "operation": op, "path": p} @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, ) from server.application.services.files_browse_service import invalidate_browse_cache invalidate_browse_cache(payload.server_id, dest_dir) return { "success": True, "mode": payload.mode, "dest_dir": dest_dir, "count": len(sources), } @router.post("/browse") async def browse_directory( payload: SyncBrowse, request: Request, admin: Admin = Depends(get_current_admin), ): """Browse remote directory listing via SSH (legacy POST; prefer GET /api/files/browse).""" from server.application.services.files_browse_service import list_remote_directory from server.infrastructure.database.server_repo import ServerRepositoryImpl session = request.state.db server_id = payload.server_id path = payload.path repo = ServerRepositoryImpl(session) server = await repo.get_by_id(server_id) if not server: raise HTTPException(status_code=404, detail="Server not found") result = await list_remote_directory(session, server_id, path) await _audit_sync( "browse_directory", "server", server_id, f"Browse {server.name}:{result.get('path', path)}", admin.username, request, ) return result @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 try: real_path = ensure_under_nexus_upload(payload.path) except PosixPathError as exc: raise HTTPException(status_code=403, detail=str(exc)) from exc 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: raise HTTPException(status_code=403, detail="无权限访问该目录") from None 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 try: real_path = ensure_under_nexus_upload(payload.path) except PosixPathError as exc: raise HTTPException(status_code=403, detail=str(exc)) from exc path = to_posix(real_path) 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: raise HTTPException(status_code=500, detail=f"删除失败: {e}") from e 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="文件或目录不存在") new_path = posix_join(posix_dirname(real_path), safe_name) 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) if os.path.exists(new_path): raise HTTPException(status_code=400, detail="目标名称已存在") try: os.rename(real_path, new_path) except OSError as e: raise HTTPException(status_code=500, detail=f"重命名失败: {e}") from e 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} @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 try: real_path = ensure_under_nexus_upload(payload.path) except PosixPathError as exc: raise HTTPException(status_code=403, detail=str(exc)) from exc path = to_posix(real_path) 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 name = posix_basename(real_path) 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, } 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, ip_address=request.client.host if request.client else "", )) except Exception: logger.warning(f"Audit log write failed for {action} on {target_type}/{target_id}", exc_info=True) @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: raise HTTPException(status_code=500, detail=f"取消操作失败: {e}") from e await _audit_sync( "sync_cancel", "sync", 0, f"取消推送: batch_id={payload.batch_id}", admin.username, request, ) return {"cancelled": True, "batch_id": payload.batch_id} @router.post("/reconcile-stale-logs") async def reconcile_stale_sync_logs( payload: ReconcileStaleLogs, request: Request, admin: Admin = Depends(get_current_admin), ): """Mark sync_logs stuck in ``running`` longer than *max_age_minutes* as failed. Repairs rows left by the historical missing ``sync_log_repo.update()`` bug. """ from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl repo = SyncLogRepositoryImpl(request.state.db) count = await repo.reconcile_stale_running(max_age_minutes=payload.max_age_minutes) await _audit_sync( "sync_reconcile_stale", "sync", 0, f"订正卡住推送记录: {count} 条(>{payload.max_age_minutes} 分钟仍为 running)", admin.username, request, ) return {"reconciled": count, "max_age_minutes": payload.max_age_minutes} # ── H5: Validate Source Path ── @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 try: real_path = resolve_nexus_push_source_path(payload.path) except PosixPathError as exc: status = 403 if "系统路径" in str(exc) else 400 if "不存在" in str(exc): status = 404 raise HTTPException(status_code=status, detail=str(exc)) from exc # 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: total_size += os.path.getsize(posix_join(root, fn)) except OSError: pass if file_count >= 10000: break if file_count >= 10000: break await _audit_sync( "validate_source_path", "sync", 0, f"验证源路径: {payload.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.ssh.remote_shell import exec_ssh_command_with_fallback from server.infrastructure.database.server_repo import ServerRepositoryImpl from server.utils.sync_error_message import translate_sync_error_message 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, "path_elevated": False, "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: stderr_zh = translate_sync_error_message((r.get("stderr") or "")[:200]) or "" result["errors"].append( f"SSH 连接异常: {stderr_zh or '退出码 ' + 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 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 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 except Exception as e: logger.debug(f"Diagnose disk check failed for server {payload.server_id}: {e}") 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}") except Exception as e: logger.debug(f"Diagnose path check failed for server {payload.server_id}: {e}") # 4. Write test if result["path_exists"]: try: test_file = remote_join(dest, ".nexus_diag_test") write_cmd = f"touch {shlex.quote(test_file)} && rm -f {shlex.quote(test_file)}" r = await exec_ssh_command_with_fallback(server, write_cmd, timeout=10) result["path_writable"] = r["exit_code"] == 0 if r["exit_code"] != 0: stderr_zh = translate_sync_error_message((r.get("stderr") or "")[:200]) or "" result["errors"].append(f"目标路径不可写: {stderr_zh}") elif r.get("elevated"): result["path_elevated"] = True 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 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="相对路径无效") local_file = resolve_nexus_host_path(posix_join(real_source, rel)) if not is_path_under_prefix(local_file, real_source): raise HTTPException(status_code=403, detail="文件路径越界") if not os.path.isfile(local_file): return { "file_name": posix_basename(rel), "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="服务器不存在") 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 # 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 except Exception as e: # SSH failed — report error logger.warning(f"File diff remote read failed for server {payload.server_id}: {e}") 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 { "file_name": posix_basename(rel), "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, } # ── File Upload via SFTP ── MAX_UPLOAD_FILE_SIZE = 524_288_000 # 500 MB def _collect_multipart_upload_files(form) -> list: """Accept ``file`` (SSOT) or legacy ``files`` from multipart form.""" collected: list = [] for key in ("file", "files"): for item in form.getlist(key): if item and hasattr(item, "filename") and item.filename: collected.append(item) if collected: return collected return [] async def _sftp_upload_one( *, server, server_id: int, remote_dir: str, upload_file, admin_username: str, request: Request, ) -> dict: """Upload a single UploadFile to remote_dir via SFTP.""" import asyncssh from server.infrastructure.ssh.asyncssh_pool import remote_write_bytes, ssh_pool from server.utils.files_elevation import get_files_elevation content = await upload_file.read() if len(content) > MAX_UPLOAD_FILE_SIZE: raise HTTPException( status_code=400, detail=f"文件大小超过限制 ({MAX_UPLOAD_FILE_SIZE // (1024 * 1024)}MB)", ) safe_filename = _safe_upload_basename(upload_file.filename or "") try: full_remote_path = remote_join(remote_dir, safe_filename) except PosixPathError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc try: conn = await ssh_pool.acquire(server) try: await remote_write_bytes( conn, full_remote_path, content, elevation=get_files_elevation(server), ) finally: await ssh_pool.release(server.id) except asyncssh.PermissionDenied: raise HTTPException( status_code=403, detail=f"SSH 权限不足,无法写入 {full_remote_path}" ) from None except asyncssh.SFTPError as e: raise HTTPException(status_code=400, detail=f"SFTP 上传失败: {e}") from e except Exception as e: raise HTTPException(status_code=502, detail=f"SSH 连接失败: {e}") from e from server.utils.files_upload_permissions import apply_upload_file_permissions permission_fixup = await apply_upload_file_permissions(server, full_remote_path) audit_detail = f"上传 {safe_filename} ({len(content)} bytes) → {server.name}:{full_remote_path}" if permission_fixup.get("applied"): pf_owner = permission_fixup.get("owner") or "" pf_group = permission_fixup.get("group") or pf_owner pf_mode = permission_fixup.get("mode") or "" audit_detail += f" [chown {pf_owner}:{pf_group} chmod {pf_mode}]" await _audit_sync( "file_upload", "server", server_id, audit_detail, admin_username, request, ) return { "remote_path": full_remote_path, "filename": safe_filename, "size": len(content), "permission_fixup": permission_fixup, } @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 (one or more; legacy alias ``files``) The file is uploaded to {remote_path}/{filename} on the target server. If the file already exists, it will be overwritten. """ form = await request.form() server_id_raw = form.get("server_id") remote_path_raw = form.get("remote_path", "/tmp") upload_items = _collect_multipart_upload_files(form) if not server_id_raw: raise HTTPException(status_code=400, detail="server_id 必填") try: server_id = int(server_id_raw) except (ValueError, TypeError): raise HTTPException(status_code=400, detail="server_id 必须为数字") from None if not upload_items: raise HTTPException(status_code=400, detail="请选择要上传的文件") remote_path = str(remote_path_raw).strip() try: remote_dir = normalize_remote_abs_path(remote_path) except PosixPathError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc session = request.state.db server = await ServerRepositoryImpl(session).get_by_id(server_id) if not server: raise HTTPException(status_code=404, detail="服务器不存在") results: list[dict] = [] for item in upload_items: results.append( await _sftp_upload_one( server=server, server_id=server_id, remote_dir=remote_dir, upload_file=item, admin_username=admin.username, request=request, ) ) from server.application.services.files_browse_service import invalidate_browse_cache invalidate_browse_cache(server_id, remote_dir) if len(results) == 1: one = results[0] return { "success": True, "server_id": server_id, "remote_path": one["remote_path"], "filename": one["filename"], "size": one["size"], "permission_fixup": one.get("permission_fixup"), } return { "success": True, "server_id": server_id, "count": len(results), "files": results, } # ── Push source upload (ZIP extract + plain files staging) ── MAX_ZIP_FILE_SIZE = 524_288_000 # 500 MB MAX_STAGING_TOTAL_SIZE = 524_288_000 # 500 MB per batch def _safe_upload_basename(filename: str) -> str: """Strip path separators from an upload filename (Nexus host or remote SFTP).""" safe = (filename or "").replace("/", "_").replace("\\", "_").strip() if not safe or safe in (".", ".."): raise HTTPException(status_code=400, detail="文件名无效") return safe def _unique_staging_name(name: str, used: set[str]) -> str: if name not in used: return name base, dot, ext = name.rpartition(".") if not dot: base, ext = name, "" n = 2 while True: candidate = f"{base}_{n}.{ext}" if ext else f"{base}_{n}" if candidate not in used: return candidate n += 1 async def _persist_staging_files(staging_dir: str, upload_items: list) -> dict: """Write multipart uploads into *staging_dir*; returns staging metadata.""" import os import shutil os.makedirs(staging_dir, exist_ok=True) staging_base = resolve_nexus_host_path(staging_dir) total_size = 0 file_count = 0 files_list: list[str] = [] used_names: set[str] = set() files_truncated = False try: for upload_file in upload_items: filename = upload_file.filename or "" content = await upload_file.read() if len(content) == 0: raise HTTPException(status_code=400, detail=f"文件为空: {filename or '(未命名)'}") if len(content) > MAX_UPLOAD_FILE_SIZE: raise HTTPException( status_code=400, detail=f"文件大小超过限制 ({MAX_UPLOAD_FILE_SIZE // (1024 * 1024)}MB): {filename}", ) total_size += len(content) if total_size > MAX_STAGING_TOTAL_SIZE: raise HTTPException( status_code=400, detail=f"本次上传总大小超过限制 ({MAX_STAGING_TOTAL_SIZE // (1024 * 1024)}MB)", ) safe_name = _unique_staging_name(_safe_upload_basename(filename), used_names) used_names.add(safe_name) dest_path = posix_join(staging_dir, safe_name) real_dest = resolve_nexus_host_path(dest_path) if not is_path_under_prefix(real_dest, staging_base): raise HTTPException(status_code=400, detail=f"非法文件名: {filename}") with open(real_dest, "wb") as fh: fh.write(content) file_count += 1 if len(files_list) < 500: files_list.append(safe_name) else: files_truncated = True if file_count == 0: raise HTTPException(status_code=400, detail="请选择至少一个文件") return { "source_path": staging_dir, "file_count": file_count, "files": files_list, "files_truncated": files_truncated, "size": total_size, } except HTTPException: shutil.rmtree(staging_dir, ignore_errors=True) raise except Exception as exc: shutil.rmtree(staging_dir, ignore_errors=True) raise HTTPException(status_code=500, detail=f"上传失败: {exc}") from exc @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 import posixpath 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: extract_base = resolve_nexus_host_path(extract_dir) for info in zf.infolist(): 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): 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 = [] for root, _dirs, fnames in os.walk(extract_dir): for fn in fnames: rel = posixpath.relpath(posix_join(root, fn), extract_dir) 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 raise HTTPException(status_code=400, detail="无效的 ZIP 文件") from None except Exception as e: shutil.rmtree(extract_dir, ignore_errors=True) try: os.unlink(zip_path) except OSError: pass raise HTTPException(status_code=500, detail=f"解压失败: {e}") from e @router.post("/upload-files") async def upload_staging_files( request: Request, admin: Admin = Depends(get_current_admin), ): """Upload one or more plain files to Nexus staging for rsync push. Multipart form fields: - file / files: UploadFile(s),任意后缀,原样写入暂存目录 Returns {"source_path": "/tmp/nexus_upload_xxx/", "file_count": N, ...} """ import uuid form = await request.form() upload_items = _collect_multipart_upload_files(form) if not upload_items: raise HTTPException(status_code=400, detail="请选择文件") upload_id = uuid.uuid4().hex[:12] staging_dir = f"/tmp/nexus_upload_{upload_id}" meta = await _persist_staging_files(staging_dir, upload_items) names = ", ".join(meta["files"][:3]) if meta["file_count"] > 3: names += f" 等 {meta['file_count']} 个" await _audit_sync( "upload_staging_files", "sync", 0, f"上传推送源文件: {names} → {staging_dir} ({meta['size']} bytes)", admin.username, request, ) return { "success": True, **meta, } # ── Post-Push Verification (sha256sum comparison) ── @router.post("/verify") async def verify_sync( payload: SyncVerify, request: Request, admin: Admin = Depends(get_current_admin), ): """Verify pushed files by comparing sha256sums between source and target servers. Walks the local source directory and computes sha256 for each file, then runs sha256sum on each target server via SSH and compares. Returns per-server results: matched / missing / mismatched file lists. """ import asyncio import hashlib import os import posixpath import shlex from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command from server.infrastructure.database.server_repo import ServerRepositoryImpl session = request.state.db try: source_path = resolve_nexus_push_source_path(payload.source_path) except PosixPathError as exc: status = 403 if "系统路径" in str(exc) else 400 raise HTTPException(status_code=status, detail=str(exc)) from exc # Build local file manifest: {relative_path: sha256hex} local_files = {} for root, _dirs, fnames in os.walk(source_path): for fn in fnames: full = posix_join(root, fn) rel = posixpath.relpath(full, to_posix(source_path)) if len(local_files) >= payload.max_files: break try: h = hashlib.sha256() 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 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 # Run sha256sum on remote server for the same relative paths # Build a file list for sha256sum to check file_list = " ".join(shlex.quote(f) for f in local_files.keys()) cmd = f"cd {shlex.quote(dest)} && sha256sum {file_list} 2>/dev/null" 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 # Parse sha256sum output: "hash filename" 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)} # ── 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 from server.infrastructure.ssh.asyncssh_pool import sftp_session, ssh_pool session = request.state.db server = await ServerRepositoryImpl(session).get_by_id(payload.server_id) if not server: raise HTTPException(status_code=404, detail="服务器不存在") try: remote_path = normalize_remote_abs_path(payload.path) except PosixPathError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc conn = await ssh_pool.acquire(server) # Pre-check in a short SFTP session; stream opens its own session so the # context manager is not exited before StreamingResponse consumes the body. try: async with sftp_session(conn) as sftp: try: stat = await sftp.stat(remote_path) except FileNotFoundError: await ssh_pool.release(server.id) raise HTTPException(status_code=404, detail="文件不存在") from None except Exception as e: await ssh_pool.release(server.id) raise HTTPException(status_code=502, detail=f"无法访问文件: {e}") from e if stat.type not in ("regular file", "unknown"): await ssh_pool.release(server.id) raise HTTPException(status_code=400, detail="只能下载文件,不能下载目录") MAX_DOWNLOAD_SIZE = 524_288_000 if hasattr(stat, "size") and stat.size and stat.size > MAX_DOWNLOAD_SIZE: await ssh_pool.release(server.id) raise HTTPException(status_code=413, detail=f"文件大小 {stat.size} 字节超过下载限制 100MB") import re as _re filename = _re.sub(r'[^\w.\-]', '_', posix_basename(remote_path)) or "download" except HTTPException: raise except Exception as e: await ssh_pool.release(server.id) raise HTTPException(status_code=502, detail=f"下载失败: {e}") from e async def file_generator(): try: async with sftp_session(conn) as sftp: async with sftp.open(remote_path, "rb") as f: while chunk := await f.read(65536): yield chunk finally: await ssh_pool.release(server.id) await _audit_sync( "file_download", "server", payload.server_id, f"下载文件: {remote_path}", admin.username, request, ) return StreamingResponse( file_generator(), media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @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. Returns content, size, path, mtime (Unix seconds), etag (sha256hex), eol (LF|CRLF). These metadata fields enable the frontend to detect concurrent modifications before saving. """ import shlex from server.infrastructure.database.server_repo import ServerRepositoryImpl from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback session = request.state.db server = await ServerRepositoryImpl(session).get_by_id(payload.server_id) if not server: raise HTTPException(status_code=404, detail="服务器不存在") 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) # stat: size + mtime in one call stat_result = await exec_ssh_command_with_fallback( server, f"stat -c '%s %Y' {path_q}", timeout=5, ) if stat_result["exit_code"] != 0: raise HTTPException(status_code=404, detail="文件不存在或无法访问") try: stat_parts = stat_result["stdout"].strip().split() file_size = int(stat_parts[0]) file_mtime = int(stat_parts[1]) if len(stat_parts) > 1 else None except (ValueError, IndexError): 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} 字节", ) # sha256 for conflict detection (best-effort; may fail on permission-only-readable files) etag: str | None = None sha_result = await exec_ssh_command_with_fallback( server, f"sha256sum {path_q}", timeout=5, ) if sha_result["exit_code"] == 0: sha_parts = sha_result["stdout"].strip().split() if sha_parts: etag = sha_parts[0] result = await exec_ssh_command_with_fallback(server, f"cat {path_q}", timeout=15) if result["exit_code"] != 0: err = ((result.get("stderr") or "") + (result.get("stdout") or ""))[:200] raise HTTPException(status_code=502, detail=f"读取失败: {err}") content: str = result["stdout"] detected = detect_text_eol(content) # Editor API only exposes LF | CRLF; classic Mac CR files map to LF label eol = "CRLF" if detected == "CRLF" else "LF" await _audit_sync( "file_read", "server", payload.server_id, f"读取文件: {remote_path} ({file_size} bytes)", admin.username, request, ) return { "content": content, "size": file_size, "path": remote_path, "mtime": file_mtime, "etag": etag, "eol": eol, } @router.post("/write-file") async def write_file( payload: FileWrite, request: Request, admin: Admin = Depends(get_current_admin), ): """Write text file content to a remote server (SFTP + SSH tee/mv/sudo fallback). If `etag` is provided, the current sha256 of the remote file is checked first. A mismatch returns HTTP 409 to signal a concurrent modification conflict. """ import shlex import asyncssh from server.infrastructure.database.server_repo import ServerRepositoryImpl from server.infrastructure.ssh.asyncssh_pool import remote_write_bytes, ssh_pool from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback session = request.state.db server = await ServerRepositoryImpl(session).get_by_id(payload.server_id) if not server: raise HTTPException(status_code=404, detail="服务器不存在") 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 content_bytes = payload.content.encode("utf-8") if len(content_bytes) > 5_000_000: raise HTTPException(status_code=413, detail="文件内容超过 5MB 限制") # Conflict detection: verify etag (sha256) before writing if payload.etag: path_q = shlex.quote(remote_path) sha_result = await exec_ssh_command_with_fallback( server, f"sha256sum {path_q}", timeout=5, ) if sha_result["exit_code"] == 0: sha_parts = sha_result["stdout"].strip().split() current_etag = sha_parts[0] if sha_parts else None if current_etag and current_etag != payload.etag: raise HTTPException( status_code=409, detail={ "message": "文件已被外部修改,etag 不匹配", "code": "ETAG_MISMATCH", "server_etag": current_etag, }, ) # sha256sum failure (e.g., permission, file not found) → skip check, allow write 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 # Compute new etag after successful write (best-effort, for frontend cache update) new_etag: str | None = None import logging as _logging try: path_q = shlex.quote(remote_path) sha_result = await exec_ssh_command_with_fallback( server, f"sha256sum {path_q}", timeout=5, ) if sha_result["exit_code"] == 0: sha_parts = sha_result["stdout"].strip().split() if sha_parts: new_etag = sha_parts[0] except Exception as _exc: _logging.getLogger(__name__).debug("post-write sha256 failed: %s", _exc) from server.application.services.files_browse_service import invalidate_browse_parents invalidate_browse_parents(payload.server_id, remote_path) await _audit_sync( "file_write", "server", payload.server_id, f"写入文件: {remote_path} ({len(content_bytes)} bytes)", admin.username, request, ) return {"success": True, "path": remote_path, "size": len(content_bytes), "etag": new_etag} @router.post("/chmod") async def chmod_file( payload: FileChmod, request: Request, admin: Admin = Depends(get_current_admin), ): """Change file permissions (and optional owner) on a remote server.""" import shlex from server.infrastructure.database.server_repo import ServerRepositoryImpl from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback session = request.state.db server = await ServerRepositoryImpl(session).get_by_id(payload.server_id) if not server: raise HTTPException(status_code=404, detail="服务器不存在") 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 " timeout = RECURSIVE_CHMOD_TIMEOUT_SEC if recursive else SINGLE_CHMOD_TIMEOUT_SEC chmod_cmd = f"{chmod_prefix}{payload.mode} {path_q}" chmod_result = await exec_ssh_command_with_fallback(server, chmod_cmd, timeout=timeout) if chmod_result["exit_code"] != 0: err = ((chmod_result.get("stderr") or "") + (chmod_result.get("stdout") or ""))[:300] raise HTTPException( status_code=403 if "permission denied" in err.lower() else 502, detail=err or "chmod 失败", ) elevated = bool(chmod_result.get("elevated")) if payload.owner: group = (payload.group or payload.owner).strip() owner_spec = shlex.quote(f"{payload.owner}:{group}") chown_cmd = f"{chown_prefix}{owner_spec} {path_q}" chown_result = await exec_ssh_command_with_fallback(server, chown_cmd, timeout=timeout) if chown_result["exit_code"] != 0: err = ((chown_result.get("stderr") or "") + (chown_result.get("stdout") or ""))[:300] raise HTTPException( status_code=403 if "permission denied" in err.lower() else 502, detail=f"权限已修改为 {payload.mode},但 chown 失败: {err or '未知错误'}", ) elevated = elevated or bool(chown_result.get("elevated")) 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}" ) from server.application.services.files_browse_service import invalidate_browse_parents invalidate_browse_parents(payload.server_id, remote_path) await _audit_sync( "file_permission_change", "server", payload.server_id, f"{' '.join(audit_bits)} {remote_path}", admin.username, request, ) return { "success": True, "path": remote_path, "mode": payload.mode, "owner": payload.owner, "group": payload.group, "recursive": recursive, "elevated": elevated, } @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 from server.infrastructure.ssh.remote_archive import run_remote_compress session = request.state.db server = await ServerRepositoryImpl(session).get_by_id(payload.server_id) if not server: raise HTTPException(status_code=404, detail="服务器不存在") 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 result = await run_remote_compress( server, paths, dest, payload.format, timeout=120, ) if result["exit_code"] != 0: 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。" ), ) from server.application.services.files_browse_service import invalidate_browse_parents invalidate_browse_parents(payload.server_id, dest, *paths) await _audit_sync( "file_compress", "server", payload.server_id, f"压缩 {len(payload.paths)} 个文件 → {dest}", admin.username, request, ) return {"success": True, "dest": dest, "format": payload.format} @router.post("/decompress") async def decompress_file( payload: FileDecompress, request: Request, admin: Admin = Depends(get_current_admin), ): """Decompress a tar.gz or zip archive.""" from server.infrastructure.database.server_repo import ServerRepositoryImpl from server.infrastructure.ssh.remote_archive import archive_format_from_path, run_remote_decompress session = request.state.db server = await ServerRepositoryImpl(session).get_by_id(payload.server_id) if not server: raise HTTPException(status_code=404, detail="服务器不存在") 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 fmt = archive_format_from_path(archive_path) if fmt is None: raise HTTPException(status_code=400, detail="仅支持 .tar.gz/.tgz 和 .zip 格式") result = await run_remote_decompress(server, archive_path, dest_dir, fmt, timeout=120) if result["exit_code"] != 0: raise HTTPException(status_code=502, detail=f"解压失败: {(result.get('stderr') or '')[:300]}") from server.application.services.files_browse_service import invalidate_browse_parents invalidate_browse_parents(payload.server_id, dest_dir, archive_path) await _audit_sync( "file_decompress", "server", payload.server_id, f"解压 {archive_path} → {dest_dir}", admin.username, request, ) return {"success": True, "path": archive_path, "dest": dest_dir} # ── Cross-server file transfer ── @router.post("/server-transfer") async def server_file_transfer_relay( payload: ServerFileTransferRelay, request: Request, admin: Admin = Depends(get_current_admin), ): """Relay copy files/directories from server A to server B via Nexus SFTP.""" from server.application.services.server_file_transfer_service import ( ServerTransferError, relay_files, ) from server.application.services.files_browse_service import invalidate_browse_cache session = request.state.db try: result = await relay_files( session, source_server_id=payload.source_server_id, dest_server_id=payload.dest_server_id, sources=payload.sources, dest_dir=payload.dest_dir, ) except RemotePathError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except ServerTransferError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: logger.exception("server_file_transfer_relay failed") raise HTTPException(status_code=502, detail=f"传输失败:{exc}") from exc await _audit_sync( "server_file_relay", "server", payload.dest_server_id, ( f"A#{payload.source_server_id} → B#{payload.dest_server_id}: " f"{result['count']} 项 → {result['dest_dir']} ({result['bytes_transferred']} B)" ), admin.username, request, ) invalidate_browse_cache(payload.dest_server_id, result["dest_dir"]) return {"success": True, **result} @router.post("/server-transfer/package") async def server_file_transfer_package( payload: ServerFileTransferPackage, request: Request, admin: Admin = Depends(get_current_admin), ): """Compress paths on source server; return signed download URL for target pull.""" from server.application.services.server_file_transfer_service import ( ServerTransferError, create_transfer_package, ) session = request.state.db try: result = await create_transfer_package( session, source_server_id=payload.source_server_id, paths=payload.paths, fmt=payload.format, ) except ServerTransferError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc await _audit_sync( "server_file_package", "server", payload.source_server_id, f"打包 {len(payload.paths)} 项 → {result.get('archive_path')}", admin.username, request, ) return {"success": True, **result} @router.post("/server-transfer/pull") async def server_file_transfer_pull( payload: ServerFileTransferPull, request: Request, admin: Admin = Depends(get_current_admin), ): """On destination server: curl package download URL; optional extract.""" from server.application.services.server_file_transfer_service import ( ServerTransferError, pull_transfer_package, ) from server.application.services.files_browse_service import invalidate_browse_parents from server.infrastructure.redis.transfer_package_store import load_package_meta session = request.state.db try: result = await pull_transfer_package( session, token=payload.token, dest_server_id=payload.dest_server_id, dest_path=payload.dest_path, extract=payload.extract, ) except ServerTransferError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc meta = await load_package_meta(payload.token) if meta and result.get("extracted_to"): invalidate_browse_parents( payload.dest_server_id, result["extracted_to"], result["dest_path"], ) else: from server.application.services.files_browse_service import invalidate_browse_cache from server.utils.posix_paths import posix_dirname invalidate_browse_cache(payload.dest_server_id, posix_dirname(result["dest_path"])) await _audit_sync( "server_file_pull", "server", payload.dest_server_id, f"拉取打包 {payload.token[:8]}… → {result['dest_path']}", admin.username, request, ) return {"success": True, **result} @router.post("/server-transfer/deliver") async def server_file_transfer_deliver( payload: ServerFileTransferDeliver, request: Request, admin: Admin = Depends(get_current_admin), ): """One-click: compress on source, curl on dest, optional extract.""" from server.application.services.server_file_transfer_service import ( ServerTransferError, deliver_transfer_package, ) from server.application.services.files_browse_service import invalidate_browse_parents, invalidate_browse_cache from server.utils.posix_paths import posix_dirname session = request.state.db try: result = await deliver_transfer_package( session, source_server_id=payload.source_server_id, paths=payload.paths, dest_server_id=payload.dest_server_id, dest_path=payload.dest_path, extract=payload.extract, fmt=payload.format, ) except ServerTransferError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: logger.exception("server_file_transfer_deliver failed") raise HTTPException(status_code=502, detail=f"投递失败:{exc}") from exc if result.get("extracted_to"): invalidate_browse_parents( payload.dest_server_id, result["extracted_to"], result["dest_path"], ) else: invalidate_browse_cache(payload.dest_server_id, posix_dirname(result["dest_path"])) await _audit_sync( "server_file_deliver", "server", payload.dest_server_id, ( f"A#{payload.source_server_id} → B#{payload.dest_server_id}: " f"打包投递 {len(payload.paths)} 项 → {result['dest_path']}" ), admin.username, request, ) return {"success": True, **result} @router.get("/transfer-download/{token}") async def server_file_transfer_download( token: str, request: Request, ): """Stream package archive from source server (token auth, no JWT — for remote curl).""" from fastapi.responses import StreamingResponse from server.application.services.server_file_transfer_service import ( ServerTransferError, iter_package_chunks, ) from server.infrastructure.database.server_repo import ServerRepositoryImpl from server.infrastructure.redis.transfer_package_store import load_package_meta session = request.state.db meta = await load_package_meta(token) if not meta: raise HTTPException(status_code=404, detail="下载链接无效或已过期") source_id = int(meta["source_server_id"]) archive_path = str(meta["archive_path"]) filename = str(meta.get("filename") or "package.tar.gz") server = await ServerRepositoryImpl(session).get_by_id(source_id) if not server: raise HTTPException(status_code=404, detail="源服务器不存在") try: async def file_generator(): try: async for chunk in iter_package_chunks(server, archive_path): yield chunk except ServerTransferError as exc: logger.warning("transfer-download stream failed: %s", exc) raise return StreamingResponse( file_generator(), media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) except ServerTransferError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc