uploads.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. """上传文件暂存、类型识别和安全落盘。"""
  2. from __future__ import annotations
  3. import hashlib
  4. import uuid
  5. import zipfile
  6. from dataclasses import dataclass
  7. from pathlib import Path, PurePosixPath
  8. import olefile
  9. from flask import current_app
  10. from werkzeug.datastructures import FileStorage
  11. from dms.common.errors import PayloadTooLargeError, UnsupportedFileTypeError
  12. from dms.storage.paths import ensure_storage_directories
  13. ALLOWED_EXTENSIONS = {"doc", "docx", "pdf", "xls", "xlsx"}
  14. MIME_TYPES = {
  15. "doc": "application/msword",
  16. "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  17. "pdf": "application/pdf",
  18. "xls": "application/vnd.ms-excel",
  19. "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  20. }
  21. OLE_HEADER = bytes.fromhex("D0CF11E0A1B11AE1")
  22. @dataclass(frozen=True, slots=True)
  23. class StagedUpload:
  24. temporary_path: Path
  25. final_path: Path
  26. relative_path: str
  27. original_file_name: str
  28. extension: str
  29. mime_type: str
  30. file_size: int
  31. file_hash: str
  32. def cleanup(self) -> None:
  33. self.temporary_path.unlink(missing_ok=True)
  34. def cleanup_all(self) -> None:
  35. self.temporary_path.unlink(missing_ok=True)
  36. self.final_path.unlink(missing_ok=True)
  37. def promote(self) -> None:
  38. self.final_path.parent.mkdir(parents=True, exist_ok=True)
  39. self.temporary_path.replace(self.final_path)
  40. def _safe_zip(path: Path, required: set[str]) -> None:
  41. try:
  42. with zipfile.ZipFile(path) as archive:
  43. if len(archive.infolist()) > 10000:
  44. raise UnsupportedFileTypeError("Office容器条目数量异常")
  45. uncompressed_size = sum(item.file_size for item in archive.infolist())
  46. if uncompressed_size > current_app.config["DMS_MAX_FILE_SIZE"] * 20:
  47. raise UnsupportedFileTypeError("Office容器解压规模异常")
  48. names = set(archive.namelist())
  49. for name in names:
  50. normalized = PurePosixPath(name.replace("\\", "/"))
  51. if normalized.is_absolute() or ".." in normalized.parts:
  52. raise UnsupportedFileTypeError("Office容器包含不安全路径")
  53. if not required <= names:
  54. raise UnsupportedFileTypeError("Office容器结构与扩展名不一致")
  55. if archive.testzip() is not None:
  56. raise UnsupportedFileTypeError("Office ZIP容器损坏")
  57. except (zipfile.BadZipFile, OSError, RuntimeError) as exc:
  58. raise UnsupportedFileTypeError("Office ZIP容器损坏") from exc
  59. def _validate(path: Path, extension: str) -> None:
  60. prefix = path.read_bytes()[:8]
  61. if extension == "pdf":
  62. if not prefix.startswith(b"%PDF-"):
  63. raise UnsupportedFileTypeError("PDF文件头与扩展名不一致")
  64. return
  65. if extension == "docx":
  66. _safe_zip(path, {"[Content_Types].xml", "word/document.xml"})
  67. return
  68. if extension == "xlsx":
  69. _safe_zip(path, {"[Content_Types].xml", "xl/workbook.xml"})
  70. return
  71. if not prefix.startswith(OLE_HEADER):
  72. raise UnsupportedFileTypeError("OLE文件头与扩展名不一致")
  73. try:
  74. with olefile.OleFileIO(path) as container:
  75. streams = {"/".join(parts) for parts in container.listdir()}
  76. except (OSError, IOError) as exc:
  77. raise UnsupportedFileTypeError("OLE/CFB容器损坏") from exc
  78. if extension == "doc" and "WordDocument" not in streams:
  79. raise UnsupportedFileTypeError("OLE容器不是有效DOC")
  80. if extension == "xls" and not ({"Workbook", "Book"} & streams):
  81. raise UnsupportedFileTypeError("OLE容器不是有效XLS")
  82. def stage_upload(file: FileStorage) -> StagedUpload:
  83. original_name = Path(file.filename or "").name
  84. if not original_name or "." not in original_name:
  85. raise UnsupportedFileTypeError("文件名缺少允许的扩展名")
  86. extension = original_name.rsplit(".", 1)[1].lower()
  87. if extension not in ALLOWED_EXTENSIONS:
  88. raise UnsupportedFileTypeError()
  89. root = ensure_storage_directories(current_app.config["DMS_STORAGE_ROOT"])
  90. temporary = root / "temporary" / f"{uuid.uuid4()}.upload"
  91. final_name = f"{uuid.uuid4()}.{extension}"
  92. final = root / "original" / final_name
  93. digest = hashlib.sha256()
  94. size = 0
  95. try:
  96. with temporary.open("xb") as stream:
  97. while chunk := file.stream.read(1024 * 1024):
  98. size += len(chunk)
  99. if size > current_app.config["DMS_MAX_FILE_SIZE"]:
  100. raise PayloadTooLargeError("单个文件超过允许大小")
  101. digest.update(chunk)
  102. stream.write(chunk)
  103. if size == 0:
  104. raise UnsupportedFileTypeError("不允许上传空文件")
  105. _validate(temporary, extension)
  106. return StagedUpload(
  107. temporary_path=temporary,
  108. final_path=final,
  109. relative_path=f"original/{final_name}",
  110. original_file_name=original_name,
  111. extension=extension,
  112. mime_type=MIME_TYPES[extension],
  113. file_size=size,
  114. file_hash=digest.hexdigest(),
  115. )
  116. except Exception:
  117. temporary.unlink(missing_ok=True)
  118. raise