167 lines
5.6 KiB
Python
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
|