- TemplateResponse now uses (request, name, context) signature for Starlette 0.36+ - Replace per-button hx-headers with global htmx:configRequest token injection in base.html - Fix JS cookie regex to handle leading semicolons correctly Tested: login, auth redirect, profile create/view/delete all return correct status codes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
202 lines
6.8 KiB
Python
202 lines
6.8 KiB
Python
"""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(request, "login.html")
|
|
|
|
|
|
@app.post("/login")
|
|
async def login(request: Request, token: str = Form(...)):
|
|
if token != AUTH_TOKEN:
|
|
return TEMPLATES.TemplateResponse(request, "login.html", {"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(request, "index.html", {
|
|
"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(request, "profile.html", {
|
|
"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)
|