功能(文档预览):集成 ONLYOFFICE 安全只读预览与工作台体验
新增 ONLYOFFICE 配置签名、内部内容接口、容器编排与反向代理。 打通网页端和桌面端独立预览工作区,完善文档入口、布局及帮助体验。 补充桌面安全发布门禁、开发脚本、使用文档和前后端测试。
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
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
|
||||
Reference in New Issue
Block a user