| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192 |
- """统一文档预览、下载授权及下载计数审计。"""
- from __future__ import annotations
- import logging
- from pathlib import Path
- from urllib.parse import quote
- from flask import current_app, send_file
- from sqlalchemy import select
- from werkzeug.utils import secure_filename
- from dms.common.enums import AuditAction, AuditTarget, DocumentType
- from dms.common.errors import (
- DocumentDownloadForbiddenError,
- FileNotFoundError as DmsFileNotFoundError,
- InternalError,
- PreviewUnavailableError,
- ResourceNotFoundError,
- SecurityLevelForbiddenError,
- )
- from dms.database.transaction import transaction
- from dms.extensions import db
- from dms.models import Document
- from dms.security.auth_context import get_auth_context
- from dms.services.audit_service import business_audit
- from dms.services.authorization_service import evaluate_plan_access
- from dms.services.authorization_service import require_attachment_access
- from dms.services.document_query_service import _require_plan_access
- from dms.services.preview_service import PreviewService
- from dms.services.recycle_bin_service import _verify_file
- from dms.storage.paths import UnsafeStoragePathError, resolve_storage_path
- logger = logging.getLogger(__name__)
- def _document(document_id: int) -> Document:
- document = db.session.scalar(
- select(Document).where(
- Document.id == document_id,
- Document.is_deleted.is_(False),
- Document.document_type.in_(
- [
- DocumentType.MAIN.value,
- DocumentType.SUB_PLAN.value,
- DocumentType.ATTACHMENT.value,
- ]
- ),
- )
- )
- if document is None:
- raise ResourceNotFoundError("文档不存在")
- return document
- def _authorize_view(document: Document) -> None:
- context = get_auth_context()
- if document.document_type == DocumentType.ATTACHMENT.value:
- require_attachment_access(document, context, download=False)
- else:
- _require_plan_access(document, context)
- def _authorize_download(document: Document) -> None:
- if document.document_type == DocumentType.ATTACHMENT.value:
- require_attachment_access(
- document, get_auth_context(), download=True
- )
- return
- access = evaluate_plan_access(document, get_auth_context())
- if access.reason == "SECURITY":
- raise SecurityLevelForbiddenError()
- if not access.allowed or not access.can_download:
- raise DocumentDownloadForbiddenError()
- def _path(document: Document) -> Path:
- try:
- path = resolve_storage_path(
- document.file_relative_path,
- current_app.config["DMS_STORAGE_ROOT"],
- )
- except UnsafeStoragePathError as exc:
- logger.error("检测到越界文档存储路径:document_id=%s", document.id)
- raise InternalError("文档存储路径异常") from exc
- if not path.is_file():
- raise DmsFileNotFoundError()
- try:
- with path.open("rb") as stream:
- stream.read(1)
- except OSError as exc:
- logger.exception("文档文件读取失败:document_id=%s", document.id)
- raise InternalError("文档文件读取失败") from exc
- return path
- def _download_name(value: str) -> str:
- safe = value.replace("\r", "").replace("\n", "").replace("\x00", "")
- return Path(safe).name or "download"
- def _inline_content_disposition(value: str) -> str:
- original = _download_name(value)
- fallback = secure_filename(original) or "preview"
- return (
- f'inline; filename="{fallback}"; '
- f"filename*=UTF-8''{quote(original, safe='')}"
- )
- def preview_document(document_id: int):
- document = _document(document_id)
- _authorize_view(document)
- verified = _verify_file(document)
- try:
- path = verified.path
- extension = document.file_extension.lower().lstrip(".")
- preview_service = PreviewService.current()
- preview_path = preview_service.preview_path_for(document)
- verified.close()
- response = send_file(
- preview_path,
- mimetype="application/pdf",
- as_attachment=False,
- download_name=_download_name(
- f"{Path(document.original_file_name).stem}.pdf"
- ),
- conditional=True,
- )
- except Exception:
- verified.close()
- raise
- response.headers["X-Content-Type-Options"] = "nosniff"
- response.headers["Cache-Control"] = "private, no-store"
- response.headers["Content-Disposition"] = _inline_content_disposition(
- f"{Path(document.original_file_name).stem}.pdf"
- )
- return response
- def _record_download(document: Document) -> None:
- document_id = document.id
- document_name = document.document_name
- target = (
- AuditTarget.ATTACHMENT
- if document.document_type == DocumentType.ATTACHMENT.value
- else AuditTarget.DOCUMENT
- )
- db.session.rollback()
- try:
- with transaction() as session:
- current = session.scalar(
- select(Document)
- .where(
- Document.id == document_id,
- Document.is_deleted.is_(False),
- )
- .with_for_update()
- )
- if current is None:
- raise ResourceNotFoundError("文档不存在")
- current.download_count += 1
- session.add(
- business_audit(
- action=AuditAction.DOWNLOAD_DOCUMENT,
- target=target,
- target_id=document_id,
- target_name=document_name,
- detail={"documentType": current.document_type},
- )
- )
- except Exception:
- db.session.rollback()
- logger.exception("文档下载计数或审计写入失败:document_id=%s", document_id)
- def download_document(document_id: int):
- document = _document(document_id)
- _authorize_download(document)
- path = _path(document)
- mime_type = document.mime_type or "application/octet-stream"
- download_name = _download_name(document.original_file_name)
- _record_download(document)
- response = send_file(
- path,
- mimetype=mime_type,
- as_attachment=True,
- download_name=download_name,
- conditional=True,
- )
- response.headers["X-Content-Type-Options"] = "nosniff"
- return response
|