file_read_service.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. """统一文档预览、下载授权及下载计数审计。"""
  2. from __future__ import annotations
  3. import logging
  4. from pathlib import Path
  5. from flask import current_app, send_file
  6. from sqlalchemy import select
  7. from dms.common.enums import AuditAction, AuditTarget, DocumentType
  8. from dms.common.errors import (
  9. DocumentDownloadForbiddenError,
  10. FileNotFoundError as DmsFileNotFoundError,
  11. InternalError,
  12. PreviewUnavailableError,
  13. ResourceNotFoundError,
  14. SecurityLevelForbiddenError,
  15. )
  16. from dms.database.transaction import transaction
  17. from dms.extensions import db
  18. from dms.models import Document
  19. from dms.security.auth_context import get_auth_context
  20. from dms.services.audit_service import business_audit
  21. from dms.services.authorization_service import evaluate_plan_access
  22. from dms.services.document_query_service import _require_plan_access
  23. from dms.storage.paths import UnsafeStoragePathError, resolve_storage_path
  24. logger = logging.getLogger(__name__)
  25. def _document(document_id: int) -> Document:
  26. document = db.session.scalar(
  27. select(Document).where(
  28. Document.id == document_id,
  29. Document.is_deleted.is_(False),
  30. Document.document_type.in_(
  31. [
  32. DocumentType.MAIN.value,
  33. DocumentType.SUB_PLAN.value,
  34. DocumentType.ATTACHMENT.value,
  35. ]
  36. ),
  37. )
  38. )
  39. if document is None:
  40. raise ResourceNotFoundError("文档不存在")
  41. return document
  42. def _authorize_view(document: Document) -> None:
  43. if document.document_type != DocumentType.ATTACHMENT.value:
  44. _require_plan_access(document, get_auth_context())
  45. def _authorize_download(document: Document) -> None:
  46. if document.document_type == DocumentType.ATTACHMENT.value:
  47. return
  48. access = evaluate_plan_access(document, get_auth_context())
  49. if access.reason == "SECURITY":
  50. raise SecurityLevelForbiddenError()
  51. if not access.allowed or not access.can_download:
  52. raise DocumentDownloadForbiddenError()
  53. def _path(document: Document) -> Path:
  54. try:
  55. path = resolve_storage_path(
  56. document.file_relative_path,
  57. current_app.config["DMS_STORAGE_ROOT"],
  58. )
  59. except UnsafeStoragePathError as exc:
  60. logger.error("检测到越界文档存储路径:document_id=%s", document.id)
  61. raise InternalError("文档存储路径异常") from exc
  62. if not path.is_file():
  63. raise DmsFileNotFoundError()
  64. try:
  65. with path.open("rb") as stream:
  66. stream.read(1)
  67. except OSError as exc:
  68. logger.exception("文档文件读取失败:document_id=%s", document.id)
  69. raise InternalError("文档文件读取失败") from exc
  70. return path
  71. def _download_name(value: str) -> str:
  72. safe = value.replace("\r", "").replace("\n", "").replace("\x00", "")
  73. return Path(safe).name or "download"
  74. def preview_document(document_id: int):
  75. document = _document(document_id)
  76. _authorize_view(document)
  77. if document.file_extension.lower() != "pdf":
  78. raise PreviewUnavailableError()
  79. path = _path(document)
  80. response = send_file(
  81. path,
  82. mimetype="application/pdf",
  83. as_attachment=False,
  84. download_name=_download_name(document.original_file_name),
  85. conditional=True,
  86. )
  87. response.headers["X-Content-Type-Options"] = "nosniff"
  88. return response
  89. def _record_download(document: Document) -> None:
  90. document_id = document.id
  91. document_name = document.document_name
  92. target = (
  93. AuditTarget.ATTACHMENT
  94. if document.document_type == DocumentType.ATTACHMENT.value
  95. else AuditTarget.DOCUMENT
  96. )
  97. db.session.rollback()
  98. try:
  99. with transaction() as session:
  100. current = session.scalar(
  101. select(Document)
  102. .where(
  103. Document.id == document_id,
  104. Document.is_deleted.is_(False),
  105. )
  106. .with_for_update()
  107. )
  108. if current is None:
  109. raise ResourceNotFoundError("文档不存在")
  110. current.download_count += 1
  111. session.add(
  112. business_audit(
  113. action=AuditAction.DOWNLOAD_DOCUMENT,
  114. target=target,
  115. target_id=document_id,
  116. target_name=document_name,
  117. detail={"documentType": current.document_type},
  118. )
  119. )
  120. except Exception:
  121. db.session.rollback()
  122. logger.exception("文档下载计数或审计写入失败:document_id=%s", document_id)
  123. def download_document(document_id: int):
  124. document = _document(document_id)
  125. _authorize_download(document)
  126. path = _path(document)
  127. mime_type = document.mime_type or "application/octet-stream"
  128. download_name = _download_name(document.original_file_name)
  129. _record_download(document)
  130. response = send_file(
  131. path,
  132. mimetype=mime_type,
  133. as_attachment=True,
  134. download_name=download_name,
  135. conditional=True,
  136. )
  137. response.headers["X-Content-Type-Options"] = "nosniff"
  138. return response