The callback was redirecting to http://ip:port/api/#/callback?token=... instead of http://ip:port/#/callback?token=... — the SPA lives at root, not /api/.
882 lines
35 KiB
Python
882 lines
35 KiB
Python
"""
|
|
EveDash — aiohttp web server with REST API, WebSocket, and static file serving.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import datetime
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import os
|
|
import pathlib
|
|
import secrets
|
|
import time
|
|
import typing
|
|
|
|
import aiohttp
|
|
from aiohttp import web
|
|
|
|
if typing.TYPE_CHECKING:
|
|
from .dashboard import EveDash
|
|
|
|
log = logging.getLogger("red.evecogs.dashboard.server")
|
|
|
|
WEB_DIR = pathlib.Path(__file__).parent / "web"
|
|
|
|
# ── helpers ──────────────────────────────────────────────────────────────
|
|
|
|
def _json(data: dict, status: int = 200) -> web.Response:
|
|
return web.json_response(data, status=status)
|
|
|
|
|
|
def _err(msg: str, status: int = 400) -> web.Response:
|
|
return _json({"error": msg}, status=status)
|
|
|
|
|
|
def _make_token(user_id: int, secret: str) -> str:
|
|
ts = str(int(time.time()))
|
|
payload = f"{user_id}:{ts}"
|
|
sig = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
|
return f"{payload}:{sig}"
|
|
|
|
|
|
def _verify_token(token: str, secret: str, max_age: int = 86400 * 7) -> int | None:
|
|
try:
|
|
parts = token.split(":")
|
|
if len(parts) != 3:
|
|
return None
|
|
user_id, ts, sig = int(parts[0]), int(parts[1]), parts[2]
|
|
expected = hmac.new(secret.encode(), f"{user_id}:{ts}".encode(), hashlib.sha256).hexdigest()
|
|
if not hmac.compare_digest(sig, expected):
|
|
return None
|
|
if time.time() - ts > max_age:
|
|
return None
|
|
return user_id
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# ── middleware ────────────────────────────────────────────────────────────
|
|
|
|
def _build_auth_middleware(cog: EveDash):
|
|
@web.middleware
|
|
async def auth_middleware(request: web.Request, handler):
|
|
request["user_id"] = None
|
|
request["is_owner"] = False
|
|
|
|
# Skip auth for static, login, callback, and health
|
|
path = request.path
|
|
if (
|
|
not path.startswith("/api/")
|
|
and not path.startswith("/ws")
|
|
):
|
|
return await handler(request)
|
|
if path in ("/api/auth/login", "/api/auth/callback", "/api/health"):
|
|
return await handler(request)
|
|
|
|
token = None
|
|
auth_header = request.headers.get("Authorization", "")
|
|
if auth_header.startswith("Bearer "):
|
|
token = auth_header[7:]
|
|
if not token:
|
|
token = request.cookies.get("eve_token")
|
|
|
|
if token:
|
|
secret = await cog.config.secret_key()
|
|
user_id = _verify_token(token, secret)
|
|
if user_id:
|
|
request["user_id"] = user_id
|
|
request["is_owner"] = user_id in cog.bot.owner_ids
|
|
|
|
return await handler(request)
|
|
|
|
return auth_middleware
|
|
|
|
|
|
def _require_auth(handler):
|
|
async def wrapper(request: web.Request):
|
|
if request["user_id"] is None:
|
|
return _err("Unauthorized", 401)
|
|
return await handler(request)
|
|
wrapper.__name__ = handler.__name__
|
|
return wrapper
|
|
|
|
|
|
def _require_owner(handler):
|
|
async def wrapper(request: web.Request):
|
|
if request["user_id"] is None:
|
|
return _err("Unauthorized", 401)
|
|
if not request["is_owner"]:
|
|
return _err("Forbidden — bot owner only", 403)
|
|
return await handler(request)
|
|
wrapper.__name__ = handler.__name__
|
|
return wrapper
|
|
|
|
|
|
# ── WebSocket manager ────────────────────────────────────────────────────
|
|
|
|
class WebSocketManager:
|
|
def __init__(self):
|
|
self.connections: dict[int, list[web.WebSocketResponse]] = {}
|
|
|
|
async def add(self, user_id: int, ws: web.WebSocketResponse):
|
|
self.connections.setdefault(user_id, []).append(ws)
|
|
|
|
async def remove(self, user_id: int, ws: web.WebSocketResponse):
|
|
if user_id in self.connections:
|
|
self.connections[user_id] = [c for c in self.connections[user_id] if c is not ws]
|
|
if not self.connections[user_id]:
|
|
del self.connections[user_id]
|
|
|
|
async def broadcast(self, event: str, data: dict, owner_only: bool = False):
|
|
msg = json.dumps({"event": event, "data": data})
|
|
dead = []
|
|
for uid, conns in self.connections.items():
|
|
for ws in conns:
|
|
try:
|
|
await ws.send_str(msg)
|
|
except Exception:
|
|
dead.append((uid, ws))
|
|
for uid, ws in dead:
|
|
await self.remove(uid, ws)
|
|
|
|
async def send_to(self, user_id: int, event: str, data: dict):
|
|
msg = json.dumps({"event": event, "data": data})
|
|
for ws in self.connections.get(user_id, []):
|
|
try:
|
|
await ws.send_str(msg)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ── Server class ─────────────────────────────────────────────────────────
|
|
|
|
class DashboardServer:
|
|
def __init__(self, cog: EveDash):
|
|
self.cog = cog
|
|
self.bot = cog.bot
|
|
self.app: web.Application | None = None
|
|
self.runner: web.AppRunner | None = None
|
|
self.site: web.TCPSite | None = None
|
|
self.ws_manager = WebSocketManager()
|
|
self._start_time = time.time()
|
|
self._command_count = 0
|
|
self._message_count = 0
|
|
|
|
async def start(self, host: str, port: int):
|
|
self.app = web.Application(middlewares=[_build_auth_middleware(self.cog)])
|
|
self._register_routes()
|
|
self.runner = web.AppRunner(self.app)
|
|
await self.runner.setup()
|
|
self.site = web.TCPSite(self.runner, host, port)
|
|
await self.site.start()
|
|
log.info(f"EveDash server started on http://{host}:{port}")
|
|
|
|
async def stop(self):
|
|
if self.runner:
|
|
await self.runner.cleanup()
|
|
log.info("EveDash server stopped")
|
|
|
|
def _register_routes(self):
|
|
r = self.app.router
|
|
|
|
# Health
|
|
r.add_get("/api/health", self._health)
|
|
|
|
# Auth
|
|
r.add_get("/api/auth/login", self._auth_login)
|
|
r.add_get("/api/auth/callback", self._auth_callback)
|
|
r.add_get("/api/auth/me", _require_auth(self._auth_me))
|
|
r.add_post("/api/auth/logout", self._auth_logout)
|
|
|
|
# Bot
|
|
r.add_get("/api/bot/info", _require_auth(self._bot_info))
|
|
r.add_get("/api/bot/stats", _require_auth(self._bot_stats))
|
|
|
|
# Guilds
|
|
r.add_get("/api/guilds", _require_auth(self._guilds_list))
|
|
r.add_get("/api/guilds/{guild_id}", _require_auth(self._guild_detail))
|
|
r.add_get("/api/guilds/{guild_id}/channels", _require_auth(self._guild_channels))
|
|
r.add_get("/api/guilds/{guild_id}/roles", _require_auth(self._guild_roles))
|
|
|
|
# Guild settings
|
|
r.add_get("/api/guilds/{guild_id}/settings", _require_auth(self._guild_settings_get))
|
|
r.add_put("/api/guilds/{guild_id}/settings", _require_auth(self._guild_settings_update))
|
|
|
|
# Commands
|
|
r.add_get("/api/guilds/{guild_id}/commands", _require_auth(self._guild_commands))
|
|
r.add_put("/api/guilds/{guild_id}/commands/{command}", _require_auth(self._guild_command_toggle))
|
|
|
|
# Cog management (owner only)
|
|
r.add_get("/api/cogs", _require_owner(self._cogs_list))
|
|
r.add_post("/api/cogs/{cog_name}/load", _require_owner(self._cog_load))
|
|
r.add_post("/api/cogs/{cog_name}/unload", _require_owner(self._cog_unload))
|
|
r.add_post("/api/cogs/{cog_name}/reload", _require_owner(self._cog_reload))
|
|
|
|
# Third-party
|
|
r.add_get("/api/guilds/{guild_id}/third-parties", _require_auth(self._third_parties_list))
|
|
r.add_get("/api/guilds/{guild_id}/third-parties/{cog_name}/{page}", _require_auth(self._third_party_page))
|
|
r.add_post("/api/guilds/{guild_id}/third-parties/{cog_name}/{page}", _require_auth(self._third_party_page))
|
|
|
|
# Admin
|
|
r.add_get("/api/admin/config", _require_owner(self._admin_config))
|
|
r.add_put("/api/admin/config", _require_owner(self._admin_config_update))
|
|
r.add_get("/api/admin/blacklist", _require_owner(self._admin_blacklist))
|
|
r.add_post("/api/admin/blacklist", _require_owner(self._admin_blacklist_add))
|
|
r.add_delete("/api/admin/blacklist/{user_id}", _require_owner(self._admin_blacklist_remove))
|
|
r.add_get("/api/admin/whitelist", _require_owner(self._admin_whitelist))
|
|
|
|
# WebSocket
|
|
r.add_get("/ws", self._ws_handler)
|
|
|
|
# Static files (SPA)
|
|
r.add_get("/", self._serve_index)
|
|
r.add_static("/static/", path=str(WEB_DIR), name="static", show_index=False)
|
|
# Catch-all for SPA routing
|
|
r.add_get("/{path:.*}", self._serve_spa)
|
|
|
|
# ── Health ───────────────────────────────────────────────────────────
|
|
|
|
async def _health(self, request: web.Request) -> web.Response:
|
|
return _json({"status": "ok", "uptime": int(time.time() - self._start_time)})
|
|
|
|
# ── Auth ─────────────────────────────────────────────────────────────
|
|
|
|
async def _auth_login(self, request: web.Request) -> web.Response:
|
|
client_id = await self.cog.config.client_id()
|
|
redirect_uri = await self.cog.config.redirect_uri()
|
|
if not client_id or not redirect_uri:
|
|
return _err("Dashboard not configured. Run [p]evedash setup", 500)
|
|
state = secrets.token_urlsafe(32)
|
|
url = (
|
|
f"https://discord.com/api/oauth2/authorize"
|
|
f"?client_id={client_id}"
|
|
f"&redirect_uri={redirect_uri}"
|
|
f"&response_type=code"
|
|
f"&scope=identify+guilds"
|
|
f"&state={state}"
|
|
)
|
|
return _json({"url": url, "state": state})
|
|
|
|
async def _auth_callback(self, request: web.Request) -> web.Response:
|
|
code = request.query.get("code")
|
|
if not code:
|
|
return _err("Missing authorization code")
|
|
|
|
client_id = await self.cog.config.client_id()
|
|
client_secret = await self.cog.config.client_secret()
|
|
redirect_uri = await self.cog.config.redirect_uri()
|
|
|
|
# Exchange code for token
|
|
async with aiohttp.ClientSession() as session:
|
|
resp = await session.post(
|
|
"https://discord.com/api/oauth2/token",
|
|
data={
|
|
"client_id": client_id,
|
|
"client_secret": client_secret,
|
|
"grant_type": "authorization_code",
|
|
"code": code,
|
|
"redirect_uri": redirect_uri,
|
|
},
|
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
)
|
|
if resp.status != 200:
|
|
return _err("Failed to exchange authorization code")
|
|
token_data = await resp.json()
|
|
|
|
# Get user info
|
|
resp2 = await session.get(
|
|
"https://discord.com/api/users/@me",
|
|
headers={"Authorization": f"Bearer {token_data['access_token']}"},
|
|
)
|
|
if resp2.status != 200:
|
|
return _err("Failed to fetch user info")
|
|
user_data = await resp2.json()
|
|
|
|
user_id = int(user_data["id"])
|
|
|
|
# Check access: must be bot owner or share a guild
|
|
is_owner = user_id in self.bot.owner_ids
|
|
has_guild = any(g.get_member(user_id) for g in self.bot.guilds)
|
|
if not is_owner and not has_guild:
|
|
return _err("You don't share any servers with this bot", 403)
|
|
|
|
# Check blacklist
|
|
blacklist = await self.cog.config.blacklist()
|
|
if user_id in blacklist:
|
|
return _err("You are blacklisted from the dashboard", 403)
|
|
|
|
# Generate token
|
|
secret = await self.cog.config.secret_key()
|
|
token = _make_token(user_id, secret)
|
|
|
|
# Redirect to frontend with token
|
|
base_url = redirect_uri.rsplit("/api/", 1)[0] # Remove /api/auth/callback
|
|
resp = web.HTTPFound(f"{base_url}/#/callback?token={token}")
|
|
resp.set_cookie(
|
|
"eve_token", token,
|
|
max_age=86400 * 7,
|
|
httponly=True,
|
|
samesite="Lax",
|
|
)
|
|
return resp
|
|
|
|
async def _auth_me(self, request: web.Request) -> web.Response:
|
|
user_id = request["user_id"]
|
|
user = self.bot.get_user(user_id)
|
|
if not user:
|
|
# Try to fetch
|
|
try:
|
|
user = await self.bot.fetch_user(user_id)
|
|
except Exception:
|
|
return _err("User not found", 404)
|
|
|
|
return _json({
|
|
"id": str(user.id),
|
|
"username": user.name,
|
|
"display_name": user.display_name,
|
|
"avatar": str(user.display_avatar.url) if user.display_avatar else None,
|
|
"is_owner": user.id in self.bot.owner_ids,
|
|
})
|
|
|
|
async def _auth_logout(self, request: web.Request) -> web.Response:
|
|
resp = _json({"status": "ok"})
|
|
resp.del_cookie("eve_token")
|
|
return resp
|
|
|
|
# ── Bot info ─────────────────────────────────────────────────────────
|
|
|
|
async def _bot_info(self, request: web.Request) -> web.Response:
|
|
bot = self.bot
|
|
app_info = await bot.application_info()
|
|
prefixes = await bot.get_valid_prefixes()
|
|
return _json({
|
|
"name": bot.user.name,
|
|
"id": str(bot.user.id),
|
|
"avatar": str(bot.user.display_avatar.url),
|
|
"discriminator": bot.user.discriminator,
|
|
"guild_count": len(bot.guilds),
|
|
"user_count": sum(g.member_count or 0 for g in bot.guilds),
|
|
"channel_count": sum(len(g.channels) for g in bot.guilds),
|
|
"cog_count": len(bot.cogs),
|
|
"command_count": len(set(bot.walk_commands())),
|
|
"prefixes": prefixes[:5],
|
|
"description": app_info.description or "",
|
|
"owner": {
|
|
"id": str(app_info.owner.id) if app_info.owner else None,
|
|
"name": str(app_info.owner) if app_info.owner else None,
|
|
},
|
|
"latency_ms": round(bot.latency * 1000, 1),
|
|
"uptime_seconds": int(time.time() - self._start_time),
|
|
"red_version": getattr(bot, "_red_version", "Unknown"),
|
|
})
|
|
|
|
async def _bot_stats(self, request: web.Request) -> web.Response:
|
|
bot = self.bot
|
|
guilds_by_size = sorted(bot.guilds, key=lambda g: g.member_count or 0, reverse=True)
|
|
top_guilds = [
|
|
{"name": g.name, "id": str(g.id), "members": g.member_count or 0, "icon": str(g.icon.url) if g.icon else None}
|
|
for g in guilds_by_size[:10]
|
|
]
|
|
|
|
# Channel type breakdown
|
|
text_channels = sum(len(g.text_channels) for g in bot.guilds)
|
|
voice_channels = sum(len(g.voice_channels) for g in bot.guilds)
|
|
categories = sum(len(g.categories) for g in bot.guilds)
|
|
|
|
return _json({
|
|
"guilds": len(bot.guilds),
|
|
"users": sum(g.member_count or 0 for g in bot.guilds),
|
|
"text_channels": text_channels,
|
|
"voice_channels": voice_channels,
|
|
"categories": categories,
|
|
"cogs_loaded": len(bot.cogs),
|
|
"commands": len(set(bot.walk_commands())),
|
|
"latency_ms": round(bot.latency * 1000, 1),
|
|
"uptime_seconds": int(time.time() - self._start_time),
|
|
"top_guilds": top_guilds,
|
|
"message_count": self._message_count,
|
|
"command_count": self._command_count,
|
|
})
|
|
|
|
# ── Guilds ───────────────────────────────────────────────────────────
|
|
|
|
async def _guilds_list(self, request: web.Request) -> web.Response:
|
|
user_id = request["user_id"]
|
|
is_owner = request["is_owner"]
|
|
query = request.query.get("q", "").lower()
|
|
|
|
guilds = []
|
|
for g in self.bot.guilds:
|
|
if not is_owner:
|
|
member = g.get_member(user_id)
|
|
if not member:
|
|
continue
|
|
if not (member.guild_permissions.manage_guild or member.guild_permissions.administrator):
|
|
continue
|
|
if query and query not in g.name.lower():
|
|
continue
|
|
guilds.append({
|
|
"id": str(g.id),
|
|
"name": g.name,
|
|
"icon": str(g.icon.url) if g.icon else None,
|
|
"member_count": g.member_count or 0,
|
|
"channel_count": len(g.channels),
|
|
"role_count": len(g.roles),
|
|
"owner_id": str(g.owner_id),
|
|
})
|
|
|
|
guilds.sort(key=lambda x: x["member_count"], reverse=True)
|
|
return _json({"guilds": guilds})
|
|
|
|
async def _guild_detail(self, request: web.Request) -> web.Response:
|
|
guild = self._get_guild(request)
|
|
if not guild:
|
|
return _err("Guild not found", 404)
|
|
|
|
if not self._check_guild_access(request, guild):
|
|
return _err("Forbidden", 403)
|
|
|
|
return _json({
|
|
"id": str(guild.id),
|
|
"name": guild.name,
|
|
"icon": str(guild.icon.url) if guild.icon else None,
|
|
"banner": str(guild.banner.url) if guild.banner else None,
|
|
"member_count": guild.member_count or 0,
|
|
"channel_count": len(guild.channels),
|
|
"text_channels": len(guild.text_channels),
|
|
"voice_channels": len(guild.voice_channels),
|
|
"role_count": len(guild.roles),
|
|
"emoji_count": len(guild.emojis),
|
|
"boost_level": guild.premium_tier,
|
|
"boost_count": guild.premium_subscription_count or 0,
|
|
"owner": {"id": str(guild.owner_id), "name": str(guild.owner) if guild.owner else "Unknown"},
|
|
"created_at": guild.created_at.isoformat(),
|
|
"features": list(guild.features),
|
|
})
|
|
|
|
async def _guild_channels(self, request: web.Request) -> web.Response:
|
|
guild = self._get_guild(request)
|
|
if not guild:
|
|
return _err("Guild not found", 404)
|
|
if not self._check_guild_access(request, guild):
|
|
return _err("Forbidden", 403)
|
|
|
|
channels = []
|
|
for c in sorted(guild.channels, key=lambda x: (x.position, x.name)):
|
|
channels.append({
|
|
"id": str(c.id),
|
|
"name": c.name,
|
|
"type": str(c.type),
|
|
"position": c.position,
|
|
"category": str(c.category_id) if c.category_id else None,
|
|
})
|
|
return _json({"channels": channels})
|
|
|
|
async def _guild_roles(self, request: web.Request) -> web.Response:
|
|
guild = self._get_guild(request)
|
|
if not guild:
|
|
return _err("Guild not found", 404)
|
|
if not self._check_guild_access(request, guild):
|
|
return _err("Forbidden", 403)
|
|
|
|
roles = []
|
|
for r in sorted(guild.roles, key=lambda x: x.position, reverse=True):
|
|
roles.append({
|
|
"id": str(r.id),
|
|
"name": r.name,
|
|
"color": str(r.color),
|
|
"position": r.position,
|
|
"mentionable": r.mentionable,
|
|
"hoist": r.hoist,
|
|
"managed": r.managed,
|
|
"member_count": len(r.members),
|
|
})
|
|
return _json({"roles": roles})
|
|
|
|
# ── Guild settings ───────────────────────────────────────────────────
|
|
|
|
async def _guild_settings_get(self, request: web.Request) -> web.Response:
|
|
guild = self._get_guild(request)
|
|
if not guild:
|
|
return _err("Guild not found", 404)
|
|
if not self._check_guild_access(request, guild):
|
|
return _err("Forbidden", 403)
|
|
|
|
prefixes = await self.bot.get_valid_prefixes(guild)
|
|
# Get admin/mod roles from Red's config
|
|
admin_role_ids = await self.bot._config.guild(guild).admin_role()
|
|
mod_role_ids = await self.bot._config.guild(guild).mod_role()
|
|
|
|
# Ignored status
|
|
ignored = await self.bot._config.guild(guild).ignored()
|
|
|
|
# Disabled commands
|
|
disabled_cmds = await self.bot._config.guild(guild).disabled_commands()
|
|
|
|
# Bot nickname
|
|
bot_member = guild.get_member(self.bot.user.id)
|
|
bot_nick = bot_member.nick if bot_member else None
|
|
|
|
return _json({
|
|
"prefixes": prefixes,
|
|
"admin_roles": [str(r) for r in admin_role_ids],
|
|
"mod_roles": [str(r) for r in mod_role_ids],
|
|
"ignored": ignored,
|
|
"disabled_commands": list(disabled_cmds),
|
|
"bot_nickname": bot_nick,
|
|
})
|
|
|
|
async def _guild_settings_update(self, request: web.Request) -> web.Response:
|
|
guild = self._get_guild(request)
|
|
if not guild:
|
|
return _err("Guild not found", 404)
|
|
if not self._check_guild_access(request, guild):
|
|
return _err("Forbidden", 403)
|
|
|
|
data = await request.json()
|
|
|
|
if "prefixes" in data:
|
|
prefixes = data["prefixes"]
|
|
if isinstance(prefixes, list) and prefixes:
|
|
await self.bot._config.guild(guild).prefix.set(prefixes)
|
|
|
|
if "admin_roles" in data:
|
|
role_ids = [int(r) for r in data["admin_roles"] if r]
|
|
await self.bot._config.guild(guild).admin_role.set(role_ids)
|
|
|
|
if "mod_roles" in data:
|
|
role_ids = [int(r) for r in data["mod_roles"] if r]
|
|
await self.bot._config.guild(guild).mod_role.set(role_ids)
|
|
|
|
if "bot_nickname" in data:
|
|
bot_member = guild.get_member(self.bot.user.id)
|
|
if bot_member:
|
|
try:
|
|
await bot_member.edit(nick=data["bot_nickname"] or None)
|
|
except Exception:
|
|
pass
|
|
|
|
return _json({"status": "ok"})
|
|
|
|
# ── Commands ─────────────────────────────────────────────────────────
|
|
|
|
async def _guild_commands(self, request: web.Request) -> web.Response:
|
|
guild = self._get_guild(request)
|
|
if not guild:
|
|
return _err("Guild not found", 404)
|
|
if not self._check_guild_access(request, guild):
|
|
return _err("Forbidden", 403)
|
|
|
|
disabled_cmds = await self.bot._config.guild(guild).disabled_commands()
|
|
|
|
cogs_data = {}
|
|
for cmd in sorted(self.bot.walk_commands(), key=lambda c: c.qualified_name):
|
|
cog_name = cmd.cog_name or "No Category"
|
|
if cog_name not in cogs_data:
|
|
cog = cmd.cog
|
|
cogs_data[cog_name] = {
|
|
"name": cog_name,
|
|
"description": (cog.__doc__ or "").strip().split("\n")[0] if cog else "",
|
|
"commands": [],
|
|
}
|
|
|
|
cogs_data[cog_name]["commands"].append({
|
|
"name": cmd.qualified_name,
|
|
"description": cmd.short_doc or "",
|
|
"enabled": cmd.qualified_name not in disabled_cmds,
|
|
"hidden": cmd.hidden,
|
|
"aliases": list(cmd.aliases),
|
|
"signature": cmd.signature,
|
|
"parent": cmd.parent.qualified_name if cmd.parent else None,
|
|
})
|
|
|
|
return _json({"cogs": cogs_data})
|
|
|
|
async def _guild_command_toggle(self, request: web.Request) -> web.Response:
|
|
guild = self._get_guild(request)
|
|
if not guild:
|
|
return _err("Guild not found", 404)
|
|
if not self._check_guild_access(request, guild):
|
|
return _err("Forbidden", 403)
|
|
|
|
cmd_name = request.match_info["command"]
|
|
data = await request.json()
|
|
enabled = data.get("enabled", True)
|
|
|
|
async with self.bot._config.guild(guild).disabled_commands() as disabled:
|
|
if enabled and cmd_name in disabled:
|
|
disabled.remove(cmd_name)
|
|
elif not enabled and cmd_name not in disabled:
|
|
disabled.append(cmd_name)
|
|
|
|
return _json({"status": "ok", "command": cmd_name, "enabled": enabled})
|
|
|
|
# ── Cog management ───────────────────────────────────────────────────
|
|
|
|
async def _cogs_list(self, request: web.Request) -> web.Response:
|
|
loaded = {}
|
|
for name, cog in self.bot.cogs.items():
|
|
loaded[name] = {
|
|
"name": name,
|
|
"loaded": True,
|
|
"description": (cog.__doc__ or "").strip().split("\n")[0],
|
|
"author": getattr(cog, "__author__", "Unknown"),
|
|
"commands": [c.qualified_name for c in cog.get_commands()],
|
|
}
|
|
|
|
# Get available cogs from downloader if available
|
|
available = []
|
|
downloader = self.bot.get_cog("Downloader")
|
|
if downloader:
|
|
try:
|
|
installed = await downloader.installed_cogs()
|
|
for inst_cog in installed:
|
|
cog_name = inst_cog.name
|
|
if cog_name not in loaded:
|
|
available.append({
|
|
"name": cog_name,
|
|
"loaded": False,
|
|
"description": "",
|
|
"repo": str(getattr(inst_cog, "repo_name", "unknown")),
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
return _json({"loaded": loaded, "available": available})
|
|
|
|
async def _cog_load(self, request: web.Request) -> web.Response:
|
|
cog_name = request.match_info["cog_name"]
|
|
try:
|
|
await self.bot.load_extension(f"cogs.{cog_name}")
|
|
return _json({"status": "ok", "message": f"{cog_name} loaded"})
|
|
except Exception as e:
|
|
# Try Red's cog loading
|
|
try:
|
|
spec = await self.bot._cog_mgr.find_cog(cog_name)
|
|
if spec:
|
|
await self.bot.load_extension(spec)
|
|
return _json({"status": "ok", "message": f"{cog_name} loaded"})
|
|
except Exception as e2:
|
|
return _err(f"Failed to load {cog_name}: {e2}")
|
|
return _err(f"Failed to load {cog_name}: {e}")
|
|
|
|
async def _cog_unload(self, request: web.Request) -> web.Response:
|
|
cog_name = request.match_info["cog_name"]
|
|
if cog_name.lower() == "dashboard":
|
|
return _err("Cannot unload the Dashboard cog from the dashboard")
|
|
try:
|
|
await self.bot.remove_cog(cog_name)
|
|
return _json({"status": "ok", "message": f"{cog_name} unloaded"})
|
|
except Exception as e:
|
|
return _err(f"Failed to unload {cog_name}: {e}")
|
|
|
|
async def _cog_reload(self, request: web.Request) -> web.Response:
|
|
cog_name = request.match_info["cog_name"]
|
|
try:
|
|
cog = self.bot.get_cog(cog_name)
|
|
if cog:
|
|
module = cog.__module__
|
|
await self.bot.remove_cog(cog_name)
|
|
await self.bot.load_extension(module)
|
|
return _json({"status": "ok", "message": f"{cog_name} reloaded"})
|
|
return _err(f"Cog {cog_name} not found")
|
|
except Exception as e:
|
|
return _err(f"Failed to reload {cog_name}: {e}")
|
|
|
|
# ── Third-party integration ──────────────────────────────────────────
|
|
|
|
async def _third_parties_list(self, request: web.Request) -> web.Response:
|
|
guild = self._get_guild(request)
|
|
if not guild:
|
|
return _err("Guild not found", 404)
|
|
if not self._check_guild_access(request, guild):
|
|
return _err("Forbidden", 403)
|
|
|
|
tp_info = self.cog.third_parties.get_third_parties_info()
|
|
return _json({"third_parties": tp_info})
|
|
|
|
async def _third_party_page(self, request: web.Request) -> web.Response:
|
|
guild = self._get_guild(request)
|
|
if not guild:
|
|
return _err("Guild not found", 404)
|
|
if not self._check_guild_access(request, guild):
|
|
return _err("Forbidden", 403)
|
|
|
|
cog_name = request.match_info["cog_name"]
|
|
page_name = request.match_info["page"]
|
|
user = self.bot.get_user(request["user_id"])
|
|
|
|
data = None
|
|
if request.method == "POST":
|
|
data = await request.json()
|
|
|
|
result = await self.cog.third_parties.call_page(
|
|
cog_name, page_name,
|
|
user=user, guild=guild,
|
|
method=request.method, data=data,
|
|
)
|
|
return _json(result)
|
|
|
|
# ── Admin ────────────────────────────────────────────────────────────
|
|
|
|
async def _admin_config(self, request: web.Request) -> web.Response:
|
|
prefixes = await self.bot._config.prefix()
|
|
return _json({
|
|
"global_prefixes": prefixes,
|
|
"owner_ids": [str(uid) for uid in self.bot.owner_ids],
|
|
"embeds": await self.bot._config.embeds(),
|
|
"color": await self.bot._config.color(),
|
|
"fuzzy": await self.bot._config.fuzzy(),
|
|
"invite_public": await self.bot._config.invite_public(),
|
|
"invite_perm": await self.bot._config.invite_perm(),
|
|
"disabled_commands": list(await self.bot._config.disabled_commands()),
|
|
"locale": await self.bot._config.locale(),
|
|
})
|
|
|
|
async def _admin_config_update(self, request: web.Request) -> web.Response:
|
|
data = await request.json()
|
|
|
|
if "global_prefixes" in data:
|
|
prefixes = data["global_prefixes"]
|
|
if isinstance(prefixes, list) and prefixes:
|
|
await self.bot._config.prefix.set(prefixes)
|
|
|
|
if "embeds" in data:
|
|
await self.bot._config.embeds.set(bool(data["embeds"]))
|
|
|
|
if "fuzzy" in data:
|
|
await self.bot._config.fuzzy.set(bool(data["fuzzy"]))
|
|
|
|
if "invite_public" in data:
|
|
await self.bot._config.invite_public.set(bool(data["invite_public"]))
|
|
|
|
if "locale" in data:
|
|
await self.bot._config.locale.set(data["locale"])
|
|
|
|
return _json({"status": "ok"})
|
|
|
|
async def _admin_blacklist(self, request: web.Request) -> web.Response:
|
|
blacklist = await self.cog.config.blacklist()
|
|
users = []
|
|
for uid in blacklist:
|
|
user = self.bot.get_user(uid)
|
|
users.append({
|
|
"id": str(uid),
|
|
"name": str(user) if user else f"Unknown#{uid}",
|
|
"avatar": str(user.display_avatar.url) if user and user.display_avatar else None,
|
|
})
|
|
return _json({"blacklist": users})
|
|
|
|
async def _admin_blacklist_add(self, request: web.Request) -> web.Response:
|
|
data = await request.json()
|
|
user_id = int(data.get("user_id", 0))
|
|
if not user_id:
|
|
return _err("Invalid user ID")
|
|
async with self.cog.config.blacklist() as bl:
|
|
if user_id not in bl:
|
|
bl.append(user_id)
|
|
return _json({"status": "ok"})
|
|
|
|
async def _admin_blacklist_remove(self, request: web.Request) -> web.Response:
|
|
user_id = int(request.match_info["user_id"])
|
|
async with self.cog.config.blacklist() as bl:
|
|
if user_id in bl:
|
|
bl.remove(user_id)
|
|
return _json({"status": "ok"})
|
|
|
|
async def _admin_whitelist(self, request: web.Request) -> web.Response:
|
|
# Red's global whitelist
|
|
try:
|
|
whitelist = await self.bot._config.whitelist()
|
|
users = []
|
|
for uid in whitelist:
|
|
user = self.bot.get_user(uid)
|
|
users.append({
|
|
"id": str(uid),
|
|
"name": str(user) if user else f"Unknown#{uid}",
|
|
})
|
|
return _json({"whitelist": users})
|
|
except Exception:
|
|
return _json({"whitelist": []})
|
|
|
|
# ── WebSocket ────────────────────────────────────────────────────────
|
|
|
|
async def _ws_handler(self, request: web.Request) -> web.WebSocketResponse:
|
|
ws = web.WebSocketResponse()
|
|
await ws.prepare(request)
|
|
|
|
# Authenticate via first message
|
|
user_id = request["user_id"]
|
|
if not user_id:
|
|
try:
|
|
msg = await asyncio.wait_for(ws.receive(), timeout=10)
|
|
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
data = json.loads(msg.data)
|
|
secret = await self.cog.config.secret_key()
|
|
user_id = _verify_token(data.get("token", ""), secret)
|
|
except Exception:
|
|
pass
|
|
|
|
if not user_id:
|
|
await ws.close(message=b"Unauthorized")
|
|
return ws
|
|
|
|
await self.ws_manager.add(user_id, ws)
|
|
await ws.send_json({"event": "connected", "data": {"user_id": str(user_id)}})
|
|
|
|
try:
|
|
async for msg in ws:
|
|
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
data = json.loads(msg.data)
|
|
if data.get("type") == "ping":
|
|
await ws.send_json({"event": "pong", "data": {}})
|
|
elif msg.type == aiohttp.WSMsgType.ERROR:
|
|
break
|
|
finally:
|
|
await self.ws_manager.remove(user_id, ws)
|
|
|
|
return ws
|
|
|
|
# ── Static serving ───────────────────────────────────────────────────
|
|
|
|
async def _serve_index(self, request: web.Request) -> web.Response:
|
|
index_path = WEB_DIR / "index.html"
|
|
if index_path.exists():
|
|
return web.FileResponse(index_path)
|
|
return _err("Frontend not found", 500)
|
|
|
|
async def _serve_spa(self, request: web.Request) -> web.Response:
|
|
path = request.match_info.get("path", "")
|
|
file_path = WEB_DIR / path
|
|
|
|
# Serve actual files if they exist
|
|
if file_path.is_file():
|
|
return web.FileResponse(file_path)
|
|
|
|
# Otherwise serve index.html (SPA routing)
|
|
index_path = WEB_DIR / "index.html"
|
|
if index_path.exists():
|
|
return web.FileResponse(index_path)
|
|
return _err("Not found", 404)
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
def _get_guild(self, request: web.Request):
|
|
try:
|
|
guild_id = int(request.match_info["guild_id"])
|
|
return self.bot.get_guild(guild_id)
|
|
except (KeyError, ValueError):
|
|
return None
|
|
|
|
def _check_guild_access(self, request: web.Request, guild) -> bool:
|
|
if request["is_owner"]:
|
|
return True
|
|
user_id = request["user_id"]
|
|
member = guild.get_member(user_id)
|
|
if not member:
|
|
return False
|
|
return member.guild_permissions.manage_guild or member.guild_permissions.administrator
|