mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-05 23:57:19 +08:00
feat: add metadata options for file status checks and optimize permission filtering logic
This commit is contained in:
@@ -299,23 +299,23 @@ class LocalAdapter:
|
|||||||
|
|
||||||
return StreamingResponse(iterator(), status_code=status, headers=headers, media_type=content_type)
|
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)
|
fp = _safe_join(root, rel)
|
||||||
if not fp.exists():
|
if not fp.exists():
|
||||||
raise FileNotFoundError(rel)
|
raise FileNotFoundError(rel)
|
||||||
st = await asyncio.to_thread(fp.stat)
|
st = await asyncio.to_thread(fp.stat)
|
||||||
|
is_dir = fp.is_dir()
|
||||||
info = {
|
info = {
|
||||||
"name": fp.name,
|
"name": fp.name,
|
||||||
"is_dir": fp.is_dir(),
|
"is_dir": is_dir,
|
||||||
"size": st.st_size,
|
"size": st.st_size,
|
||||||
"mtime": int(st.st_mtime),
|
"mtime": int(st.st_mtime),
|
||||||
"mode": stat.S_IMODE(st.st_mode),
|
"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),
|
"path": str(fp),
|
||||||
}
|
}
|
||||||
# exif信息
|
if include_metadata and not is_dir:
|
||||||
exif = None
|
exif = None
|
||||||
if not fp.is_dir():
|
|
||||||
mime, _ = mimetypes.guess_type(fp.name)
|
mime, _ = mimetypes.guess_type(fp.name)
|
||||||
if mime and mime.startswith("image/"):
|
if mime and mime.startswith("image/"):
|
||||||
try:
|
try:
|
||||||
@@ -326,7 +326,7 @@ class LocalAdapter:
|
|||||||
exif = {str(k): str(v) for k, v in exif_data.items()}
|
exif = {str(k): str(v) for k, v in exif_data.items()}
|
||||||
except Exception:
|
except Exception:
|
||||||
exif = None
|
exif = None
|
||||||
info["exif"] = exif
|
info["exif"] = exif
|
||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -376,7 +376,7 @@ class WebDAVAdapter:
|
|||||||
|
|
||||||
return StreamingResponse(segmented_body(), status_code=status_code, headers=resp_headers, media_type=content_type)
|
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)
|
url = self._build_url(rel)
|
||||||
async with self._client() as client:
|
async with self._client() as client:
|
||||||
# PROPFIND 获取属性
|
# PROPFIND 获取属性
|
||||||
@@ -426,9 +426,8 @@ class WebDAVAdapter:
|
|||||||
info["mtime"] = 0
|
info["mtime"] = 0
|
||||||
elif info["mtime"] is None:
|
elif info["mtime"] is None:
|
||||||
info["mtime"] = 0
|
info["mtime"] = 0
|
||||||
# exif信息
|
if include_metadata and not info["is_dir"]:
|
||||||
exif = None
|
exif = None
|
||||||
if not info["is_dir"]:
|
|
||||||
mime, _ = mimetypes.guess_type(info["name"])
|
mime, _ = mimetypes.guess_type(info["name"])
|
||||||
if mime and mime.startswith("image/"):
|
if mime and mime.startswith("image/"):
|
||||||
try:
|
try:
|
||||||
@@ -442,7 +441,7 @@ class WebDAVAdapter:
|
|||||||
exif = {str(k): str(v) for k, v in exif_data.items()}
|
exif = {str(k): str(v) for k, v in exif_data.items()}
|
||||||
except Exception:
|
except Exception:
|
||||||
exif = None
|
exif = None
|
||||||
info["exif"] = exif
|
info["exif"] = exif
|
||||||
return info
|
return info
|
||||||
|
|
||||||
async def exists(self, root: str, rel: str) -> bool:
|
async def exists(self, root: str, rel: str) -> bool:
|
||||||
|
|||||||
+158
-90
@@ -1,3 +1,4 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
@@ -17,14 +18,145 @@ from .types import (
|
|||||||
PERMISSION_DEFINITIONS,
|
PERMISSION_DEFINITIONS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PermissionContext:
|
||||||
|
exists: bool
|
||||||
|
is_admin: bool
|
||||||
|
path_rules: List[PathRule]
|
||||||
|
|
||||||
|
|
||||||
class PermissionService:
|
class PermissionService:
|
||||||
"""权限检查服务"""
|
"""权限检查服务"""
|
||||||
|
|
||||||
# 权限检查结果缓存(简单的内存缓存)
|
# 权限检查结果缓存(简单的内存缓存)
|
||||||
_cache: dict[str, tuple[bool, float]] = {}
|
_cache: dict[str, tuple[bool, float]] = {}
|
||||||
|
_context_cache: dict[int, tuple[PermissionContext, float]] = {}
|
||||||
_cache_ttl = 300 # 5分钟缓存
|
_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
|
@classmethod
|
||||||
async def check_path_permission(
|
async def check_path_permission(
|
||||||
cls, user_id: int, path: str, action: str
|
cls, user_id: int, path: str, action: str
|
||||||
@@ -40,51 +172,15 @@ class PermissionService:
|
|||||||
Returns:
|
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)
|
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
|
||||||
|
|
||||||
# 按优先级和具体程度匹配
|
context = await cls._get_permission_context(user_id)
|
||||||
result = cls._match_path_rules(normalized_path, action, list(path_rules))
|
result = cls._check_path_permission_with_context(user_id, normalized_path, action, context)
|
||||||
|
cls._cache[cache_key] = (result, cls._now())
|
||||||
# 如果没有匹配到规则,检查父目录(继承)
|
|
||||||
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())
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -97,31 +193,7 @@ class PermissionService:
|
|||||||
Returns:
|
Returns:
|
||||||
True/False 表示明确的权限结果,None 表示没有匹配到规则
|
True/False 表示明确的权限结果,None 表示没有匹配到规则
|
||||||
"""
|
"""
|
||||||
# 按优先级和具体程度排序
|
return cls._match_sorted_path_rules(path, action, cls._sort_path_rules(rules))
|
||||||
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
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def check_system_permission(cls, user_id: int, permission_code: str) -> bool:
|
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
|
cls, user_id: int, path: str, action: str
|
||||||
) -> PathPermissionResult:
|
) -> PathPermissionResult:
|
||||||
"""检查路径权限并返回详细结果"""
|
"""检查路径权限并返回详细结果"""
|
||||||
user = await UserAccount.get_or_none(id=user_id)
|
context = await cls._get_permission_context(user_id)
|
||||||
if not user:
|
if not context.exists:
|
||||||
return PathPermissionResult(path=path, action=action, allowed=False)
|
return PathPermissionResult(path=path, action=action, allowed=False)
|
||||||
|
|
||||||
# 超级管理员
|
if context.is_admin:
|
||||||
if user.is_admin:
|
|
||||||
return PathPermissionResult(path=path, action=action, allowed=True)
|
return PathPermissionResult(path=path, action=action, allowed=True)
|
||||||
|
|
||||||
# 获取用户角色
|
if not context.path_rules:
|
||||||
user_roles = await UserRole.filter(user_id=user_id)
|
|
||||||
role_ids = [ur.role_id for ur in user_roles]
|
|
||||||
|
|
||||||
if not role_ids:
|
|
||||||
return PathPermissionResult(path=path, action=action, allowed=False)
|
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)
|
normalized_path = PathMatcher.normalize_path(path)
|
||||||
|
|
||||||
# 查找匹配的规则
|
|
||||||
matched_rule = None
|
matched_rule = None
|
||||||
for rule in sorted(
|
for rule in context.path_rules:
|
||||||
path_rules,
|
|
||||||
key=lambda r: (
|
|
||||||
r.priority,
|
|
||||||
PathMatcher.get_pattern_specificity(r.path_pattern, r.is_regex),
|
|
||||||
),
|
|
||||||
reverse=True,
|
|
||||||
):
|
|
||||||
if PathMatcher.match_pattern(
|
if PathMatcher.match_pattern(
|
||||||
normalized_path, rule.path_pattern, rule.is_regex
|
normalized_path, rule.path_pattern, rule.is_regex
|
||||||
):
|
):
|
||||||
@@ -322,19 +379,30 @@ class PermissionService:
|
|||||||
"""清除权限缓存"""
|
"""清除权限缓存"""
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
cls._cache.clear()
|
cls._cache.clear()
|
||||||
|
cls._context_cache.clear()
|
||||||
else:
|
else:
|
||||||
# 清除特定用户的缓存
|
|
||||||
keys_to_delete = [k for k in cls._cache if k.startswith(f"{user_id}:")]
|
keys_to_delete = [k for k in cls._cache if k.startswith(f"{user_id}:")]
|
||||||
for k in keys_to_delete:
|
for k in keys_to_delete:
|
||||||
del cls._cache[k]
|
del cls._cache[k]
|
||||||
|
cls._context_cache.pop(user_id, None)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def filter_paths_by_permission(
|
async def filter_paths_by_permission(
|
||||||
cls, user_id: int, paths: List[str], action: str
|
cls, user_id: int, paths: List[str], action: str
|
||||||
) -> List[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 = []
|
result = []
|
||||||
for path in paths:
|
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)
|
result.append(path)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -84,8 +84,9 @@ async def get_file_stat(
|
|||||||
full_path: str,
|
full_path: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
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)
|
return success(stat)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import inspect
|
||||||
from typing import Any, Dict, List, Tuple
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
@@ -14,6 +15,23 @@ from .resolver import VirtualFSResolverMixin
|
|||||||
|
|
||||||
|
|
||||||
class VirtualFSListingMixin(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
|
@classmethod
|
||||||
async def path_is_directory(cls, path: str) -> bool:
|
async def path_is_directory(cls, path: str) -> bool:
|
||||||
adapter_instance, _, root, rel = await cls.resolve_adapter_and_rel(path)
|
adapter_instance, _, root, rel = await cls.resolve_adapter_and_rel(path)
|
||||||
@@ -24,7 +42,7 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
|
|||||||
if not callable(stat_func):
|
if not callable(stat_func):
|
||||||
raise HTTPException(501, detail="Adapter does not implement stat_file")
|
raise HTTPException(501, detail="Adapter does not implement stat_file")
|
||||||
try:
|
try:
|
||||||
info = await stat_func(root, rel)
|
info = await cls._call_stat_file(stat_func, root, rel, include_metadata=False)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(404, detail="Path not found")
|
raise HTTPException(404, detail="Path not found")
|
||||||
if isinstance(info, dict):
|
if isinstance(info, dict):
|
||||||
@@ -110,7 +128,12 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
|
|||||||
stat_file = getattr(adapter_instance, "stat_file", None)
|
stat_file = getattr(adapter_instance, "stat_file", None)
|
||||||
if callable(stat_file):
|
if callable(stat_file):
|
||||||
try:
|
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):
|
if isinstance(parent_info, dict):
|
||||||
parent_info.setdefault("name", rel.split("/")[-1])
|
parent_info.setdefault("name", rel.split("/")[-1])
|
||||||
parent_info["is_dir"] = bool(parent_info.get("is_dir", True))
|
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)
|
stat_file = getattr(adapter_instance, "stat_file", None)
|
||||||
if callable(stat_file):
|
if callable(stat_file):
|
||||||
try:
|
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):
|
if isinstance(parent_info, dict):
|
||||||
parent_info.setdefault("name", parent_rel.split("/")[-1])
|
parent_info.setdefault("name", parent_rel.split("/")[-1])
|
||||||
parent_info["is_dir"] = bool(parent_info.get("is_dir", True))
|
parent_info["is_dir"] = bool(parent_info.get("is_dir", True))
|
||||||
@@ -222,13 +250,18 @@ class VirtualFSListingMixin(VirtualFSResolverMixin):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@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)
|
adapter_instance, _, root, rel = await cls.resolve_adapter_and_rel(path)
|
||||||
stat_func = getattr(adapter_instance, "stat_file", None)
|
stat_func = getattr(adapter_instance, "stat_file", None)
|
||||||
if not callable(stat_func):
|
if not callable(stat_func):
|
||||||
raise HTTPException(501, detail="Adapter does not implement stat_file")
|
raise HTTPException(501, detail="Adapter does not implement stat_file")
|
||||||
try:
|
try:
|
||||||
info = await stat_func(root, rel)
|
info = await cls._call_stat_file(
|
||||||
|
stat_func,
|
||||||
|
root,
|
||||||
|
rel,
|
||||||
|
include_metadata=verbose,
|
||||||
|
)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
raise HTTPException(404, detail=str(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]
|
rel_name = rel.rstrip("/").split("/")[-1] if rel else path.rstrip("/").split("/")[-1]
|
||||||
name_hint = str(info.get("name") or rel_name or "")
|
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)))
|
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)
|
vector_index = await cls._gather_vector_index(path)
|
||||||
if vector_index is not None:
|
if vector_index is not None:
|
||||||
info["vector_index"] = vector_index
|
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)
|
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", [])
|
items = result.get("items", [])
|
||||||
if not items:
|
if not items:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
norm = cls._normalize_path(path).rstrip("/") or "/"
|
norm = cls._normalize_path(path).rstrip("/") or "/"
|
||||||
filtered_items = []
|
path_pairs: List[Tuple[str, Dict]] = []
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
item_name = item.get("name", "")
|
item_name = item.get("name", "")
|
||||||
if norm == "/":
|
if norm == "/":
|
||||||
item_path = f"/{item_name}"
|
item_path = f"/{item_name}"
|
||||||
else:
|
else:
|
||||||
item_path = f"{norm}/{item_name}"
|
item_path = f"{norm}/{item_name}"
|
||||||
|
path_pairs.append((item_path, item))
|
||||||
|
|
||||||
# 检查用户是否有读取权限
|
allowed_paths = await PermissionService.filter_paths_by_permission(
|
||||||
has_permission = await PermissionService.check_path_permission(
|
user_id,
|
||||||
user_id, item_path, PathAction.READ
|
[item_path for item_path, _ in path_pairs],
|
||||||
)
|
PathAction.READ,
|
||||||
if has_permission:
|
)
|
||||||
filtered_items.append(item)
|
allowed_set = set(allowed_paths)
|
||||||
|
result["items"] = [item for item_path, item in path_pairs if item_path in allowed_set]
|
||||||
# 更新结果
|
|
||||||
result["items"] = filtered_items
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -144,9 +144,9 @@ class VirtualFSRouteMixin(VirtualFSTempLinkMixin):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
@classmethod
|
@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)
|
full_path = cls._normalize_path(full_path)
|
||||||
return await cls.stat_file(full_path)
|
return await cls.stat_file(full_path, verbose=verbose)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def write_uploaded_file(cls, full_path: str, data: bytes):
|
async def write_uploaded_file(cls, full_path: str, data: bytes):
|
||||||
|
|||||||
@@ -28,12 +28,12 @@ async def search_files(
|
|||||||
data = await VirtualFSSearchService.search(q, top_k, mode, page, page_size)
|
data = await VirtualFSSearchService.search(q, top_k, mode, page, page_size)
|
||||||
items = data.get("items") if isinstance(data, dict) else None
|
items = data.get("items") if isinstance(data, dict) else None
|
||||||
if isinstance(items, list) and items:
|
if isinstance(items, list) and items:
|
||||||
filtered = []
|
path_pairs = [(str(item.path), item) for item in items if getattr(item, "path", None)]
|
||||||
for item in items:
|
allowed_paths = await PermissionService.filter_paths_by_permission(
|
||||||
path = getattr(item, "path", None)
|
user.id,
|
||||||
if not path:
|
[path for path, _ in path_pairs],
|
||||||
continue
|
PathAction.READ,
|
||||||
if await PermissionService.check_path_permission(user.id, str(path), PathAction.READ):
|
)
|
||||||
filtered.append(item)
|
allowed_set = set(allowed_paths)
|
||||||
data["items"] = filtered
|
data["items"] = [item for path, item in path_pairs if path in allowed_set]
|
||||||
return success(data)
|
return success(data)
|
||||||
|
|||||||
+6
-1
@@ -86,7 +86,12 @@ export const vfsApi = {
|
|||||||
thumb: (path: string, w=256, h=256, fit='cover') =>
|
thumb: (path: string, w=256, h=256, fit='cover') =>
|
||||||
request<ArrayBuffer>(`/fs/thumb/${encodeURI(path.replace(/^\/+/, ''))}?w=${w}&h=${h}&fit=${fit}`),
|
request<ArrayBuffer>(`/fs/thumb/${encodeURI(path.replace(/^\/+/, ''))}?w=${w}&h=${h}&fit=${fit}`),
|
||||||
streamUrl: (path: string) => `${API_BASE_URL}/fs/stream/${encodeURI(path.replace(/^\/+/, ''))}`,
|
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) =>
|
getTempLinkToken: (path: string, expiresIn: number = 3600) =>
|
||||||
request<{token: string, path: string, url: string}>(`/fs/temp-link/${encodeURI(path.replace(/^\/+/, ''))}?expires_in=${expiresIn}`),
|
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}`,
|
getTempPublicUrl: (token: string) => `${API_BASE_URL}/fs/public/${token}`,
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
try {
|
try {
|
||||||
const fullPath = (entryBasePath === '/' ? '' : entryBasePath) + '/' + entry.name;
|
const fullPath = (entryBasePath === '/' ? '' : entryBasePath) + '/' + entry.name;
|
||||||
const stat = await vfsApi.stat(fullPath);
|
const stat = await vfsApi.stat(fullPath, { verbose: true });
|
||||||
setDetailData(stat as Record<string, unknown>);
|
setDetailData(stat as Record<string, unknown>);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const messageText = error instanceof Error ? error.message : String(error);
|
const messageText = error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
@@ -168,22 +168,19 @@ export function useFileActions({ path, refresh, clearSelection, onShare, onGetDi
|
|||||||
refresh();
|
refresh();
|
||||||
}, [normalizeDestination, normalizeFullPath, t, buildEntryDestination, refresh]);
|
}, [normalizeDestination, normalizeFullPath, t, buildEntryDestination, refresh]);
|
||||||
|
|
||||||
const doDownload = useCallback(async (entry: VfsEntry) => {
|
const doDownload = useCallback((entry: VfsEntry) => {
|
||||||
if (entry.is_dir) {
|
if (entry.is_dir) {
|
||||||
message.warning(t('Downloading folders is not supported'));
|
message.warning(t('Downloading folders is not supported'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const buf = await vfsApi.readFile((path === '/' ? '' : path) + '/' + entry.name);
|
const url = vfsApi.streamUrl((path === '/' ? '' : path) + '/' + entry.name);
|
||||||
const blob = new Blob([buf]);
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = entry.name;
|
a.download = entry.name;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || t('Download failed'));
|
message.error(e.message || t('Download failed'));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user