418 lines
17 KiB
Python
418 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
import httpx
|
|
from fastapi import HTTPException, Request, status
|
|
from jose import JWTError, jwt
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.config import settings
|
|
from app.models.collaboration import (
|
|
CollaborationCallbackReceipt,
|
|
CollaborationFile,
|
|
CollaborationRevision,
|
|
CollaborationSession,
|
|
CollaborationShareLink,
|
|
)
|
|
from app.models.user import User
|
|
from app.schemas.collaboration import CollaborationCallbackPayload, CollaborationEditorConfigRead
|
|
from app.services import collaboration_service, onlyoffice_service
|
|
|
|
|
|
def collaboration_document_key(file_id: uuid.UUID, generation: int) -> str:
|
|
fingerprint = f"{settings.ONLYOFFICE_INSTANCE_ID or ''}:collaboration:{file_id}:{generation}"
|
|
return f"ctms-collab-{hashlib.sha256(fingerprint.encode('utf-8')).hexdigest()}"
|
|
|
|
|
|
def _content_url(session_id: uuid.UUID) -> str:
|
|
return (
|
|
f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}"
|
|
f"/internal/onlyoffice/collaboration/sessions/{session_id}/content"
|
|
)
|
|
|
|
|
|
def _callback_url(session_id: uuid.UUID) -> str:
|
|
return (
|
|
f"{settings.ONLYOFFICE_STORAGE_BASE_URL.rstrip('/')}"
|
|
f"/internal/onlyoffice/collaboration/sessions/{session_id}/callback"
|
|
)
|
|
|
|
|
|
async def _active_session(
|
|
db: AsyncSession, item: CollaborationFile, user_id: uuid.UUID
|
|
) -> CollaborationSession:
|
|
session = await db.scalar(
|
|
select(CollaborationSession).where(
|
|
CollaborationSession.file_id == item.id,
|
|
CollaborationSession.generation == item.generation,
|
|
).order_by(CollaborationSession.created_at.desc())
|
|
)
|
|
if session:
|
|
if session.status != "ACTIVE":
|
|
session.status = "ACTIVE"
|
|
session.closed_at = None
|
|
await db.commit()
|
|
await db.refresh(session)
|
|
return session
|
|
if not item.current_revision_id:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可编辑内容")
|
|
session = CollaborationSession(
|
|
file_id=item.id,
|
|
base_revision_id=item.current_revision_id,
|
|
document_key=collaboration_document_key(item.id, item.generation),
|
|
generation=item.generation,
|
|
started_by=user_id,
|
|
)
|
|
db.add(session)
|
|
await db.commit()
|
|
await db.refresh(session)
|
|
return session
|
|
|
|
|
|
async def build_editor_config(
|
|
db: AsyncSession, item: CollaborationFile, user
|
|
) -> CollaborationEditorConfigRead:
|
|
await onlyoffice_service.ensure_onlyoffice_available()
|
|
revision = await db.get(CollaborationRevision, item.current_revision_id)
|
|
if not revision or not Path(revision.file_uri).exists():
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件内容不存在")
|
|
can_edit = await collaboration_service.can_edit_file(db, item, user)
|
|
can_request_edit = await collaboration_service.can_request_edit_file(db, item, user)
|
|
can_download = await collaboration_service.can_export_file(db, item, user)
|
|
can_save_as = can_download and await collaboration_service.can_create_file(db, item, user)
|
|
session = await _active_session(db, item, user.id)
|
|
now = datetime.now(timezone.utc)
|
|
expires_at = now + timedelta(seconds=settings.ONLYOFFICE_CONFIG_TTL_SECONDS)
|
|
config: dict[str, Any] = {
|
|
"type": "desktop",
|
|
"documentType": item.file_type,
|
|
"document": {
|
|
"fileType": item.extension,
|
|
"key": session.document_key,
|
|
"title": item.title,
|
|
"url": _content_url(session.id),
|
|
"permissions": {
|
|
"chat": False,
|
|
"copy": can_download,
|
|
"comment": can_edit,
|
|
"download": can_download,
|
|
# In view mode ONLYOFFICE displays "Edit current file" only
|
|
# when edit=true and onRequestEditRights is registered. CTMS
|
|
# handles that event as an approval request, not an escalation.
|
|
"edit": can_edit or can_request_edit,
|
|
"fillForms": False,
|
|
"modifyContentControl": can_edit,
|
|
"modifyFilter": can_edit,
|
|
"print": can_download,
|
|
"protect": False,
|
|
"review": False,
|
|
},
|
|
},
|
|
"editorConfig": {
|
|
"callbackUrl": _callback_url(session.id),
|
|
"coEditing": {"mode": "fast", "change": False},
|
|
"customization": {
|
|
"autosave": True,
|
|
"chat": False,
|
|
"comments": can_edit,
|
|
"forcesave": can_edit,
|
|
"help": False,
|
|
"plugins": False,
|
|
},
|
|
"lang": "zh-CN",
|
|
"mode": "edit" if can_edit else "view",
|
|
"user": {"id": str(user.id), "name": user.full_name},
|
|
},
|
|
}
|
|
config["token"] = jwt.encode(
|
|
{**config, "iat": int(now.timestamp()), "exp": int(expires_at.timestamp())},
|
|
settings.ONLYOFFICE_JWT_SECRET or "",
|
|
algorithm="HS256",
|
|
)
|
|
return CollaborationEditorConfigRead(
|
|
file_id=item.id,
|
|
file_name=item.title,
|
|
access_mode="edit" if can_edit else "view",
|
|
can_save_as=can_save_as,
|
|
can_download=can_download,
|
|
can_request_edit=can_request_edit,
|
|
expires_at=expires_at,
|
|
config=config,
|
|
)
|
|
|
|
|
|
async def build_shared_editor_config(
|
|
db: AsyncSession,
|
|
item: CollaborationFile,
|
|
link: CollaborationShareLink,
|
|
*,
|
|
client_id: str,
|
|
display_name: str,
|
|
) -> CollaborationEditorConfigRead:
|
|
await onlyoffice_service.ensure_onlyoffice_available()
|
|
revision = await db.get(CollaborationRevision, item.current_revision_id)
|
|
if not revision or not Path(revision.file_uri).exists():
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享文件内容不存在")
|
|
can_edit = link.access_mode == "EDIT"
|
|
session = await _active_session(db, item, item.owner_id)
|
|
now = datetime.now(timezone.utc)
|
|
expires_at = now + timedelta(seconds=settings.ONLYOFFICE_CONFIG_TTL_SECONDS)
|
|
if link.expires_at and link.expires_at < expires_at:
|
|
expires_at = link.expires_at
|
|
external_user_id = f"share-{link.id.hex[:12]}-{client_id[:32]}"
|
|
config: dict[str, Any] = {
|
|
"type": "desktop",
|
|
"documentType": item.file_type,
|
|
"document": {
|
|
"fileType": item.extension,
|
|
"key": session.document_key,
|
|
"title": item.title,
|
|
"url": _content_url(session.id),
|
|
"permissions": {
|
|
"chat": False,
|
|
"copy": item.allow_export,
|
|
"comment": can_edit,
|
|
"download": item.allow_export,
|
|
"edit": can_edit,
|
|
"fillForms": False,
|
|
"modifyContentControl": can_edit,
|
|
"modifyFilter": can_edit,
|
|
"print": item.allow_export,
|
|
"protect": False,
|
|
"review": False,
|
|
},
|
|
},
|
|
"editorConfig": {
|
|
"callbackUrl": _callback_url(session.id),
|
|
"coEditing": {"mode": "fast", "change": False},
|
|
"customization": {
|
|
"autosave": can_edit,
|
|
"chat": False,
|
|
"comments": can_edit,
|
|
"forcesave": can_edit,
|
|
"help": False,
|
|
"plugins": False,
|
|
},
|
|
"lang": "zh-CN",
|
|
"mode": "edit" if can_edit else "view",
|
|
"user": {"id": external_user_id, "name": display_name},
|
|
},
|
|
}
|
|
config["token"] = jwt.encode(
|
|
{**config, "iat": int(now.timestamp()), "exp": int(expires_at.timestamp())},
|
|
settings.ONLYOFFICE_JWT_SECRET or "",
|
|
algorithm="HS256",
|
|
)
|
|
return CollaborationEditorConfigRead(
|
|
file_id=item.id,
|
|
file_name=item.title,
|
|
access_mode="edit" if can_edit else "view",
|
|
can_save_as=False,
|
|
can_download=item.allow_export,
|
|
expires_at=expires_at,
|
|
config=config,
|
|
)
|
|
|
|
|
|
async def get_session_content(
|
|
db: AsyncSession, session_id: uuid.UUID, authorization: str | None
|
|
) -> tuple[CollaborationRevision, CollaborationFile]:
|
|
session = await db.get(CollaborationSession, session_id)
|
|
if not session:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作会话不存在")
|
|
onlyoffice_service.validate_outbox_token(authorization, _content_url(session_id))
|
|
revision = await db.get(CollaborationRevision, session.base_revision_id)
|
|
item = await db.get(CollaborationFile, session.file_id)
|
|
if not revision or not item or not Path(revision.file_uri).exists():
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件内容不存在")
|
|
return revision, item
|
|
|
|
|
|
def validate_callback_token(token: str | None, payload: CollaborationCallbackPayload) -> dict[str, Any]:
|
|
if not token:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="缺少 ONLYOFFICE 回调签名")
|
|
value = token.strip()
|
|
if " " in value:
|
|
scheme, credential = value.split(" ", 1)
|
|
if scheme.lower() != "bearer":
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调签名格式无效")
|
|
value = credential.strip()
|
|
try:
|
|
decoded = onlyoffice_service.decode_onlyoffice_token(value)
|
|
except JWTError as exc:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调签名无效") from exc
|
|
signed = decoded.get("payload") if isinstance(decoded.get("payload"), dict) else decoded
|
|
if not isinstance(signed, dict):
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调载荷无效")
|
|
signed_key = signed.get("key")
|
|
signed_status = signed.get("status")
|
|
if not isinstance(signed_key, str) or not hmac.compare_digest(signed_key, payload.key):
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调 key 不匹配")
|
|
if not isinstance(signed_status, int) or signed_status != payload.status:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调状态不匹配")
|
|
if payload.url:
|
|
signed_url = signed.get("url")
|
|
if not isinstance(signed_url, str) or not hmac.compare_digest(signed_url, payload.url):
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="ONLYOFFICE 回调文件地址不匹配")
|
|
return decoded
|
|
|
|
|
|
def _callback_fingerprint(payload: CollaborationCallbackPayload) -> str:
|
|
normalized = payload.model_dump(mode="json", exclude_none=True)
|
|
return hashlib.sha256(json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
|
|
|
|
def _url_origin_matches(actual, expected) -> bool:
|
|
actual_port = actual.port or (443 if actual.scheme == "https" else 80)
|
|
expected_port = expected.port or (443 if expected.scheme == "https" else 80)
|
|
return (
|
|
actual.scheme == expected.scheme
|
|
and actual.hostname
|
|
and actual.hostname.lower() == (expected.hostname or "").lower()
|
|
and actual_port == expected_port
|
|
)
|
|
|
|
|
|
def _validate_result_url(url: str) -> str:
|
|
actual = urlsplit(url)
|
|
if (
|
|
actual.scheme not in {"http", "https"}
|
|
or actual.username
|
|
or actual.password
|
|
or actual.fragment
|
|
or not actual.hostname
|
|
):
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存地址不受信任")
|
|
|
|
internal = urlsplit(settings.ONLYOFFICE_INTERNAL_URL.rstrip("/"))
|
|
if _url_origin_matches(actual, internal):
|
|
return urlunsplit((internal.scheme, internal.netloc, actual.path, actual.query, ""))
|
|
|
|
public = urlsplit(settings.FRONTEND_PUBLIC_URL.rstrip("/"))
|
|
proxy_prefix = "/onlyoffice/"
|
|
if not _url_origin_matches(actual, public) or not actual.path.startswith(proxy_prefix):
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存地址不受信任")
|
|
internal_path = f"{internal.path.rstrip('/')}/{actual.path[len(proxy_prefix):]}"
|
|
return urlunsplit((internal.scheme, internal.netloc, internal_path, actual.query, ""))
|
|
|
|
|
|
async def _download_result(url: str) -> bytes:
|
|
download_url = _validate_result_url(url)
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30.0, follow_redirects=False) as client:
|
|
async with client.stream("GET", download_url) as response:
|
|
if response.status_code != status.HTTP_200_OK:
|
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="ONLYOFFICE 保存文件下载失败")
|
|
content = bytearray()
|
|
async for chunk in response.aiter_bytes():
|
|
content.extend(chunk)
|
|
if len(content) > settings.COLLABORATION_MAX_FILE_BYTES:
|
|
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="ONLYOFFICE 保存文件超出限制")
|
|
except httpx.HTTPError as exc:
|
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="ONLYOFFICE 保存文件下载失败") from exc
|
|
return bytes(content)
|
|
|
|
|
|
async def _callback_user(
|
|
db: AsyncSession, payload: CollaborationCallbackPayload, session: CollaborationSession
|
|
) -> User | None:
|
|
has_public_share_user = False
|
|
for value in payload.users:
|
|
if value.startswith("share-"):
|
|
has_public_share_user = True
|
|
continue
|
|
try:
|
|
user = await db.get(User, uuid.UUID(value))
|
|
except (ValueError, TypeError):
|
|
user = None
|
|
if user:
|
|
return user
|
|
if has_public_share_user:
|
|
return None
|
|
user = await db.get(User, session.started_by)
|
|
if not user:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作会话用户不存在")
|
|
return user
|
|
|
|
|
|
async def process_callback(
|
|
db: AsyncSession,
|
|
session_id: uuid.UUID,
|
|
payload: CollaborationCallbackPayload,
|
|
) -> dict[str, int]:
|
|
session = await db.scalar(
|
|
select(CollaborationSession).where(CollaborationSession.id == session_id).with_for_update()
|
|
)
|
|
if not session:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作会话不存在")
|
|
if not hmac.compare_digest(session.document_key, payload.key):
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作会话 key 不匹配")
|
|
fingerprint = _callback_fingerprint(payload)
|
|
duplicate = await db.scalar(select(CollaborationCallbackReceipt.id).where(
|
|
CollaborationCallbackReceipt.session_id == session.id,
|
|
CollaborationCallbackReceipt.fingerprint == fingerprint,
|
|
))
|
|
if duplicate:
|
|
return {"error": 0}
|
|
|
|
item = await db.get(CollaborationFile, session.file_id)
|
|
if not item:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件不存在")
|
|
session.last_callback_at = datetime.now(timezone.utc)
|
|
session.active_users = json.dumps(payload.users, ensure_ascii=True)
|
|
result = "ACKNOWLEDGED"
|
|
saved_revision_id = None
|
|
|
|
if payload.status in {2, 6}:
|
|
if not payload.url:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存回调缺少文件地址")
|
|
if session.generation != item.generation:
|
|
result = "STALE"
|
|
else:
|
|
content = await _download_result(payload.url)
|
|
actor = await _callback_user(db, payload, session)
|
|
source = "SESSION_CLOSE" if payload.status == 2 else "FORCE_SAVE"
|
|
if actor is None:
|
|
source = "SHARE_SESSION_CLOSE" if payload.status == 2 else "SHARE_FORCE_SAVE"
|
|
revision, created = await collaboration_service.append_revision(
|
|
db, item, content, source=source, created_by=actor.id if actor else None
|
|
)
|
|
saved_revision_id = revision.id
|
|
result = "SAVED" if created else "UNCHANGED"
|
|
if payload.status == 2:
|
|
item.generation += 1
|
|
session.status = "CLOSED"
|
|
session.closed_at = datetime.now(timezone.utc)
|
|
else:
|
|
# 强制保存不结束当前共同编辑会话;同步基线可保证 Document
|
|
# Server 缓存重建时仍从最近一次持久化内容恢复。
|
|
session.base_revision_id = revision.id
|
|
elif payload.status == 4:
|
|
session.status = "CLOSED"
|
|
session.closed_at = datetime.now(timezone.utc)
|
|
result = "UNCHANGED"
|
|
elif payload.status in {3, 7}:
|
|
session.status = "ERROR"
|
|
result = "ERROR"
|
|
|
|
db.add(CollaborationCallbackReceipt(
|
|
session_id=session.id,
|
|
fingerprint=fingerprint,
|
|
callback_status=payload.status,
|
|
result=result,
|
|
revision_id=saved_revision_id,
|
|
))
|
|
await db.commit()
|
|
# ONLYOFFICE 要求回调处理器在接收并记录状态后固定确认成功。
|
|
# status 3/7 表示文档服务自身保存失败,不应通过 error=1 制造重试环。
|
|
return {"error": 0}
|