file_read_service.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  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_query_service import _require_plan_access
  26. from dms.services.recycle_bin_service import _verify_file
  27. from dms.storage.paths import UnsafeStoragePathError, resolve_storage_path
  28. logger = logging.getLogger(__name__)
  29. def _document(document_id: int) -> Document:
  30. document = db.session.scalar(
  31. select(Document).where(
  32. Document.id == document_id,
  33. Document.is_deleted.is_(False),
  34. Document.document_type.in_(
  35. [
  36. DocumentType.MAIN.value,
  37. DocumentType.SUB_PLAN.value,
  38. DocumentType.ATTACHMENT.value,
  39. ]
  40. ),
  41. )
  42. )
  43. if document is None:
  44. raise ResourceNotFoundError("文档不存在")
  45. return document
  46. def _authorize_view(document: Document) -> None:
  47. context = get_auth_context()
  48. if document.document_type == DocumentType.ATTACHMENT.value:
  49. require_attachment_access(document, context, download=False)
  50. else:
  51. _require_plan_access(document, context)
  52. def _authorize_download(document: Document) -> None:
  53. if document.document_type == DocumentType.ATTACHMENT.value:
  54. require_attachment_access(
  55. document, get_auth_context(), download=True
  56. )
  57. return
  58. access = evaluate_plan_access(document, get_auth_context())
  59. if access.reason == "SECURITY":
  60. raise SecurityLevelForbiddenError()
  61. if not access.allowed or not access.can_download:
  62. raise DocumentDownloadForbiddenError()
  63. def _path(document: Document) -> Path:
  64. try:
  65. path = resolve_storage_path(
  66. document.file_relative_path,
  67. current_app.config["DMS_STORAGE_ROOT"],
  68. )
  69. except UnsafeStoragePathError as exc:
  70. logger.error("检测到越界文档存储路径:document_id=%s", document.id)
  71. raise InternalError("文档存储路径异常") from exc
  72. if not path.is_file():
  73. raise DmsFileNotFoundError()
  74. try:
  75. with path.open("rb") as stream:
  76. stream.read(1)
  77. except OSError as exc:
  78. logger.exception("文档文件读取失败:document_id=%s", document.id)
  79. raise InternalError("文档文件读取失败") from exc
  80. return path
  81. def _download_name(value: str) -> str:
  82. safe = value.replace("\r", "").replace("\n", "").replace("\x00", "")
  83. return Path(safe).name or "download"
  84. def _inline_content_disposition(value: str) -> str:
  85. original = _download_name(value)
  86. fallback = secure_filename(original) or "preview"
  87. return (
  88. f'inline; filename="{fallback}"; '
  89. f"filename*=UTF-8''{quote(original, safe='')}"
  90. )
  91. def preview_document(document_id: int):
  92. document = _document(document_id)
  93. _authorize_view(document)
  94. verified = _verify_file(document)
  95. try:
  96. path = verified.path
  97. extension = document.file_extension.lower().lstrip(".")
  98. if extension == "pdf":
  99. mime_type = "application/pdf"
  100. elif extension == "docx":
  101. mime_type = (
  102. "application/vnd.openxmlformats-officedocument."
  103. "wordprocessingml.document"
  104. )
  105. else:
  106. raise PreviewUnavailableError()
  107. verified.close()
  108. response = send_file(
  109. path,
  110. mimetype=mime_type,
  111. as_attachment=False,
  112. download_name=_download_name(document.original_file_name),
  113. conditional=True,
  114. )
  115. except Exception:
  116. verified.close()
  117. raise
  118. response.headers["X-Content-Type-Options"] = "nosniff"
  119. response.headers["Cache-Control"] = "private, no-store"
  120. response.headers["Content-Disposition"] = _inline_content_disposition(
  121. document.original_file_name
  122. )
  123. return response
  124. def _record_download(document: Document) -> None:
  125. document_id = document.id
  126. document_name = document.document_name
  127. target = (
  128. AuditTarget.ATTACHMENT
  129. if document.document_type == DocumentType.ATTACHMENT.value
  130. else AuditTarget.DOCUMENT
  131. )
  132. db.session.rollback()
  133. try:
  134. with transaction() as session:
  135. current = session.scalar(
  136. select(Document)
  137. .where(
  138. Document.id == document_id,
  139. Document.is_deleted.is_(False),
  140. )
  141. .with_for_update()
  142. )
  143. if current is None:
  144. raise ResourceNotFoundError("文档不存在")
  145. current.download_count += 1
  146. session.add(
  147. business_audit(
  148. action=AuditAction.DOWNLOAD_DOCUMENT,
  149. target=target,
  150. target_id=document_id,
  151. target_name=document_name,
  152. detail={"documentType": current.document_type},
  153. )
  154. )
  155. except Exception:
  156. db.session.rollback()
  157. logger.exception("文档下载计数或审计写入失败:document_id=%s", document_id)
  158. def download_document(document_id: int):
  159. document = _document(document_id)
  160. _authorize_download(document)
  161. path = _path(document)
  162. mime_type = document.mime_type or "application/octet-stream"
  163. download_name = _download_name(document.original_file_name)
  164. _record_download(document)
  165. response = send_file(
  166. path,
  167. mimetype=mime_type,
  168. as_attachment=True,
  169. download_name=download_name,
  170. conditional=True,
  171. )
  172. response.headers["X-Content-Type-Options"] = "nosniff"
  173. return response