1d26646a96
- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。 - 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。 - 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。 - 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。 - 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。 - 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。 - 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。 - 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。 - 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。 - 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。 - 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。 - 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。
303 lines
10 KiB
Python
303 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
import hashlib
|
|
import hmac
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from anyio import to_thread
|
|
from fastapi import HTTPException, status
|
|
from jose import JWTError, jwt
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.config import settings
|
|
from app.core.security import hash_password, verify_password
|
|
from app.models.collaboration import CollaborationFile, CollaborationShareLink
|
|
from app.schemas.collaboration import (
|
|
CollaborationPublicShareMetadata,
|
|
CollaborationShareAccessGrant,
|
|
CollaborationShareLinkRead,
|
|
CollaborationShareLinkUpdate,
|
|
)
|
|
from app.services import collaboration_service
|
|
|
|
|
|
SHARE_PATH = "/collaboration/share"
|
|
SHARE_ACCESS_TTL_SECONDS = 30 * 60
|
|
PASSWORD_FAILURE_WINDOW = timedelta(minutes=15)
|
|
PASSWORD_LOCK_DURATION = timedelta(minutes=15)
|
|
PASSWORD_FAILURE_LIMIT = 5
|
|
_ACCESS_PURPOSE = "ctms-collaboration-share-access"
|
|
_EXPIRY_DURATIONS = {
|
|
"ONE_DAY": timedelta(days=1),
|
|
"SEVEN_DAYS": timedelta(days=7),
|
|
"THIRTY_DAYS": timedelta(days=30),
|
|
"PERMANENT": None,
|
|
}
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _signing_key() -> bytes:
|
|
return hmac.new(
|
|
settings.JWT_SECRET_KEY.encode("utf-8"),
|
|
b"ctms-collaboration-share-v1",
|
|
hashlib.sha256,
|
|
).digest()
|
|
|
|
|
|
def _b64encode(value: bytes) -> str:
|
|
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
|
|
|
|
|
def _b64decode(value: str) -> bytes:
|
|
padding = "=" * (-len(value) % 4)
|
|
return base64.urlsafe_b64decode(f"{value}{padding}".encode("ascii"))
|
|
|
|
|
|
def share_token(link: CollaborationShareLink) -> str:
|
|
payload = f"{link.id}.{link.token_version}".encode("ascii")
|
|
signature = hmac.new(_signing_key(), payload, hashlib.sha256).digest()
|
|
return f"{_b64encode(payload)}.{_b64encode(signature)}"
|
|
|
|
|
|
def _decode_share_token(value: str | None) -> tuple[uuid.UUID, int]:
|
|
token = (value or "").strip()
|
|
if not token or len(token) > 256 or token.count(".") != 1:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效")
|
|
encoded_payload, encoded_signature = token.split(".", 1)
|
|
try:
|
|
payload = _b64decode(encoded_payload)
|
|
actual_signature = _b64decode(encoded_signature)
|
|
if (
|
|
not hmac.compare_digest(_b64encode(payload), encoded_payload)
|
|
or not hmac.compare_digest(_b64encode(actual_signature), encoded_signature)
|
|
):
|
|
raise ValueError("non-canonical token encoding")
|
|
expected_signature = hmac.new(_signing_key(), payload, hashlib.sha256).digest()
|
|
if not hmac.compare_digest(actual_signature, expected_signature):
|
|
raise ValueError("signature mismatch")
|
|
raw_id, raw_version = payload.decode("ascii").split(".", 1)
|
|
return uuid.UUID(raw_id), int(raw_version)
|
|
except (ValueError, UnicodeError, TypeError, binascii.Error) as exc:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效") from exc
|
|
|
|
|
|
def _expiry_for_policy(policy: str, now: datetime) -> datetime | None:
|
|
duration = _EXPIRY_DURATIONS[policy]
|
|
return now + duration if duration else None
|
|
|
|
|
|
def _is_expired(link: CollaborationShareLink, now: datetime | None = None) -> bool:
|
|
return bool(link.expires_at and link.expires_at <= (now or _now()))
|
|
|
|
|
|
async def _link_for_file(
|
|
db: AsyncSession,
|
|
item: CollaborationFile,
|
|
user,
|
|
*,
|
|
create: bool,
|
|
lock: bool = False,
|
|
) -> CollaborationShareLink | None:
|
|
await collaboration_service.require_file_manager(db, item, user)
|
|
statement = select(CollaborationShareLink).where(CollaborationShareLink.file_id == item.id)
|
|
if lock:
|
|
statement = statement.with_for_update()
|
|
link = await db.scalar(statement)
|
|
if link or not create:
|
|
return link
|
|
now = _now()
|
|
link = CollaborationShareLink(
|
|
file_id=item.id,
|
|
enabled=False,
|
|
access_mode="VIEW",
|
|
expiry_policy="SEVEN_DAYS",
|
|
expires_at=now + timedelta(days=7),
|
|
created_by=user.id,
|
|
updated_by=user.id,
|
|
)
|
|
db.add(link)
|
|
await db.flush()
|
|
return link
|
|
|
|
|
|
def share_link_read(link: CollaborationShareLink) -> CollaborationShareLinkRead:
|
|
return CollaborationShareLinkRead(
|
|
id=link.id,
|
|
file_id=link.file_id,
|
|
enabled=link.enabled,
|
|
access_mode=link.access_mode,
|
|
expiry_policy=link.expiry_policy,
|
|
expires_at=link.expires_at,
|
|
has_password=bool(link.password_hash),
|
|
share_path=SHARE_PATH,
|
|
share_token=share_token(link) if link.enabled else None,
|
|
created_at=link.created_at,
|
|
updated_at=link.updated_at,
|
|
)
|
|
|
|
|
|
async def get_share_link(
|
|
db: AsyncSession, item: CollaborationFile, user
|
|
) -> CollaborationShareLinkRead:
|
|
link = await _link_for_file(db, item, user, create=True)
|
|
assert link is not None
|
|
await db.commit()
|
|
await db.refresh(link)
|
|
return share_link_read(link)
|
|
|
|
|
|
async def update_share_link(
|
|
db: AsyncSession,
|
|
item: CollaborationFile,
|
|
payload: CollaborationShareLinkUpdate,
|
|
user,
|
|
) -> CollaborationShareLinkRead:
|
|
link = await _link_for_file(db, item, user, create=True, lock=True)
|
|
assert link is not None
|
|
now = _now()
|
|
link.enabled = payload.enabled
|
|
link.access_mode = payload.access_mode
|
|
link.expiry_policy = payload.expiry_policy
|
|
link.expires_at = _expiry_for_policy(payload.expiry_policy, now)
|
|
link.updated_by = user.id
|
|
if payload.password_mode == "SET":
|
|
link.password_hash = await to_thread.run_sync(hash_password, payload.password or "")
|
|
link.failed_attempts = 0
|
|
link.last_failed_at = None
|
|
link.locked_until = None
|
|
elif payload.password_mode == "CLEAR":
|
|
link.password_hash = None
|
|
link.failed_attempts = 0
|
|
link.last_failed_at = None
|
|
link.locked_until = None
|
|
await collaboration_service._audit(
|
|
db,
|
|
item,
|
|
"COLLABORATION_SHARE_LINK_UPDATED",
|
|
user,
|
|
{
|
|
"enabled": link.enabled,
|
|
"access_mode": link.access_mode,
|
|
"file_allow_export": item.allow_export,
|
|
"expiry_policy": link.expiry_policy,
|
|
"has_password": bool(link.password_hash),
|
|
},
|
|
)
|
|
await db.commit()
|
|
await db.refresh(link)
|
|
return share_link_read(link)
|
|
|
|
|
|
async def resolve_active_share(
|
|
db: AsyncSession,
|
|
token: str | None,
|
|
*,
|
|
lock: bool = False,
|
|
) -> tuple[CollaborationShareLink, CollaborationFile]:
|
|
link_id, version = _decode_share_token(token)
|
|
statement = select(CollaborationShareLink).where(CollaborationShareLink.id == link_id)
|
|
if lock:
|
|
statement = statement.with_for_update()
|
|
link = await db.scalar(statement)
|
|
if not link or link.token_version != version or not link.enabled:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享链接不存在或已失效")
|
|
if _is_expired(link):
|
|
raise HTTPException(status_code=status.HTTP_410_GONE, detail="共享链接已过期")
|
|
item = await db.get(CollaborationFile, link.file_id)
|
|
if not item or item.deleted_at or item.status != "ACTIVE":
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="共享文件不存在或已停止共享")
|
|
return link, item
|
|
|
|
|
|
async def public_metadata(
|
|
db: AsyncSession, token: str | None
|
|
) -> CollaborationPublicShareMetadata:
|
|
link, item = await resolve_active_share(db, token)
|
|
return CollaborationPublicShareMetadata(
|
|
file_name=item.title,
|
|
file_type=item.file_type,
|
|
access_mode="edit" if link.access_mode == "EDIT" else "view",
|
|
allow_export=item.allow_export,
|
|
requires_password=bool(link.password_hash),
|
|
expires_at=link.expires_at,
|
|
)
|
|
|
|
|
|
def _grant_token(link: CollaborationShareLink) -> CollaborationShareAccessGrant:
|
|
now = _now()
|
|
expires_at = now + timedelta(seconds=SHARE_ACCESS_TTL_SECONDS)
|
|
if link.expires_at and link.expires_at < expires_at:
|
|
expires_at = link.expires_at
|
|
value = jwt.encode(
|
|
{
|
|
"purpose": _ACCESS_PURPOSE,
|
|
"sub": str(link.id),
|
|
"ver": link.token_version,
|
|
"iat": int(now.timestamp()),
|
|
"exp": int(expires_at.timestamp()),
|
|
},
|
|
_signing_key().hex(),
|
|
algorithm="HS256",
|
|
)
|
|
return CollaborationShareAccessGrant(access_token=value, expires_at=expires_at)
|
|
|
|
|
|
async def verify_share_password(
|
|
db: AsyncSession,
|
|
token: str | None,
|
|
password: str,
|
|
) -> CollaborationShareAccessGrant:
|
|
link, _ = await resolve_active_share(db, token, lock=True)
|
|
if not link.password_hash:
|
|
return _grant_token(link)
|
|
now = _now()
|
|
if link.locked_until and link.locked_until > now:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail="密码尝试次数过多,请稍后再试",
|
|
)
|
|
if link.last_failed_at and now - link.last_failed_at > PASSWORD_FAILURE_WINDOW:
|
|
link.failed_attempts = 0
|
|
valid = await to_thread.run_sync(verify_password, password, link.password_hash)
|
|
if not valid:
|
|
link.failed_attempts += 1
|
|
link.last_failed_at = now
|
|
if link.failed_attempts >= PASSWORD_FAILURE_LIMIT:
|
|
link.locked_until = now + PASSWORD_LOCK_DURATION
|
|
await db.commit()
|
|
if link.locked_until:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail="密码尝试次数过多,请稍后再试",
|
|
)
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="链接密码不正确")
|
|
link.failed_attempts = 0
|
|
link.last_failed_at = None
|
|
link.locked_until = None
|
|
await db.commit()
|
|
return _grant_token(link)
|
|
|
|
|
|
def validate_access_grant(link: CollaborationShareLink, value: str | None) -> None:
|
|
if not link.password_hash:
|
|
return
|
|
if not value:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请输入链接密码")
|
|
try:
|
|
payload = jwt.decode(value, _signing_key().hex(), algorithms=["HS256"])
|
|
if (
|
|
payload.get("purpose") != _ACCESS_PURPOSE
|
|
or not hmac.compare_digest(str(payload.get("sub") or ""), str(link.id))
|
|
or payload.get("ver") != link.token_version
|
|
):
|
|
raise JWTError("share grant mismatch")
|
|
except JWTError as exc:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="链接访问凭证已失效") from exc
|