file_read_service.py 6.3 KB

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