diff --git a/Dockerfile b/Dockerfile index 3099c0b..dde0c38 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,16 +2,19 @@ FROM python:3.11-slim WORKDIR /app -# Установка системных зависимостей -RUN apt-get update && apt-get install -y \ - gcc \ - g++ \ - curl \ +# Установка системных зависимостей (только необходимые) +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + gcc \ + g++ \ + curl \ + ffmpeg \ + libmagic1 \ && rm -rf /var/lib/apt/lists/* -# Копирование requirements и установка Python зависимостей -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt + # Копирование requirements и установка Python зависимостей + COPY requirements.txt . + RUN python -m pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt # Копирование кода приложения COPY app/ ./app/ diff --git a/app/api/fastapi_compat_routes.py b/app/api/fastapi_compat_routes.py new file mode 100644 index 0000000..b639a38 --- /dev/null +++ b/app/api/fastapi_compat_routes.py @@ -0,0 +1,326 @@ +""" +Compatibility routes to preserve deprecated uploader-bot API surface (v1/system). +These endpoints mirror legacy paths so older clients continue to function, +while new v3 sync API works in parallel. +""" + +import base64 +import os +from typing import Optional, List + +import aiofiles +from fastapi import APIRouter, UploadFile, File, HTTPException, Query +from fastapi.responses import JSONResponse, StreamingResponse, PlainTextResponse +from sqlalchemy import select + +from app.core.logging import get_logger +from app.core.config import get_settings +from app.core.database import db_manager +from app.core.models.content_models import StoredContent as Content +from app.core.storage import LocalStorageBackend + +router = APIRouter(prefix="", tags=["compat-v1"]) +logger = get_logger(__name__) +settings = get_settings() + + +@router.get("/") +async def index_root(): + return PlainTextResponse("MY Network Node", status_code=200) + + +@router.get("/favicon.ico") +async def favicon(): + return PlainTextResponse("", status_code=204) + + +@router.get("/api/system.version") +async def system_version(): + codebase_hash = os.getenv("CODEBASE_HASH", "unknown") + codebase_branch = os.getenv("CODEBASE_BRANCH", os.getenv("GIT_BRANCH", "main")) + return {"codebase_hash": codebase_hash, "codebase_branch": codebase_branch} + + +@router.post("/api/system.sendStatus") +async def system_send_status(payload: dict): + try: + message_b58 = payload.get("message") + signature = payload.get("signature") + if not message_b58 or not signature: + raise HTTPException(status_code=400, detail="message and signature required") + await logger.ainfo("Compat system.sendStatus", signature=signature) + return {"ok": True} + except HTTPException: + raise + except Exception as e: + await logger.aerror("sendStatus failed", error=str(e)) + raise HTTPException(status_code=500, detail="sendStatus failed") + + +@router.get("/api/tonconnect-manifest.json") +async def tonconnect_manifest(): + host = str(getattr(settings, "PROJECT_HOST", "")) or os.getenv("PROJECT_HOST", "") or "http://localhost:8000" + return { + "url": host, + "name": "MY Network Node", + "iconUrl": f"{host}/static/icon.png", + "termsOfUseUrl": f"{host}/terms", + "privacyPolicyUrl": f"{host}/privacy", + "bridgeUrl": "https://bridge.tonapi.io/bridge", + "manifestVersion": 2 + } + + +@router.get("/api/platform-metadata.json") +async def platform_metadata(): + host = str(getattr(settings, "PROJECT_HOST", "")) or os.getenv("PROJECT_HOST", "") or "http://localhost:8000" + return { + "name": "MY Network Platform", + "symbol": "MYN", + "description": "Decentralized content platform (v3)", + "image": f"{host}/static/platform.png", + "external_url": host, + "version": "3.0.0" + } + + +@router.get("/api/v1/node") +async def v1_node(): + from app.core.crypto import get_ed25519_manager + cm = get_ed25519_manager() + return { + "id": cm.node_id, + "node_address": "", + "master_address": "", + "indexer_height": 0, + "services": {} + } + + +@router.get("/api/v1/nodeFriendly") +async def v1_node_friendly(): + from app.core.crypto import get_ed25519_manager + cm = get_ed25519_manager() + return PlainTextResponse(f"Node ID: {cm.node_id}\nIndexer height: 0\nServices: none\n") + + +@router.post("/api/v1/auth.twa") +async def v1_auth_twa(payload: dict): + user_ref = payload.get("user") or {} + token = base64.b64encode(f"twa:{user_ref}".encode()).decode() + return {"token": token} + + +@router.get("/api/v1/auth.me") +async def v1_auth_me(): + return {"user": None, "status": "guest"} + + +@router.post("/api/v1/auth.selectWallet") +async def v1_auth_select_wallet(payload: dict): + return {"ok": True} + + +@router.get("/api/v1/tonconnect.new") +async def v1_tonconnect_new(): + return {"ok": True} + + +@router.post("/api/v1/tonconnect.logout") +async def v1_tonconnect_logout(payload: dict): + return {"ok": True} + + +@router.post("/api/v1.5/storage") +async def v1_5_storage_upload(file: UploadFile = File(...)): + return await v1_storage_upload(file) + + +@router.get("/api/v1.5/storage/{file_hash}") +async def v1_5_storage_get(file_hash: str): + return await v1_storage_get(file_hash) + + +@router.post("/api/v1/storage") +async def v1_storage_upload(file: UploadFile = File(...)): + try: + data = await file.read() + if not data: + raise HTTPException(status_code=400, detail="empty file") + + backend = LocalStorageBackend() + file_hash = sha256(data).hexdigest() + file_path = os.path.join(backend.files_path, file_hash) + async with aiofiles.open(file_path, 'wb') as f: + await f.write(data) + + async with db_manager.get_session() as session: + existing = await session.execute(select(Content).where(Content.hash == file_hash)) + if existing.scalars().first() is None: + content = Content( + hash=file_hash, + filename=file.filename or file_hash, + file_size=len(data), + mime_type=file.content_type or "application/octet-stream", + file_path=str(file_path), + ) + session.add(content) + await session.commit() + + return {"hash": file_hash} + except HTTPException: + raise + except Exception as e: + await logger.aerror("v1 upload failed", error=str(e)) + raise HTTPException(status_code=500, detail="upload failed") + + +@router.get("/api/v1/storage/{file_hash}") +async def v1_storage_get(file_hash: str): + try: + async with db_manager.get_session() as session: + result = await session.execute(select(Content).where(Content.hash == file_hash)) + content = result.scalars().first() + if not content or not content.file_path: + raise HTTPException(status_code=404, detail="not found") + + backend = LocalStorageBackend() + return StreamingResponse(backend.get_file_stream(content.file_path)) + except HTTPException: + raise + except Exception as e: + await logger.aerror("v1 storage get failed", error=str(e)) + raise HTTPException(status_code=500, detail="failed") + + +@router.get("/api/v1/storage.decodeContentId/{content_id}") +async def v1_decode_content_id(content_id: str): + try: + async with db_manager.get_session() as session: + result = await session.execute(select(Content).where(Content.id == content_id)) + content = result.scalars().first() + if not content: + raise HTTPException(status_code=404, detail="not found") + return { + "id": content.id, + "hash": content.hash, + "filename": content.filename, + "size": content.file_size, + "mime_type": content.mime_type, + } + except HTTPException: + raise + except Exception as e: + await logger.aerror("decodeContentId failed", error=str(e)) + raise HTTPException(status_code=500, detail="failed") + + +@router.get("/api/v1/content.list") +async def v1_content_list(limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0)): + try: + async with db_manager.get_session() as session: + result = await session.execute(select(Content).offset(offset).limit(limit)) + items: List[Content] = result.scalars().all() + return { + "items": [ + { + "id": it.id, + "hash": it.hash, + "filename": it.filename, + "size": it.file_size, + "mime_type": it.mime_type, + } for it in items + ], + "limit": limit, + "offset": offset + } + except Exception as e: + await logger.aerror("content.list failed", error=str(e)) + raise HTTPException(status_code=500, detail="failed") + + +@router.get("/api/v1/content.view") +async def v1_content_view(hash: Optional[str] = None, id: Optional[str] = None): + try: + if not hash and not id: + raise HTTPException(status_code=400, detail="hash or id required") + async with db_manager.get_session() as session: + stmt = select(Content) + if hash: + stmt = stmt.where(Content.hash == hash) + if id: + stmt = stmt.where(Content.id == id) + result = await session.execute(stmt) + content = result.scalars().first() + if not content: + raise HTTPException(status_code=404, detail="not found") + return { + "id": content.id, + "hash": content.hash, + "filename": content.filename, + "size": content.file_size, + "mime_type": content.mime_type, + "created_at": getattr(content, "created_at", None) + } + except HTTPException: + raise + except Exception as e: + await logger.aerror("content.view failed", error=str(e)) + raise HTTPException(status_code=500, detail="failed") + + +@router.get("/api/v1/content.view/{content_address}") +async def v1_content_view_path(content_address: str): + try: + async with db_manager.get_session() as session: + result = await session.execute(select(Content).where((Content.id == content_address) | (Content.hash == content_address))) + content = result.scalars().first() + if not content: + raise HTTPException(status_code=404, detail="not found") + return { + "id": content.id, + "hash": content.hash, + "filename": content.filename, + "size": content.file_size, + "mime_type": content.mime_type, + "created_at": getattr(content, "created_at", None) + } + except HTTPException: + raise + except Exception as e: + await logger.aerror("content.view(path) failed", error=str(e)) + raise HTTPException(status_code=500, detail="failed") + + +@router.get("/api/v1/content.friendlyList") +async def v1_content_friendly_list(limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0)): + return await v1_content_list(limit, offset) + + +@router.get("/api/v1.5/content.list") +async def v1_5_content_list(limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0)): + return await v1_content_list(limit, offset) + + +@router.post("/api/v1/blockchain.sendNewContentMessage") +async def v1_chain_send_new_content(payload: dict): + await logger.ainfo("compat blockchain.sendNewContentMessage", payload=payload) + return {"ok": True} + + +@router.post("/api/v1/blockchain.sendPurchaseContent") +async def v1_chain_send_purchase(payload: dict): + await logger.ainfo("compat blockchain.sendPurchaseContent", payload=payload) + return {"ok": True} + + +@router.post("/api/v1/blockchain.sendPurchaseContentMessage") +async def v1_chain_send_purchase_message(payload: dict): + await logger.ainfo("compat blockchain.sendPurchaseContentMessage", payload=payload) + return {"ok": True} + + +@router.get("/api/v1/account") +async def v1_account(): + return {"ok": True} + diff --git a/app/api/fastapi_node_routes.py b/app/api/fastapi_node_routes.py index 0176d4e..046d5a0 100644 --- a/app/api/fastapi_node_routes.py +++ b/app/api/fastapi_node_routes.py @@ -10,6 +10,7 @@ from fastapi.responses import JSONResponse from app.core.crypto import get_ed25519_manager from app.core.logging import get_logger +from app.core.database import get_cache_manager logger = get_logger(__name__) @@ -44,6 +45,24 @@ async def validate_node_request(request: Request) -> Dict[str, Any]: try: message_data = json.loads(body.decode()) + # Anti-replay: validate timestamp and nonce + try: + ts = message_data.get("timestamp") + nonce = message_data.get("nonce") + if ts: + from datetime import datetime, timezone + now = datetime.now(timezone.utc).timestamp() + if abs(float(ts) - float(now)) > 300: + raise HTTPException(status_code=400, detail="stale timestamp") + if nonce: + cache = await get_cache_manager() + cache_key = f"replay:{node_id}:{nonce}" + if await cache.get(cache_key): + raise HTTPException(status_code=400, detail="replay detected") + await cache.set(cache_key, True, ttl=600) + except Exception: + # Backward compatible: missing fields + pass except json.JSONDecodeError: raise HTTPException(status_code=400, detail="Invalid JSON in request body") @@ -411,4 +430,4 @@ async def v3_network_stats(): except Exception as e: logger.error(f"V3 network stats error: {e}") - raise HTTPException(status_code=500, detail="Internal server error") \ No newline at end of file + raise HTTPException(status_code=500, detail="Internal server error") diff --git a/app/fastapi_main.py b/app/fastapi_main.py index bafa7f9..6a11a15 100644 --- a/app/fastapi_main.py +++ b/app/fastapi_main.py @@ -39,6 +39,8 @@ from app.api.fastapi_content_routes import router as content_router from app.api.fastapi_storage_routes import router as storage_router from app.api.fastapi_node_routes import router as node_router from app.api.fastapi_system_routes import router as system_router +from app.api.fastapi_compat_routes import router as compat_router +from app.api.fastapi_v3_routes import router as v3_router # ДОБАВЛЕНО: импорт дополнительных роутеров из app/api/routes/ from app.api.routes.content_access_routes import router as content_access_router @@ -226,6 +228,8 @@ def create_fastapi_app() -> FastAPI: # ВНИМАНИЕ: эти роутеры уже имеют собственные префиксы (prefix=...), поэтому include без доп. prefix app.include_router(content_access_router) # /api/content/* app.include_router(node_stats_router) # /api/node/stats/* + app.include_router(compat_router) # legacy v1/system + app.include_router(v3_router) # /api/v3/* # Дополнительные обработчики событий setup_exception_handlers(app) @@ -310,6 +314,8 @@ def setup_exception_handlers(app: FastAPI): # Увеличиваем счетчик ошибок для мониторинга from app.api.fastapi_system_routes import increment_error_counter +from app.api.fastapi_compat_routes import router as compat_router +from app.api.fastapi_v3_routes import router as v3_router await increment_error_counter() return JSONResponse( @@ -334,6 +340,8 @@ def setup_middleware_hooks(app: FastAPI): # Увеличиваем счетчик запросов from app.api.fastapi_system_routes import increment_request_counter +from app.api.fastapi_compat_routes import router as compat_router +from app.api.fastapi_v3_routes import router as v3_router await increment_request_counter() # Проверяем режим обслуживания @@ -379,6 +387,8 @@ def setup_middleware_hooks(app: FastAPI): ) from app.api.fastapi_system_routes import increment_error_counter +from app.api.fastapi_compat_routes import router as compat_router +from app.api.fastapi_v3_routes import router as v3_router await increment_error_counter() raise diff --git a/bootstrap.json b/bootstrap.json index cfbf53d..0deed5f 100644 --- a/bootstrap.json +++ b/bootstrap.json @@ -1,14 +1,14 @@ { "version": "3.0.0", - "network_id": "my-network-1754810662", - "created_at": "2025-08-10T07:24:22Z", + "network_id": "my-network-1755263533", + "created_at": "2025-08-15T13:12:13Z", "bootstrap_nodes": [ { - "id": "node-7fd144167286645c", - "node_id": "node-7fd144167286645c", + "id": "node-e3ebfd8e2444dd4f", + "node_id": "node-e3ebfd8e2444dd4f", "address": "2a02:6b40:2000:16b1::1", "port": 8000, - "public_key": "7fd144167286645c3b01cd16d87a775f57ec134dbad412e13f3016c33e936177", + "public_key": "e3ebfd8e2444dd4f8747472a3c753708e45a47b16f33401790caa5c5ca67534d", "trusted": true, "node_type": "bootstrap" } diff --git a/requirements.txt b/requirements.txt index 017c052..1f3a824 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,9 @@ asyncpg==0.29.0 redis==5.0.1 aioredis==2.0.1 aiofiles==23.2.1 +aiohttp==3.12.15 +yarl==1.17.1 +multidict==6.0.5 cryptography==41.0.7 python-jose[cryptography]==3.3.0 python-multipart==0.0.6 @@ -28,10 +31,16 @@ python-magic==0.4.27 jinja2==3.1.2 starlette==0.27.0 structlog==23.2.0 -aiogram==3.3.0 +aiogram==3.21.0 +magic-filter==1.0.12 sanic==23.12.1 PyJWT==2.8.0 -cryptography==41.0.7 ed25519==1.5 tonsdk==1.0.15 -pytonconnect==0.3.0 +pytonconnect==0.3.2 +prometheus-client==0.22.1 +pydub==0.25.1 +pycryptodome==3.23.0 +psycopg2-binary==2.9.10 +PyNaCl==1.5.0 +uvloop==0.21.0