"""SimpleX Manager — FastAPI app.""" import asyncio import json import logging import os from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, Form, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates import db import profiles as pm logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") log = logging.getLogger(__name__) BASE = Path(__file__).parent TEMPLATES = Jinja2Templates(directory=str(BASE / "templates")) AUTH_TOKEN = os.environ.get("MANAGER_TOKEN", "changeme") @asynccontextmanager async def lifespan(app: FastAPI): db.init_db() # Auto-restart any previously running bots on startup for profile in db.list_profiles(): if profile.get("address"): # had an address = was running before await pm.start_bot(profile, _save_address) yield # Graceful shutdown for pid in list(pm._running): await pm.stop_bot(pid) app = FastAPI(title="SimpleX Manager", lifespan=lifespan) app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static") async def _save_address(profile_id: int, address: str) -> None: db.update_address(profile_id, address) def _check_auth(request: Request) -> bool: token = request.cookies.get("token") or request.headers.get("X-Token") return token == AUTH_TOKEN def _require_auth(request: Request) -> None: if not _check_auth(request): from fastapi.responses import RedirectResponse raise HTTPException(status_code=401, detail="Unauthorized") def _redirect_if_unauth(request: Request): if not _check_auth(request): return RedirectResponse("/login", status_code=303) return None # ── Auth ────────────────────────────────────────────────────────────────────── @app.get("/login", response_class=HTMLResponse) async def login_page(request: Request): return TEMPLATES.TemplateResponse("login.html", {"request": request}) @app.post("/login") async def login(request: Request, token: str = Form(...)): if token != AUTH_TOKEN: return TEMPLATES.TemplateResponse("login.html", {"request": request, "error": "Invalid token"}) resp = RedirectResponse("/", status_code=303) resp.set_cookie("token", token, httponly=True, samesite="lax") return resp @app.get("/logout") async def logout(): resp = RedirectResponse("/login", status_code=303) resp.delete_cookie("token") return resp # ── Pages ───────────────────────────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) async def index(request: Request): if redir := _redirect_if_unauth(request): return redir all_profiles = db.list_profiles() for p in all_profiles: p["running"] = pm.is_running(p["id"]) p["config"] = json.loads(p.get("config") or "{}") return TEMPLATES.TemplateResponse("index.html", { "request": request, "profiles": all_profiles, "bot_types": pm.BOT_TYPES, }) @app.get("/profile/{profile_id}", response_class=HTMLResponse) async def profile_page(request: Request, profile_id: int): if redir := _redirect_if_unauth(request): return redir profile = db.get_profile(profile_id) if not profile: raise HTTPException(404, "Profile not found") profile["config"] = json.loads(profile.get("config") or "{}") profile["running"] = pm.is_running(profile_id) bot = pm.get_running(profile_id) contacts = bot.contacts if bot else [] groups = bot.groups if bot else [] log_lines = bot.log_lines[-50:] if bot else [] return TEMPLATES.TemplateResponse("profile.html", { "request": request, "profile": profile, "contacts": contacts, "groups": groups, "log_lines": log_lines, "bot_types": pm.BOT_TYPES, }) # ── API ─────────────────────────────────────────────────────────────────────── @app.post("/api/profiles") async def create_profile(request: Request): _require_auth(request) data = await request.json() name = data.get("name", "").strip() bot_type = data.get("bot_type", "echo") config = data.get("config", {}) if not name: raise HTTPException(400, "name required") if bot_type not in pm.BOT_TYPES: raise HTTPException(400, f"bot_type must be one of {pm.BOT_TYPES}") try: profile = db.create_profile(name, bot_type, config) except Exception as e: raise HTTPException(400, str(e)) return JSONResponse(profile, status_code=201) @app.delete("/api/profiles/{profile_id}") async def delete_profile(request: Request, profile_id: int): _require_auth(request) await pm.stop_bot(profile_id) db.delete_profile(profile_id) return JSONResponse({"ok": True}) @app.post("/api/profiles/{profile_id}/start") async def start_profile(request: Request, profile_id: int): _require_auth(request) profile = db.get_profile(profile_id) if not profile: raise HTTPException(404, "Profile not found") await pm.start_bot(profile, _save_address) return JSONResponse({"ok": True, "status": "starting"}) @app.post("/api/profiles/{profile_id}/stop") async def stop_profile(request: Request, profile_id: int): _require_auth(request) await pm.stop_bot(profile_id) return JSONResponse({"ok": True, "status": "stopped"}) @app.get("/api/profiles/{profile_id}/status") async def profile_status(request: Request, profile_id: int): _require_auth(request) profile = db.get_profile(profile_id) if not profile: raise HTTPException(404) bot = pm.get_running(profile_id) return JSONResponse({ "running": bot is not None, "address": bot.address if bot else profile.get("address", ""), "contacts": len(bot.contacts) if bot else 0, "groups": len(bot.groups) if bot else 0, "log": bot.log_lines[-20:] if bot else [], }) @app.post("/api/profiles/{profile_id}/send") async def send_message(request: Request, profile_id: int): _require_auth(request) data = await request.json() to = data.get("to", "") text = data.get("text", "") if not to or not text: raise HTTPException(400, "to and text required") ok = await pm.send_message(profile_id, to, text) return JSONResponse({"ok": ok}) if __name__ == "__main__": import uvicorn uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)