Files
Nexus/server/application/services/files_browse_service.py
T
Nexus Agent 79105dcec8 fix(files): 改权限后列表不更新及 chown 连带失败
ETag 纳入权限指纹避免 304 旧数据;chmod/chown 分步执行;仅变更属主/属组时才提交 chown。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 18:12:57 +08:00

180 lines
5.9 KiB
Python

"""Remote directory listing — shared by POST /api/sync/browse and GET /api/files/browse."""
from __future__ import annotations
import hashlib
import logging
import shlex
import time
from typing import Any
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
from server.utils.posix_paths import PosixPathError, normalize_remote_abs_path
from server.utils.unix_ls import parse_ls_la_line, perms_to_mode_octal
logger = logging.getLogger(__name__)
BROWSE_ETAG_CACHE_TTL_SEC = 5.0
BROWSE_ETAG_CACHE_MAX = 200
_BROWSE_ETAG_CACHE: dict[str, tuple[str, dict[str, Any], float]] = {}
def browse_cache_key(server_id: int, path: str) -> str:
return f"{server_id}:{path}"
def compute_browse_etag(path: str, items: list[dict[str, Any]]) -> str:
"""Weak ETag from path + sorted entry fingerprints (name|mtime|size|perms|owner|group)."""
lines = [path]
for item in sorted(items, key=lambda x: x.get("name") or ""):
lines.append(
"|".join(
[
str(item.get("name", "")),
str(item.get("modified", "")),
str(item.get("size", 0)),
str(item.get("permissions", "")),
str(item.get("owner", "")),
str(item.get("group", "")),
str(item.get("mode_octal", "")),
]
)
)
digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
return f'W/"{digest}"'
def _prune_browse_etag_cache() -> None:
if len(_BROWSE_ETAG_CACHE) <= BROWSE_ETAG_CACHE_MAX:
return
oldest_key = min(_BROWSE_ETAG_CACHE, key=lambda k: _BROWSE_ETAG_CACHE[k][2])
_BROWSE_ETAG_CACHE.pop(oldest_key, None)
def get_cached_browse(server_id: int, path: str) -> tuple[str, dict[str, Any]] | None:
"""Return (etag, payload) if server-side ETag cache is still fresh."""
key = browse_cache_key(server_id, path)
entry = _BROWSE_ETAG_CACHE.get(key)
if not entry:
return None
etag, payload, at = entry
if time.monotonic() - at > BROWSE_ETAG_CACHE_TTL_SEC:
_BROWSE_ETAG_CACHE.pop(key, None)
return None
return etag, payload
def set_cached_browse(server_id: int, path: str, etag: str, payload: dict[str, Any]) -> None:
key = browse_cache_key(server_id, path)
_BROWSE_ETAG_CACHE[key] = (etag, payload, time.monotonic())
_prune_browse_etag_cache()
def invalidate_browse_parents(server_id: int, *paths: str) -> None:
"""Drop ETag cache for parent directories of one or more remote paths."""
from server.utils.posix_paths import PosixPathError, normalize_remote_abs_path, posix_dirname
seen: set[str] = set()
for raw in paths:
if not raw:
continue
try:
parent = posix_dirname(normalize_remote_abs_path(raw))
except PosixPathError:
continue
if parent in seen:
continue
seen.add(parent)
invalidate_browse_cache(server_id, parent)
def invalidate_browse_cache(server_id: int | None = None, path: str | None = None) -> None:
"""Drop server ETag cache after mutations or when forcing refresh."""
if server_id is None:
_BROWSE_ETAG_CACHE.clear()
return
if path is None:
prefix = f"{server_id}:"
for key in list(_BROWSE_ETAG_CACHE):
if key.startswith(prefix):
_BROWSE_ETAG_CACHE.pop(key, None)
return
_BROWSE_ETAG_CACHE.pop(browse_cache_key(server_id, path), None)
async def list_remote_directory(
session: AsyncSession,
server_id: int,
path: str,
) -> dict[str, Any]:
"""SSH ls -la on remote path; same JSON shape as legacy POST /sync/browse."""
if not server_id:
raise HTTPException(status_code=400, detail="server_id required")
repo = ServerRepositoryImpl(session)
server = await repo.get_by_id(server_id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
try:
path = normalize_remote_abs_path(path)
except PosixPathError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
path_q = shlex.quote(path)
result = await exec_ssh_command_with_fallback(
server,
f"ls -la --time-style=long-iso {path_q}",
timeout=10,
)
if result["exit_code"] != 0:
result = await exec_ssh_command_with_fallback(
server, f"ls -la {path_q}", timeout=10
)
if result["exit_code"] != 0:
return {"path": path, "items": [], "error": (result.get("stderr") or "")[:500]}
raw_entries = []
for line in result["stdout"].split("\n"):
parsed = parse_ls_la_line(line)
if parsed:
raw_entries.append(parsed)
items: list[dict[str, Any]] = []
for entry in raw_entries:
is_dir = bool(entry["is_dir"])
is_link = bool(entry["is_link"])
try:
size = int(entry["size"]) if not is_dir and not is_link else 0
except (TypeError, ValueError):
size = 0
if is_dir:
ftype = "directory"
elif is_link:
ftype = "symlink"
else:
ftype = "file"
mode_octal = perms_to_mode_octal(entry["perms"]) or ""
items.append({
"name": entry["name"],
"type": ftype,
"size": size,
"modified": entry["modified"],
"permissions": entry["perms"],
"owner": entry.get("owner") or "",
"group": entry.get("group") or "",
"mode_octal": mode_octal,
"link_target": entry.get("link_target"),
})
return {"path": path, "items": items}
def etag_for_payload(payload: dict[str, Any]) -> str:
return compute_browse_etag(str(payload.get("path") or "/"), list(payload.get("items") or []))