feat(files): 上传后自动 chown www:www 与文件 chmod

复用推送的 RSYNC_PUSH 配置,上传成功即 SSH 修正属主/权限;失败时仍保留文件并返回 warning。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-11 18:23:31 +08:00
parent 79105dcec8
commit 4e117c04ea
5 changed files with 256 additions and 3 deletions
@@ -0,0 +1,39 @@
# 2026-06-11 文件管理上传后自动 www 权限
## 摘要
文件管理上传完成后,自动对远端文件执行与推送相同的 `chown www:www` 与文件 `chmod`(默认 `755`,来自 `NEXUS_RSYNC_PUSH_CHOWN` / `NEXUS_RSYNC_PUSH_CHMOD`)。
## 动机
上传文件默认属主常为 SSH 登录用户(如 root),与站点运行用户 `www` 不一致,导致 Web 无法读写。推送已用 rsync 权限参数统一属主,上传路径此前缺失同类修正。
## 涉及文件
- `server/utils/files_upload_permissions.py` — 解析 push 配置并 SSH chown/chmod
- `server/api/sync_v2.py``_sftp_upload_one` 写入后调用 fixup,响应与审计带 `permission_fixup`
- `frontend/src/composables/files/useFilesActions.ts` — 权限修正失败时 warning 提示
- `tests/test_files_upload_permissions.py` — 单元测试
## 配置
与推送共用环境变量(默认已启用):
- `NEXUS_RSYNC_PUSH_CHOWN=www:www`
- `NEXUS_RSYNC_PUSH_CHMOD=D755,F755`(上传取 `F755` → 文件 `755`
置空两者则跳过上传后权限修正。
## 迁移 / 重启
- 无需数据库迁移
- 需重启 API 容器使后端生效;前端需重新 build 部署
## 验证
```bash
pytest tests/test_files_upload_permissions.py -q
bash scripts/local_verify.sh
```
浏览器:文件管理上传 → `ls -la` 应显示 `www www` 及预期 mode;若 sudo 白名单不足,界面 warning 且上传仍成功。
@@ -491,12 +491,19 @@ export function useFilesActions(options: {
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)
const res = await http.upload<{
permission_fixup?: { applied?: boolean; error?: string | null }
}>('/sync/upload', form)
showUpload.value = false
uploadFiles.value = []
options.invalidateAfterMutation()
await options.browse({ force: true })
snackbar('上传成功')
const pf = res?.permission_fixup
if (pf?.error) {
snackbar(`上传成功,但权限修正失败: ${pf.error}`, 'warning')
} else {
snackbar('上传成功')
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '上传失败'
snackbar(msg, 'error')
+14 -1
View File
@@ -927,11 +927,22 @@ async def _sftp_upload_one(
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,
f"上传 {safe_filename} ({len(content)} bytes) → {server.name}:{full_remote_path}",
audit_detail,
admin_username,
request,
)
@@ -940,6 +951,7 @@ async def _sftp_upload_one(
"remote_path": full_remote_path,
"filename": safe_filename,
"size": len(content),
"permission_fixup": permission_fixup,
}
@@ -1009,6 +1021,7 @@ async def upload_file(
"remote_path": one["remote_path"],
"filename": one["filename"],
"size": one["size"],
"permission_fixup": one.get("permission_fixup"),
}
return {
+104
View File
@@ -0,0 +1,104 @@
"""Post-upload permission fixup for file manager (reuse rsync push defaults)."""
from __future__ import annotations
import logging
import re
import shlex
from typing import TYPE_CHECKING
from server.application.services.sync_engine_v2 import _RSYNC_CHMOD_RE, _RSYNC_CHOWN_RE
if TYPE_CHECKING:
from server.domain.models import Server
logger = logging.getLogger(__name__)
def parse_upload_file_mode(chmod_setting: str) -> str | None:
"""Extract octal file mode from rsync-style ``D755,F755`` or plain ``644``."""
raw = (chmod_setting or "").strip()
if not raw or not _RSYNC_CHMOD_RE.fullmatch(raw):
return None
m = re.search(r"F([0-7]{3,4})", raw, re.IGNORECASE)
if m:
return m.group(1)
if re.fullmatch(r"[0-7]{3,4}", raw):
return raw
for part in reversed(raw.split(",")):
piece = part.strip()
if len(piece) > 1 and piece[0] in "Ff" and re.fullmatch(r"[0-7]{3,4}", piece[1:]):
return piece[1:]
return None
def parse_upload_chown(chown_setting: str) -> tuple[str, str] | None:
"""Return ``(owner, group)`` when setting is valid; else None."""
raw = (chown_setting or "").strip()
if not raw or not _RSYNC_CHOWN_RE.fullmatch(raw):
return None
if ":" in raw:
owner, group = raw.split(":", 1)
return owner.strip(), group.strip()
return raw, raw
def upload_permission_plan() -> tuple[tuple[str, str] | None, str | None]:
"""Read ``NEXUS_RSYNC_PUSH_CHOWN`` / ``NEXUS_RSYNC_PUSH_CHMOD`` for uploads."""
from server.config import settings
return (
parse_upload_chown(settings.RSYNC_PUSH_CHOWN or ""),
parse_upload_file_mode(settings.RSYNC_PUSH_CHMOD or ""),
)
async def apply_upload_file_permissions(server: Server, remote_path: str) -> dict:
"""chown/chmod uploaded file; mirrors rsync push defaults (www:www, F755)."""
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
chown_pair, file_mode = upload_permission_plan()
if not chown_pair and not file_mode:
return {"applied": False, "skipped": True}
path_q = shlex.quote(remote_path)
elevated = False
errors: list[str] = []
if chown_pair:
owner_spec = shlex.quote(f"{chown_pair[0]}:{chown_pair[1]}")
result = await exec_ssh_command_with_fallback(
server, f"chown {owner_spec} {path_q}", timeout=30,
)
elevated = bool(result.get("elevated"))
if result.get("exit_code") != 0:
err = ((result.get("stderr") or "") + (result.get("stdout") or "")).strip()[:200]
errors.append(err or "chown 失败")
if file_mode and not errors:
result = await exec_ssh_command_with_fallback(
server, f"chmod {file_mode} {path_q}", timeout=30,
)
elevated = elevated or bool(result.get("elevated"))
if result.get("exit_code") != 0:
err = ((result.get("stderr") or "") + (result.get("stdout") or "")).strip()[:200]
errors.append(err or "chmod 失败")
if errors:
logger.warning(
"Upload permission fixup failed for %s on server %s: %s",
remote_path,
getattr(server, "id", "?"),
"; ".join(errors),
)
owner, group = chown_pair if chown_pair else (None, None)
return {
"applied": not errors and bool(chown_pair or file_mode),
"skipped": False,
"owner": owner,
"group": group,
"mode": file_mode,
"elevated": elevated,
"error": "; ".join(errors) if errors else None,
}
+90
View File
@@ -0,0 +1,90 @@
"""Tests for post-upload permission fixup."""
import pytest
from server.utils.files_upload_permissions import (
apply_upload_file_permissions,
parse_upload_chown,
parse_upload_file_mode,
upload_permission_plan,
)
from server.config import settings
def test_parse_upload_file_mode_from_rsync_default():
assert parse_upload_file_mode("D755,F755") == "755"
assert parse_upload_file_mode("F644") == "644"
assert parse_upload_file_mode("755") == "755"
def test_parse_upload_file_mode_invalid():
assert parse_upload_file_mode("") is None
assert parse_upload_file_mode("bad") is None
def test_parse_upload_chown():
assert parse_upload_chown("www:www") == ("www", "www")
assert parse_upload_chown("root") == ("root", "root")
assert parse_upload_chown("www;evil") is None
def test_upload_permission_plan_defaults():
owner_group, mode = upload_permission_plan()
assert owner_group == ("www", "www")
assert mode == "755"
def test_upload_permission_plan_disabled(monkeypatch):
monkeypatch.setattr(settings, "RSYNC_PUSH_CHOWN", "")
monkeypatch.setattr(settings, "RSYNC_PUSH_CHMOD", "")
assert upload_permission_plan() == (None, None)
@pytest.mark.asyncio
async def test_apply_upload_file_permissions_success(monkeypatch):
calls: list[tuple[str, str]] = []
async def fake_exec(server, cmd, timeout=30):
calls.append((getattr(server, "id", "?"), cmd))
return {"exit_code": 0, "elevated": True}
monkeypatch.setattr(
"server.infrastructure.ssh.remote_shell.exec_ssh_command_with_fallback",
fake_exec,
)
class FakeServer:
id = 1
result = await apply_upload_file_permissions(FakeServer(), "/var/www/test.txt")
assert result["applied"] is True
assert result["owner"] == "www"
assert result["group"] == "www"
assert result["mode"] == "755"
assert result["elevated"] is True
assert result["error"] is None
assert len(calls) == 2
assert "chown" in calls[0][1] and "www:www" in calls[0][1]
assert "chmod 755" in calls[1][1]
@pytest.mark.asyncio
async def test_apply_upload_file_permissions_chown_failure(monkeypatch):
async def fake_exec(server, cmd, timeout=30):
if cmd.startswith("chown"):
return {"exit_code": 1, "stderr": "permission denied", "elevated": False}
return {"exit_code": 0, "elevated": False}
monkeypatch.setattr(
"server.infrastructure.ssh.remote_shell.exec_ssh_command_with_fallback",
fake_exec,
)
class FakeServer:
id = 2
result = await apply_upload_file_permissions(FakeServer(), "/tmp/x")
assert result["applied"] is False
assert "permission denied" in (result["error"] or "")