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:
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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({})) == []
|
||||
Reference in New Issue
Block a user