feat: add metadata options for file status checks and optimize permission filtering logic

This commit is contained in:
shiyu
2026-04-10 17:56:01 +08:00
parent 93c4d7a748
commit 0609cf6971
11 changed files with 242 additions and 151 deletions
View File
+7 -7
View File
@@ -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
+4 -5
View File
@@ -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:
+160 -92
View File
@@ -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,74 +18,169 @@ 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
) -> bool: ) -> bool:
""" """
检查用户对路径的操作权限 检查用户对路径的操作权限
Args: Args:
user_id: 用户ID user_id: 用户ID
path: 要检查的路径 path: 要检查的路径
action: 操作类型 (read/write/delete/share) action: 操作类型 (read/write/delete/share)
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
+2 -1
View File
@@ -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)
+50 -29
View File
@@ -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))
# 检查用户是否有读取权限
has_permission = await PermissionService.check_path_permission( allowed_paths = await PermissionService.filter_paths_by_permission(
user_id, item_path, PathAction.READ user_id,
) [item_path for item_path, _ in path_pairs],
if has_permission: PathAction.READ,
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
+2 -2
View File
@@ -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):
+8 -8
View File
@@ -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
View File
@@ -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'));
} }