diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/domain/adapters/providers/local.py b/domain/adapters/providers/local.py index 4f4d5e2..65de173 100644 --- a/domain/adapters/providers/local.py +++ b/domain/adapters/providers/local.py @@ -299,23 +299,23 @@ class LocalAdapter: return StreamingResponse(iterator(), status_code=status, headers=headers, media_type=content_type) - async def stat_file(self, root: str, rel: str): + async def stat_file(self, root: str, rel: str, include_metadata: bool = False): fp = _safe_join(root, rel) if not fp.exists(): raise FileNotFoundError(rel) st = await asyncio.to_thread(fp.stat) + is_dir = fp.is_dir() info = { "name": fp.name, - "is_dir": fp.is_dir(), + "is_dir": is_dir, "size": st.st_size, "mtime": int(st.st_mtime), "mode": stat.S_IMODE(st.st_mode), - "type": "dir" if fp.is_dir() else "file", + "type": "dir" if is_dir else "file", "path": str(fp), } - # exif信息 - exif = None - if not fp.is_dir(): + if include_metadata and not is_dir: + exif = None mime, _ = mimetypes.guess_type(fp.name) if mime and mime.startswith("image/"): try: @@ -326,7 +326,7 @@ class LocalAdapter: exif = {str(k): str(v) for k, v in exif_data.items()} except Exception: exif = None - info["exif"] = exif + info["exif"] = exif return info diff --git a/domain/adapters/providers/webdav.py b/domain/adapters/providers/webdav.py index 570bbc1..001ea54 100644 --- a/domain/adapters/providers/webdav.py +++ b/domain/adapters/providers/webdav.py @@ -376,7 +376,7 @@ class WebDAVAdapter: return StreamingResponse(segmented_body(), status_code=status_code, headers=resp_headers, media_type=content_type) - async def stat_file(self, root: str, rel: str): + async def stat_file(self, root: str, rel: str, include_metadata: bool = False): url = self._build_url(rel) async with self._client() as client: # PROPFIND 获取属性 @@ -426,9 +426,8 @@ class WebDAVAdapter: info["mtime"] = 0 elif info["mtime"] is None: info["mtime"] = 0 - # exif信息 - exif = None - if not info["is_dir"]: + if include_metadata and not info["is_dir"]: + exif = None mime, _ = mimetypes.guess_type(info["name"]) if mime and mime.startswith("image/"): try: @@ -442,7 +441,7 @@ class WebDAVAdapter: exif = {str(k): str(v) for k, v in exif_data.items()} except Exception: exif = None - info["exif"] = exif + info["exif"] = exif return info async def exists(self, root: str, rel: str) -> bool: diff --git a/domain/permission/service.py b/domain/permission/service.py index 0498f0c..fb045c4 100644 --- a/domain/permission/service.py +++ b/domain/permission/service.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from typing import List, Optional from fastapi import HTTPException @@ -17,74 +18,169 @@ from .types import ( PERMISSION_DEFINITIONS, ) +@dataclass(slots=True) +class PermissionContext: + exists: bool + is_admin: bool + path_rules: List[PathRule] + class PermissionService: """权限检查服务""" # 权限检查结果缓存(简单的内存缓存) _cache: dict[str, tuple[bool, float]] = {} + _context_cache: dict[int, tuple[PermissionContext, float]] = {} _cache_ttl = 300 # 5分钟缓存 + @classmethod + def _now(cls) -> float: + import time + + return time.time() + + @classmethod + def _is_cache_valid(cls, timestamp: float) -> bool: + return cls._now() - timestamp < cls._cache_ttl + + @classmethod + def _get_cached_result(cls, cache_key: str) -> Optional[bool]: + cached = cls._cache.get(cache_key) + if not cached: + return None + result, timestamp = cached + if cls._is_cache_valid(timestamp): + return result + cls._cache.pop(cache_key, None) + return None + + @classmethod + def _sort_path_rules(cls, rules: List[PathRule]) -> List[PathRule]: + return sorted( + rules, + key=lambda r: ( + r.priority, + PathMatcher.get_pattern_specificity(r.path_pattern, r.is_regex), + ), + reverse=True, + ) + + @classmethod + def _match_sorted_path_rules( + cls, path: str, action: str, sorted_rules: List[PathRule] + ) -> Optional[bool]: + for rule in sorted_rules: + if PathMatcher.match_pattern(path, rule.path_pattern, rule.is_regex): + if action == PathAction.READ: + return rule.can_read + if action == PathAction.WRITE: + return rule.can_write + if action == PathAction.DELETE: + return rule.can_delete + if action == PathAction.SHARE: + return rule.can_share + return False + return None + + @classmethod + async def _get_permission_context(cls, user_id: int) -> PermissionContext: + cached = cls._context_cache.get(user_id) + if cached: + context, timestamp = cached + if cls._is_cache_valid(timestamp): + return context + cls._context_cache.pop(user_id, None) + + user = await UserAccount.get_or_none(id=user_id) + if not user: + context = PermissionContext(exists=False, is_admin=False, path_rules=[]) + cls._context_cache[user_id] = (context, cls._now()) + return context + + if user.is_admin: + context = PermissionContext(exists=True, is_admin=True, path_rules=[]) + cls._context_cache[user_id] = (context, cls._now()) + return context + + user_roles = await UserRole.filter(user_id=user_id) + role_ids = [ur.role_id for ur in user_roles] + if not role_ids: + context = PermissionContext(exists=True, is_admin=False, path_rules=[]) + cls._context_cache[user_id] = (context, cls._now()) + return context + + path_rules = await PathRule.filter(role_id__in=role_ids) + context = PermissionContext( + exists=True, + is_admin=False, + path_rules=cls._sort_path_rules(list(path_rules)), + ) + cls._context_cache[user_id] = (context, cls._now()) + return context + + @classmethod + def _check_path_permission_with_context( + cls, + user_id: int, + normalized_path: str, + action: str, + context: PermissionContext, + ) -> bool: + if not context.exists: + return False + if context.is_admin: + return True + + checked_cache_keys: List[str] = [] + current_path = normalized_path + + while True: + cache_key = f"{user_id}:{current_path}:{action}" + cached_result = cls._get_cached_result(cache_key) + if cached_result is not None: + result = cached_result + break + + checked_cache_keys.append(cache_key) + result = cls._match_sorted_path_rules(current_path, action, context.path_rules) + if result is not None: + break + + parent_path = PathMatcher.get_parent_path(current_path) + if not parent_path: + result = False + break + current_path = parent_path + + timestamp = cls._now() + for cache_key in checked_cache_keys: + cls._cache[cache_key] = (result, timestamp) + return result + @classmethod async def check_path_permission( cls, user_id: int, path: str, action: str ) -> bool: """ 检查用户对路径的操作权限 - + Args: user_id: 用户ID path: 要检查的路径 action: 操作类型 (read/write/delete/share) - + Returns: 是否有权限 """ - import time - - # 检查缓存 - cache_key = f"{user_id}:{path}:{action}" - if cache_key in cls._cache: - result, timestamp = cls._cache[cache_key] - if time.time() - timestamp < cls._cache_ttl: - return result - - # 获取用户 - user = await UserAccount.get_or_none(id=user_id) - if not user: - return False - - # 超级管理员直接放行 - if user.is_admin: - cls._cache[cache_key] = (True, time.time()) - return True - - # 获取用户所有角色 - user_roles = await UserRole.filter(user_id=user_id).prefetch_related("role") - role_ids = [ur.role_id for ur in user_roles] - - if not role_ids: - cls._cache[cache_key] = (False, time.time()) - return False - - # 获取所有角色的路径规则 - path_rules = await PathRule.filter(role_id__in=role_ids).order_by("-priority") - - # 规范化路径 normalized_path = PathMatcher.normalize_path(path) + cache_key = f"{user_id}:{normalized_path}:{action}" + cached_result = cls._get_cached_result(cache_key) + if cached_result is not None: + return cached_result - # 按优先级和具体程度匹配 - result = cls._match_path_rules(normalized_path, action, list(path_rules)) - - # 如果没有匹配到规则,检查父目录(继承) - if result is None: - parent_path = PathMatcher.get_parent_path(normalized_path) - if parent_path: - result = await cls.check_path_permission(user_id, parent_path, action) - else: - result = False # 默认拒绝 - - cls._cache[cache_key] = (result, time.time()) + context = await cls._get_permission_context(user_id) + result = cls._check_path_permission_with_context(user_id, normalized_path, action, context) + cls._cache[cache_key] = (result, cls._now()) return result @classmethod @@ -97,31 +193,7 @@ class PermissionService: Returns: True/False 表示明确的权限结果,None 表示没有匹配到规则 """ - # 按优先级和具体程度排序 - sorted_rules = sorted( - rules, - key=lambda r: ( - r.priority, - PathMatcher.get_pattern_specificity(r.path_pattern, r.is_regex), - ), - reverse=True, - ) - - for rule in sorted_rules: - if PathMatcher.match_pattern(path, rule.path_pattern, rule.is_regex): - # 匹配到规则,检查具体操作权限 - if action == PathAction.READ: - return rule.can_read - elif action == PathAction.WRITE: - return rule.can_write - elif action == PathAction.DELETE: - return rule.can_delete - elif action == PathAction.SHARE: - return rule.can_share - else: - return False - - return None + return cls._match_sorted_path_rules(path, action, cls._sort_path_rules(rules)) @classmethod async def check_system_permission(cls, user_id: int, permission_code: str) -> bool: @@ -251,35 +323,20 @@ class PermissionService: cls, user_id: int, path: str, action: str ) -> PathPermissionResult: """检查路径权限并返回详细结果""" - user = await UserAccount.get_or_none(id=user_id) - if not user: + context = await cls._get_permission_context(user_id) + if not context.exists: return PathPermissionResult(path=path, action=action, allowed=False) - # 超级管理员 - if user.is_admin: + if context.is_admin: return PathPermissionResult(path=path, action=action, allowed=True) - # 获取用户角色 - user_roles = await UserRole.filter(user_id=user_id) - role_ids = [ur.role_id for ur in user_roles] - - if not role_ids: + if not context.path_rules: return PathPermissionResult(path=path, action=action, allowed=False) - # 获取路径规则 - path_rules = await PathRule.filter(role_id__in=role_ids).order_by("-priority") normalized_path = PathMatcher.normalize_path(path) - # 查找匹配的规则 matched_rule = None - for rule in sorted( - path_rules, - key=lambda r: ( - r.priority, - PathMatcher.get_pattern_specificity(r.path_pattern, r.is_regex), - ), - reverse=True, - ): + for rule in context.path_rules: if PathMatcher.match_pattern( normalized_path, rule.path_pattern, rule.is_regex ): @@ -322,19 +379,30 @@ class PermissionService: """清除权限缓存""" if user_id is None: cls._cache.clear() + cls._context_cache.clear() else: - # 清除特定用户的缓存 keys_to_delete = [k for k in cls._cache if k.startswith(f"{user_id}:")] for k in keys_to_delete: del cls._cache[k] + cls._context_cache.pop(user_id, None) @classmethod async def filter_paths_by_permission( cls, user_id: int, paths: List[str], action: str ) -> List[str]: """过滤出用户有权限的路径列表""" + if not paths: + return [] + + context = await cls._get_permission_context(user_id) + if not context.exists: + return [] + if context.is_admin: + return list(paths) + result = [] for path in paths: - if await cls.check_path_permission(user_id, path, action): + normalized_path = PathMatcher.normalize_path(path) + if cls._check_path_permission_with_context(user_id, normalized_path, action, context): result.append(path) return result diff --git a/domain/virtual_fs/api.py b/domain/virtual_fs/api.py index be99a0e..2a6e6bc 100644 --- a/domain/virtual_fs/api.py +++ b/domain/virtual_fs/api.py @@ -84,8 +84,9 @@ async def get_file_stat( full_path: str, request: Request, current_user: Annotated[User, Depends(get_current_active_user)], + verbose: bool = Query(False, description="是否返回扩展元数据"), ): - stat = await VirtualFSService.stat(full_path) + stat = await VirtualFSService.stat(full_path, verbose=verbose) return success(stat) diff --git a/domain/virtual_fs/listing.py b/domain/virtual_fs/listing.py index ee43d46..7fd478f 100644 --- a/domain/virtual_fs/listing.py +++ b/domain/virtual_fs/listing.py @@ -1,3 +1,4 @@ +import inspect from typing import Any, Dict, List, Tuple from fastapi import HTTPException @@ -14,6 +15,23 @@ from .resolver import VirtualFSResolverMixin class VirtualFSListingMixin(VirtualFSResolverMixin): + @staticmethod + async def _call_stat_file( + stat_func, + root: str, + rel: str, + *, + include_metadata: bool = False, + ): + try: + parameters = inspect.signature(stat_func).parameters + except (TypeError, ValueError): + parameters = {} + + if "include_metadata" in parameters: + return await stat_func(root, rel, include_metadata=include_metadata) + return await stat_func(root, rel) + @classmethod async def path_is_directory(cls, path: str) -> bool: adapter_instance, _, root, rel = await cls.resolve_adapter_and_rel(path) @@ -24,7 +42,7 @@ class VirtualFSListingMixin(VirtualFSResolverMixin): if not callable(stat_func): raise HTTPException(501, detail="Adapter does not implement stat_file") try: - info = await stat_func(root, rel) + info = await cls._call_stat_file(stat_func, root, rel, include_metadata=False) except FileNotFoundError: raise HTTPException(404, detail="Path not found") if isinstance(info, dict): @@ -110,7 +128,12 @@ class VirtualFSListingMixin(VirtualFSResolverMixin): stat_file = getattr(adapter_instance, "stat_file", None) if callable(stat_file): try: - parent_info = await stat_file(effective_root, rel) + parent_info = await cls._call_stat_file( + stat_file, + effective_root, + rel, + include_metadata=False, + ) if isinstance(parent_info, dict): parent_info.setdefault("name", rel.split("/")[-1]) parent_info["is_dir"] = bool(parent_info.get("is_dir", True)) @@ -121,7 +144,12 @@ class VirtualFSListingMixin(VirtualFSResolverMixin): stat_file = getattr(adapter_instance, "stat_file", None) if callable(stat_file): try: - parent_info = await stat_file(effective_root, parent_rel) + parent_info = await cls._call_stat_file( + stat_file, + effective_root, + parent_rel, + include_metadata=False, + ) if isinstance(parent_info, dict): parent_info.setdefault("name", parent_rel.split("/")[-1]) parent_info["is_dir"] = bool(parent_info.get("is_dir", True)) @@ -222,13 +250,18 @@ class VirtualFSListingMixin(VirtualFSResolverMixin): } @classmethod - async def stat_file(cls, path: str): + async def stat_file(cls, path: str, verbose: bool = False): adapter_instance, _, root, rel = await cls.resolve_adapter_and_rel(path) stat_func = getattr(adapter_instance, "stat_file", None) if not callable(stat_func): raise HTTPException(501, detail="Adapter does not implement stat_file") try: - info = await stat_func(root, rel) + info = await cls._call_stat_file( + stat_func, + root, + rel, + include_metadata=verbose, + ) except FileNotFoundError as exc: raise HTTPException(404, detail=str(exc)) @@ -241,7 +274,7 @@ class VirtualFSListingMixin(VirtualFSResolverMixin): rel_name = rel.rstrip("/").split("/")[-1] if rel else path.rstrip("/").split("/")[-1] name_hint = str(info.get("name") or rel_name or "") info["has_thumbnail"] = bool(not is_dir and (is_image_filename(name_hint) or is_video_filename(name_hint))) - if not is_dir: + if verbose and not is_dir: vector_index = await cls._gather_vector_index(path) if vector_index is not None: info["vector_index"] = vector_index @@ -263,38 +296,26 @@ class VirtualFSListingMixin(VirtualFSResolverMixin): 过滤掉用户没有读取权限的条目 """ - # 首先获取完整的目录列表 result = await cls.list_virtual_dir(path, page_num, page_size, sort_by, sort_order) - - # 检查用户是否是管理员(管理员可以看到所有内容) - from models.database import UserAccount - user = await UserAccount.get_or_none(id=user_id) - if user and user.is_admin: - return result - - # 过滤无权限的条目 items = result.get("items", []) if not items: return result - + norm = cls._normalize_path(path).rstrip("/") or "/" - filtered_items = [] - + path_pairs: List[Tuple[str, Dict]] = [] for item in items: item_name = item.get("name", "") if norm == "/": item_path = f"/{item_name}" else: item_path = f"{norm}/{item_name}" - - # 检查用户是否有读取权限 - has_permission = await PermissionService.check_path_permission( - user_id, item_path, PathAction.READ - ) - if has_permission: - filtered_items.append(item) - - # 更新结果 - result["items"] = filtered_items - + path_pairs.append((item_path, item)) + + allowed_paths = await PermissionService.filter_paths_by_permission( + user_id, + [item_path for item_path, _ in path_pairs], + PathAction.READ, + ) + allowed_set = set(allowed_paths) + result["items"] = [item for item_path, item in path_pairs if item_path in allowed_set] return result diff --git a/domain/virtual_fs/routes.py b/domain/virtual_fs/routes.py index 5a0b44b..475e2ff 100644 --- a/domain/virtual_fs/routes.py +++ b/domain/virtual_fs/routes.py @@ -144,9 +144,9 @@ class VirtualFSRouteMixin(VirtualFSTempLinkMixin): return response @classmethod - async def stat(cls, full_path: str): + async def stat(cls, full_path: str, verbose: bool = False): full_path = cls._normalize_path(full_path) - return await cls.stat_file(full_path) + return await cls.stat_file(full_path, verbose=verbose) @classmethod async def write_uploaded_file(cls, full_path: str, data: bytes): diff --git a/domain/virtual_fs/search/search_api.py b/domain/virtual_fs/search/search_api.py index fdeb0ef..db921ff 100644 --- a/domain/virtual_fs/search/search_api.py +++ b/domain/virtual_fs/search/search_api.py @@ -28,12 +28,12 @@ async def search_files( data = await VirtualFSSearchService.search(q, top_k, mode, page, page_size) items = data.get("items") if isinstance(data, dict) else None if isinstance(items, list) and items: - filtered = [] - for item in items: - path = getattr(item, "path", None) - if not path: - continue - if await PermissionService.check_path_permission(user.id, str(path), PathAction.READ): - filtered.append(item) - data["items"] = filtered + path_pairs = [(str(item.path), item) for item in items if getattr(item, "path", None)] + allowed_paths = await PermissionService.filter_paths_by_permission( + user.id, + [path for path, _ in path_pairs], + PathAction.READ, + ) + allowed_set = set(allowed_paths) + data["items"] = [item for path, item in path_pairs if path in allowed_set] return success(data) diff --git a/web/src/api/vfs.ts b/web/src/api/vfs.ts index 7235cf4..44f4fc4 100644 --- a/web/src/api/vfs.ts +++ b/web/src/api/vfs.ts @@ -86,7 +86,12 @@ export const vfsApi = { thumb: (path: string, w=256, h=256, fit='cover') => request(`/fs/thumb/${encodeURI(path.replace(/^\/+/, ''))}?w=${w}&h=${h}&fit=${fit}`), streamUrl: (path: string) => `${API_BASE_URL}/fs/stream/${encodeURI(path.replace(/^\/+/, ''))}`, - stat: (path: string) => request(`/fs/stat/${encodeURI(path.replace(/^\/+/, ''))}`), + stat: (path: string, options?: { verbose?: boolean }) => { + const params = new URLSearchParams(); + if (options?.verbose) params.set('verbose', 'true'); + const query = params.toString(); + return request(`/fs/stat/${encodeURI(path.replace(/^\/+/, ''))}${query ? `?${query}` : ''}`); + }, getTempLinkToken: (path: string, expiresIn: number = 3600) => request<{token: string, path: string, url: string}>(`/fs/temp-link/${encodeURI(path.replace(/^\/+/, ''))}?expires_in=${expiresIn}`), getTempPublicUrl: (token: string) => `${API_BASE_URL}/fs/public/${token}`, diff --git a/web/src/pages/FileExplorerPage/FileExplorerPage.tsx b/web/src/pages/FileExplorerPage/FileExplorerPage.tsx index 3c65884..5e4dd08 100644 --- a/web/src/pages/FileExplorerPage/FileExplorerPage.tsx +++ b/web/src/pages/FileExplorerPage/FileExplorerPage.tsx @@ -181,7 +181,7 @@ const FileExplorerPage = memo(function FileExplorerPage() { setDetailLoading(true); try { const fullPath = (entryBasePath === '/' ? '' : entryBasePath) + '/' + entry.name; - const stat = await vfsApi.stat(fullPath); + const stat = await vfsApi.stat(fullPath, { verbose: true }); setDetailData(stat as Record); } catch (error) { const messageText = error instanceof Error ? error.message : String(error); diff --git a/web/src/pages/FileExplorerPage/hooks/useFileActions.tsx b/web/src/pages/FileExplorerPage/hooks/useFileActions.tsx index 451fa35..c8a88c3 100644 --- a/web/src/pages/FileExplorerPage/hooks/useFileActions.tsx +++ b/web/src/pages/FileExplorerPage/hooks/useFileActions.tsx @@ -168,22 +168,19 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi refresh(); }, [normalizeDestination, normalizeFullPath, t, buildEntryDestination, refresh]); - const doDownload = useCallback(async (entry: VfsEntry) => { + const doDownload = useCallback((entry: VfsEntry) => { if (entry.is_dir) { message.warning(t('Downloading folders is not supported')); return; } try { - const buf = await vfsApi.readFile((path === '/' ? '' : path) + '/' + entry.name); - const blob = new Blob([buf]); - const url = URL.createObjectURL(blob); + const url = vfsApi.streamUrl((path === '/' ? '' : path) + '/' + entry.name); const a = document.createElement('a'); a.href = url; a.download = entry.name; document.body.appendChild(a); a.click(); document.body.removeChild(a); - URL.revokeObjectURL(url); } catch (e: any) { message.error(e.message || t('Download failed')); }