2116 lines
73 KiB
Python
2116 lines
73 KiB
Python
|
|
"""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
|