feat: migrate ORM base to sqlmodel-ext, add file viewers and WOPI integration
All checks were successful
Test / test (push) Successful in 1m45s
All checks were successful
Test / test (push) Successful in 1m45s
- Migrate SQLModel base classes, mixins, and database management to external sqlmodel-ext package; remove sqlmodels/base/, sqlmodels/mixin/, and sqlmodels/database.py - Add file viewer/editor system with WOPI protocol support for collaborative editing (OnlyOffice, Collabora) - Add enterprise edition license verification module (ee/) - Add Dockerfile multi-stage build with Cython compilation support - Add new dependencies: sqlmodel-ext, cryptography, whatthepatch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
42
ee/__init__.py
Normal file
42
ee/__init__.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
DiskNext Enterprise Edition (EE) 模块
|
||||
|
||||
通过 ``try: from ee import init_ee`` 检测是否存在。
|
||||
CE 版本中此目录不存在,ImportError 被 main.py 捕获。
|
||||
"""
|
||||
from loguru import logger as l
|
||||
|
||||
from utils.conf import appmeta
|
||||
|
||||
_ee_initialized: bool = False
|
||||
|
||||
|
||||
def is_pro() -> bool:
|
||||
"""当前实例是否以 Pro 版本运行。"""
|
||||
return _ee_initialized
|
||||
|
||||
|
||||
async def init_ee() -> None:
|
||||
"""
|
||||
初始化企业版功能。
|
||||
|
||||
1. 加载并验证许可证
|
||||
2. 设置 appmeta.IsPro = True
|
||||
3. 标记 EE 已初始化
|
||||
|
||||
许可证无效或缺失时抛出异常,阻止应用启动。
|
||||
"""
|
||||
global _ee_initialized
|
||||
|
||||
from ee.service.license_service import load_and_validate_license
|
||||
|
||||
payload = await load_and_validate_license()
|
||||
|
||||
appmeta.IsPro = True
|
||||
_ee_initialized = True
|
||||
|
||||
l.info(
|
||||
f"Pro 版本已激活 — 域名: {payload.domain}, "
|
||||
f"过期: {payload.expires_at.isoformat()}, "
|
||||
f"功能: {payload.features}"
|
||||
)
|
||||
86
ee/license.py
Normal file
86
ee/license.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
RSA-PSS 许可证验签核心(编译为 .so 后公钥藏入二进制)
|
||||
|
||||
此文件只包含纯函数和常量,不包含 SQLModel 类。
|
||||
"""
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import orjson
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
|
||||
|
||||
_PUBLIC_KEY_PEM: bytes = b"""-----BEGIN PUBLIC KEY-----
|
||||
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAyNltXQ/Nuechx3kjj3T5
|
||||
oR6pZvTmpsDowqqxXJy7FXUI8d7XprhV+HrBQPsrT/Ngo9FwW3XyiK10m1WrzpGW
|
||||
eaf9990Z5Z2naEn5TzGrh71p/D7mZcNGVumo9uAuhtNEemm6xB3FoyGYZj7X0cwA
|
||||
VDvIiKAwYyRJX2LqVh1/tZM6tTO3oaGZXRMZzCNUPFSo4ZZudU3Boa5oQg08evu4
|
||||
vaOqeFrMX47R3MSUmO9hOh+NS53XNqO0f0zw5sv95CtyR5qvJ4gpkgYaRCSQFd19
|
||||
TnHU5saFVrH9jdADz1tdkMYcyYE+uJActZBapxCHSYB2tSCKWjDxeUFl/oY/ZFtY
|
||||
l4MNz1ovkjNhpmR3g+I5fbvN0cxDIjnZ9vJ84ozGqTGT9s1jHaLbpLri/vhuT4F2
|
||||
7kifXk8ImwtMZpZvzhmucH9/5VgcWKNuMATzEMif+YjFpuOGx8gc1XL1W/3q+dH0
|
||||
EFESp+/knjcVIfwpAkIKyV7XvDgFHsif1SeI0zZMW4utowVvGocP1ZzK5BGNTk2z
|
||||
CEtQDO7Rqo+UDckOJSG66VW3c2QO8o6uuy6fzx7q0MFEmUMwGf2iMVtR/KnXe99C
|
||||
enOT0BpU1EQvqssErUqivDss7jm98iD8M/TCE7pFboqZ+SC9G+QAqNIQNFWh8bWA
|
||||
R9hyXM/x5ysHd6MC4eEQnhMCAwEAAQ==
|
||||
-----END PUBLIC KEY-----"""
|
||||
|
||||
|
||||
class LicenseError(Exception):
|
||||
"""许可证验证基础异常"""
|
||||
|
||||
|
||||
class LicenseExpiredError(LicenseError):
|
||||
"""许可证已过期"""
|
||||
|
||||
|
||||
def verify_license(raw: str) -> dict:
|
||||
"""
|
||||
验证许可证字符串并返回载荷字典。
|
||||
|
||||
:param raw: 格式为 ``base64(json_payload).base64(signature)``
|
||||
:returns: 解析后的载荷字典
|
||||
:raises LicenseError: 格式无效或签名验证失败
|
||||
:raises LicenseExpiredError: 许可证已过期
|
||||
"""
|
||||
parts = raw.strip().split(".")
|
||||
if len(parts) != 2:
|
||||
raise LicenseError("许可证格式无效:需要 payload.signature")
|
||||
|
||||
payload_b64, signature_b64 = parts
|
||||
|
||||
try:
|
||||
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
||||
signature = base64.urlsafe_b64decode(signature_b64)
|
||||
except Exception as exc:
|
||||
raise LicenseError(f"许可证 base64 解码失败: {exc}") from exc
|
||||
|
||||
public_key = serialization.load_pem_public_key(_PUBLIC_KEY_PEM)
|
||||
|
||||
try:
|
||||
public_key.verify( # type: ignore[union-attr]
|
||||
signature,
|
||||
payload_bytes,
|
||||
padding.PSS(
|
||||
mgf=padding.MGF1(hashes.SHA256()),
|
||||
salt_length=padding.PSS.MAX_LENGTH,
|
||||
),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise LicenseError(f"许可证签名验证失败: {exc}") from exc
|
||||
|
||||
data: dict = orjson.loads(payload_bytes)
|
||||
|
||||
expires_at_str: str | None = data.get('expires_at')
|
||||
if not expires_at_str:
|
||||
raise LicenseError("许可证缺少 expires_at 字段")
|
||||
|
||||
expires_at = datetime.fromisoformat(expires_at_str)
|
||||
if expires_at < datetime.now(timezone.utc):
|
||||
raise LicenseExpiredError(
|
||||
f"许可证已过期: {expires_at.isoformat()}"
|
||||
)
|
||||
|
||||
return data
|
||||
6
ee/routers/__init__.py
Normal file
6
ee/routers/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .pro import router as pro_router
|
||||
|
||||
ee_router = APIRouter()
|
||||
ee_router.include_router(pro_router)
|
||||
69
ee/routers/pro/__init__.py
Normal file
69
ee/routers/pro/__init__.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Pro 版本状态端点
|
||||
|
||||
提供许可证状态查询,需管理员权限。
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ee.service import LicensePayload
|
||||
from ee.service.license_service import get_cached_license
|
||||
from middleware.auth import admin_required
|
||||
from sqlmodel_ext import SQLModelBase
|
||||
|
||||
router = APIRouter(prefix="/pro")
|
||||
|
||||
|
||||
class ProStatusResponse(SQLModelBase):
|
||||
"""Pro 版本状态响应"""
|
||||
|
||||
is_active: bool
|
||||
"""许可证是否有效"""
|
||||
|
||||
domain: str
|
||||
"""授权域名"""
|
||||
|
||||
expires_at: datetime
|
||||
"""过期时间"""
|
||||
|
||||
max_users: int
|
||||
"""最大用户数(0 = 无限制)"""
|
||||
|
||||
features: list[str]
|
||||
"""已授权的功能列表"""
|
||||
|
||||
|
||||
@router.get(
|
||||
'/status',
|
||||
dependencies=[Depends(admin_required)],
|
||||
)
|
||||
async def get_pro_status() -> ProStatusResponse:
|
||||
"""
|
||||
查询 Pro 版本许可证状态
|
||||
|
||||
认证:
|
||||
- JWT token in Authorization header
|
||||
- 需要管理员权限
|
||||
|
||||
响应:
|
||||
- ProStatusResponse: 当前许可证信息
|
||||
|
||||
错误处理:
|
||||
- HTTPException 401: 未授权
|
||||
- HTTPException 403: 非管理员
|
||||
- HTTPException 500: 许可证缓存异常
|
||||
"""
|
||||
payload: LicensePayload | None = get_cached_license()
|
||||
if not payload:
|
||||
# init_ee 成功后 payload 一定存在,此处做防御性编程
|
||||
from utils.http.http_exceptions import raise_internal_error
|
||||
raise_internal_error()
|
||||
|
||||
return ProStatusResponse(
|
||||
is_active=True,
|
||||
domain=payload.domain,
|
||||
expires_at=payload.expires_at,
|
||||
max_users=payload.max_users,
|
||||
features=payload.features,
|
||||
)
|
||||
30
ee/service/__init__.py
Normal file
30
ee/service/__init__.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
EE 许可证服务模块
|
||||
|
||||
提供许可证加载、验证和缓存功能。
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlmodel_ext import SQLModelBase
|
||||
|
||||
|
||||
class LicensePayload(SQLModelBase):
|
||||
"""许可证载荷(RSA 验签后的明文数据)"""
|
||||
|
||||
domain: str
|
||||
"""授权域名"""
|
||||
|
||||
expires_at: datetime
|
||||
"""过期时间(UTC)"""
|
||||
|
||||
max_users: int
|
||||
"""最大用户数(0 = 无限制)"""
|
||||
|
||||
features: list[str]
|
||||
"""已授权的功能列表"""
|
||||
|
||||
issued_at: datetime
|
||||
"""签发时间(UTC)"""
|
||||
|
||||
|
||||
from .license_service import get_cached_license, load_and_validate_license
|
||||
49
ee/service/license_service.py
Normal file
49
ee/service/license_service.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
许可证加载与缓存服务(编译为 .so)
|
||||
|
||||
从环境变量 LICENSE_KEY 或 license.key 文件加载许可证,
|
||||
调用 verify_license() 验证后缓存结果。
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
|
||||
from ee.license import LicenseError, verify_license
|
||||
from ee.service import LicensePayload
|
||||
|
||||
_cached_payload: LicensePayload | None = None
|
||||
|
||||
|
||||
async def load_and_validate_license() -> LicensePayload:
|
||||
"""
|
||||
加载并验证许可证,成功后缓存。
|
||||
|
||||
加载优先级:
|
||||
1. 环境变量 ``LICENSE_KEY``
|
||||
2. 项目根目录 ``license.key`` 文件
|
||||
|
||||
:returns: 验证通过的 LicensePayload
|
||||
:raises LicenseError: 未找到许可证 / 验证失败 / 已过期
|
||||
"""
|
||||
global _cached_payload
|
||||
|
||||
raw: str | None = os.getenv("LICENSE_KEY")
|
||||
|
||||
if not raw:
|
||||
key_path = Path("license.key")
|
||||
if key_path.is_file():
|
||||
async with aiofiles.open(key_path, 'r') as f:
|
||||
raw = (await f.read()).strip()
|
||||
|
||||
if not raw:
|
||||
raise LicenseError("未找到许可证:请设置 LICENSE_KEY 环境变量或提供 license.key 文件")
|
||||
|
||||
data = verify_license(raw)
|
||||
_cached_payload = LicensePayload.model_validate(data)
|
||||
return _cached_payload
|
||||
|
||||
|
||||
def get_cached_license() -> LicensePayload | None:
|
||||
"""获取已缓存的许可证载荷(未加载时返回 None)。"""
|
||||
return _cached_payload
|
||||
5
ee/sqlmodels/__init__.py
Normal file
5
ee/sqlmodels/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
EE 版本数据库模型
|
||||
|
||||
后续 Pro 功能的 SQLModel 定义位置。
|
||||
"""
|
||||
Reference in New Issue
Block a user