file_read_service.py 7.2 KB

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