"""统一文档预览、下载授权及下载计数审计。""" 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