from fastapi import FastAPI, HTTPException, Request, Response from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse import uvicorn import aiohttp from pydantic import BaseModel from datetime import datetime, timedelta import logging import json from database import db from config import PLANS, CONFIG from marzban import marzban # Setup logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger("server") app = FastAPI() app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) @app.on_event("startup") async def startup(): await db.connect() logger.info("Database connected") @app.get("/api/plans") async def get_plans(): plans_list = [] # Only return first 3 plans for the shop visible_plans = list(PLANS.items())[:3] for pid, p in visible_plans: plans_list.append({ "id": pid, **p }) return plans_list from aiogram.types import LabeledPrice class BuyPlanRequest(BaseModel): user_id: int plan_id: str promo_code: str = None class PromoCheckRequest(BaseModel): code: str @app.post("/api/check-promo") async def check_promo_code(req: PromoCheckRequest): promo = await db.get_promo_code(req.code) if not promo: return JSONResponse(status_code=404, content={"error": "Invalid or expired promo code"}) return { "code": promo["code"], "discount": promo["discount"], "bonus_days": promo["bonus_days"], "is_unlimited": promo["is_unlimited"], "description": f"Discount {promo['discount']}%" + (f" + {promo['bonus_days']} Days" if promo['bonus_days'] else "") } @app.post("/api/create-invoice") async def create_invoice(req: BuyPlanRequest, request: Request): bot = getattr(request.app.state, "bot", None) if not bot: return JSONResponse(status_code=500, content={"error": "Bot instance not initialized"}) plan = PLANS.get(req.plan_id) if not plan: return JSONResponse(status_code=404, content={"error": "Plan not found"}) price = plan['price'] # Validating Promo discount = 0 if req.promo_code: promo = await db.get_promo_code(req.promo_code) if promo: discount = promo['discount'] final_price = int(price * (100 - discount) / 100) if final_price < 1: final_price = 1 try: limit_desc = f"{plan['data_limit']}GB" if plan['data_limit'] > 0 else "Unlimited" invoice_link = await bot.create_invoice_link( title=f"Sub: {plan['name']}", description=f"{limit_desc} / {plan['days']} days", payload=f"{req.plan_id}:{req.promo_code or ''}", provider_token="", # Empty for Stars currency="XTR", prices=[LabeledPrice(label=plan['name'], amount=final_price)] ) return {"invoice_link": invoice_link} except Exception as e: logger.error(f"Error generating invoice: {e}") return JSONResponse(status_code=500, content={"error": str(e)}) @app.get("/api/user/{user_id}") async def get_user_stats(user_id: int, username: str = "Unknown", lang: str = "en"): user = await db.get_user(user_id) # Auto-register admin if not exists if not user and user_id in CONFIG["ADMIN_IDS"]: marzban_username = username.lower() if username and username != "Unknown" else f"admin_{user_id}" await db.create_user(user_id, username, marzban_username) user = await db.get_user(user_id) if not user: return JSONResponse(status_code=404, content={"error": "User not found"}) sub_until = user['subscription_until'] days_left = 0 status = "Inactive" expire_str = "No active subscription" if sub_until: if isinstance(sub_until, str): try: sub_until = datetime.fromisoformat(sub_until) except: pass if isinstance(sub_until, datetime): expire_str = sub_until.strftime("%d.%m.%Y") if sub_until > datetime.now(): delta = sub_until - datetime.now() days_left = delta.days status = "Active" else: status = "Expired" # Fetch detailed stats from Marzban sub_url = "" used_traffic = 0 if user['marzban_username']: try: m_user = await marzban.get_user(user['marzban_username']) # Check for error in response if isinstance(m_user, dict) and not m_user.get('detail'): used_traffic = m_user.get('used_traffic', 0) sub_url = m_user.get('subscription_url', "") # Fix relative URL if sub_url and sub_url.startswith('/'): base = CONFIG.get('BASE_URL') or CONFIG['MARZBAN_URL'] sub_url = f"{base.rstrip('/')}{sub_url}" except Exception as e: logger.error(f"Marzban fetch error: {e}") # Registration Date created_at = user['created_at'] reg_date = "Unknown" if created_at: if isinstance(created_at, str): try: created_at = datetime.fromisoformat(created_at) except: pass if isinstance(created_at, datetime): reg_date = created_at.strftime("%d.%m.%Y") # Referral and Payment Stats ref_count = await db.get_referrals_count(user_id) payments_info = await db.get_user_payments_info(user_id) return { "status": status, "days_left": days_left, "expire_date": expire_str, "data_limit_gb": user['data_limit'] or 0, "used_traffic_gb": round(used_traffic / (1024**3), 2), "plan": "Premium", "subscription_url": sub_url, "username": user['username'], "marzban_username": user['marzban_username'], "photo_url": f"/api/user-photo/{user_id}", "reg_date": reg_date, "referrals_count": ref_count, "total_payments": payments_info["total_count"], "total_spent": payments_info["total_amount"], "is_admin": is_admin(user_id) } @app.get("/api/user-photo/{user_id}") async def get_user_photo(user_id: int): # This is a proxy to get TG photo without leaking BOT_TOKEN async with aiohttp.ClientSession() as session: # 1. Get user profile photos get_photos_url = f"https://api.telegram.org/bot{CONFIG['BOT_TOKEN']}/getUserProfilePhotos?user_id={user_id}&limit=1" async with session.get(get_photos_url) as resp: if resp.status != 200: return Response(status_code=404) data = await resp.json() if not data.get('ok') or not data['result']['photos']: return Response(status_code=404) file_id = data['result']['photos'][0][0]['file_id'] # 2. Get file path get_file_url = f"https://api.telegram.org/bot{CONFIG['BOT_TOKEN']}/getFile?file_id={file_id}" async with session.get(get_file_url) as resp: if resp.status != 200: return Response(status_code=404) file_data = await resp.json() if not file_data.get('ok'): return Response(status_code=404) file_path = file_data['result']['file_path'] # 3. Download and stream the file file_url = f"https://api.telegram.org/file/bot{CONFIG['BOT_TOKEN']}/{file_path}" async with session.get(file_url) as resp: if resp.status != 200: return Response(status_code=404) content = await resp.read() return Response(content=content, media_type="image/jpeg") # ... existing code ... class SupportRequest(BaseModel): user_id: int message: str username: str = "Unknown" @app.post("/api/support") async def send_support_message(req: SupportRequest, request: Request): bot = getattr(request.app.state, "bot", None) if not bot: return JSONResponse(status_code=500, content={"error": "Bot not init"}) # Send to admins for admin_id in CONFIG["ADMIN_IDS"]: try: text = f"📩 **Support Request**\nFrom: @{req.username} (ID: {req.user_id})\n\n{req.message}" await bot.send_message(chat_id=admin_id, text=text, parse_mode="Markdown") except Exception as e: logger.error(f"Failed to send support msg to {admin_id}: {e}") return {"status": "sent"} # --- ADMIN API --- def is_admin(user_id: int): return user_id in CONFIG["ADMIN_IDS"] @app.get("/api/admin/stats") async def get_admin_stats(user_id: int): if not is_admin(user_id): return JSONResponse(status_code=403, content={"error": "Forbidden"}) db_stats = await db.get_stats() try: sys_stats = await marzban.get_system_stats() marz_user_stats = await marzban.get_users_stats() except: sys_stats = {} marz_user_stats = {} return { "bot": db_stats, "server": { "cpu": sys_stats.get('cpu_usage', 'N/A'), "ram_used": round(sys_stats.get('mem_used', 0) / (1024**3), 2), "ram_total": round(sys_stats.get('mem_total', 0) / (1024**3), 2), "active_users": marz_user_stats.get('active_users', 0), "total_traffic_gb": round(marz_user_stats.get('total_usage', 0) / (1024**3), 2) } } @app.get("/api/admin/users") async def admin_list_users(user_id: int, query: str = None): if not is_admin(user_id): return JSONResponse(status_code=403, content={"error": "Forbidden"}) if query: users = await db.search_users(query) else: users = await db.get_all_users() # Convert to dict and add photo_url results = [] for u in users: d = dict(u) d['photo_url'] = f"/api/user-photo/{u['user_id']}" results.append(d) # Sort alphabetically by username (case-insensitive), or ID if username is missing results.sort(key=lambda x: (x.get('username') or str(x['user_id'])).lower()) # Return first 50 results return results[:50] @app.get("/api/admin/user/{target_id}") async def admin_get_user(target_id: int, user_id: int): if not is_admin(user_id): return JSONResponse(status_code=403, content={"error": "Forbidden"}) user = await db.get_user(target_id) if not user: return JSONResponse(status_code=404, content={"error": "User not found"}) marz_info = {} try: marz_info = await marzban.get_user(user['marzban_username']) except: pass return { "user": dict(user), "marzban": marz_info } @app.post("/api/admin/user/{target_id}/action") async def admin_user_action(target_id: int, req: Request): data = await req.json() admin_id = data.get("user_id") if not is_admin(admin_id): return JSONResponse(status_code=403, content={"error": "Forbidden"}) action = data.get("action") user = await db.get_user(target_id) if not user: return JSONResponse(status_code=404, content={"error": "User not found"}) try: if action == "reset_traffic": await marzban.reset_user_traffic(user['marzban_username']) elif action == "toggle_status": marz_user = await marzban.get_user(user['marzban_username']) new_status = "disabled" if marz_user.get('status') == 'active' else 'active' limit_gb = (marz_user.get('data_limit') or 0) / (1024**3) await marzban.modify_user(user['marzban_username'], limit_gb=limit_gb, status=new_status, expire_timestamp=marz_user.get('expire')) elif action == "add_days": days = int(data.get("days", 0)) current_limit = user['data_limit'] or 0 await db.update_subscription(target_id, days, current_limit) # Sync to Marzban u = await db.get_user(target_id) sub_until = u['subscription_until'] if isinstance(sub_until, str): sub_until = datetime.fromisoformat(sub_until) days_left = (sub_until - datetime.now()).days + 1 if sub_until else 0 await marzban.modify_user(u['marzban_username'], (current_limit / (1024**3)), days_left) elif action == "set_limit": limit_gb = float(data.get("limit_gb", 0)) marz_user = await marzban.get_user(user['marzban_username']) # 0 and negative in Marzban is unlimited marz_limit = limit_gb if limit_gb > 0 else 0 await marzban.modify_user(user['marzban_username'], marz_limit, status=marz_user.get('status'), expire_timestamp=marz_user.get('expire')) # If 0, we store a very large number in DB to represent infinity limit_bytes = int(limit_gb * (1024**3)) if limit_gb > 0 else 999999 * (1024**3) await db.execute("UPDATE users SET data_limit = $1 WHERE user_id = $2", limit_bytes, target_id) elif action == "set_expiry": days = int(data.get("days", 0)) # Set fixed expiry from NOW if days > 10000: new_date = datetime(2099, 12, 31) else: new_date = datetime.now() + timedelta(days=days) await db.execute("UPDATE users SET subscription_until = $1 WHERE user_id = $2", new_date, target_id) # Sync to Marzban u = await db.get_user(target_id) marz_user = await marzban.get_user(u['marzban_username']) # In Marzban, we pass days left days_left = (new_date - datetime.now()).days + 1 if days > 0 else 0 # If days > 10000 (forever), Marzban should be None marz_days = days_left if days < 10000 else 0 await marzban.modify_user(u['marzban_username'], (u['data_limit'] / (1024**3)), marz_days if days > 0 else 1) # 1 sec if expire elif action == "set_plan": plan_id = data.get("plan_id") plan = PLANS.get(plan_id) if not plan: return JSONResponse(status_code=404, content={"error": "Plan not found"}) # Use grant_subscription logic total_days = plan['days'] data_limit_gb = plan['data_limit'] limit_bytes = int(data_limit_gb * (1024**3)) if data_limit_gb > 0 else 999999 * (1024**3) await db.execute("UPDATE users SET data_limit = $1 WHERE user_id = $2", limit_bytes, target_id) # Update expiry relative to now new_date = datetime.now() + timedelta(days=total_days) await db.execute("UPDATE users SET subscription_until = $1 WHERE user_id = $2", new_date, target_id) # Sync to Marzban marz_days = total_days if total_days > 0 else 0 marz_limit = data_limit_gb if data_limit_gb > 0 else 0 await marzban.modify_user(user['marzban_username'], marz_limit, marz_days if marz_days > 0 else 1) elif action == "delete_sub": await db.remove_subscription(target_id) expire_ts = int(datetime.now().timestamp()) marz_user = await marzban.get_user(user['marzban_username']) limit_gb = (marz_user.get('data_limit') or 0) / (1024**3) await marzban.modify_user(user['marzban_username'], limit_gb, expire_timestamp=expire_ts) else: return JSONResponse(status_code=400, content={"error": "Invalid action"}) return {"status": "ok"} except Exception as e: logger.error(f"Admin action error: {e}") return JSONResponse(status_code=500, content={"error": str(e)}) @app.get("/api/admin/promos") async def admin_list_promos(user_id: int): if not is_admin(user_id): return JSONResponse(status_code=403, content={"error": "Forbidden"}) promos = await db.fetch("SELECT * FROM promo_codes") return [dict(p) for p in promos] @app.get("/api/admin/plans_full") async def admin_get_plans_full(user_id: int): if not is_admin(user_id): return JSONResponse(status_code=403, content={"error": "Forbidden"}) plans_list = [] for pid, p in PLANS.items(): plans_list.append({"id": pid, **p}) return plans_list class CreatePromoRequest(BaseModel): user_id: int code: str discount: int uses: int days: int is_unlimited: bool = False bonus_days: int = 0 is_sticky: bool = False @app.post("/api/admin/promos/create") async def admin_create_promo(req: CreatePromoRequest): if not is_admin(req.user_id): return JSONResponse(status_code=403, content={"error": "Forbidden"}) expires_at = None if req.days > 0: expires_at = datetime.now() + timedelta(days=req.days) try: await db.create_promo_code( req.code.upper().strip(), req.discount, req.uses, req.user_id, expires_at, req.is_unlimited, req.bonus_days, req.is_sticky ) return {"status": "ok"} except Exception as e: return JSONResponse(status_code=500, content={"error": str(e)}) @app.delete("/api/admin/promo/{code}") async def admin_delete_promo(code: str, user_id: int): if not is_admin(user_id): return JSONResponse(status_code=403, content={"error": "Forbidden"}) await db.delete_promo_code(code) return {"status": "ok"} class BroadcastRequest(BaseModel): user_id: int message: str @app.post("/api/admin/broadcast") async def admin_broadcast(req: BroadcastRequest, request: Request): if not is_admin(req.user_id): return JSONResponse(status_code=403, content={"error": "Forbidden"}) bot = getattr(request.app.state, "bot", None) if not bot: return JSONResponse(status_code=500, content={"error": "Bot not init"}) users = await db.get_users_for_broadcast() count = 0 for u in users: try: await bot.send_message(chat_id=u['user_id'], text=req.message) count += 1 except: pass return {"sent": count} # Serve Static Files (must be last) app.mount("/", StaticFiles(directory="web_app/static", html=True), name="static") if __name__ == "__main__": from config import CONFIG uvicorn.run(app, host="0.0.0.0", port=CONFIG["WEB_APP_PORT"])