file_read_service.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. """统一文档预览、下载授权及下载计数审计。"""
  2. from __future__ import annotations
  3. import logging
  4. from io import BytesIO
  5. from pathlib import Path
  6. from urllib.parse import quote
  7. from flask import current_app, send_file
  8. from sqlalchemy import select
  9. from werkzeug.utils import secure_filename
  10. from dms.common.enums import AuditAction, AuditTarget, DocumentType
  11. from dms.common.errors import (
  12. DocumentDownloadForbiddenError,
  13. FileNotFoundError as DmsFileNotFoundError,
  14. InternalError,
  15. PreviewUnavailableError,
  16. ResourceNotFoundError,
  17. SecurityLevelForbiddenError,
  18. )
  19. from dms.database.transaction import transaction
  20. from dms.extensions import db
  21. from dms.models import LOCK_DEFER_LOB, Document
  22. from dms.security.auth_context import get_auth_context
  23. from dms.services.audit_service import business_audit
  24. from dms.services.authorization_service import evaluate_plan_access
  25. from dms.services.authorization_service import require_attachment_access
  26. from dms.services.document_password_service import (
  27. DocumentPasswordAction,
  28. get_request_password,
  29. require_document_password,
  30. )
  31. from dms.services.document_query_service import _require_plan_access
  32. from dms.services.preview_service import PreviewService
  33. from dms.services.recycle_bin_service import _verify_file
  34. from dms.storage.envelope import is_envelope, read_plaintext_bytes
  35. from dms.storage.paths import UnsafeStoragePathError, resolve_storage_path
  36. from dms.storage.uploads import IMAGE_EXTENSIONS, MIME_TYPES as UPLOAD_MIME_TYPES
  37. logger = logging.getLogger(__name__)
  38. def _document(document_id: int) -> Document:
  39. document = db.session.scalar(
  40. select(Document).where(
  41. Document.id == document_id,
  42. Document.is_deleted.is_(False),
  43. Document.document_type.in_(
  44. [
  45. DocumentType.MAIN.value,
  46. DocumentType.SUB_PLAN.value,
  47. DocumentType.ATTACHMENT.value,
  48. ]
  49. ),
  50. )
  51. )
  52. if document is None:
  53. raise ResourceNotFoundError("文档不存在")
  54. return document
  55. def _authorize_view(document: Document) -> None:
  56. context = get_auth_context()
  57. if document.document_type == DocumentType.ATTACHMENT.value:
  58. require_attachment_access(document, context, download=False)
  59. else:
  60. _require_plan_access(document, context)
  61. def _authorize_download(document: Document) -> None:
  62. if document.document_type == DocumentType.ATTACHMENT.value:
  63. require_attachment_access(
  64. document, get_auth_context(), download=True
  65. )
  66. return
  67. access = evaluate_plan_access(document, get_auth_context())
  68. if access.reason == "SECURITY":
  69. raise SecurityLevelForbiddenError()
  70. if not access.allowed or not access.can_download:
  71. raise DocumentDownloadForbiddenError()
  72. def _path(document: Document) -> Path:
  73. try:
  74. path = resolve_storage_path(
  75. document.file_relative_path,
  76. current_app.config["DMS_STORAGE_ROOT"],
  77. )
  78. except UnsafeStoragePathError as exc:
  79. logger.error("检测到越界文档存储路径:document_id=%s", document.id)
  80. raise InternalError("文档存储路径异常") from exc
  81. if not path.is_file():
  82. raise DmsFileNotFoundError()
  83. try:
  84. with path.open("rb") as stream:
  85. stream.read(1)
  86. except OSError as exc:
  87. logger.exception("文档文件读取失败:document_id=%s", document.id)
  88. raise InternalError("文档文件读取失败") from exc
  89. return path
  90. def _download_name(value: str) -> str:
  91. safe = value.replace("\r", "").replace("\n", "").replace("\x00", "")
  92. return Path(safe).name or "download"
  93. def _inline_content_disposition(value: str) -> str:
  94. original = _download_name(value)
  95. fallback = secure_filename(original) or "preview"
  96. return (
  97. f'inline; filename="{fallback}"; '
  98. f"filename*=UTF-8''{quote(original, safe='')}"
  99. )
  100. def preview_document(document_id: int):
  101. document = _document(document_id)
  102. _authorize_view(document)
  103. require_document_password(
  104. document,
  105. DocumentPasswordAction.VIEW.value,
  106. get_request_password(),
  107. get_auth_context(),
  108. )
  109. verified = _verify_file(document)
  110. try:
  111. path = verified.path
  112. extension = document.file_extension.lower().lstrip(".")
  113. # 图片原图直出(MIME 必须准确,响应带 nosniff);其余统一以 PDF 预览
  114. if extension in IMAGE_EXTENSIONS:
  115. preview_name = _download_name(document.original_file_name)
  116. preview_mime = UPLOAD_MIME_TYPES.get(
  117. extension, "application/octet-stream"
  118. )
  119. else:
  120. preview_name = _download_name(
  121. f"{Path(document.original_file_name).stem}.pdf"
  122. )
  123. preview_mime = "application/pdf"
  124. preview_service = PreviewService.current()
  125. preview_path = preview_service.preview_path_for(document)
  126. verified.close()
  127. # 契约1.21:信封加密的 original 直出文件解密后经内存流发送
  128. send_source: Path | BytesIO = preview_path
  129. if is_envelope(preview_path):
  130. send_source = BytesIO(read_plaintext_bytes(preview_path))
  131. response = send_file(
  132. send_source,
  133. mimetype=preview_mime,
  134. as_attachment=False,
  135. download_name=preview_name,
  136. conditional=True,
  137. )
  138. except Exception:
  139. verified.close()
  140. raise
  141. response.headers["X-Content-Type-Options"] = "nosniff"
  142. response.headers["Cache-Control"] = "private, no-store"
  143. response.headers["Content-Disposition"] = _inline_content_disposition(
  144. preview_name
  145. )
  146. return response
  147. def _record_download(document: Document) -> None:
  148. document_id = document.id
  149. document_name = document.document_name
  150. target = (
  151. AuditTarget.ATTACHMENT
  152. if document.document_type == DocumentType.ATTACHMENT.value
  153. else AuditTarget.DOCUMENT
  154. )
  155. db.session.rollback()
  156. try:
  157. with transaction() as session:
  158. current = session.scalar(
  159. select(Document)
  160. .where(
  161. Document.id == document_id,
  162. Document.is_deleted.is_(False),
  163. )
  164. .options(*LOCK_DEFER_LOB)
  165. .with_for_update()
  166. )
  167. if current is None:
  168. raise ResourceNotFoundError("文档不存在")
  169. current.download_count += 1
  170. session.add(
  171. business_audit(
  172. action=AuditAction.DOWNLOAD_DOCUMENT,
  173. target=target,
  174. target_id=document_id,
  175. target_name=document_name,
  176. detail={"documentType": current.document_type},
  177. )
  178. )
  179. except Exception:
  180. db.session.rollback()
  181. logger.exception("文档下载计数或审计写入失败:document_id=%s", document_id)
  182. def download_document(document_id: int):
  183. document = _document(document_id)
  184. _authorize_download(document)
  185. require_document_password(
  186. document,
  187. DocumentPasswordAction.DOWNLOAD.value,
  188. get_request_password(),
  189. get_auth_context(),
  190. )
  191. path = _path(document)
  192. mime_type = document.mime_type or "application/octet-stream"
  193. download_name = _download_name(document.original_file_name)
  194. _record_download(document)
  195. # 契约1.21:信封加密文件解密后经内存流发送
  196. send_source: Path | BytesIO = path
  197. if is_envelope(path):
  198. send_source = BytesIO(read_plaintext_bytes(path))
  199. response = send_file(
  200. send_source,
  201. mimetype=mime_type,
  202. as_attachment=True,
  203. download_name=download_name,
  204. conditional=True,
  205. )
  206. response.headers["X-Content-Type-Options"] = "nosniff"
  207. return response