fix(files,deploy): 文件上传字段名与 Layer3 巡检脚本

后端同时接受 file/files 并支持多文件 SFTP 上传;修复 health_monitor.sh 引号语法使 Layer 3 cron 可运行。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-07 23:00:01 +08:00
parent 7ea94a60ab
commit 9337f37da8
6 changed files with 205 additions and 60 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ normalize_env_value() {
if [[ ${#v} -ge 2 && ${v:0:1} == '"' && ${v: -1} == '"' ]]; then
v="${v:1:-1}"
v="${v//\\n/$'\n'}"
v="${v//\\"/\"}"
v="${v//\\\"/\"}"
v="${v//\\\\/\\}"
fi
echo "$v"
@@ -0,0 +1,24 @@
# 2026-06-07 — 文件页上传 FormData 字段名修复
## 摘要
修复文件管理页 `POST /api/sync/upload`:前端发 `files`,后端只读 `file`,导致「请选择要上传的文件」。
## 变更
| 文件 | 说明 |
|------|------|
| `server/api/sync_v2.py` | 接受 `file` / `files`;支持多文件;抽取 `_sftp_upload_one` |
| `frontend/src/composables/files/useFilesActions.ts` | FormData 字段改为 `file` |
| `tests/test_sync_upload_form.py` | 字段解析单测 |
## 迁移 / 重启
需重启 API;前端需 vite build 后部署(仅后端修复亦可兼容旧前端)。
## 验证
```bash
pytest tests/test_sync_upload_form.py -q
# 文件页 server_id=1 → 上传小文件 → 列表刷新可见
```
@@ -0,0 +1,20 @@
# 2026-06-07 — health_monitor.sh 引号语法修复(Layer 3 巡检)
## 摘要
修复 `normalize_env_value``\"` 替换写法导致 bash 解析失败,Layer 3 cron 巡检脚本无法执行。
## 动机
生产 `/var/log/nexus_health.log``unexpected EOF while looking for matching '"'`,Telegram 告警与自动重启逻辑均未运行。
## 变更
- `deploy/health_monitor.sh``${v//\\"/\"}``${v//\\\"/\"}`
## 验证
```bash
bash -n deploy/health_monitor.sh
sudo NEXUS_DEPLOY_DIR=/opt/nexus bash deploy/health_monitor.sh # exit 0
```
@@ -488,7 +488,7 @@ export function useFilesActions(options: {
uploading.value = true
try {
const form = new FormData()
for (const f of uploadFiles.value) form.append('files', f)
for (const f of uploadFiles.value) form.append('file', f)
form.append('server_id', String(selectedServer.value))
form.append('remote_path', currentPath.value)
await http.upload('/sync/upload', form)
+112 -58
View File
@@ -838,70 +838,49 @@ async def file_diff(
MAX_UPLOAD_FILE_SIZE = 104_857_600 # 100 MB
@router.post("/upload")
async def upload_file(
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,
admin: Admin = Depends(get_current_admin),
):
"""Upload a file to a remote server via SFTP.
Multipart form fields:
- server_id: int (required)
- remote_path: str (target directory, required)
- file: UploadFile (required)
The file is uploaded to {remote_path}/{filename} on the target server.
If the file already exists, it will be overwritten.
"""
) -> 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
form = await request.form()
server_id_raw = form.get("server_id")
remote_path_raw = form.get("remote_path", "/tmp")
file = form.get("file")
if not server_id_raw:
raise HTTPException(status_code=400, detail="server_id 必填")
try:
server_id = int(server_id_raw)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="server_id 必须为数字") from None
if not file or not hasattr(file, "filename") or not file.filename:
raise HTTPException(status_code=400, detail="请选择要上传的文件")
# Read file content with size check
content = await file.read()
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)")
raise HTTPException(
status_code=400,
detail=f"文件大小超过限制 ({MAX_UPLOAD_FILE_SIZE // (1024 * 1024)}MB)",
)
remote_path = str(remote_path_raw).strip()
filename = file.filename
try:
remote_dir = normalize_remote_abs_path(remote_path)
except PosixPathError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# Sanitize filename — strip slashes to prevent path injection
filename = upload_file.filename or ""
safe_filename = filename.replace("/", "_").replace("\\", "_").strip()
if not safe_filename:
raise HTTPException(status_code=400, detail="文件名无效")
# Get server from DB
session = request.state.db
server = await ServerRepositoryImpl(session).get_by_id(server_id)
if not server:
raise HTTPException(status_code=404, detail="服务器不存在")
try:
full_remote_path = remote_join(remote_dir, safe_filename)
except PosixPathError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
from server.utils.files_elevation import get_files_elevation
try:
conn = await ssh_pool.acquire(server)
try:
@@ -914,28 +893,103 @@ async def upload_file(
finally:
await ssh_pool.release(server.id)
except asyncssh.PermissionDenied:
raise HTTPException(status_code=403, detail=f"SSH 权限不足,无法写入 {full_remote_path}") from None
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
await _audit_sync(
"file_upload",
"server",
server_id,
f"上传 {safe_filename} ({len(content)} bytes) → {server.name}:{full_remote_path}",
admin_username,
request,
)
return {
"remote_path": full_remote_path,
"filename": safe_filename,
"size": len(content),
}
@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)
await _audit_sync(
"file_upload", "server", server_id,
f"上传 {safe_filename} ({len(content)} bytes) → {server.name}:{full_remote_path}",
admin.username, request,
)
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"],
}
return {
"success": True,
"server_id": server_id,
"remote_path": full_remote_path,
"filename": safe_filename,
"size": len(content),
"count": len(results),
"files": results,
}
+47
View File
@@ -0,0 +1,47 @@
"""Multipart field name for POST /api/sync/upload."""
from types import SimpleNamespace
from server.api.sync_v2 import _collect_multipart_upload_files
class _FakeForm:
def __init__(self, data: dict[str, list]):
self._data = data
def getlist(self, key: str) -> list:
return list(self._data.get(key, []))
def test_collect_file_field():
form = _FakeForm({
"file": [SimpleNamespace(filename="a.txt")],
})
items = _collect_multipart_upload_files(form)
assert len(items) == 1
assert items[0].filename == "a.txt"
def test_collect_legacy_files_field():
form = _FakeForm({
"files": [
SimpleNamespace(filename="one.bin"),
SimpleNamespace(filename="two.bin"),
],
})
items = _collect_multipart_upload_files(form)
assert len(items) == 2
def test_collect_prefers_file_over_files():
form = _FakeForm({
"file": [SimpleNamespace(filename="primary.txt")],
"files": [SimpleNamespace(filename="legacy.txt")],
})
items = _collect_multipart_upload_files(form)
assert len(items) == 1
assert items[0].filename == "primary.txt"
def test_collect_empty():
assert _collect_multipart_upload_files(_FakeForm({})) == []