from __future__ import annotations import json import os import uuid from pathlib import Path from urllib.parse import quote from fastapi import APIRouter, Depends, Request, Response, status from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession from app.api.v1.attachments import _ensure_attachment_permission, _ensure_study_exists from app.core.deps import get_current_user, get_db_session, get_operator_role_label from app.crud import attachment as attachment_crud from app.crud import audit as audit_crud from app.crud import document as document_crud from app.crud import document_version as version_crud from app.models.collaboration import CollaborationRevision from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead from app.services import document_service, onlyoffice_service router = APIRouter() internal_router = APIRouter(include_in_schema=False) def _content_disposition(filename: str) -> str: fallback = "".join( character if 32 <= ord(character) < 127 and character not in {'"', "\\"} else "_" for character in filename ) or "document" encoded = quote(filename, safe="") return f'inline; filename="{fallback}"; filename*=UTF-8\'\'{encoded}' async def _log_preview_open( db: AsyncSession, *, study_id: uuid.UUID, resource_type: str, resource_id: uuid.UUID, current_user, ) -> None: await audit_crud.log_action( db, study_id=study_id, entity_type="ATTACHMENT" if resource_type == "attachment" else "DOCUMENT_VERSION", entity_id=resource_id, action="OFFICE_PREVIEW_OPEN", detail=json.dumps( {"resource_type": resource_type, "resource_id": str(resource_id), "result": "issued"}, ensure_ascii=True, ), operator_id=current_user.id, operator_role=await get_operator_role_label(db, study_id, current_user), ) @router.get( "/attachments/{attachment_id}/config", response_model=OnlyOfficePreviewConfigRead, ) async def get_attachment_preview_config( attachment_id: uuid.UUID, response: Response, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> OnlyOfficePreviewConfigRead: attachment = await attachment_crud.get_attachment(db, attachment_id) if not attachment: raise onlyoffice_service.onlyoffice_error( "ATTACHMENT_NOT_FOUND", "附件不存在", status.HTTP_404_NOT_FOUND ) await _ensure_study_exists(db, attachment.study_id) await _ensure_attachment_permission( db, attachment.study_id, attachment.entity_type, attachment.entity_id, "read", current_user, ) if not os.path.exists(attachment.file_path): raise onlyoffice_service.onlyoffice_error( "ATTACHMENT_FILE_NOT_FOUND", "服务器未找到文件", status.HTTP_404_NOT_FOUND ) if not onlyoffice_service.office_format_for_filename(attachment.filename): raise onlyoffice_service.onlyoffice_error( "ONLYOFFICE_FORMAT_UNSUPPORTED", "该文件格式不支持 Office 在线预览", status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, ) await onlyoffice_service.ensure_onlyoffice_available() result = onlyoffice_service.build_preview_config( resource_type="attachment", resource_id=attachment.id, file_name=attachment.filename, user_id=current_user.id, user_name=current_user.full_name, ) await _log_preview_open( db, study_id=attachment.study_id, resource_type="attachment", resource_id=attachment.id, current_user=current_user, ) response.headers["Cache-Control"] = "no-store" return result @router.get( "/versions/{version_id}/config", response_model=OnlyOfficePreviewConfigRead, ) async def get_version_preview_config( version_id: uuid.UUID, response: Response, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> OnlyOfficePreviewConfigRead: version = await version_crud.get(db, version_id) if not version: raise onlyoffice_service.onlyoffice_error( "DOCUMENT_VERSION_NOT_FOUND", "版本不存在", status.HTTP_404_NOT_FOUND ) document = await document_crud.get(db, version.document_id) if not document: raise onlyoffice_service.onlyoffice_error( "DOCUMENT_NOT_FOUND", "文档不存在", status.HTTP_404_NOT_FOUND ) await document_service._ensure_study_access(db, document.trial_id, current_user, action="view") file_path = Path(version.file_uri) if not file_path.exists(): raise onlyoffice_service.onlyoffice_error( "DOCUMENT_FILE_NOT_FOUND", "文件不存在", status.HTTP_404_NOT_FOUND ) file_name = version.original_filename or document_service._legacy_download_filename(version, document) if not onlyoffice_service.office_format_for_filename(file_name): raise onlyoffice_service.onlyoffice_error( "ONLYOFFICE_FORMAT_UNSUPPORTED", "该文件格式不支持 Office 在线预览", status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, ) await onlyoffice_service.ensure_onlyoffice_available() result = onlyoffice_service.build_preview_config( resource_type="version", resource_id=version.id, file_name=file_name, file_hash=version.file_hash, user_id=current_user.id, user_name=current_user.full_name, ) await _log_preview_open( db, study_id=document.trial_id, resource_type="version", resource_id=version.id, current_user=current_user, ) response.headers["Cache-Control"] = "no-store" return result def _authorize_internal_file_request(request: Request, expected_url: str) -> None: onlyoffice_service.validate_outbox_token(request.headers.get("AuthorizationJwt"), expected_url) @internal_router.get("/internal/onlyoffice/attachments/{attachment_id}/content") async def get_internal_attachment_content( attachment_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db_session), ) -> FileResponse: expected_url = onlyoffice_service.onlyoffice_content_url("attachment", attachment_id) _authorize_internal_file_request(request, expected_url) attachment = await attachment_crud.get_attachment(db, attachment_id) if not attachment or not os.path.exists(attachment.file_path): raise onlyoffice_service.onlyoffice_error( "ATTACHMENT_FILE_NOT_FOUND", "文件不存在", status.HTTP_404_NOT_FOUND ) return FileResponse( path=attachment.file_path, media_type=attachment.content_type or "application/octet-stream", headers={"Content-Disposition": _content_disposition(attachment.filename)}, ) @internal_router.get("/internal/onlyoffice/versions/{version_id}/content") async def get_internal_version_content( version_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db_session), ) -> FileResponse: expected_url = onlyoffice_service.onlyoffice_content_url("version", version_id) _authorize_internal_file_request(request, expected_url) version = await version_crud.get(db, version_id) if not version: raise onlyoffice_service.onlyoffice_error( "DOCUMENT_VERSION_NOT_FOUND", "版本不存在", status.HTTP_404_NOT_FOUND ) document = await document_crud.get(db, version.document_id) file_path = Path(version.file_uri) if not document or not file_path.exists(): raise onlyoffice_service.onlyoffice_error( "DOCUMENT_FILE_NOT_FOUND", "文件不存在", status.HTTP_404_NOT_FOUND ) file_name = version.original_filename or document_service._legacy_download_filename(version, document) return FileResponse( path=str(file_path), media_type=version.mime_type or "application/octet-stream", headers={"Content-Disposition": _content_disposition(file_name)}, ) @internal_router.get("/internal/onlyoffice/collaboration-revisions/{revision_id}/content") async def get_internal_collaboration_revision_content( revision_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db_session), ) -> FileResponse: expected_url = onlyoffice_service.onlyoffice_content_url("collaboration_revision", revision_id) _authorize_internal_file_request(request, expected_url) revision = await db.get(CollaborationRevision, revision_id) file_path = Path(revision.file_uri) if revision else None if not revision or getattr(revision, "deleted_at", None) is not None or not file_path or not file_path.exists(): raise onlyoffice_service.onlyoffice_error( "COLLABORATION_REVISION_NOT_FOUND", "协作修订不存在", status.HTTP_404_NOT_FOUND ) return FileResponse( path=str(file_path), media_type=revision.mime_type or "application/octet-stream", headers={"Content-Disposition": _content_disposition(revision.original_filename)}, )