功能(文档预览):集成 ONLYOFFICE 安全只读预览与工作台体验
新增 ONLYOFFICE 配置签名、内部内容接口、容器编排与反向代理。 打通网页端和桌面端独立预览工作区,完善文档入口、布局及帮助体验。 补充桌面安全发布门禁、开发脚本、使用文档和前后端测试。
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
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.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)},
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, desktop_notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles
|
||||
from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, desktop_notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles, onlyoffice
|
||||
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -17,6 +17,7 @@ api_router.include_router(api_permissions.router, tags=["api-permissions"])
|
||||
api_router.include_router(api_permissions.study_router, prefix="/studies/{study_id}", tags=["api-permissions"])
|
||||
api_router.include_router(attachments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/attachments", tags=["attachments"])
|
||||
api_router.include_router(attachments.global_router, prefix="/attachments", tags=["attachments"])
|
||||
api_router.include_router(onlyoffice.router, prefix="/onlyoffice", tags=["onlyoffice"])
|
||||
api_router.include_router(audit_logs.router, prefix="/studies/{study_id}/audit-logs", tags=["audit-logs"])
|
||||
api_router.include_router(dashboard.router, prefix="/studies/{study_id}/dashboard", tags=["dashboard"])
|
||||
api_router.include_router(subjects.router, prefix="/studies/{study_id}/subjects", tags=["subjects"])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from functools import lru_cache
|
||||
from typing import Literal, Optional
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
@@ -50,6 +51,12 @@ class Settings(BaseSettings):
|
||||
MONITORING_RETENTION_INTERVAL_SECONDS: int = Field(default=86400, ge=60, le=604800)
|
||||
USER_LOGIN_ACTIVITY_RETENTION_DAYS: int = Field(default=180, ge=30, le=3650)
|
||||
USER_SESSION_ONLINE_SECONDS: int = Field(default=300, ge=60, le=3600)
|
||||
ONLYOFFICE_ENABLED: bool = False
|
||||
ONLYOFFICE_JWT_SECRET: Optional[str] = None
|
||||
ONLYOFFICE_INTERNAL_URL: str = "http://onlyoffice"
|
||||
ONLYOFFICE_STORAGE_BASE_URL: str = "http://backend:8000"
|
||||
ONLYOFFICE_INSTANCE_ID: Optional[str] = None
|
||||
ONLYOFFICE_CONFIG_TTL_SECONDS: int = Field(default=300, ge=60, le=900)
|
||||
|
||||
|
||||
@lru_cache
|
||||
@@ -60,6 +67,30 @@ def get_settings() -> Settings:
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def validate_onlyoffice_configuration() -> None:
|
||||
if not settings.ONLYOFFICE_ENABLED:
|
||||
return
|
||||
secret = (settings.ONLYOFFICE_JWT_SECRET or "").strip()
|
||||
instance_id = (settings.ONLYOFFICE_INSTANCE_ID or "").strip()
|
||||
if secret == settings.JWT_SECRET_KEY:
|
||||
raise RuntimeError("ONLYOFFICE_JWT_SECRET must not reuse JWT_SECRET_KEY")
|
||||
if len(secret) < 32:
|
||||
raise RuntimeError("ONLYOFFICE_JWT_SECRET must contain at least 32 characters when ONLYOFFICE is enabled")
|
||||
if not instance_id:
|
||||
raise RuntimeError("ONLYOFFICE_INSTANCE_ID is required when ONLYOFFICE is enabled")
|
||||
for name, value in (
|
||||
("ONLYOFFICE_INTERNAL_URL", settings.ONLYOFFICE_INTERNAL_URL),
|
||||
("ONLYOFFICE_STORAGE_BASE_URL", settings.ONLYOFFICE_STORAGE_BASE_URL),
|
||||
):
|
||||
parsed = urlsplit(value.strip())
|
||||
if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname:
|
||||
raise RuntimeError(f"{name} must be an HTTP(S) URL")
|
||||
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise RuntimeError(f"{name} must not contain credentials, a query, or a fragment")
|
||||
if parsed.path not in {"", "/"}:
|
||||
raise RuntimeError(f"{name} must not contain a path")
|
||||
|
||||
|
||||
def get_cors_allowed_origins() -> list[str]:
|
||||
return [
|
||||
origin.strip()
|
||||
|
||||
+4
-1
@@ -11,7 +11,8 @@ from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.api.v1.router import api_router
|
||||
from app.core.config import get_cors_allowed_origins, settings
|
||||
from app.api.v1.onlyoffice import internal_router as onlyoffice_internal_router
|
||||
from app.core.config import get_cors_allowed_origins, settings, validate_onlyoffice_configuration
|
||||
from app.core.exceptions import register_exception_handlers
|
||||
from app.core.login_crypto import validate_login_crypto_configuration
|
||||
from app.crud.user import ensure_admin_exists
|
||||
@@ -111,6 +112,7 @@ async def _ensure_legacy_primary_keys(conn) -> None:
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
validate_login_crypto_configuration()
|
||||
validate_onlyoffice_configuration()
|
||||
app = FastAPI(
|
||||
title="CTMS 后端 API",
|
||||
description="临床试验项目管理系统后端接口文档",
|
||||
@@ -297,6 +299,7 @@ def create_app() -> FastAPI:
|
||||
return {"totals": totals, "by_endpoint": summary}
|
||||
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
app.include_router(onlyoffice_internal_router)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class OnlyOfficePreviewConfigRead(BaseModel):
|
||||
resource_type: Literal["attachment", "version"]
|
||||
resource_id: uuid.UUID
|
||||
file_name: str
|
||||
host_path: str = "/onlyoffice-host.html"
|
||||
expires_at: datetime
|
||||
config: dict[str, Any]
|
||||
@@ -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
|
||||
@@ -0,0 +1,136 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi import Response
|
||||
from jose import jwt
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.api.v1 import onlyoffice as onlyoffice_api
|
||||
from app.core.config import settings
|
||||
from app.core.exceptions import AppException
|
||||
from app.services import onlyoffice_service
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def onlyoffice_settings(monkeypatch):
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_ENABLED", True)
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", "office-preview-secret-that-is-long-enough")
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_INSTANCE_ID", "ctms-test")
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_STORAGE_BASE_URL", "http://backend:8000")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attachment_config_reuses_permission_and_preserves_original_name(monkeypatch, tmp_path):
|
||||
resource_id = uuid.uuid4()
|
||||
study_id = uuid.uuid4()
|
||||
entity_id = uuid.uuid4()
|
||||
user = SimpleNamespace(id=uuid.uuid4(), full_name="预览用户")
|
||||
source_file = tmp_path / "stored-uuid"
|
||||
source_file.write_bytes(b"office")
|
||||
attachment = SimpleNamespace(
|
||||
id=resource_id,
|
||||
study_id=study_id,
|
||||
entity_type="startup_initiation",
|
||||
entity_id=entity_id,
|
||||
filename="研究方案最终版.DOCX",
|
||||
file_path=str(source_file),
|
||||
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
)
|
||||
permission = AsyncMock()
|
||||
audit = AsyncMock()
|
||||
monkeypatch.setattr(onlyoffice_api.attachment_crud, "get_attachment", AsyncMock(return_value=attachment))
|
||||
monkeypatch.setattr(onlyoffice_api, "_ensure_study_exists", AsyncMock())
|
||||
monkeypatch.setattr(onlyoffice_api, "_ensure_attachment_permission", permission)
|
||||
monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
|
||||
monkeypatch.setattr(onlyoffice_api, "_log_preview_open", audit)
|
||||
response = Response()
|
||||
db = object()
|
||||
|
||||
result = await onlyoffice_api.get_attachment_preview_config(resource_id, response, db, user)
|
||||
|
||||
permission.assert_awaited_once_with(db, study_id, "startup_initiation", entity_id, "read", user)
|
||||
assert result.file_name == "研究方案最终版.DOCX"
|
||||
assert result.config["document"]["title"] == "研究方案最终版.DOCX"
|
||||
assert response.headers["Cache-Control"] == "no-store"
|
||||
audit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attachment_permission_failure_does_not_issue_config(monkeypatch, tmp_path):
|
||||
source_file = tmp_path / "source.xlsx"
|
||||
source_file.write_bytes(b"office")
|
||||
attachment = SimpleNamespace(
|
||||
id=uuid.uuid4(), study_id=uuid.uuid4(), entity_type="ethics", entity_id=uuid.uuid4(),
|
||||
filename="数据.xlsx", file_path=str(source_file),
|
||||
)
|
||||
denied = AppException(code="FORBIDDEN", message="权限不足", status_code=403)
|
||||
monkeypatch.setattr(onlyoffice_api.attachment_crud, "get_attachment", AsyncMock(return_value=attachment))
|
||||
monkeypatch.setattr(onlyoffice_api, "_ensure_study_exists", AsyncMock())
|
||||
monkeypatch.setattr(onlyoffice_api, "_ensure_attachment_permission", AsyncMock(side_effect=denied))
|
||||
health = AsyncMock()
|
||||
monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", health)
|
||||
|
||||
with pytest.raises(AppException) as error:
|
||||
await onlyoffice_api.get_attachment_preview_config(
|
||||
attachment.id, Response(), object(), SimpleNamespace(id=uuid.uuid4(), full_name="无权限用户")
|
||||
)
|
||||
|
||||
assert error.value.status_code == 403
|
||||
health.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_version_config_reuses_document_access_and_version_hash(monkeypatch, tmp_path):
|
||||
source_file = tmp_path / "uuid-storage"
|
||||
source_file.write_bytes(b"office")
|
||||
version = SimpleNamespace(
|
||||
id=uuid.uuid4(), document_id=uuid.uuid4(), file_uri=str(source_file), original_filename="统计表.xlsx",
|
||||
file_hash="sha256-current", mime_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
document = SimpleNamespace(id=version.document_id, trial_id=uuid.uuid4())
|
||||
user = SimpleNamespace(id=uuid.uuid4(), full_name="管理员")
|
||||
access = AsyncMock()
|
||||
monkeypatch.setattr(onlyoffice_api.version_crud, "get", AsyncMock(return_value=version))
|
||||
monkeypatch.setattr(onlyoffice_api.document_crud, "get", AsyncMock(return_value=document))
|
||||
monkeypatch.setattr(onlyoffice_api.document_service, "_ensure_study_access", access)
|
||||
monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
|
||||
monkeypatch.setattr(onlyoffice_api, "_log_preview_open", AsyncMock())
|
||||
|
||||
result = await onlyoffice_api.get_version_preview_config(version.id, Response(), object(), user)
|
||||
|
||||
assert access.await_args.args[1:] == (document.trial_id, user)
|
||||
assert access.await_args.kwargs == {"action": "view"}
|
||||
assert result.file_name == "统计表.xlsx"
|
||||
assert result.config["document"]["key"] == onlyoffice_service.onlyoffice_document_key(
|
||||
"version", version.id, file_hash="sha256-current"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_internal_attachment_requires_url_bound_jwt_and_returns_original_name(monkeypatch, tmp_path):
|
||||
source_file = tmp_path / "uuid-storage-name"
|
||||
source_file.write_bytes(b"office")
|
||||
attachment = SimpleNamespace(
|
||||
id=uuid.uuid4(), filename="中文原始文件名.xlsx", file_path=str(source_file),
|
||||
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
expected_url = onlyoffice_service.onlyoffice_content_url("attachment", attachment.id)
|
||||
token = jwt.encode({"payload": {"url": expected_url}}, settings.ONLYOFFICE_JWT_SECRET, algorithm="HS256")
|
||||
request = Request({
|
||||
"type": "http", "method": "GET", "path": f"/internal/onlyoffice/attachments/{attachment.id}/content",
|
||||
"headers": [(b"authorizationjwt", token.encode())],
|
||||
})
|
||||
monkeypatch.setattr(onlyoffice_api.attachment_crud, "get_attachment", AsyncMock(return_value=attachment))
|
||||
|
||||
response = await onlyoffice_api.get_internal_attachment_content(attachment.id, request, object())
|
||||
|
||||
assert "uuid-storage-name" not in response.headers["content-disposition"]
|
||||
assert "UTF-8''" in response.headers["content-disposition"]
|
||||
assert response.media_type == attachment.content_type
|
||||
|
||||
missing_token_request = Request({"type": "http", "method": "GET", "path": "/", "headers": []})
|
||||
with pytest.raises(AppException) as error:
|
||||
await onlyoffice_api.get_internal_attachment_content(attachment.id, missing_token_request, object())
|
||||
assert error.value.code == "ONLYOFFICE_SOURCE_UNAUTHORIZED"
|
||||
@@ -0,0 +1,194 @@
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from jose import jwt
|
||||
|
||||
from app.core.config import settings, validate_onlyoffice_configuration
|
||||
from app.core.exceptions import AppException
|
||||
from app.services import onlyoffice_service
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def onlyoffice_settings(monkeypatch):
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_ENABLED", True)
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", "office-preview-secret-that-is-long-enough")
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_INSTANCE_ID", "ctms-test")
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_INTERNAL_URL", "http://onlyoffice")
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_STORAGE_BASE_URL", "http://backend:8000")
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_CONFIG_TTL_SECONDS", 300)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "expected"),
|
||||
[
|
||||
("方案.DOCX", ("docx", "word")),
|
||||
("数据.xlsx", ("xlsx", "cell")),
|
||||
("培训.PPTX", ("pptx", "slide")),
|
||||
("国产格式.wps", ("wps", "word")),
|
||||
("国产格式.et", ("et", "cell")),
|
||||
("国产格式.dps", ("dps", "slide")),
|
||||
("扫描件.pdf", None),
|
||||
("图片.png", None),
|
||||
],
|
||||
)
|
||||
def test_office_format_contract(filename, expected):
|
||||
assert onlyoffice_service.office_format_for_filename(filename) == expected
|
||||
|
||||
|
||||
def test_all_supported_format_groups_match_the_public_contract():
|
||||
expected = {
|
||||
**{extension: "word" for extension in ("doc", "docx", "docm", "dot", "dotx", "dotm", "odt", "ott", "rtf", "txt", "wps", "wpt")},
|
||||
**{extension: "cell" for extension in ("xls", "xlsx", "xlsm", "xlsb", "xlt", "xltx", "xltm", "ods", "ots", "csv", "et", "ett")},
|
||||
**{extension: "slide" for extension in ("ppt", "pptx", "pptm", "pps", "ppsx", "ppsm", "pot", "potx", "potm", "odp", "otp", "dps", "dpt")},
|
||||
}
|
||||
for extension, document_type in expected.items():
|
||||
assert onlyoffice_service.office_format_for_filename(f"原始文件名.{extension.upper()}") == (
|
||||
extension,
|
||||
document_type,
|
||||
)
|
||||
|
||||
|
||||
def test_preview_config_is_read_only_and_uses_original_filename():
|
||||
resource_id = uuid.uuid4()
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
result = onlyoffice_service.build_preview_config(
|
||||
resource_type="attachment",
|
||||
resource_id=resource_id,
|
||||
file_name="研究方案 V1.0.docx",
|
||||
user_id=user_id,
|
||||
user_name="测试用户",
|
||||
)
|
||||
|
||||
document = result.config["document"]
|
||||
permissions = document["permissions"]
|
||||
assert result.file_name == "研究方案 V1.0.docx"
|
||||
assert document["title"] == "研究方案 V1.0.docx"
|
||||
assert document["fileType"] == "docx"
|
||||
assert result.config["documentType"] == "word"
|
||||
assert result.config["editorConfig"]["mode"] == "view"
|
||||
assert all(permissions[key] is False for key in ("copy", "comment", "download", "edit", "fillForms", "print", "review"))
|
||||
assert "token=" not in document["url"]
|
||||
assert "access_token=" not in document["url"]
|
||||
assert document["url"].endswith(f"/internal/onlyoffice/attachments/{resource_id}/content")
|
||||
|
||||
payload = jwt.decode(
|
||||
result.config["token"],
|
||||
settings.ONLYOFFICE_JWT_SECRET,
|
||||
algorithms=["HS256"],
|
||||
)
|
||||
assert payload["document"]["title"] == "研究方案 V1.0.docx"
|
||||
assert payload["editorConfig"]["user"] == {"id": str(user_id), "name": "测试用户"}
|
||||
assert payload["exp"] - payload["iat"] == settings.ONLYOFFICE_CONFIG_TTL_SECONDS
|
||||
|
||||
|
||||
def test_document_key_is_stable_and_version_hash_changes_it():
|
||||
resource_id = uuid.uuid4()
|
||||
first = onlyoffice_service.onlyoffice_document_key("version", resource_id, file_hash="hash-a")
|
||||
repeated = onlyoffice_service.onlyoffice_document_key("version", resource_id, file_hash="hash-a")
|
||||
changed = onlyoffice_service.onlyoffice_document_key("version", resource_id, file_hash="hash-b")
|
||||
|
||||
assert first == repeated
|
||||
assert first != changed
|
||||
assert first.startswith("ctms-")
|
||||
assert len(first) <= 128
|
||||
|
||||
|
||||
def test_outbox_token_must_be_hs256_and_bound_to_exact_url():
|
||||
resource_id = uuid.uuid4()
|
||||
expected_url = onlyoffice_service.onlyoffice_content_url("attachment", resource_id)
|
||||
token = jwt.encode({"payload": {"url": expected_url}}, settings.ONLYOFFICE_JWT_SECRET, algorithm="HS256")
|
||||
|
||||
assert onlyoffice_service.validate_outbox_token(token, expected_url)["payload"]["url"] == expected_url
|
||||
assert onlyoffice_service.validate_outbox_token(f"Bearer {token}", expected_url)["payload"]["url"] == expected_url
|
||||
|
||||
with pytest.raises(AppException) as mismatch:
|
||||
onlyoffice_service.validate_outbox_token(token, f"{expected_url}-other")
|
||||
assert mismatch.value.code == "ONLYOFFICE_SOURCE_URL_MISMATCH"
|
||||
|
||||
config_token = jwt.encode(
|
||||
{"document": {"url": expected_url}},
|
||||
settings.ONLYOFFICE_JWT_SECRET,
|
||||
algorithm="HS256",
|
||||
)
|
||||
with pytest.raises(AppException) as wrong_purpose:
|
||||
onlyoffice_service.validate_outbox_token(config_token, expected_url)
|
||||
assert wrong_purpose.value.code == "ONLYOFFICE_SOURCE_URL_MISMATCH"
|
||||
|
||||
legacy_top_level_url = jwt.encode(
|
||||
{"url": expected_url},
|
||||
settings.ONLYOFFICE_JWT_SECRET,
|
||||
algorithm="HS256",
|
||||
)
|
||||
with pytest.raises(AppException) as wrong_shape:
|
||||
onlyoffice_service.validate_outbox_token(legacy_top_level_url, expected_url)
|
||||
assert wrong_shape.value.code == "ONLYOFFICE_SOURCE_URL_MISMATCH"
|
||||
|
||||
substituted_algorithm = jwt.encode(
|
||||
{"payload": {"url": expected_url}},
|
||||
settings.ONLYOFFICE_JWT_SECRET,
|
||||
algorithm="HS512",
|
||||
)
|
||||
with pytest.raises(AppException) as invalid_algorithm:
|
||||
onlyoffice_service.validate_outbox_token(substituted_algorithm, expected_url)
|
||||
assert invalid_algorithm.value.code == "ONLYOFFICE_SOURCE_UNAUTHORIZED"
|
||||
|
||||
with pytest.raises(AppException) as missing:
|
||||
onlyoffice_service.validate_outbox_token(None, expected_url)
|
||||
assert missing.value.code == "ONLYOFFICE_SOURCE_UNAUTHORIZED"
|
||||
|
||||
with pytest.raises(AppException) as wrong_scheme:
|
||||
onlyoffice_service.validate_outbox_token(f"Basic {token}", expected_url)
|
||||
assert wrong_scheme.value.code == "ONLYOFFICE_SOURCE_UNAUTHORIZED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_probe_is_cached_for_fifteen_seconds(monkeypatch):
|
||||
onlyoffice_service._health_checked_at = 0.0
|
||||
onlyoffice_service._health_available = False
|
||||
request = type("Response", (), {"status_code": 200})()
|
||||
get = AsyncMock(return_value=request)
|
||||
client = MagicMock()
|
||||
client.__aenter__ = AsyncMock(return_value=type("Client", (), {"get": get})())
|
||||
client.__aexit__ = AsyncMock(return_value=False)
|
||||
monkeypatch.setattr(onlyoffice_service.httpx, "AsyncClient", MagicMock(return_value=client))
|
||||
|
||||
await onlyoffice_service.ensure_onlyoffice_available()
|
||||
await onlyoffice_service.ensure_onlyoffice_available()
|
||||
|
||||
get.assert_awaited_once()
|
||||
|
||||
|
||||
def test_enabled_configuration_requires_a_distinct_secret_and_instance(monkeypatch):
|
||||
validate_onlyoffice_configuration()
|
||||
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", settings.JWT_SECRET_KEY)
|
||||
with pytest.raises(RuntimeError, match="must not reuse"):
|
||||
validate_onlyoffice_configuration()
|
||||
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", "office-preview-secret-that-is-long-enough")
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_INSTANCE_ID", "")
|
||||
with pytest.raises(RuntimeError, match="INSTANCE_ID"):
|
||||
validate_onlyoffice_configuration()
|
||||
|
||||
|
||||
def test_enabled_configuration_rejects_unsafe_service_urls(monkeypatch):
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_INTERNAL_URL", "http://user:password@onlyoffice")
|
||||
with pytest.raises(RuntimeError, match="credentials"):
|
||||
validate_onlyoffice_configuration()
|
||||
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_INTERNAL_URL", "http://onlyoffice/internal-path")
|
||||
with pytest.raises(RuntimeError, match="must not contain a path"):
|
||||
validate_onlyoffice_configuration()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_service_returns_stable_error(monkeypatch):
|
||||
monkeypatch.setattr(settings, "ONLYOFFICE_ENABLED", False)
|
||||
|
||||
with pytest.raises(AppException) as error:
|
||||
await onlyoffice_service.ensure_onlyoffice_available()
|
||||
|
||||
assert error.value.code == "ONLYOFFICE_DISABLED"
|
||||
assert error.value.status_code == 503
|
||||
Reference in New Issue
Block a user