79105dcec8
ETag 纳入权限指纹避免 304 旧数据;chmod/chown 分步执行;仅变更属主/属组时才提交 chown。 Co-authored-by: Cursor <cursoragent@cursor.com>
180 lines
5.9 KiB
Python
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 []))
|