from __future__ import annotations import asyncio import hashlib import hmac import time import uuid from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Literal import httpx from fastapi import status from jose import JWTError, jwt from app.core.config import settings from app.core.exceptions import AppException from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead OnlyOfficeDocumentType = Literal["word", "cell", "slide"] OnlyOfficeResourceType = Literal["attachment", "version"] WORD_FORMATS = frozenset({ "doc", "docx", "docm", "dot", "dotx", "dotm", "odt", "ott", "rtf", "txt", "wps", "wpt", }) CELL_FORMATS = frozenset({ "xls", "xlsx", "xlsm", "xlsb", "xlt", "xltx", "xltm", "ods", "ots", "csv", "et", "ett", }) SLIDE_FORMATS = frozenset({ "ppt", "pptx", "pptm", "pps", "ppsx", "ppsm", "pot", "potx", "potm", "odp", "otp", "dps", "dpt", }) _health_lock = asyncio.Lock() _health_checked_at = 0.0 _health_available = False _HEALTH_CACHE_SECONDS = 15.0 def onlyoffice_error(code: str, message: str, status_code: int) -> AppException: return AppException(code=code, message=message, status_code=status_code) def office_format_for_filename(filename: str) -> tuple[str, OnlyOfficeDocumentType] | None: suffix = Path(filename).suffix.lower().lstrip(".") if suffix in WORD_FORMATS: return suffix, "word" if suffix in CELL_FORMATS: return suffix, "cell" if suffix in SLIDE_FORMATS: return suffix, "slide" return None def onlyoffice_content_url(resource_type: OnlyOfficeResourceType, resource_id: uuid.UUID) -> str: plural = "attachments" if resource_type == "attachment" else "versions" return ( f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}" f"/internal/onlyoffice/{plural}/{resource_id}/content" ) def onlyoffice_document_key( resource_type: OnlyOfficeResourceType, resource_id: uuid.UUID, *, file_hash: str | None = None, ) -> str: instance_id = (settings.ONLYOFFICE_INSTANCE_ID or "").strip() fingerprint = f"{instance_id}{resource_type}{resource_id}" if resource_type == "version": fingerprint = f"{fingerprint}{file_hash or ''}" return f"ctms-{hashlib.sha256(fingerprint.encode('utf-8')).hexdigest()}" async def ensure_onlyoffice_available() -> None: global _health_available, _health_checked_at if not settings.ONLYOFFICE_ENABLED: raise onlyoffice_error( "ONLYOFFICE_DISABLED", "Office 预览服务尚未启用", status.HTTP_503_SERVICE_UNAVAILABLE, ) now = time.monotonic() if now - _health_checked_at < _HEALTH_CACHE_SECONDS: if _health_available: return raise onlyoffice_error( "ONLYOFFICE_UNAVAILABLE", "Office 预览服务暂不可用,请稍后重试", status.HTTP_503_SERVICE_UNAVAILABLE, ) async with _health_lock: now = time.monotonic() if now - _health_checked_at >= _HEALTH_CACHE_SECONDS: available = False try: async with httpx.AsyncClient(timeout=2.0, follow_redirects=False) as client: response = await client.get(f"{settings.ONLYOFFICE_INTERNAL_URL.rstrip('/')}/healthcheck") available = response.status_code == status.HTTP_200_OK except httpx.HTTPError: available = False _health_available = available _health_checked_at = now if not _health_available: raise onlyoffice_error( "ONLYOFFICE_UNAVAILABLE", "Office 预览服务暂不可用,请稍后重试", status.HTTP_503_SERVICE_UNAVAILABLE, ) def build_preview_config( *, resource_type: OnlyOfficeResourceType, resource_id: uuid.UUID, file_name: str, user_id: uuid.UUID, user_name: str, file_hash: str | None = None, ) -> OnlyOfficePreviewConfigRead: format_info = office_format_for_filename(file_name) if not format_info: raise onlyoffice_error( "ONLYOFFICE_FORMAT_UNSUPPORTED", "该文件格式不支持 Office 在线预览", status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, ) file_type, document_type = format_info now = datetime.now(timezone.utc) expires_at = now + timedelta(seconds=settings.ONLYOFFICE_CONFIG_TTL_SECONDS) config: dict[str, Any] = { "type": "desktop", "documentType": document_type, "document": { "fileType": file_type, "key": onlyoffice_document_key(resource_type, resource_id, file_hash=file_hash), "title": file_name, "url": onlyoffice_content_url(resource_type, resource_id), "permissions": { "copy": False, "comment": False, "download": False, "edit": False, "fillForms": False, "modifyContentControl": False, "modifyFilter": False, "print": False, "review": False, }, }, "editorConfig": { "coEditing": {"mode": "strict", "change": False}, "customization": { "chat": False, "comments": False, "forcesave": False, }, "lang": "zh-CN", "mode": "view", "user": {"id": str(user_id), "name": user_name}, }, } token_payload = { **config, "iat": int(now.timestamp()), "exp": int(expires_at.timestamp()), } config["token"] = jwt.encode( token_payload, settings.ONLYOFFICE_JWT_SECRET or "", algorithm="HS256", ) return OnlyOfficePreviewConfigRead( resource_type=resource_type, resource_id=resource_id, file_name=file_name, expires_at=expires_at, config=config, ) def validate_outbox_token(token: str | None, expected_url: str) -> dict[str, Any]: if not token: raise onlyoffice_error( "ONLYOFFICE_SOURCE_UNAUTHORIZED", "无法验证 Office 文件请求", status.HTTP_401_UNAUTHORIZED, ) token = token.strip() if " " in token: scheme, credential = token.split(" ", 1) if scheme.lower() != "bearer" or not credential.strip(): raise onlyoffice_error( "ONLYOFFICE_SOURCE_UNAUTHORIZED", "无法验证 Office 文件请求", status.HTTP_401_UNAUTHORIZED, ) token = credential.strip() try: header = jwt.get_unverified_header(token) if header.get("alg") != "HS256": raise JWTError("unexpected algorithm") payload = jwt.decode( token, settings.ONLYOFFICE_JWT_SECRET or "", algorithms=["HS256"], options={"verify_aud": False}, ) except JWTError as exc: raise onlyoffice_error( "ONLYOFFICE_SOURCE_UNAUTHORIZED", "无法验证 Office 文件请求", status.HTTP_401_UNAUTHORIZED, ) from exc request_payload = payload.get("payload") token_url = request_payload.get("url") if isinstance(request_payload, dict) else None if not isinstance(token_url, str) or not hmac.compare_digest(token_url, expected_url): raise onlyoffice_error( "ONLYOFFICE_SOURCE_URL_MISMATCH", "Office 文件请求地址不匹配", status.HTTP_401_UNAUTHORIZED, ) return payload