| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134 |
- """上传文件暂存、类型识别和安全落盘。"""
- from __future__ import annotations
- import hashlib
- import uuid
- import zipfile
- from dataclasses import dataclass
- from pathlib import Path, PurePosixPath
- import olefile
- from flask import current_app
- from werkzeug.datastructures import FileStorage
- from dms.common.errors import PayloadTooLargeError, UnsupportedFileTypeError
- from dms.storage.paths import ensure_storage_directories
- ALLOWED_EXTENSIONS = {"doc", "docx", "pdf", "xls", "xlsx"}
- MIME_TYPES = {
- "doc": "application/msword",
- "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
- "pdf": "application/pdf",
- "xls": "application/vnd.ms-excel",
- "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- }
- OLE_HEADER = bytes.fromhex("D0CF11E0A1B11AE1")
- @dataclass(frozen=True, slots=True)
- class StagedUpload:
- temporary_path: Path
- final_path: Path
- relative_path: str
- original_file_name: str
- extension: str
- mime_type: str
- file_size: int
- file_hash: str
- def cleanup(self) -> None:
- self.temporary_path.unlink(missing_ok=True)
- def cleanup_all(self) -> None:
- self.temporary_path.unlink(missing_ok=True)
- self.final_path.unlink(missing_ok=True)
- def promote(self) -> None:
- self.final_path.parent.mkdir(parents=True, exist_ok=True)
- self.temporary_path.replace(self.final_path)
- def _safe_zip(path: Path, required: set[str]) -> None:
- try:
- with zipfile.ZipFile(path) as archive:
- if len(archive.infolist()) > 10000:
- raise UnsupportedFileTypeError("Office容器条目数量异常")
- uncompressed_size = sum(item.file_size for item in archive.infolist())
- if uncompressed_size > current_app.config["DMS_MAX_FILE_SIZE"] * 20:
- raise UnsupportedFileTypeError("Office容器解压规模异常")
- names = set(archive.namelist())
- for name in names:
- normalized = PurePosixPath(name.replace("\\", "/"))
- if normalized.is_absolute() or ".." in normalized.parts:
- raise UnsupportedFileTypeError("Office容器包含不安全路径")
- if not required <= names:
- raise UnsupportedFileTypeError("Office容器结构与扩展名不一致")
- if archive.testzip() is not None:
- raise UnsupportedFileTypeError("Office ZIP容器损坏")
- except (zipfile.BadZipFile, OSError, RuntimeError) as exc:
- raise UnsupportedFileTypeError("Office ZIP容器损坏") from exc
- def _validate(path: Path, extension: str) -> None:
- prefix = path.read_bytes()[:8]
- if extension == "pdf":
- if not prefix.startswith(b"%PDF-"):
- raise UnsupportedFileTypeError("PDF文件头与扩展名不一致")
- return
- if extension == "docx":
- _safe_zip(path, {"[Content_Types].xml", "word/document.xml"})
- return
- if extension == "xlsx":
- _safe_zip(path, {"[Content_Types].xml", "xl/workbook.xml"})
- return
- if not prefix.startswith(OLE_HEADER):
- raise UnsupportedFileTypeError("OLE文件头与扩展名不一致")
- try:
- with olefile.OleFileIO(path) as container:
- streams = {"/".join(parts) for parts in container.listdir()}
- except (OSError, IOError) as exc:
- raise UnsupportedFileTypeError("OLE/CFB容器损坏") from exc
- if extension == "doc" and "WordDocument" not in streams:
- raise UnsupportedFileTypeError("OLE容器不是有效DOC")
- if extension == "xls" and not ({"Workbook", "Book"} & streams):
- raise UnsupportedFileTypeError("OLE容器不是有效XLS")
- def stage_upload(file: FileStorage) -> StagedUpload:
- original_name = Path(file.filename or "").name
- if not original_name or "." not in original_name:
- raise UnsupportedFileTypeError("文件名缺少允许的扩展名")
- extension = original_name.rsplit(".", 1)[1].lower()
- if extension not in ALLOWED_EXTENSIONS:
- raise UnsupportedFileTypeError()
- root = ensure_storage_directories(current_app.config["DMS_STORAGE_ROOT"])
- temporary = root / "temporary" / f"{uuid.uuid4()}.upload"
- final_name = f"{uuid.uuid4()}.{extension}"
- final = root / "original" / final_name
- digest = hashlib.sha256()
- size = 0
- try:
- with temporary.open("xb") as stream:
- while chunk := file.stream.read(1024 * 1024):
- size += len(chunk)
- if size > current_app.config["DMS_MAX_FILE_SIZE"]:
- raise PayloadTooLargeError("单个文件超过允许大小")
- digest.update(chunk)
- stream.write(chunk)
- if size == 0:
- raise UnsupportedFileTypeError("不允许上传空文件")
- _validate(temporary, extension)
- return StagedUpload(
- temporary_path=temporary,
- final_path=final,
- relative_path=f"original/{final_name}",
- original_file_name=original_name,
- extension=extension,
- mime_type=MIME_TYPES[extension],
- file_size=size,
- file_hash=digest.hexdigest(),
- )
- except Exception:
- temporary.unlink(missing_ok=True)
- raise
|