""" 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 from fastapi import APIRouter, UploadFile, File, HTTPException, Query from fastapi.responses import JSONResponse, StreamingResponse, PlainTextResponse from sqlalchemy import text import aiofiles from app.core.logging import get_logger from app.core.config import get_settings from app.core.database import db_manager from app.core.storage import LocalStorageBackend router = APIRouter(prefix="", tags=["compat-v1"]) logger = get_logger(__name__) settings = get_settings() def _is_table_missing_error(exc: Exception) -> bool: try: msg = str(exc) return 'UndefinedTable' in msg or 'does not exist' in msg or ('relation' in msg and 'does not exist' in msg) except Exception: return False @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" } # Legacy index and favicon @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) # Legacy node endpoints @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") # Legacy auth endpoints @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/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() from hashlib import sha256 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) # Возвращаем hash без записи ORM, чтобы избежать конфликтов схем 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(text("SELECT file_path FROM my_network_content WHERE hash=:h LIMIT 1"), {"h": file_hash}) row = result.first() if not row or not row[0]: raise HTTPException(status_code=404, detail="not found") backend = LocalStorageBackend() return StreamingResponse(backend.get_file_stream(row[0])) except HTTPException: raise except Exception as e: if _is_table_missing_error(e): raise HTTPException(status_code=404, detail="not found") 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(text("SELECT id, hash, filename, file_size, mime_type FROM my_network_content WHERE id=:i LIMIT 1"), {"i": content_id}) row = result.first() if not row: raise HTTPException(status_code=404, detail="not found") return {"id": str(row[0]), "hash": row[1], "filename": row[2], "size": row[3], "mime_type": row[4]} except HTTPException: raise except Exception as e: if _is_table_missing_error(e): raise HTTPException(status_code=404, detail="not found") 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( text("SELECT id, hash, filename, file_size, mime_type FROM my_network_content ORDER BY created_at DESC LIMIT :lim OFFSET :off"), {"lim": limit, "off": offset} ) rows = result.fetchall() or [] return { "items": [ {"id": str(r[0]), "hash": r[1], "filename": r[2], "size": r[3], "mime_type": r[4]} for r in rows ], "limit": limit, "offset": offset } except Exception as e: if _is_table_missing_error(e): return {"items": [], "limit": limit, "offset": offset} 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: if hash: result = await session.execute(text("SELECT id, hash, filename, file_size, mime_type FROM my_network_content WHERE hash=:h LIMIT 1"), {"h": hash}) else: result = await session.execute(text("SELECT id, hash, filename, file_size, mime_type FROM my_network_content WHERE id=:i LIMIT 1"), {"i": id}) row = result.first() if not row: raise HTTPException(status_code=404, detail="not found") return {"id": str(row[0]), "hash": row[1], "filename": row[2], "size": row[3], "mime_type": row[4], "created_at": None} except HTTPException: raise except Exception as e: if _is_table_missing_error(e): raise HTTPException(status_code=404, detail="not found") 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(text("SELECT id, hash, filename, file_size, mime_type FROM my_network_content WHERE id=:v OR hash=:v LIMIT 1"), {"v": content_address}) row = result.first() if not row: raise HTTPException(status_code=404, detail="not found") return {"id": str(row[0]), "hash": row[1], "filename": row[2], "size": row[3], "mime_type": row[4], "created_at": None} except HTTPException: raise except Exception as e: if _is_table_missing_error(e): raise HTTPException(status_code=404, detail="not found") 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}