feat(files): 上传后自动 chown www:www 与文件 chmod
复用推送的 RSYNC_PUSH 配置,上传成功即 SSH 修正属主/权限;失败时仍保留文件并返回 warning。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 "")
|
||||
Reference in New Issue
Block a user