Compare commits
3 Commits
19837b4817
...
1ecc0fdc1c
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ecc0fdc1c | |||
| 71883d32c0 | |||
| ccadfe57cd |
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
|||||||
[submodule "ee"]
|
|
||||||
path = ee
|
|
||||||
url = https://git.yxqi.cn/Yuerchu/disknext-ee.git
|
|
||||||
1
ee
1
ee
Submodule ee deleted from 52921f9ffe
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