uploads.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  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", "wps"}
  14. MIME_TYPES = {
  15. "doc": "application/msword",
  16. "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  17. "pdf": "application/pdf",
  18. "wps": "application/vnd.ms-works",
  19. }
  20. OLE_HEADER = bytes.fromhex("D0CF11E0A1B11AE1")
  21. @dataclass(frozen=True, slots=True)
  22. class StagedUpload:
  23. temporary_path: Path
  24. final_path: Path
  25. relative_path: str
  26. original_file_name: str
  27. extension: str
  28. mime_type: str
  29. file_size: int
  30. file_hash: str
  31. def cleanup(self) -> None:
  32. self.temporary_path.unlink(missing_ok=True)
  33. def cleanup_all(self) -> None:
  34. self.temporary_path.unlink(missing_ok=True)
  35. self.final_path.unlink(missing_ok=True)
  36. def promote(self) -> None:
  37. self.final_path.parent.mkdir(parents=True, exist_ok=True)
  38. self.temporary_path.replace(self.final_path)
  39. def _safe_zip(path: Path, required: set[str]) -> None:
  40. try:
  41. with zipfile.ZipFile(path) as archive:
  42. if len(archive.infolist()) > 10000:
  43. raise UnsupportedFileTypeError("Office容器条目数量异常")
  44. uncompressed_size = sum(item.file_size for item in archive.infolist())
  45. if uncompressed_size > current_app.config["DMS_MAX_FILE_SIZE"] * 20:
  46. raise UnsupportedFileTypeError("Office容器解压规模异常")
  47. names = set(archive.namelist())
  48. for name in names:
  49. normalized = PurePosixPath(name.replace("\\", "/"))
  50. if normalized.is_absolute() or ".." in normalized.parts:
  51. raise UnsupportedFileTypeError("Office容器包含不安全路径")
  52. if not required <= names:
  53. raise UnsupportedFileTypeError("Office容器结构与扩展名不一致")
  54. if archive.testzip() is not None:
  55. raise UnsupportedFileTypeError("Office ZIP容器损坏")
  56. except (zipfile.BadZipFile, OSError, RuntimeError) as exc:
  57. raise UnsupportedFileTypeError("Office ZIP容器损坏") from exc
  58. def _validate(path: Path, extension: str) -> None:
  59. prefix = path.read_bytes()[:8]
  60. if extension == "pdf":
  61. if not prefix.startswith(b"%PDF-"):
  62. raise UnsupportedFileTypeError("PDF文件头与扩展名不一致")
  63. return
  64. if extension == "docx":
  65. _safe_zip(path, {"[Content_Types].xml", "word/document.xml"})
  66. return
  67. if not prefix.startswith(OLE_HEADER):
  68. raise UnsupportedFileTypeError("OLE文件头与扩展名不一致")
  69. try:
  70. with olefile.OleFileIO(path) as container:
  71. streams = {"/".join(parts) for parts in container.listdir()}
  72. except (OSError, IOError) as exc:
  73. raise UnsupportedFileTypeError("OLE/CFB容器损坏") from exc
  74. if extension == "doc" and "WordDocument" not in streams:
  75. raise UnsupportedFileTypeError("OLE容器不是有效DOC")
  76. # wps:与 DOC 同族的 OLE 容器,但内部流名不保证 WordDocument,仅校验容器有效性;
  77. # 内容合法性由 LibreOffice 转换环节兜底(转换失败返回预览不可用)。
  78. def stage_upload(file: FileStorage) -> StagedUpload:
  79. original_name = Path(file.filename or "").name
  80. if not original_name or "." not in original_name:
  81. raise UnsupportedFileTypeError("文件名缺少允许的扩展名")
  82. extension = original_name.rsplit(".", 1)[1].lower()
  83. if extension not in ALLOWED_EXTENSIONS:
  84. raise UnsupportedFileTypeError()
  85. root = ensure_storage_directories(current_app.config["DMS_STORAGE_ROOT"])
  86. temporary = root / "temporary" / f"{uuid.uuid4()}.upload"
  87. final_name = f"{uuid.uuid4()}.{extension}"
  88. final = root / "original" / final_name
  89. digest = hashlib.sha256()
  90. size = 0
  91. try:
  92. with temporary.open("xb") as stream:
  93. while chunk := file.stream.read(1024 * 1024):
  94. size += len(chunk)
  95. if size > current_app.config["DMS_MAX_FILE_SIZE"]:
  96. raise PayloadTooLargeError("单个文件超过允许大小")
  97. digest.update(chunk)
  98. stream.write(chunk)
  99. if size == 0:
  100. raise UnsupportedFileTypeError("不允许上传空文件")
  101. _validate(temporary, extension)
  102. return StagedUpload(
  103. temporary_path=temporary,
  104. final_path=final,
  105. relative_path=f"original/{final_name}",
  106. original_file_name=original_name,
  107. extension=extension,
  108. mime_type=MIME_TYPES[extension],
  109. file_size=size,
  110. file_hash=digest.hexdigest(),
  111. )
  112. except Exception:
  113. temporary.unlink(missing_ok=True)
  114. raise