uploader-bot/app/core/access/content_access_manager.py

169 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import base64
import json
import logging
import os
import secrets
import time
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Dict, Optional, Tuple, Callable
from app.core._blockchain.ton.nft_license_manager import NFTLicenseManager
from app.core.crypto.content_cipher import ContentCipher
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class StreamingToken:
token: str
content_id: str
owner_address: str
issued_at: float
expires_at: float
def is_valid(self, now: Optional[float] = None) -> bool:
now = now or time.time()
return now < self.expires_at
class ContentAccessManager:
"""
Управление доступом к зашифрованному контенту по NFT лицензиям в TON.
Обязанности:
- grant_access(): принять tonProof + content_id, проверить лицензию, выдать временный токен
- verify_access(): валидация токена при запросе стрима/скачивания
- create_streaming_token(): генерация подписанного/непредсказуемого токена с TTL
- stream/decrypt: интеграция с ContentCipher — расшифровка возможна только при валидной лицензии/токене
"""
DEFAULT_TOKEN_TTL_SEC = int(os.getenv("STREAM_TOKEN_TTL_SEC", "600")) # 10 минут по умолчанию
def __init__(
self,
nft_manager: Optional[NFTLicenseManager] = None,
cipher: Optional[ContentCipher] = None,
):
self.nft_manager = nft_manager or NFTLicenseManager()
self.cipher = cipher or ContentCipher()
# Простой in-memory storage токенов. Для продакшена стоит заменить на Redis или БД.
self._tokens: Dict[str, StreamingToken] = {}
logger.debug("ContentAccessManager initialized; token_ttl=%s", self.DEFAULT_TOKEN_TTL_SEC)
def create_streaming_token(self, content_id: str, owner_address: str, ttl_sec: Optional[int] = None) -> StreamingToken:
ttl = ttl_sec or self.DEFAULT_TOKEN_TTL_SEC
token = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("ascii").rstrip("=")
now = time.time()
st = StreamingToken(
token=token,
content_id=content_id,
owner_address=owner_address,
issued_at=now,
expires_at=now + ttl,
)
self._tokens[token] = st
logger.info("Streaming token issued content_id=%s owner=%s ttl=%s", content_id, owner_address, ttl)
return st
def verify_access(self, token: str, content_id: str) -> Tuple[bool, Optional[str], Optional[StreamingToken]]:
if not token:
return False, "Missing token", None
st = self._tokens.get(token)
if not st:
return False, "Token not found", None
if not st.is_valid():
# Удаляем просроченный
self._tokens.pop(token, None)
return False, "Token expired", None
if st.content_id != content_id:
return False, "Token/content mismatch", None
logger.debug("Streaming token verified for content_id=%s owner=%s", st.content_id, st.owner_address)
return True, None, st
async def grant_access(
self,
ton_proof: Dict[str, Any],
content_id: str,
nft_address: Optional[str] = None,
token_ttl_sec: Optional[int] = None,
) -> Tuple[bool, Optional[str], Optional[Dict[str, Any]]]:
"""
Композитный сценарий: валидируем tonProof, проверяем владение NFT лицензией,
создаем временный токен для стриминга.
Возвращает: (ok, error, payload)
payload: { token, expires_at, owner_address, nft_item }
"""
try:
ok, err, nft_item = await self.nft_manager.check_license_validity(
ton_proof=ton_proof,
content_id=content_id,
nft_address=nft_address,
)
if not ok:
return False, err, None
owner_address = nft_proof_owner(ton_proof)
token = self.create_streaming_token(content_id, owner_address, token_ttl_sec)
payload = {
"token": token.token,
"expires_at": token.expires_at,
"owner_address": token.owner_address,
"nft_item": nft_item,
}
return True, None, payload
except Exception as e:
logger.exception("grant_access failed")
return False, str(e), None
def decrypt_for_stream(
self,
encrypted_obj: Dict[str, Any],
content_key_provider: Callable[[str], bytes],
token: str,
content_id: str,
associated_data: Optional[bytes] = None,
) -> Tuple[bool, Optional[str], Optional[bytes]]:
"""
Расшифровка данных для стрима. Требует валидного стрим-токена.
content_key_provider(content_id) -> bytes (32)
"""
ok, err, st = self.verify_access(token, content_id)
if not ok:
return False, err, None
try:
# В идеале проверяем целостность до расшифровки
# Здесь можем опционально вызвать verify_content_integrity, если есть сигнатуры
# Но основной критерий — валидный токен.
key = content_key_provider(content_id)
pt = self.cipher.decrypt_content(
ciphertext_b64=encrypted_obj["ciphertext_b64"],
nonce_b64=encrypted_obj["nonce_b64"],
tag_b64=encrypted_obj["tag_b64"],
key=key,
associated_data=associated_data,
)
logger.info("Decryption for stream succeeded content_id=%s owner=%s", content_id, st.owner_address)
return True, None, pt
except Exception as e:
logger.exception("decrypt_for_stream failed")
return False, str(e), None
def nft_proof_owner(ton_proof: Dict[str, Any]) -> str:
"""
Извлечь адрес владельца из структуры tonProof запроса клиента.
Совместимо с TonConnect unpack_wallet_info формой.
"""
# Поддержка как плоской формы, так и вложенной ton_proof
if "address" in ton_proof:
return ton_proof["address"]
if "account" in ton_proof and ton_proof["account"] and "address" in ton_proof["account"]:
return ton_proof["account"]["address"]
if "ton_proof" in ton_proof and ton_proof["ton_proof"] and "address" in ton_proof["ton_proof"]:
return ton_proof["ton_proof"]["address"]
# В противном случае бросаем: пусть вызывающий слой отловит
raise ValueError("Cannot extract owner address from ton_proof")