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