file_read_service.py 6.8 KB

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