You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

532 lines
20 KiB
Python

import ast
import os
import re
import shutil
import urllib.parse
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
from app.config.setting import settings
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.utils.excel_util import ExcelUtil
from .schema import (
ResourceCopySchema,
ResourceCreateDirSchema,
ResourceDirectorySchema,
ResourceItemSchema,
ResourceMoveSchema,
ResourceRenameSchema,
ResourceSearchQueryParam,
)
class ResourceService:
"""资源管理模块服务层 - 管理系统静态文件目录(仅管理 upload 目录)"""
MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB
MAX_SEARCH_RESULTS = 1000
MAX_PATH_DEPTH = 20
@staticmethod
def _get_resource_root() -> str:
if not settings.STATIC_ENABLE:
raise CustomException(msg="静态文件服务未启用")
resource_root = os.path.join(str(settings.STATIC_ROOT), "upload", "resource")
os.makedirs(resource_root, exist_ok=True)
return resource_root
@staticmethod
def _get_safe_path(path: str | None = None) -> str:
resource_root = ResourceService._get_resource_root()
if not path or not isinstance(path, str):
return resource_root
static_prefix = settings.STATIC_URL.rstrip("/")
root_prefix = settings.ROOT_PATH.rstrip("/") if getattr(settings, "ROOT_PATH", "") else ""
root_static_prefix = f"{root_prefix}{static_prefix}" if root_prefix else static_prefix
def strip_prefix(p: str) -> str:
if p.startswith(root_static_prefix):
return p[len(root_static_prefix):].lstrip("/")
if p.startswith(static_prefix):
return p[len(static_prefix):].lstrip("/")
return p
if path.startswith(("http://", "https://")):
parsed = urlparse(path)
url_path = parsed.path or ""
path = strip_prefix(url_path)
else:
path = strip_prefix(path)
path = path.strip().replace("//", "/").replace("\\\\\\\\", "/").replace("\\\\", "/")
if path.startswith("/"):
path = path[1:]
if path.startswith("upload/"):
path = path[7:]
if ".." in path or "\x00" in path:
logger.error(f"检测到路径遍历攻击尝试: {path}")
raise CustomException(msg="非法的路径格式")
decoded_path = urllib.parse.unquote(path)
if ".." in decoded_path:
logger.error(f"检测到编码后的路径遍历攻击: {path}")
raise CustomException(msg="非法的路径格式")
safe_path = os.path.normpath(os.path.join(resource_root, path))
resource_root_abs = os.path.normpath(os.path.abspath(resource_root))
safe_path_abs = os.path.normpath(os.path.abspath(safe_path))
if not safe_path_abs.startswith(resource_root_abs + os.sep) and safe_path_abs != resource_root_abs:
logger.error(f"路径遍历攻击被阻止: 尝试访问 {safe_path_abs}, 但根目录是 {resource_root_abs}")
raise CustomException(msg="访问路径不在允许范围内")
try:
relative_path = os.path.relpath(safe_path_abs, resource_root_abs)
if relative_path.count(os.sep) > ResourceService.MAX_PATH_DEPTH:
raise CustomException(msg="路径深度超过限制")
except ValueError:
raise CustomException(msg="无效的路径")
return safe_path_abs
@staticmethod
def _path_exists(path: str) -> bool:
try:
safe_path = ResourceService._get_safe_path(path)
return os.path.exists(safe_path)
except Exception as e:
raise CustomException(msg=f"检查路径是否存在失败: {e!s}")
@staticmethod
def _sanitize_filename(filename: str) -> str:
if not filename:
return f"file_{datetime.now().strftime('%Y%m%d%H%M%S')}"
dangerous_patterns = [
r"\.\.",
r"[\/]",
r"\x00",
r"%2e%2e",
r"%252e%252e",
]
for pattern in dangerous_patterns:
if re.search(pattern, filename, re.IGNORECASE):
logger.error(f"检测到文件名路径遍历攻击: {filename}")
return f"file_{datetime.now().strftime('%Y%m%d%H%M%S')}"
decoded = urllib.parse.unquote(filename)
decoded_twice = urllib.parse.unquote(decoded)
for check in [decoded, decoded_twice]:
if ".." in check or "/" in check or "\\" in check:
logger.error(f"检测到编码后的文件名攻击: {filename}")
return f"file_{datetime.now().strftime('%Y%m%d%H%M%S')}"
filename = os.path.basename(filename)
filename = re.sub(r'[<>:"|?*\x00-\x1f]', "", filename)
filename = re.sub(r"\.{2,}", ".", filename)
filename = filename.strip(". ")
if not filename:
filename = f"file_{datetime.now().strftime('%Y%m%d%H%M%S')}"
return filename
@staticmethod
def _detect_file_type(content: bytes) -> str | None:
if content.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if content.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if content.startswith(b"GIF87a") or content.startswith(b"GIF89a"):
return "image/gif"
if content.startswith(b"PK\x03\x04"):
if b"[Content_Types].xml" in content[:1000]:
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
return "application/zip"
if content.startswith(b"%PDF"):
return "application/pdf"
if content.startswith(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"):
return "application/msword"
return None
@staticmethod
def _generate_http_url(file_path: str, base_url: str | None = None) -> str:
static_root = str(settings.STATIC_ROOT)
try:
relative_path = os.path.relpath(file_path, static_root)
url_path = relative_path.replace(os.sep, "/")
except ValueError:
url_path = os.path.basename(file_path)
if base_url:
base_part = base_url.rstrip("/")
static_part = settings.STATIC_URL.lstrip("/")
file_part = url_path.lstrip("/")
http_url = f"{base_part}/{static_part}/{file_part}".replace("//", "/").replace(":/", "://")
else:
http_url = f"{settings.STATIC_URL}/{url_path}".replace("//", "/")
return http_url
@staticmethod
def _get_file_info(file_path: str, base_url: str | None = None) -> ResourceItemSchema | None:
try:
safe_path = file_path
if not os.path.exists(safe_path):
return None
stat = os.stat(safe_path)
path_obj = Path(safe_path)
resource_root = ResourceService._get_resource_root()
try:
relative_path = os.path.relpath(safe_path, resource_root)
except ValueError:
relative_path = os.path.basename(safe_path)
http_url = ResourceService._generate_http_url(safe_path, base_url)
is_hidden = path_obj.name.startswith(".")
return ResourceItemSchema(
name=path_obj.name,
file_url=http_url,
relative_path=relative_path,
is_file=os.path.isfile(safe_path),
is_dir=os.path.isdir(safe_path),
size=stat.st_size if os.path.isfile(safe_path) else None,
created_time=datetime.fromtimestamp(stat.st_ctime),
modified_time=datetime.fromtimestamp(stat.st_mtime),
is_hidden=is_hidden,
)
except Exception as e:
logger.error(f"获取文件信息失败: {e!s}")
return None
@staticmethod
async def get_directory_list(
path: str | None = None,
include_hidden: bool = False,
base_url: str | None = None,
) -> ResourceDirectorySchema:
try:
if path is None:
safe_path = ResourceService._get_resource_root()
display_path = ResourceService._generate_http_url(safe_path, base_url)
else:
safe_path = ResourceService._get_safe_path(path)
display_path = ResourceService._generate_http_url(safe_path, base_url)
if not os.path.exists(safe_path):
raise CustomException(msg="目录不存在")
if not os.path.isdir(safe_path):
raise CustomException(msg="路径不是目录")
items = []
total_files = 0
total_dirs = 0
total_size = 0
try:
for item_name in os.listdir(safe_path):
if not include_hidden and item_name.startswith("."):
continue
item_path = os.path.join(safe_path, item_name)
file_info = ResourceService._get_file_info(item_path, base_url)
if file_info:
items.append(file_info)
if file_info.is_file:
total_files += 1
total_size += file_info.size or 0
elif file_info.is_dir:
total_dirs += 1
except PermissionError:
raise CustomException(msg="没有权限访问此目录")
return ResourceDirectorySchema(
path=display_path,
name=os.path.basename(safe_path),
items=items,
total_files=total_files,
total_dirs=total_dirs,
total_size=total_size,
)
except CustomException:
raise
except Exception as e:
logger.error(f"获取目录列表失败: {e!s}")
raise CustomException(msg=f"获取目录列表失败: {e!s}")
@staticmethod
async def get_resources_list(
search: ResourceSearchQueryParam | None = None,
order_by: str | None = None,
base_url: str | None = None,
) -> list[ResourceItemSchema]:
try:
if search and hasattr(search, "path") and search.path and isinstance(search.path, str):
resource_root = ResourceService._get_safe_path(search.path)
else:
resource_root = ResourceService._get_resource_root()
if not os.path.exists(resource_root):
raise CustomException(msg="目录不存在")
if not os.path.isdir(resource_root):
raise CustomException(msg="路径不是目录")
all_resources = []
try:
for item_name in os.listdir(resource_root):
if item_name.startswith("."):
continue
item_path = os.path.join(resource_root, item_name)
file_info = ResourceService._get_file_info(item_path, base_url)
if file_info:
if search and hasattr(search, "name") and search.name and search.name[1]:
search_keyword = search.name[1].lower()
if search_keyword not in file_info.name.lower():
continue
all_resources.append(file_info)
except PermissionError:
raise CustomException(msg="没有权限访问此目录")
sorted_resources = ResourceService._sort_results(all_resources, order_by)
if len(sorted_resources) > ResourceService.MAX_SEARCH_RESULTS:
sorted_resources = sorted_resources[: ResourceService.MAX_SEARCH_RESULTS]
return sorted_resources
except Exception as e:
logger.error(f"搜索资源失败: {e!s}")
raise CustomException(msg=f"搜索资源失败: {e!s}")
@staticmethod
async def export_resource(data_list: list[ResourceItemSchema]) -> bytes:
mapping_dict = {
"name": "文件名",
"path": "文件路径",
"size": "文件大小",
"created_time": "创建时间",
"modified_time": "修改时间",
"parent_path": "父目录",
}
export_data = [item.model_dump() for item in data_list]
for item in export_data:
if item.get("size"):
item["size"] = ResourceService._format_file_size(item["size"])
return ExcelUtil.export_list2excel(list_data=export_data, mapping_dict=mapping_dict)
@staticmethod
async def download_file(file_path: str) -> str:
safe_path = ResourceService._get_safe_path(file_path)
if not os.path.exists(safe_path):
raise CustomException(msg="文件不存在")
if not os.path.isfile(safe_path):
raise CustomException(msg="路径不是文件")
return safe_path
@staticmethod
async def delete_file(paths: list[str]) -> None:
for path_item in paths:
safe_path = ResourceService._get_safe_path(path_item)
if not os.path.exists(safe_path):
raise CustomException(msg=f"文件不存在: {path_item}")
try:
if os.path.isfile(safe_path):
os.remove(safe_path)
elif os.path.isdir(safe_path):
shutil.rmtree(safe_path)
else:
raise CustomException(msg=f"无法识别的文件类型: {path_item}")
except PermissionError:
raise CustomException(msg=f"没有权限删除: {path_item}")
except OSError as e:
raise CustomException(msg=f"删除失败: {path_item} - {e!s}")
logger.info(f"成功删除: {path_item}")
@staticmethod
async def move_file(data: ResourceMoveSchema) -> None:
source_safe = ResourceService._get_safe_path(data.source_path)
target_dir_safe = ResourceService._get_safe_path(data.target_dir)
if not os.path.exists(source_safe):
raise CustomException(msg=f"源文件不存在: {data.source_path}")
if not os.path.isdir(target_dir_safe):
raise CustomException(msg=f"目标目录不存在: {data.target_dir}")
filename = os.path.basename(source_safe)
target_path = os.path.join(target_dir_safe, filename)
if os.path.exists(target_path):
raise CustomException(msg=f"目标位置已存在同名文件: {filename}")
try:
shutil.move(source_safe, target_path)
except PermissionError:
raise CustomException(msg=f"没有权限移动文件: {data.source_path}")
except OSError as e:
raise CustomException(msg=f"移动文件失败: {e!s}")
logger.info(f"成功移动文件: {data.source_path} -> {data.target_dir}")
@staticmethod
async def copy_file(data: ResourceCopySchema) -> None:
source_safe = ResourceService._get_safe_path(data.source_path)
target_dir_safe = ResourceService._get_safe_path(data.target_dir)
if not os.path.exists(source_safe):
raise CustomException(msg=f"源文件不存在: {data.source_path}")
if not os.path.isdir(target_dir_safe):
raise CustomException(msg=f"目标目录不存在: {data.target_dir}")
filename = os.path.basename(source_safe)
target_path = os.path.join(target_dir_safe, filename)
if os.path.exists(target_path):
raise CustomException(msg=f"目标位置已存在同名文件: {filename}")
try:
if os.path.isdir(source_safe):
shutil.copytree(source_safe, target_path)
else:
shutil.copy2(source_safe, target_path)
except PermissionError:
raise CustomException(msg=f"没有权限复制文件: {data.source_path}")
except OSError as e:
raise CustomException(msg=f"复制文件失败: {e!s}")
logger.info(f"成功复制文件: {data.source_path} -> {data.target_dir}")
@staticmethod
async def rename_file(data: ResourceRenameSchema) -> None:
safe_path = ResourceService._get_safe_path(data.file_path)
parent_dir = os.path.dirname(safe_path)
safe_name = ResourceService._sanitize_filename(data.new_name)
new_path = os.path.join(parent_dir, safe_name)
if os.path.exists(new_path):
raise CustomException(msg=f"目标文件名已存在: {safe_name}")
try:
os.rename(safe_path, new_path)
except PermissionError:
raise CustomException(msg=f"没有权限重命名: {data.file_path}")
except OSError as e:
raise CustomException(msg=f"重命名失败: {e!s}")
logger.info(f"成功重命名: {data.file_path} -> {safe_name}")
@staticmethod
async def create_directory(data: ResourceCreateDirSchema) -> None:
parent_dir = ResourceService._get_safe_path(data.parent_path)
if not os.path.isdir(parent_dir):
raise CustomException(msg=f"父目录不存在: {data.parent_path}")
safe_name = ResourceService._sanitize_filename(data.dir_name)
new_dir = os.path.join(parent_dir, safe_name)
if os.path.exists(new_dir):
raise CustomException(msg=f"目录已存在: {data.dir_name}")
try:
os.makedirs(new_dir, exist_ok=False)
except PermissionError:
raise CustomException(msg=f"没有权限创建目录: {data.dir_name}")
except OSError as e:
raise CustomException(msg=f"创建目录失败: {e!s}")
logger.info(f"成功创建目录: {data.parent_path}/{safe_name}")
@staticmethod
async def _get_directory_stats(path: str, include_hidden: bool = False) -> dict[str, int]:
stats = {"files": 0, "dirs": 0, "size": 0}
try:
for root, dirs, files in os.walk(path):
if not include_hidden:
dirs[:] = [d for d in dirs if not d.startswith(".")]
files = [f for f in files if not f.startswith(".")]
stats["dirs"] += len(dirs)
stats["files"] += len(files)
for file in files:
file_path = os.path.join(root, file)
try:
stats["size"] += os.path.getsize(file_path)
except OSError:
continue
except Exception:
pass
return stats
@staticmethod
def _sort_results(results: list[ResourceItemSchema], order_by: str | None = None) -> list[ResourceItemSchema]:
try:
if not order_by:
return sorted(results, key=lambda x: x.name, reverse=False)
sort_conditions = ast.literal_eval(order_by)
if isinstance(sort_conditions, list):
def sort_key(item):
keys = []
for cond in sort_conditions:
field = cond.get("field", "name")
value = getattr(item, field, "")
if field in ["created_time", "modified_time", "accessed_time"] and value:
if isinstance(value, str):
value = datetime.fromisoformat(value)
keys.append(value)
return keys
reverse = False
if sort_conditions and isinstance(sort_conditions[0], dict):
order = sort_conditions[0].get("order", "asc")
reverse = order.lower() == "desc"
return sorted(results, key=sort_key, reverse=reverse)
return sorted(results, key=lambda x: x.name, reverse=False)
except (ValueError, SyntaxError):
return sorted(results, key=lambda x: x.name, reverse=False)
@staticmethod
def _format_file_size(size_bytes: int) -> str:
for unit in ["B", "KB", "MB", "GB"]:
if size_bytes < 1024:
return f"{size_bytes:.2f} {unit}"
size_bytes /= 1024
return f"{size_bytes:.2f} TB"