Files

167 lines
5.6 KiB
Python

"""
Chunks Saver
分片保存器,用于处理文件分片上传
"""
import os
from pathlib import Path
from typing import Optional
from datetime import datetime, timezone
from fastapi import UploadFile
from app.core.logging import get_logger
from app.module.shared.file_models import ChunkUploadRequestDto
logger = get_logger(__name__)
class ChunksSaver:
"""分片保存器"""
TEMP_DIR_NAME_FORMAT = "req_%s_chunks"
@staticmethod
def save(
file_upload_request: ChunkUploadRequestDto,
pre_upload_req_id: str,
upload_path: str,
file_content: bytes
) -> Optional[Path]:
"""
保存分片
Args:
file_upload_request: 上传分片的请求
pre_upload_req_id: 预上传请求ID
upload_path: 上传基础路径
file_content: 文件内容(字节)
Returns:
保存后的文件路径,如果不是最后一个分片则返回None
"""
start_time = datetime.now(timezone.utc)
temp_dir = Path(upload_path) / (
ChunksSaver.TEMP_DIR_NAME_FORMAT % pre_upload_req_id
)
temp_dir.mkdir(parents=True, exist_ok=True)
temp_file = temp_dir / str(file_upload_request.file_no)
ChunksSaver._append_to_target_file(temp_file, file_content)
if file_upload_request.total_chunk_num != file_upload_request.chunk_no:
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds()
logger.debug(f"save chunk {file_upload_request.chunk_no} cost {elapsed}s")
return None
final_file = Path(upload_path) / file_upload_request.file_name
# 防止路径穿越:规范化后校验仍在 upload_path 下
resolved_final = final_file.resolve()
resolved_base = Path(upload_path).resolve()
if not str(resolved_final).startswith(str(resolved_base) + os.sep):
logger.error(
f"Path traversal attempt in chunk save: file_name={file_upload_request.file_name}, "
f"resolved_path={resolved_final}"
)
raise ValueError("Path traversal detected in file name")
try:
temp_file.rename(final_file)
except OSError as e:
logger.error(
f"failed to mv file: {temp_file.name}, req id: {pre_upload_req_id}, error: {e}"
)
raise ValueError("failed to move file to target dir") from e
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds()
logger.debug(f"save chunk {file_upload_request.chunk_no} cost {elapsed}s")
return final_file
@staticmethod
def save_file(
file_upload_request: ChunkUploadRequestDto,
upload_path: str,
file_content: bytes
) -> Path:
"""
保存文件(不分片)
Args:
file_upload_request: 上传请求
upload_path: 上传路径
file_content: 文件内容(字节)
Returns:
保存后的文件路径
"""
target_file = Path(upload_path) / file_upload_request.file_name
# 防止路径穿越:规范化后校验仍在 upload_path 下
resolved_target = target_file.resolve()
resolved_base = Path(upload_path).resolve()
if not str(resolved_target).startswith(str(resolved_base) + os.sep):
logger.error(
f"Path traversal attempt in save_file: file_name={file_upload_request.file_name}, "
f"resolved_path={resolved_target}"
)
raise ValueError("Path traversal detected in file name")
logger.info(f"file path {target_file}, file size {len(file_content)}")
try:
target_file.parent.mkdir(parents=True, exist_ok=True)
target_file.write_bytes(file_content)
except OSError as e:
logger.error(f"failed to save file: {target_file}, error: {e}")
raise ValueError("failed to save file") from e
return target_file
@staticmethod
def delete_folder(folder_path: str) -> None:
"""
删除指定路径下的所有文件
Args:
folder_path: 文件夹路径
"""
folder = Path(folder_path)
if not folder.exists():
logger.info(f"folder {folder_path} does not exist")
return
try:
for item in folder.glob("*"):
if item.is_file():
item.unlink()
elif item.is_dir():
for sub_item in item.glob("*"):
if sub_item.is_file():
sub_item.unlink()
elif sub_item.is_dir():
ChunksSaver.delete_folder(str(sub_item))
item.rmdir()
except OSError as e:
logger.error(f"failed to delete folder: {folder_path}, error: {e}")
raise ValueError("failed to delete folder") from e
@staticmethod
def _append_to_target_file(target_file: Path, content: bytes) -> None:
"""
追加内容到目标文件末尾
Args:
target_file: 目标文件
content: 要追加的内容
"""
try:
with open(target_file, "ab") as f:
f.write(content)
except OSError as e:
logger.error(f"failed to append to file: {target_file}, error: {e}")
raise ValueError("failed to append content to file") from e