Compare commits

..

3 Commits

Author SHA1 Message Date
1ecc0fdc1c feat: implement source link endpoints and enforce policy rules
All checks were successful
Test / test (push) Successful in 1m56s
- Add POST/GET source link endpoints for file sharing via permanent URLs
- Enforce max_size check in PATCH /file/content to prevent size limit bypass
- Support is_private (proxy) vs public (302 redirect) storage modes
- Replace all ResponseBase(data=...) with proper DTOs or 204 responses
- Add 18 integration tests for source link and policy rule enforcement

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:07:20 +08:00
71883d32c0 feat: add PATCH /user/settings/password endpoint for changing password
All checks were successful
Test / test (push) Successful in 1m43s
Register the fixed /password route before the wildcard /{option} to
prevent FastAPI from matching it as a path parameter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 15:11:56 +08:00
ccadfe57cd feat: migrate ORM base to sqlmodel-ext, add file viewers and WOPI integration
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>
2026-02-14 14:23:17 +08:00
9 changed files with 287 additions and 4 deletions

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "ee"]
path = ee
url = https://git.yxqi.cn/Yuerchu/disknext-ee.git

1
ee

Submodule ee deleted from 52921f9ffe

42
ee/__init__.py Normal file
View 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
View 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
View 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)

View 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
View 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

View 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
View File

@@ -0,0 +1,5 @@
"""
EE 版本数据库模型
后续 Pro 功能的 SQLModel 定义位置。
"""