uploads.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  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.envelope import promote_encrypt
  13. from dms.storage.paths import ensure_storage_directories
  14. ALLOWED_EXTENSIONS = {
  15. "doc", "docx", "pdf", "wps",
  16. "xls", "xlsx", "et",
  17. "jpg", "jpeg", "png", "gif", "bmp", "webp",
  18. }
  19. MIME_TYPES = {
  20. "doc": "application/msword",
  21. "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  22. "pdf": "application/pdf",
  23. "wps": "application/vnd.ms-works",
  24. "xls": "application/vnd.ms-excel",
  25. "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  26. "et": "application/vnd.kingsoft.spreadsheet",
  27. "jpg": "image/jpeg",
  28. "jpeg": "image/jpeg",
  29. "png": "image/png",
  30. "gif": "image/gif",
  31. "bmp": "image/bmp",
  32. "webp": "image/webp",
  33. }
  34. # 图片类扩展名:上传仅做魔数校验,预览时原图直出(不走 PDF 转换)
  35. IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "bmp", "webp"}
  36. # 图片魔数签名:extension -> (签名, 起始偏移);webp 为 RIFF 容器,偏移 8 处为 WEBP
  37. _IMAGE_SIGNATURES: dict[str, tuple[bytes, int]] = {
  38. "jpg": (b"\xff\xd8\xff", 0),
  39. "jpeg": (b"\xff\xd8\xff", 0),
  40. "png": (b"\x89PNG\r\n\x1a\n", 0),
  41. "gif": (b"GIF8", 0),
  42. "bmp": (b"BM", 0),
  43. "webp": (b"WEBP", 8),
  44. }
  45. OLE_HEADER = bytes.fromhex("D0CF11E0A1B11AE1")
  46. @dataclass(frozen=True, slots=True)
  47. class StagedUpload:
  48. temporary_path: Path
  49. final_path: Path
  50. relative_path: str
  51. original_file_name: str
  52. extension: str
  53. mime_type: str
  54. file_size: int
  55. file_hash: str
  56. def cleanup(self) -> None:
  57. self.temporary_path.unlink(missing_ok=True)
  58. def cleanup_all(self) -> None:
  59. self.temporary_path.unlink(missing_ok=True)
  60. self.final_path.unlink(missing_ok=True)
  61. def promote(self) -> None:
  62. self.final_path.parent.mkdir(parents=True, exist_ok=True)
  63. # 契约1.21:original/ 且已配置 KEK 时按信封格式加密落盘,否则明文迁移
  64. if promote_encrypt(
  65. self.temporary_path, self.final_path, self.relative_path
  66. ):
  67. return
  68. self.temporary_path.replace(self.final_path)
  69. def _safe_zip(path: Path, required: set[str]) -> None:
  70. try:
  71. with zipfile.ZipFile(path) as archive:
  72. if len(archive.infolist()) > 10000:
  73. raise UnsupportedFileTypeError("Office容器条目数量异常")
  74. uncompressed_size = sum(item.file_size for item in archive.infolist())
  75. if uncompressed_size > current_app.config["DMS_MAX_FILE_SIZE"] * 20:
  76. raise UnsupportedFileTypeError("Office容器解压规模异常")
  77. names = set(archive.namelist())
  78. for name in names:
  79. normalized = PurePosixPath(name.replace("\\", "/"))
  80. if normalized.is_absolute() or ".." in normalized.parts:
  81. raise UnsupportedFileTypeError("Office容器包含不安全路径")
  82. if not required <= names:
  83. raise UnsupportedFileTypeError("Office容器结构与扩展名不一致")
  84. if archive.testzip() is not None:
  85. raise UnsupportedFileTypeError("Office ZIP容器损坏")
  86. except (zipfile.BadZipFile, OSError, RuntimeError) as exc:
  87. raise UnsupportedFileTypeError("Office ZIP容器损坏") from exc
  88. def _validate_image(prefix: bytes, extension: str) -> None:
  89. signature, offset = _IMAGE_SIGNATURES[extension]
  90. valid = prefix[offset : offset + len(signature)] == signature
  91. if extension == "webp":
  92. valid = valid and prefix.startswith(b"RIFF")
  93. if not valid:
  94. raise UnsupportedFileTypeError("图片文件头与扩展名不一致")
  95. def _validate(path: Path, extension: str) -> None:
  96. # 读取 12 字节:webp 需校验偏移 8~11 处的 WEBP 标识,其余类型不受影响
  97. prefix = path.read_bytes()[:12]
  98. if extension == "pdf":
  99. if not prefix.startswith(b"%PDF-"):
  100. raise UnsupportedFileTypeError("PDF文件头与扩展名不一致")
  101. return
  102. if extension == "docx":
  103. _safe_zip(path, {"[Content_Types].xml", "word/document.xml"})
  104. return
  105. if extension == "xlsx":
  106. _safe_zip(path, {"[Content_Types].xml", "xl/workbook.xml"})
  107. return
  108. if extension in IMAGE_EXTENSIONS:
  109. _validate_image(prefix, extension)
  110. return
  111. if not prefix.startswith(OLE_HEADER):
  112. raise UnsupportedFileTypeError("OLE文件头与扩展名不一致")
  113. try:
  114. with olefile.OleFileIO(path) as container:
  115. streams = {"/".join(parts) for parts in container.listdir()}
  116. except (OSError, IOError) as exc:
  117. raise UnsupportedFileTypeError("OLE/CFB容器损坏") from exc
  118. if extension == "doc" and "WordDocument" not in streams:
  119. raise UnsupportedFileTypeError("OLE容器不是有效DOC")
  120. if extension == "xls" and "Workbook" not in streams:
  121. raise UnsupportedFileTypeError("OLE容器不是有效XLS")
  122. # wps/et:与 DOC/XLS 同族的 OLE 容器,但内部流名不保证 WordDocument/Workbook,
  123. # 仅校验容器有效性;内容合法性由 LibreOffice 转换环节兜底(转换失败返回预览不可用)。
  124. def stage_upload(file: FileStorage) -> StagedUpload:
  125. original_name = Path(file.filename or "").name
  126. if not original_name or "." not in original_name:
  127. raise UnsupportedFileTypeError("文件名缺少允许的扩展名")
  128. extension = original_name.rsplit(".", 1)[1].lower()
  129. if extension not in ALLOWED_EXTENSIONS:
  130. raise UnsupportedFileTypeError()
  131. root = ensure_storage_directories(current_app.config["DMS_STORAGE_ROOT"])
  132. temporary = root / "temporary" / f"{uuid.uuid4()}.upload"
  133. final_name = f"{uuid.uuid4()}.{extension}"
  134. final = root / "original" / final_name
  135. digest = hashlib.sha256()
  136. size = 0
  137. try:
  138. with temporary.open("xb") as stream:
  139. while chunk := file.stream.read(1024 * 1024):
  140. size += len(chunk)
  141. if size > current_app.config["DMS_MAX_FILE_SIZE"]:
  142. raise PayloadTooLargeError("单个文件超过允许大小")
  143. digest.update(chunk)
  144. stream.write(chunk)
  145. if size == 0:
  146. raise UnsupportedFileTypeError("不允许上传空文件")
  147. _validate(temporary, extension)
  148. return StagedUpload(
  149. temporary_path=temporary,
  150. final_path=final,
  151. relative_path=f"original/{final_name}",
  152. original_file_name=original_name,
  153. extension=extension,
  154. mime_type=MIME_TYPES[extension],
  155. file_size=size,
  156. file_hash=digest.hexdigest(),
  157. )
  158. except Exception:
  159. temporary.unlink(missing_ok=True)
  160. raise