fixes global
This commit is contained in:
1 parent
13dc4f39c8
commit
cad0f6aebe
64 files changed
+10379
-254
No files matched your search
@@ -0,0 +1,176 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse, JSONResponse
|
||||
|
||||
from app.core.access.content_access_manager import ContentAccessManager
|
||||
from app.core._blockchain.ton.nft_license_manager import NFTLicenseManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/content", tags=["content-access"])
|
||||
|
||||
|
||||
def _json_ok(data: Dict[str, Any]) -> JSONResponse:
|
||||
return JSONResponse({"success": True, "data": data})
|
||||
|
||||
|
||||
@router.post("/request-access")
|
||||
async def request_access(body: Dict[str, Any]):
|
||||
"""
|
||||
POST /api/content/request-access
|
||||
Тело:
|
||||
{
|
||||
"content_id": "sha256...",
|
||||
"ton_proof": {
|
||||
"address": "...", "public_key": "...", "timestamp": 0,
|
||||
"domain_val": "...", "domain_len": 0, "payload": "...", "signature": "..."
|
||||
},
|
||||
"nft_address": "EQ...." (optional),
|
||||
"token_ttl_sec": 600 (optional)
|
||||
}
|
||||
Ответ:
|
||||
{"success": true, "data": {"token": "...", "expires_at": 0, "owner_address": "...", "nft_item": {...}}}
|
||||
"""
|
||||
try:
|
||||
content_id = body.get("content_id")
|
||||
ton_proof = body.get("ton_proof") or {}
|
||||
nft_address = body.get("nft_address")
|
||||
token_ttl_sec = body.get("token_ttl_sec")
|
||||
|
||||
if not content_id:
|
||||
raise HTTPException(status_code=400, detail="content_id is required")
|
||||
if not ton_proof:
|
||||
raise HTTPException(status_code=400, detail="ton_proof is required")
|
||||
|
||||
mgr = ContentAccessManager(nft_manager=NFTLicenseManager())
|
||||
ok, err, payload = await mgr.grant_access(
|
||||
ton_proof=ton_proof,
|
||||
content_id=content_id,
|
||||
nft_address=nft_address,
|
||||
token_ttl_sec=token_ttl_sec,
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=403, detail=err or "Access denied")
|
||||
|
||||
return _json_ok(payload)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("request_access failed")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/verify-license")
|
||||
async def verify_license(body: Dict[str, Any]):
|
||||
"""
|
||||
POST /api/content/verify-license
|
||||
Тело:
|
||||
{
|
||||
"content_id": "sha256...",
|
||||
"ton_proof": { ... as above ... },
|
||||
"nft_address": "EQ...." (optional)
|
||||
}
|
||||
Ответ:
|
||||
{"success": true, "data": {"valid": true, "owner_address": "...", "nft_item": {...}}}
|
||||
"""
|
||||
try:
|
||||
content_id = body.get("content_id")
|
||||
ton_proof = body.get("ton_proof") or {}
|
||||
nft_address = body.get("nft_address")
|
||||
|
||||
if not content_id:
|
||||
raise HTTPException(status_code=400, detail="content_id is required")
|
||||
if not ton_proof:
|
||||
raise HTTPException(status_code=400, detail="ton_proof is required")
|
||||
|
||||
nft_mgr = NFTLicenseManager()
|
||||
ok, err, nft_item = await nft_mgr.check_license_validity(
|
||||
ton_proof=ton_proof, content_id=content_id, nft_address=nft_address
|
||||
)
|
||||
if not ok:
|
||||
return _json_ok({"valid": False, "error": err})
|
||||
|
||||
# Извлечем адрес владельца для удобства клиента
|
||||
owner_address = None
|
||||
try:
|
||||
# небольшой импорт без цикла, чтобы не тянуть все сверху
|
||||
from app.core.access.content_access_manager import nft_proof_owner # noqa
|
||||
owner_address = nft_proof_owner(ton_proof)
|
||||
except Exception:
|
||||
owner_address = None
|
||||
|
||||
return _json_ok({"valid": True, "owner_address": owner_address, "nft_item": nft_item})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("verify_license failed")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/stream/{content_id}")
|
||||
async def stream_content(
|
||||
request: Request,
|
||||
content_id: str,
|
||||
token: str = Query(..., description="Временный токен, полученный через /request-access"),
|
||||
):
|
||||
"""
|
||||
GET /api/content/stream/{content_id}?token=...
|
||||
Возвращает поток расшифрованного контента при валидном временном токене.
|
||||
|
||||
Примечание:
|
||||
- Здесь требуется провайдер ключа контента (content_key_provider), который по content_id вернет 32-байтовый ключ.
|
||||
В текущем сервисе ключ не выдается из NFT, он хранится в ноде/сети вне блокчейна и не возвращается клиенту.
|
||||
- В данном роуте показан каркас: откуда читать зашифрованные данные (encrypted_obj) зависит от вашей БД/фс.
|
||||
"""
|
||||
try:
|
||||
mgr = ContentAccessManager()
|
||||
|
||||
# Заглушка чтения зашифрованного объекта контента.
|
||||
# Здесь нужно интегрировать фактическое хранилище, например БД/файловую систему, и извлечь объект,
|
||||
# совместимый с ContentCipher.decrypt_content входом.
|
||||
# Формат encrypted_obj:
|
||||
# {
|
||||
# "ciphertext_b64": "...",
|
||||
# "nonce_b64": "...",
|
||||
# "tag_b64": "...",
|
||||
# "metadata": {...},
|
||||
# "content_id": "sha256..."
|
||||
# }
|
||||
encrypted_obj: Optional[Dict[str, Any]] = None
|
||||
|
||||
if not encrypted_obj:
|
||||
raise HTTPException(status_code=404, detail="Encrypted content not found")
|
||||
|
||||
# Провайдер ключа шифрования по content_id — внедрите вашу реализацию
|
||||
def content_key_provider(cid: str) -> bytes:
|
||||
# Должен вернуть 32-байтовый ключ (из secure-хранилища узла)
|
||||
# raise NotImplementedError / или извлечение из KMS/базы
|
||||
raise HTTPException(status_code=501, detail="content_key_provider is not configured")
|
||||
|
||||
ok, err, pt = mgr.decrypt_for_stream(
|
||||
encrypted_obj=encrypted_obj,
|
||||
content_key_provider=content_key_provider,
|
||||
token=token,
|
||||
content_id=content_id,
|
||||
associated_data=None,
|
||||
)
|
||||
if not ok or pt is None:
|
||||
raise HTTPException(status_code=403, detail=err or "Access denied")
|
||||
|
||||
async def stream_bytes():
|
||||
# Простейшая потоковая отдача всего буфера.
|
||||
# Для больших данных отдавайте чанками.
|
||||
yield pt
|
||||
|
||||
# Тип контента может определяться по metadata или по хранимому mime-type
|
||||
return StreamingResponse(stream_bytes(), media_type="application/octet-stream")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("stream_content failed")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
|
||||
from app.core.converter.conversion_manager import ConversionManager
|
||||
from app.core.models.converter.conversion_models import (
|
||||
ContentMetadata,
|
||||
ConversionPriority,
|
||||
ConversionStatus,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/converter", tags=["converter"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Глобальный singleton менеджера (можно заменить DI контейнером)
|
||||
_conversion_manager: Optional[ConversionManager] = None
|
||||
|
||||
|
||||
def get_manager() -> ConversionManager:
|
||||
global _conversion_manager
|
||||
if _conversion_manager is None:
|
||||
_conversion_manager = ConversionManager()
|
||||
return _conversion_manager
|
||||
|
||||
|
||||
@router.post("/submit")
|
||||
async def submit_conversion(
|
||||
file: UploadFile = File(...),
|
||||
title: str = Form(...),
|
||||
description: Optional[str] = Form(None),
|
||||
author: Optional[str] = Form(None),
|
||||
collection: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None), # CSV
|
||||
language: Optional[str] = Form(None),
|
||||
explicit: Optional[bool] = Form(None),
|
||||
quality: str = Form("high"), # "high" | "low"
|
||||
input_ext: Optional[str] = Form(None), # если неизвестно — попытаемся из файла
|
||||
priority: int = Form(50),
|
||||
trim: Optional[str] = Form(None),
|
||||
custom: Optional[str] = Form(None), # произвольные ffmpeg-параметры через пробел
|
||||
):
|
||||
"""
|
||||
Принимает файл и ставит задачу конвертации в очередь.
|
||||
Возвращает task_id.
|
||||
"""
|
||||
try:
|
||||
# Сохраняем входной файл во временное хранилище uploader-bot
|
||||
uploads_dir = "uploader-bot/uploader-bot/data/uploads"
|
||||
os.makedirs(uploads_dir, exist_ok=True)
|
||||
input_name = file.filename or f"upload-{uuid.uuid4().hex}"
|
||||
local_path = os.path.join(uploads_dir, input_name)
|
||||
|
||||
with open(local_path, "wb") as f:
|
||||
f.write(await file.read())
|
||||
|
||||
# Определяем расширение, если не передано
|
||||
in_ext = input_ext or os.path.splitext(input_name)[1].lstrip(".").lower() or "bin"
|
||||
|
||||
# Метаданные
|
||||
md = ContentMetadata(
|
||||
title=title,
|
||||
description=description,
|
||||
author=author,
|
||||
collection=collection,
|
||||
tags=[t.strip() for t in (tags.split(","))] if tags else [],
|
||||
language=language,
|
||||
explicit=explicit,
|
||||
attributes={},
|
||||
)
|
||||
|
||||
prio = ConversionPriority.NORMAL
|
||||
try:
|
||||
# нормализуем диапазон int -> enum
|
||||
p_int = int(priority)
|
||||
if p_int >= ConversionPriority.CRITICAL:
|
||||
prio = ConversionPriority.CRITICAL
|
||||
elif p_int >= ConversionPriority.HIGH:
|
||||
prio = ConversionPriority.HIGH
|
||||
elif p_int >= ConversionPriority.NORMAL:
|
||||
prio = ConversionPriority.NORMAL
|
||||
else:
|
||||
prio = ConversionPriority.LOW
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
custom_list: List[str] = []
|
||||
if custom:
|
||||
# Разбиваем по пробелам, без сложного парсинга
|
||||
custom_list = [c for c in custom.split(" ") if c]
|
||||
|
||||
manager = get_manager()
|
||||
task_id = await manager.process_upload(
|
||||
local_input_path=local_path,
|
||||
input_ext=in_ext,
|
||||
quality="high" if quality == "high" else "low",
|
||||
metadata=md,
|
||||
priority=prio,
|
||||
custom=custom_list,
|
||||
trim=trim,
|
||||
)
|
||||
|
||||
return JSONResponse({"task_id": task_id})
|
||||
except Exception as e:
|
||||
logger.exception("submit_conversion failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/status/{task_id}")
|
||||
async def get_status(task_id: str):
|
||||
"""
|
||||
Возвращает статус задачи.
|
||||
"""
|
||||
try:
|
||||
manager = get_manager()
|
||||
status = await manager.get_conversion_status(task_id)
|
||||
return JSONResponse({"task_id": task_id, "status": status.value})
|
||||
except Exception as e:
|
||||
logger.exception("get_status failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/result/{task_id}")
|
||||
async def get_result(task_id: str):
|
||||
"""
|
||||
Возвращает результат задачи с content_id, чанками и nft метаданными.
|
||||
"""
|
||||
try:
|
||||
manager = get_manager()
|
||||
res = await manager.handle_conversion_result(task_id)
|
||||
if not res:
|
||||
# если задача всё ещё идёт/в очереди
|
||||
status = await manager.get_conversion_status(task_id)
|
||||
if status in (ConversionStatus.QUEUED, ConversionStatus.RUNNING):
|
||||
return JSONResponse({"task_id": task_id, "status": status.value})
|
||||
raise HTTPException(status_code=404, detail="result not ready")
|
||||
return JSONResponse(res.to_dict())
|
||||
except Exception as e:
|
||||
logger.exception("get_result failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,266 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Depends, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.crypto import get_ed25519_manager
|
||||
from app.core.content.chunk_manager import ChunkManager
|
||||
from app.core.content.sync_manager import ContentSyncManager
|
||||
from app.core.models.content.chunk import ContentChunk
|
||||
from app.core.models.api.sync_models import (
|
||||
ContentRequest,
|
||||
ContentProvideResponse,
|
||||
ContentStatusResponse,
|
||||
ContentVerifyRequest,
|
||||
)
|
||||
from app.core.validation.content_validator import ContentValidator
|
||||
from app.core.validation.integrity_checker import IntegrityChecker
|
||||
from app.core.validation.trust_manager import TrustManager
|
||||
from app.core.models.validation.validation_models import ContentSignature, ValidationResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/node/content", tags=["node-content-sync"])
|
||||
|
||||
# Глобальные вспомогательные объекты (можно заменить DI при необходимости)
|
||||
_trust_manager = TrustManager()
|
||||
_content_validator = ContentValidator()
|
||||
_integrity_checker = IntegrityChecker()
|
||||
|
||||
|
||||
async def _verify_inter_node_request(request: Request) -> Dict[str, Any]:
|
||||
"""
|
||||
Проверка заголовков и Ed25519 подписи межузлового запроса.
|
||||
Используем ту же схему заголовков, что и в fastapi_node_routes.
|
||||
Дополнительно — первичная фильтрация по доверию ноды (blacklist/override/score).
|
||||
"""
|
||||
required_headers = ["x-node-communication", "x-node-id", "x-node-public-key", "x-node-signature"]
|
||||
for header in required_headers:
|
||||
if header not in request.headers:
|
||||
logger.warning("Missing header on inter-node request: %s", header)
|
||||
raise HTTPException(status_code=400, detail=f"Missing required header: {header}")
|
||||
|
||||
if request.headers.get("x-node-communication") != "true":
|
||||
raise HTTPException(status_code=400, detail="Not a valid inter-node communication")
|
||||
|
||||
body = await request.body()
|
||||
if not body:
|
||||
raise HTTPException(status_code=400, detail="Empty message body")
|
||||
|
||||
try:
|
||||
message_data = json.loads(body.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON in request body")
|
||||
|
||||
signature = request.headers.get("x-node-signature")
|
||||
node_id = request.headers.get("x-node-id")
|
||||
public_key = request.headers.get("x-node-public-key")
|
||||
|
||||
# Проверка подписи межузлового сообщения
|
||||
crypto_manager = get_ed25519_manager()
|
||||
is_valid = crypto_manager.verify_signature(message_data, signature, public_key)
|
||||
if not is_valid:
|
||||
logger.warning("Invalid signature from node %s", node_id)
|
||||
# При невалидной подписи сразу штрафуем доверие и отклоняем
|
||||
_trust_manager.update_trust_score(node_id, delta=-0.2, reason="invalid_inter_node_signature")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid cryptographic signature")
|
||||
|
||||
# Обновим доверие за валидную подпись
|
||||
_trust_manager.update_trust_score(node_id, delta=0.02, reason="valid_inter_node_signature")
|
||||
|
||||
# Проверка blacklist/override/минимального порога
|
||||
if not _trust_manager.is_node_trusted(node_id):
|
||||
logger.warning("Request rejected by trust policy: node_id=%s", node_id)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Untrusted node")
|
||||
|
||||
request.state.inter_node_communication = True
|
||||
request.state.source_node_id = node_id
|
||||
request.state.source_public_key = public_key
|
||||
return {"node_id": node_id, "public_key": public_key, "message": message_data}
|
||||
|
||||
|
||||
def _create_signed_response(data: Dict[str, Any]) -> JSONResponse:
|
||||
"""Формирование подписанного ответа и стандартных заголовков межузлового взаимодействия."""
|
||||
crypto_manager = get_ed25519_manager()
|
||||
payload = {
|
||||
"success": True,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"node_id": crypto_manager.node_id,
|
||||
"data": data,
|
||||
}
|
||||
signature = crypto_manager.sign_message(payload)
|
||||
headers = {
|
||||
"X-Node-ID": crypto_manager.node_id,
|
||||
"X-Node-Public-Key": crypto_manager.public_key_hex,
|
||||
"X-Node-Communication": "true",
|
||||
"X-Node-Signature": signature,
|
||||
}
|
||||
return JSONResponse(content=payload, headers=headers)
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
async def node_content_sync(request: Request, body: ContentRequest):
|
||||
"""
|
||||
POST /api/node/content/sync
|
||||
Универсальный endpoint для межузловой синхронизации чанков.
|
||||
|
||||
Поддерживаемые сценарии:
|
||||
- sync_type == "content_request": получить набор чанков по content_id и списку индексов
|
||||
ожидается content_info: { content_id: str, indexes: List[int] }
|
||||
Ответ: ContentProvideResponse со списком чанков (валидированные и подписанные при создании).
|
||||
- sync_type == "new_content": уведомление о новом контенте (пока лишь логируем, ок подтверждаем)
|
||||
- sync_type == "content_list": запрос списка контента (пока возвращаем пусто)
|
||||
"""
|
||||
# Проверка подписи и доверия запроса
|
||||
ctx = await _verify_inter_node_request(request)
|
||||
source_node_id = ctx["node_id"]
|
||||
|
||||
sync_mgr = ContentSyncManager()
|
||||
chunk_mgr = sync_mgr.chunk_manager
|
||||
|
||||
try:
|
||||
if body.sync_type == "content_request":
|
||||
content_info = body.content_info
|
||||
content_id = content_info["content_id"]
|
||||
indexes: List[int] = list(map(int, content_info["indexes"]))
|
||||
|
||||
# Локальный storage_reader. В реальном проекте заменить на обращение к хранилищу чанков.
|
||||
def storage_reader(cid: str, idx: int) -> Optional[ContentChunk]:
|
||||
# Здесь можно реализовать доступ к БД/файловой системе. Пока возвращаем None.
|
||||
return None
|
||||
|
||||
provided = await sync_mgr.provide_chunks(
|
||||
content_id=content_id,
|
||||
indexes=indexes,
|
||||
storage_reader=storage_reader,
|
||||
)
|
||||
# Доп. защита: прогоняем полученные чанки через IntegrityChecker (если есть)
|
||||
chunks_models: List[ContentChunk] = []
|
||||
for c in provided.get("chunks", []):
|
||||
try:
|
||||
chunks_models.append(ContentChunk.from_dict(c))
|
||||
except Exception as e:
|
||||
logger.error("content_request: invalid provided chunk from storage: %s", e)
|
||||
|
||||
if chunks_models:
|
||||
chain_result = _integrity_checker.verify_content_chain(chunks_models, verify_signatures=True)
|
||||
if not chain_result.ok:
|
||||
logger.warning("integrity check failed for provided chunks: %s", chain_result.reason)
|
||||
# Понижаем доверие источнику запроса (как попытка манипуляции/атаки)
|
||||
_trust_manager.update_trust_score(source_node_id, delta=-0.05, reason="invalid_chain_on_provide")
|
||||
|
||||
# Pydantic-ответ
|
||||
resp = ContentProvideResponse(
|
||||
success=True,
|
||||
chunks=[c.to_dict() for c in chunks_models],
|
||||
errors=provided.get("errors", []),
|
||||
)
|
||||
return _create_signed_response(resp.dict())
|
||||
|
||||
elif body.sync_type == "new_content":
|
||||
# Нода сообщает о новом контенте — можно валидировать метаданные/подписи при наличии
|
||||
logger.info("new_content received: %s", body.content_info)
|
||||
_trust_manager.update_trust_score(source_node_id, delta=0.01, reason="announce_new_content")
|
||||
return _create_signed_response({"sync_result": "ack", "info": body.content_info})
|
||||
|
||||
elif body.sync_type == "content_list":
|
||||
return _create_signed_response({"content_list": [], "total_items": 0})
|
||||
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown sync_type: {body.sync_type}")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("node_content_sync error")
|
||||
_trust_manager.update_trust_score(source_node_id, delta=-0.02, reason="sync_handler_exception")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/status/{content_id}")
|
||||
async def node_content_status(content_id: str):
|
||||
"""
|
||||
GET /api/node/content/status/{content_id}
|
||||
Вернуть статус хранения контента на ноде:
|
||||
- какие индексы имеются
|
||||
- какие отсутствуют
|
||||
- общий ожидаемый total_chunks (если известен; иначе 0)
|
||||
"""
|
||||
try:
|
||||
have_indexes: List[int] = []
|
||||
total_chunks = 0
|
||||
missing = sorted(set(range(total_chunks)) - set(have_indexes)) if total_chunks else []
|
||||
|
||||
resp = ContentStatusResponse(
|
||||
content_id=content_id,
|
||||
total_chunks=total_chunks,
|
||||
have_indexes=have_indexes,
|
||||
missing_indexes=missing,
|
||||
verified=None,
|
||||
message="ok",
|
||||
)
|
||||
return resp.dict()
|
||||
except Exception as e:
|
||||
logger.exception("node_content_status error")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/verify")
|
||||
async def node_content_verify(request: Request, body: ContentVerifyRequest):
|
||||
"""
|
||||
POST /api/node/content/verify
|
||||
Проверка валидности набора чанков (хеш и Ed25519 подпись каждой записи),
|
||||
а также расширенная проверка целостности цепочки чанков и оценка доверия источнику.
|
||||
"""
|
||||
ctx = await _verify_inter_node_request(request)
|
||||
source_node_id = ctx["node_id"]
|
||||
source_pubkey = ctx["public_key"]
|
||||
|
||||
try:
|
||||
chunk_mgr = ChunkManager()
|
||||
errors: List[Dict[str, Any]] = []
|
||||
ok_count = 0
|
||||
chunks_models: List[ContentChunk] = []
|
||||
|
||||
for ch in body.chunks:
|
||||
try:
|
||||
model = ContentChunk.from_dict(ch.dict())
|
||||
chunks_models.append(model)
|
||||
ok, err = chunk_mgr.verify_chunk_integrity(model, verify_signature=body.verify_signatures)
|
||||
if not ok:
|
||||
errors.append({"chunk_id": model.chunk_id, "error": err})
|
||||
else:
|
||||
ok_count += 1
|
||||
except Exception as ce:
|
||||
logger.error("verify: failed to parse/validate chunk", extra={"error": str(ce)})
|
||||
errors.append({"error": str(ce), "chunk_ref": ch.dict()})
|
||||
|
||||
# Дополнительно проверим целостность всей цепочки
|
||||
if chunks_models:
|
||||
chain_res = _integrity_checker.verify_content_chain(chunks_models, verify_signatures=body.verify_signatures)
|
||||
if not chain_res.ok:
|
||||
errors.append({"chain_error": chain_res.reason, "details": chain_res.details})
|
||||
|
||||
# Итоговая оценка доверия по исходу операции
|
||||
if errors:
|
||||
_trust_manager.update_trust_score(source_node_id, delta=-0.05, reason="verify_errors_detected")
|
||||
else:
|
||||
_trust_manager.update_trust_score(source_node_id, delta=0.02, reason="verify_ok")
|
||||
|
||||
result = {
|
||||
"verified_ok": ok_count,
|
||||
"errors": errors,
|
||||
"trust": _trust_manager.assess_node_trust(source_node_id).to_dict(),
|
||||
}
|
||||
return _create_signed_response(result)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("node_content_verify error")
|
||||
_trust_manager.update_trust_score(source_node_id, delta=-0.02, reason="verify_exception")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.crypto import get_ed25519_manager
|
||||
from app.core.content.chunk_manager import ChunkManager
|
||||
from app.core.models.api.stats_models import (
|
||||
NodeHealthResponse,
|
||||
NodeContentStatsResponse,
|
||||
ContentStatsItem,
|
||||
NodeStatsReport,
|
||||
)
|
||||
# NEW imports for detailed stats and network overview
|
||||
from app.core.stats.metrics_collector import MetricsCollector
|
||||
from app.core.stats.stats_aggregator import StatsAggregator
|
||||
from app.core.stats.gossip_manager import GossipManager
|
||||
from app.core.models.stats.metrics_models import NodeStats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/node/stats", tags=["node-stats"])
|
||||
|
||||
# Singleton-ish local instances for this router scope
|
||||
_metrics_collector = MetricsCollector()
|
||||
_stats_aggregator = StatsAggregator()
|
||||
_gossip_manager = GossipManager()
|
||||
|
||||
|
||||
async def _verify_inter_node_request_optional(request: Request) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Опциональная проверка межузловых заголовков + подписи.
|
||||
Используется там, где межузловой вызов возможен (например, report).
|
||||
Возвращает dict с информацией о ноде при успехе, иначе None.
|
||||
"""
|
||||
if request.headers.get("x-node-communication") != "true":
|
||||
return None
|
||||
|
||||
# Требуются обязательные заголовки
|
||||
required_headers = ["x-node-id", "x-node-public-key", "x-node-signature"]
|
||||
for header in required_headers:
|
||||
if header not in request.headers:
|
||||
logger.warning("Missing header on inter-node request: %s", header)
|
||||
raise HTTPException(status_code=400, detail=f"Missing required header: {header}")
|
||||
|
||||
# Читаем тело
|
||||
body = await request.body()
|
||||
if not body:
|
||||
raise HTTPException(status_code=400, detail="Empty message body")
|
||||
|
||||
try:
|
||||
message_data = json.loads(body.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON in request body")
|
||||
|
||||
signature = request.headers.get("x-node-signature")
|
||||
node_id = request.headers.get("x-node-id")
|
||||
public_key = request.headers.get("x-node-public-key")
|
||||
|
||||
crypto_manager = get_ed25519_manager()
|
||||
is_valid = crypto_manager.verify_signature(message_data, signature, public_key)
|
||||
if not is_valid:
|
||||
logger.warning("Invalid signature from node %s (stats)", node_id)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid cryptographic signature")
|
||||
|
||||
request.state.inter_node_communication = True
|
||||
request.state.source_node_id = node_id
|
||||
request.state.source_public_key = public_key
|
||||
return {"node_id": node_id, "public_key": public_key, "message": message_data}
|
||||
|
||||
|
||||
def _create_signed_response(data: Dict[str, Any]) -> JSONResponse:
|
||||
"""Формирование подписанного ответа и стандартных межузловых заголовков."""
|
||||
crypto_manager = get_ed25519_manager()
|
||||
payload = {
|
||||
"success": True,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"node_id": crypto_manager.node_id,
|
||||
"data": data,
|
||||
}
|
||||
signature = crypto_manager.sign_message(payload)
|
||||
headers = {
|
||||
"X-Node-ID": crypto_manager.node_id,
|
||||
"X-Node-Public-Key": crypto_manager.public_key_hex,
|
||||
"X-Node-Communication": "true",
|
||||
"X-Node-Signature": signature,
|
||||
}
|
||||
return JSONResponse(content=payload, headers=headers)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def node_health():
|
||||
"""
|
||||
GET /api/node/stats/health
|
||||
Возвращает состояние ноды и базовые метрики.
|
||||
"""
|
||||
try:
|
||||
crypto_manager = get_ed25519_manager()
|
||||
|
||||
# Собираем базовые метрики (простые заглушки без psutil, чтобы не добавлять зависимостей)
|
||||
uptime = int(time.time() - int(os.getenv("NODE_START_TS", str(int(time.time())))))
|
||||
cpu_usage = None
|
||||
mem_usage = None
|
||||
disk_free = None
|
||||
|
||||
resp = NodeHealthResponse(
|
||||
status="ok",
|
||||
node_id=crypto_manager.node_id,
|
||||
public_key=crypto_manager.public_key_hex,
|
||||
uptime_seconds=uptime,
|
||||
cpu_usage=cpu_usage,
|
||||
memory_usage_mb=mem_usage,
|
||||
disk_free_mb=disk_free,
|
||||
last_sync_ts=None,
|
||||
details={
|
||||
"version": "3.0.0",
|
||||
"protocols": ["ed25519", "content_sync"],
|
||||
},
|
||||
)
|
||||
# Открытый health можно вернуть без подписи, чтобы не ломать мониторинги
|
||||
return resp.dict()
|
||||
except Exception as e:
|
||||
logger.exception("node_health error")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/content")
|
||||
async def node_content_stats():
|
||||
"""
|
||||
GET /api/node/stats/content
|
||||
Аггрегированная статистика по контенту на ноде.
|
||||
"""
|
||||
try:
|
||||
# Заглушка: интеграция со стореджем ноды/БД для реальных значений
|
||||
contents: List[ContentStatsItem] = []
|
||||
total_chunks = sum(c.total_chunks for c in contents)
|
||||
stored_chunks = sum(c.stored_chunks for c in contents)
|
||||
missing_chunks = sum(c.missing_chunks for c in contents)
|
||||
|
||||
resp = NodeContentStatsResponse(
|
||||
total_contents=len(contents),
|
||||
total_chunks=total_chunks,
|
||||
stored_chunks=stored_chunks,
|
||||
missing_chunks=missing_chunks,
|
||||
contents=contents,
|
||||
)
|
||||
return resp.dict()
|
||||
except Exception as e:
|
||||
logger.exception("node_content_stats error")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/report")
|
||||
async def node_stats_report(request: Request, body: NodeStatsReport):
|
||||
"""
|
||||
POST /api/node/stats/report
|
||||
Прием отчета от других нод (подписанного ed25519).
|
||||
"""
|
||||
await _verify_inter_node_request_optional(request)
|
||||
try:
|
||||
# Бизнес-логика обработки отчета: логируем, возможно сохраняем в БД
|
||||
logger.info("Received stats report", extra={"report": body.dict()})
|
||||
|
||||
# Вытаскиваем вложенную метрику если есть и валидируем через GossipManager
|
||||
metrics = body.metrics
|
||||
if isinstance(metrics, dict) and metrics.get("node_id") and metrics.get("signature"):
|
||||
try:
|
||||
node_stats = await _gossip_manager.receive_stats(metrics)
|
||||
await _stats_aggregator.add_peer_snapshot(node_stats)
|
||||
except Exception as ge:
|
||||
logger.warning("Peer stats rejected: %s", ge)
|
||||
|
||||
return _create_signed_response({"accepted": True})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("node_stats_report error")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# NEW: подробная статистика ноды
|
||||
@router.get("/detailed")
|
||||
async def node_detailed_stats():
|
||||
"""
|
||||
GET /api/node/stats/detailed
|
||||
Подробная системная и прикладная статистика текущей ноды, с историческими агрегатами.
|
||||
"""
|
||||
try:
|
||||
crypto = get_ed25519_manager()
|
||||
# собрать свежие метрики и добавить в агрегатор
|
||||
system, app = await _metrics_collector.get_current_stats()
|
||||
local_snapshot = NodeStats(
|
||||
node_id=crypto.node_id,
|
||||
public_key=crypto.public_key_hex,
|
||||
system=system,
|
||||
app=app,
|
||||
)
|
||||
await _stats_aggregator.add_local_snapshot(local_snapshot)
|
||||
|
||||
aggregates = await _stats_aggregator.aggregate_node_stats(node_id=None, last_n=20)
|
||||
trends = await _stats_aggregator.calculate_trends(node_id=None, window=60)
|
||||
latest = await _stats_aggregator.get_latest_local()
|
||||
latest_dict = latest.to_dict() if latest else None
|
||||
|
||||
data = {
|
||||
"node_id": crypto.node_id,
|
||||
"latest": latest_dict,
|
||||
"aggregates": aggregates,
|
||||
"trends": trends,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
return _create_signed_response(data)
|
||||
except Exception as e:
|
||||
logger.exception("node_detailed_stats error")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# NEW: статистика сети (агрегированная по известным нодам)
|
||||
@router.get("/network")
|
||||
async def node_network_stats():
|
||||
"""
|
||||
GET /api/node/stats/network
|
||||
Сводка по сети: число нод, активные, средние CPU/MEM, суммарный доступный контент и т.д.
|
||||
"""
|
||||
try:
|
||||
overview = await _stats_aggregator.get_network_overview()
|
||||
data = {
|
||||
"overview": overview.to_dict(),
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
return _create_signed_response(data)
|
||||
except Exception as e:
|
||||
logger.exception("node_network_stats error")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
Reference in new issue
Block a user