file_read_service.py 7.9 KB

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