1d26646a96
- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。 - 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。 - 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。 - 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。 - 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。 - 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。 - 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。 - 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。 - 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。 - 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。 - 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。 - 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。
236 lines
7.7 KiB
Python
236 lines
7.7 KiB
Python
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", "collaboration_revision"]
|
|
|
|
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 = {
|
|
"attachment": "attachments",
|
|
"version": "versions",
|
|
"collaboration_revision": "collaboration-revisions",
|
|
}[resource_type]
|
|
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:
|
|
payload = decode_onlyoffice_token(token)
|
|
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
|
|
|
|
|
|
def decode_onlyoffice_token(token: str) -> dict[str, Any]:
|
|
header = jwt.get_unverified_header(token)
|
|
if header.get("alg") != "HS256":
|
|
raise JWTError("unexpected algorithm")
|
|
return jwt.decode(
|
|
token,
|
|
settings.ONLYOFFICE_JWT_SECRET or "",
|
|
algorithms=["HS256"],
|
|
options={"verify_aud": False},
|
|
)
|