Update configuration and README for improved setup and security; disable default API and emoji sync, and enhance Discord permission checks in API routes.

This commit is contained in:
TheOnlyMace
2026-07-21 16:00:52 +02:00
parent 1fdaf4dd6c
commit 127f8a032f
25 changed files with 780 additions and 434 deletions

View File

@@ -175,8 +175,8 @@ Copy `.env.example` to `.env` and fill in the values:
TOKEN = your_discord_bot_token TOKEN = your_discord_bot_token
brand_name = 'ZyroX' brand_name = 'ZyroX'
# ── Owner IDs (comma-separated) ─────────────────────────────────── # ── Owner IDs (REQUIRED — your Discord user ID) ───────────────────
OWNER_IDS = 870179991462236170,767979794411028491 OWNER_IDS = YOUR_DISCORD_USER_ID_HERE
# ── Lavalink ────────────────────────────────────────────────────── # ── Lavalink ──────────────────────────────────────────────────────
LAVALINK_HOST = "your-lavalink-host" LAVALINK_HOST = "your-lavalink-host"
@@ -185,21 +185,23 @@ LAVALINK_SECURE = "true"
LAVALINK_PORT = "" LAVALINK_PORT = ""
# ── Emoji Sync ──────────────────────────────────────────────────── # ── Emoji Sync ────────────────────────────────────────────────────
EMOJI_SYNC = "true" EMOJI_SYNC = "false"
# ── API / Dashboard Backend ─────────────────────────────────────── # ── API / Dashboard Backend ───────────────────────────────────────
API_ENABLED = "true" API_ENABLED = "false"
API_HOST = "127.0.0.1"
API_PORT = "8000" API_PORT = "8000"
DASHBOARD_API_KEY = "change_this_to_a_strong_secret" DASHBOARD_API_KEY = "change_this_to_a_strong_secret"
DASHBOARD_ADMIN_IDS = YOUR_DISCORD_USER_ID_HERE
CORS_ORIGINS = "" CORS_ORIGINS = ""
# ── Cloudflare Tunnel ───────────────────────────────────────────── # ── Cloudflare Tunnel (optional) ──────────────────────────────────
TUNNEL_ENABLED = "true" TUNNEL_ENABLED = "false"
CF_TUNNEL_TOKEN = "your_tunnel_token" CF_TUNNEL_TOKEN = "your_tunnel_token"
CF_TUNNEL_URL = "https://api.yourdomain.com" CF_TUNNEL_URL = "https://api.yourdomain.com"
# ── Webhooks ────────────────────────────────────────────────────── # ── Webhooks (optional — leave unset to disable) ──────────────────
WEBHOOK_URL = "https://discord.com/api/webhooks/..." # CMD_WEBHOOK_URL = "https://discord.com/api/webhooks/ID/TOKEN"
``` ```
**4 — Run the bot** **4 — Run the bot**
@@ -224,8 +226,8 @@ npm install
Copy `.env.example` to `.env.local`: Copy `.env.example` to `.env.local`:
```env ```env
NEXT_PUBLIC_API_URL = https://api.yourdomain.com/api/v1 DASHBOARD_API_URL = http://127.0.0.1:8000/api/v1
NEXT_PUBLIC_DASHBOARD_API_KEY = your_shared_api_key DASHBOARD_API_KEY = same_secret_as_bot_DASHBOARD_API_KEY
NEXTAUTH_URL = http://localhost:3000 NEXTAUTH_URL = http://localhost:3000
NEXTAUTH_SECRET = a_long_random_string NEXTAUTH_SECRET = a_long_random_string
@@ -233,6 +235,7 @@ NEXTAUTH_SECRET = a_long_random_string
DISCORD_CLIENT_ID = your_discord_oauth_client_id DISCORD_CLIENT_ID = your_discord_oauth_client_id
DISCORD_CLIENT_SECRET = your_discord_oauth_client_secret DISCORD_CLIENT_SECRET = your_discord_oauth_client_secret
ADMIN_IDS = your_discord_user_id
NEXT_PUBLIC_ADMIN_IDS = your_discord_user_id NEXT_PUBLIC_ADMIN_IDS = your_discord_user_id
NEXT_PUBLIC_BRAND_NAME = "ZyroX" NEXT_PUBLIC_BRAND_NAME = "ZyroX"
NEXT_PUBLIC_BRAND_NAME_WORD = "ZX" NEXT_PUBLIC_BRAND_NAME_WORD = "ZX"
@@ -255,35 +258,42 @@ Open [http://localhost:3000](http://localhost:3000)
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `TOKEN` | — | Discord bot token | | `TOKEN` | — | Discord bot token |
| `OWNER_IDS` | | Comma-separated owner Discord user IDs | | `OWNER_IDS` | _(required)_ | Comma-separated owner Discord user IDs**must be your own ID** |
| `LAVALINK_HOST` | — | Lavalink server hostname (no protocol) | | `LAVALINK_HOST` | — | Lavalink server hostname (no protocol) |
| `LAVALINK_PASSWORD` | — | Lavalink password | | `LAVALINK_PASSWORD` | — | Lavalink password |
| `LAVALINK_SECURE` | `true` | `true` = HTTPS, `false` = HTTP | | `LAVALINK_SECURE` | `true` | `true` = HTTPS, `false` = HTTP |
| `LAVALINK_PORT` | _(empty)_ | Port — only needed when `LAVALINK_SECURE=false` | | `LAVALINK_PORT` | _(empty)_ | Port — only needed when `LAVALINK_SECURE=false` |
| `EMOJI_SYNC` | `true` | Run application emoji sync on startup | | `EMOJI_SYNC` | `false` | Run application emoji sync on startup |
| `API_ENABLED` | `true` | Start the FastAPI dashboard backend | | `JISHAKU_ENABLED` | `false` | Enable owner debug REPL (disable in production) |
| `API_ENABLED` | `false` | Start the FastAPI dashboard backend |
| `API_HOST` | `127.0.0.1` | Bind address — use `127.0.0.1` unless behind a reverse proxy |
| `API_PORT` | `8000` | Port the backend listens on | | `API_PORT` | `8000` | Port the backend listens on |
| `DASHBOARD_API_KEY` | — | Shared secret between bot API and dashboard | | `DASHBOARD_API_KEY` | — | Shared secret between bot API and dashboard (server-side only) |
| `DASHBOARD_ADMIN_IDS` | — | Discord user IDs allowed to use admin API routes |
| `CORS_ORIGINS` | _(empty)_ | Extra CORS-allowed origins, comma-separated | | `CORS_ORIGINS` | _(empty)_ | Extra CORS-allowed origins, comma-separated |
| `WEBHOOK_URL` | — | Discord webhook for command logs | | `CMD_WEBHOOK_URL` | _(empty)_ | Optional Discord webhook for command logging |
| `TUNNEL_ENABLED` | `true` | Expose the API over HTTPS via Cloudflare Tunnel | | `TUNNEL_ENABLED` | `false` | Expose the API over HTTPS via Cloudflare Tunnel |
| `CF_TUNNEL_TOKEN` | — | Token from Cloudflare Zero Trust dashboard | | `CF_TUNNEL_TOKEN` | — | Token from Cloudflare Zero Trust dashboard |
| `CF_TUNNEL_URL` | — | Your permanent public URL (e.g. `https://api.yourdomain.com`) | | `CF_TUNNEL_URL` | — | Your permanent public URL (e.g. `https://api.yourdomain.com`) |
| `LOG_CHANNEL_ID` | _(empty)_ | Optional channel for join/leave logs |
### Dashboard — `dashboard/.env.local` ### Dashboard — `dashboard/.env.local`
| Variable | Description | | Variable | Description |
|---|---| |---|---|
| `NEXT_PUBLIC_API_URL` | Full URL to the bot's FastAPI backend — use Cloudflare Tunnel URL | | `DASHBOARD_API_URL` | Bot API URL (server-side, e.g. `http://127.0.0.1:8000/api/v1`) |
| `NEXT_PUBLIC_DASHBOARD_API_KEY` | Must match `DASHBOARD_API_KEY` in the bot | | `DASHBOARD_API_KEY` | Must match `DASHBOARD_API_KEY` in the bot**never use NEXT_PUBLIC_** |
| `NEXTAUTH_URL` | Your dashboard's public URL | | `NEXTAUTH_URL` | Your dashboard's public URL |
| `NEXTAUTH_SECRET` | Random secret for NextAuth session signing | | `NEXTAUTH_SECRET` | Random secret for NextAuth session signing |
| `DISCORD_CLIENT_ID` | Discord OAuth2 client ID | | `DISCORD_CLIENT_ID` | Discord OAuth2 client ID |
| `DISCORD_CLIENT_SECRET` | Discord OAuth2 client secret | | `DISCORD_CLIENT_SECRET` | Discord OAuth2 client secret |
| `NEXT_PUBLIC_ADMIN_IDS` | Comma-separated Discord user IDs with admin access | | `ADMIN_IDS` | Comma-separated Discord user IDs with admin panel access (server-side) |
| `NEXT_PUBLIC_ADMIN_IDS` | Same IDs for client-side admin UI visibility |
| `NEXT_PUBLIC_BRAND_NAME` | Bot name shown in the dashboard UI | | `NEXT_PUBLIC_BRAND_NAME` | Bot name shown in the dashboard UI |
| `NEXT_PUBLIC_BRAND_NAME_WORD` | Short abbreviation shown in the dashboard | | `NEXT_PUBLIC_BRAND_NAME_WORD` | Short abbreviation shown in the dashboard |
> **API security:** Guild routes require a valid Discord OAuth token with Manage Server permission. The dashboard forwards this automatically via `/api/proxy`.
--- ---
## ✦ HTTPS Tunnel (Cloudflare) ## ✦ HTTPS Tunnel (Cloudflare)
@@ -376,7 +386,9 @@ Runs automatically on startup when `EMOJI_SYNC=true`:
| Bot fails to start | Check `TOKEN` is set and bot has correct gateway intents | | Bot fails to start | Check `TOKEN` is set and bot has correct gateway intents |
| Music not working | Verify `LAVALINK_HOST`, `LAVALINK_SECURE`, and `LAVALINK_PORT` | | Music not working | Verify `LAVALINK_HOST`, `LAVALINK_SECURE`, and `LAVALINK_PORT` |
| Dashboard auth error | Check Discord OAuth client ID/secret and redirect URI | | Dashboard auth error | Check Discord OAuth client ID/secret and redirect URI |
| Dashboard can't load data | Confirm `API_ENABLED=true`, bot is running, `NEXT_PUBLIC_API_URL` is correct | | Dashboard can't load data | Confirm `API_ENABLED=true`, bot running, `DASHBOARD_API_URL` correct, user logged in via Discord |
| API 403 on guild routes | User needs Manage Server or Admin permission on that Discord server |
| API key rejected (401) | `DASHBOARD_API_KEY` must exactly match in bot and dashboard `.env` |
| Emojis showing as plain text | Run with `EMOJI_SYNC=true` once to upload and patch IDs | | Emojis showing as plain text | Run with `EMOJI_SYNC=true` once to upload and patch IDs |
| CORS errors from dashboard | Add your Vercel URL to `CORS_ORIGINS` in `bot/.env` | | CORS errors from dashboard | Add your Vercel URL to `CORS_ORIGINS` in `bot/.env` |
| Tunnel not starting | Check `CF_TUNNEL_TOKEN` is valid and `pycloudflared` is installed | | Tunnel not starting | Check `CF_TUNNEL_TOKEN` is valid and `pycloudflared` is installed |

View File

@@ -1,53 +1,43 @@
TOKEN = TOKEN_HERE TOKEN=TOKEN_HERE
brand_name='ZyroX' brand_name='ZyroX'
NEXT_PUBLIC_BRAND_NAME='ZyroX' NEXT_PUBLIC_BRAND_NAME='ZyroX'
# ── Owner / Staff IDs (comma-separated Discord user IDs) ────────────────────── # ── Owner / Staff IDs (REQUIRED — comma-separated Discord user IDs) ───────────
# Edit this list — no code changes needed anywhere else # Set your own Discord user ID(s). No foreign IDs are used as fallback.
OWNER_IDS=870179991462236170,767979794411028491,1432771000629596225,1382744437049790495,1263404140965396555 OWNER_IDS=YOUR_DISCORD_USER_ID_HERE
# ── Optional: debug REPL for owners (disabled by default) ─────────────────────
JISHAKU_ENABLED=false
# ── Optional: Discord log / stats channels (leave empty to disable) ───────────
# LOG_CHANNEL_ID=
# SERVER_COUNT_CHANNEL_ID=
# USER_COUNT_CHANNEL_ID=
# ── Lavalink ────────────────────────────────────────────────────────────────── # ── Lavalink ──────────────────────────────────────────────────────────────────
LAVALINK_HOST="lavalink.jirayu.net" LAVALINK_HOST="lavalink.jirayu.net"
LAVALINK_PASSWORD="youshallnotpass" LAVALINK_PASSWORD="youshallnotpass"
# Set to true if the node uses HTTPS/WSS (secure), false for HTTP/WS
LAVALINK_SECURE="false" LAVALINK_SECURE="false"
# Only needed when LAVALINK_SECURE is false (e.g. 2333)
LAVALINK_PORT="13592" LAVALINK_PORT="13592"
# ── Emoji Sync ──────────────────────────────────────────────────────────────── # ── Emoji Sync ────────────────────────────────────────────────────────────────
# Set to true to enable automatic application emoji sync on startup
EMOJI_SYNC="false" EMOJI_SYNC="false"
# ── API / Dashboard Backend ─────────────────────────────────────────────────── # ── API / Dashboard Backend ───────────────────────────────────────────────────
# Set to true to enable the dashboard API server
API_ENABLED="false" API_ENABLED="false"
# Port the API server listens on API_HOST="127.0.0.1"
API_PORT="8000" API_PORT="8000"
DASHBOARD_API_KEY="ZYROX_SECURE_API_KEY_12345_CHANGE_THIS_ASAP_BY_CODEX_DEVS" DASHBOARD_API_KEY="generate_a_long_random_secret_here"
# Extra CORS-allowed origins, comma-separated (optional) # Discord user IDs allowed to use /api/v1/admin (comma-separated; falls back to OWNER_IDS)
DASHBOARD_ADMIN_IDS=YOUR_DISCORD_USER_ID_HERE
CORS_ORIGINS="" CORS_ORIGINS=""
# CODEX DEVS # ── Webhooks (leave empty or unset to disable command logging) ────────────────
# CMD_WEBHOOK_URL must be a full Discord webhook URL to enable logging.
# CMD_WEBHOOK_URL=
# ── Webhooks ────────────────────────────────────────────────────────────────── # ── Cloudflare Tunnel (disabled by default) ───────────────────────────────────
WEBHOOK_URL="https://discord.com/api/webhooks/" TUNNEL_ENABLED="false"
CMD_WEBHOOK_URL="https://discord.com/api/webhooks/" TUNNEL_ALLOW_BINARY_DOWNLOAD="false"
# CF_TUNNEL_TOKEN=
# CF_TUNNEL_URL=
# ── Cloudflare Tunnel For API Server ─────────────────────────────────────────────────────────
#
# How to get CF_TUNNEL_TOKEN (browser only, no CLI):
# 1. Go to https://one.dash.cloudflare.com
# 2. Networks → Tunnels → Create a tunnel → Choose "Cloudflared"
# 3. Give it a name (e.g. zyrox-api) and save
# 4. On the "Install connector" step, copy the token from the shown command:
# cloudflared tunnel run --token <COPY_THIS_TOKEN>
# 5. Go to "Published Application Routes" tab → Add a Published Application Routes:
# Subdomain: zyrox-api Domain: yourdomain.com Service: http://localhost:8000
# 6. Paste the token below — that's it. Permanent URL, unlimited traffic.
#
TUNNEL_ENABLED="true"
CF_TUNNEL_TOKEN="xxx_xxxx"
CF_TUNNEL_URL="https://zyrox-api.yourdomain.com"

View File

@@ -35,51 +35,50 @@ from utils.config import *
from utils.emoji import SUCCESS, ERROR, TICK, CROSS, REACTION_TEST_EMOJIS from utils.emoji import SUCCESS, ERROR, TICK, CROSS, REACTION_TEST_EMOJIS
from utils.sync_emojis import run_sync from utils.sync_emojis import run_sync
import jishaku
import cogs import cogs
os.environ["JISHAKU_NO_DM_TRACEBACK"] = "False"
os.environ["JISHAKU_HIDE"] = "True"
os.environ["JISHAKU_NO_UNDERSCORE"] = "True"
os.environ["JISHAKU_FORCE_PAGINATOR"] = "True"
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
TOKEN = os.getenv("TOKEN") TOKEN = os.getenv("TOKEN")
# --- Configuration --- if JISHAKU_ENABLED:
# IMPORTANT: Replace these with your actual channel IDs. os.environ["JISHAKU_NO_DM_TRACEBACK"] = "False"
SERVER_COUNT_CHANNEL_ID = 1419729255977189467 # Replace with your server count channel ID os.environ["JISHAKU_HIDE"] = "True"
USER_COUNT_CHANNEL_ID = 1419729283861184632 # Replace with your user count channel ID os.environ["JISHAKU_NO_UNDERSCORE"] = "True"
LOG_CHANNEL_ID = 1396794297386532978 # Replace with the channel ID for join/leave logs os.environ["JISHAKU_FORCE_PAGINATOR"] = "True"
if not OWNER_IDS:
print("\033[33m⚠ OWNER_IDS is not set in .env — no bot owners configured.\033[0m")
print("\033[33m Set OWNER_IDS=YOUR_DISCORD_USER_ID before running in production.\033[0m")
client = zyrox() client = zyrox()
tree = client.tree tree = client.tree
# --- Background Task for Stats --- # --- Background Task for Stats ---
async def update_stats(): async def update_stats():
"""A background task to update server and user stats in channel names.""" """Update server/user counts in channel names when channel IDs are configured."""
if not SERVER_COUNT_CHANNEL_ID and not USER_COUNT_CHANNEL_ID:
return
await client.wait_until_ready() await client.wait_until_ready()
while not client.is_closed(): while not client.is_closed():
try: try:
servers = len(client.guilds) servers = len(client.guilds)
users = sum(guild.member_count for guild in client.guilds if guild.member_count is not None) users = sum(guild.member_count for guild in client.guilds if guild.member_count is not None)
server_channel = client.get_channel(SERVER_COUNT_CHANNEL_ID) if SERVER_COUNT_CHANNEL_ID:
user_channel = client.get_channel(USER_COUNT_CHANNEL_ID) server_channel = client.get_channel(SERVER_COUNT_CHANNEL_ID)
if server_channel:
if server_channel: await server_channel.edit(name=f"Servers: {servers}")
await server_channel.edit(name=f"Servers: {servers}")
if USER_COUNT_CHANNEL_ID:
if user_channel: user_channel = client.get_channel(USER_COUNT_CHANNEL_ID)
await user_channel.edit(name=f"Users: {users}") if user_channel:
await user_channel.edit(name=f"Users: {users}")
except Exception as e: except Exception as e:
print(f"Error updating stats: {e}") print(f"Error updating stats: {e}")
await asyncio.sleep(600) # Update every 10 minutes await asyncio.sleep(600)
# --- Event Handlers --- # --- Event Handlers ---
@client.event @client.event
@@ -113,49 +112,50 @@ async def on_ready():
print(f"Error syncing command tree: {e}") print(f"Error syncing command tree: {e}")
client.loop.create_task(sync_commands()) client.loop.create_task(sync_commands())
client.loop.create_task(update_stats()) if SERVER_COUNT_CHANNEL_ID or USER_COUNT_CHANNEL_ID:
client.loop.create_task(update_stats())
@client.event @client.event
async def on_guild_join(guild: discord.Guild): async def on_guild_join(guild: discord.Guild):
# Log when the bot joins a server if not LOG_CHANNEL_ID:
return
log_channel = client.get_channel(LOG_CHANNEL_ID) log_channel = client.get_channel(LOG_CHANNEL_ID)
if log_channel: if log_channel:
await log_channel.send(f"{BRAND_NAME} has been added to the server: **{guild.name}** (ID: `{guild.id}`)") await log_channel.send(f"{BRAND_NAME} has been added to the server: **{guild.name}** (ID: `{guild.id}`)")
@client.event @client.event
async def on_command_completion(context: commands.Context) -> None: async def on_command_completion(context: commands.Context) -> None:
if context.author.id in OWNER_IDS: if not CMD_WEBHOOK_URL or context.command is None or context.author.id in OWNER_IDS:
return return
full_command_name = context.command.qualified_name full_command_name = context.command.qualified_name
split = full_command_name.split("\n") split = full_command_name.split("\n")
executed_command = str(split[0]) executed_command = str(split[0])
webhook_url = CMD_WEBHOOK_URL
async with aiohttp.ClientSession() as session:
webhook = discord.Webhook.from_url(webhook_url, session=session)
embed_color = 0xFF0000 embed_color = 0xFF0000
embed = discord.Embed(color=embed_color) embed = discord.Embed(color=embed_color)
avatar_url = context.author.display_avatar.url avatar_url = context.author.display_avatar.url
embed.set_author(name=f"Cmd Executed: {executed_command}", icon_url=avatar_url) embed.set_author(name=f"Cmd Executed: {executed_command}", icon_url=avatar_url)
embed.set_thumbnail(url=avatar_url) embed.set_thumbnail(url=avatar_url)
if context.guild is not None: if context.guild is not None:
embed.add_field(name="User", value=f"{context.author.mention} (`{context.author.id}`)", inline=False) embed.add_field(name="User", value=f"{context.author.mention} (`{context.author.id}`)", inline=False)
embed.add_field(name="Server", value=f"{context.guild.name} (`{context.guild.id}`)", inline=False) embed.add_field(name="Server", value=f"{context.guild.name} (`{context.guild.id}`)", inline=False)
embed.add_field(name="Channel", value=f"{context.channel.mention} (`{context.channel.id}`)", inline=False) embed.add_field(name="Channel", value=f"{context.channel.mention} (`{context.channel.id}`)", inline=False)
else: else:
embed.add_field(name="User (DM)", value=f"{context.author.mention} (`{context.author.id}`)", inline=False) embed.add_field(name="User (DM)", value=f"{context.author.mention} (`{context.author.id}`)", inline=False)
embed.timestamp = discord.utils.utcnow() embed.timestamp = discord.utils.utcnow()
embed.set_footer(text=f"{BRAND_NAME} Development™ ❤️", icon_url=client.user.display_avatar.url) embed.set_footer(text=f"{BRAND_NAME} Development™ ❤️", icon_url=client.user.display_avatar.url)
try: try:
async with aiohttp.ClientSession() as session:
webhook = discord.Webhook.from_url(CMD_WEBHOOK_URL, session=session)
await webhook.send(embed=embed) await webhook.send(embed=embed)
except Exception as e: except Exception as e:
print(f'Command log webhook failed: {e}') print(f'Command log webhook failed: {e}')
# --- Utility Commands --- # --- Utility Commands ---
@@ -318,23 +318,22 @@ fastapi_app = create_app()
fastapi_app.state.bot = client fastapi_app.state.bot = client
set_bot(client) set_bot(client)
API_ENABLED = os.getenv("API_ENABLED", "true").strip().lower() == "true"
API_PORT = int(os.getenv("API_PORT", "8000"))
def run_api(): def run_api():
uvicorn.run(fastapi_app, host='0.0.0.0', port=API_PORT, log_level="warning") uvicorn.run(fastapi_app, host=API_HOST, port=API_PORT, log_level="warning")
def keep_alive(): def keep_alive():
if not API_ENABLED: if not API_ENABLED:
print(f"\033[33m◈ API Server: Disabled via API_ENABLED=false\033[0m") print(f"\033[33m◈ API Server: Disabled via API_ENABLED=false\033[0m")
return return
print(f"\033[32m◈ API Server: Starting on port {API_PORT}\033[0m") print(f"\033[32m◈ API Server: Starting on {API_HOST}:{API_PORT}\033[0m")
if API_HOST in ("0.0.0.0", "::"):
print("\033[33m ⚠ API bound to all interfaces — use API_HOST=127.0.0.1 unless behind a reverse proxy.\033[0m")
server = Thread(target=run_api, daemon=True) server = Thread(target=run_api, daemon=True)
server.start() server.start()
keep_alive() keep_alive()
# --- Cloudflare Tunnel (HTTPS for API) --- # --- Cloudflare Tunnel (HTTPS for API) — only when explicitly enabled ---
from utils.tunnel import start_tunnel from utils.tunnel import start_tunnel
start_tunnel() start_tunnel()
@@ -342,7 +341,11 @@ start_tunnel()
async def main(): async def main():
async with client: async with client:
os.system("clear") os.system("clear")
await client.load_extension("jishaku") if JISHAKU_ENABLED:
await client.load_extension("jishaku")
print("\033[33m◈ Jishaku: enabled (owner-only debug REPL)\033[0m")
else:
print("\033[32m◈ Jishaku: disabled (set JISHAKU_ENABLED=true to enable)\033[0m")
max_retries = 5 max_retries = 5
for attempt in range(max_retries): for attempt in range(max_retries):

View File

@@ -180,8 +180,8 @@ Create a `.env` file (copy from `.env.example`):
TOKEN = your_discord_bot_token TOKEN = your_discord_bot_token
brand_name = 'ZyroX' brand_name = 'ZyroX'
# ── Owner IDs (comma-separated — no code changes needed) ────────── # ── Owner IDs (REQUIRED — your Discord user ID) ───────────────────
OWNER_IDS = 870179991462236170,767979794411028491 OWNER_IDS = YOUR_DISCORD_USER_ID_HERE
# ── Lavalink ────────────────────────────────────────────────────── # ── Lavalink ──────────────────────────────────────────────────────
LAVALINK_HOST = "your-lavalink-host" LAVALINK_HOST = "your-lavalink-host"
@@ -190,21 +190,23 @@ LAVALINK_SECURE = "true"
LAVALINK_PORT = "" LAVALINK_PORT = ""
# ── Emoji Sync ──────────────────────────────────────────────────── # ── Emoji Sync ────────────────────────────────────────────────────
EMOJI_SYNC = "true" EMOJI_SYNC = "false"
# ── API / Dashboard Backend ─────────────────────────────────────── # ── API / Dashboard Backend ───────────────────────────────────────
API_ENABLED = "true" API_ENABLED = "false"
API_HOST = "127.0.0.1"
API_PORT = "8000" API_PORT = "8000"
DASHBOARD_API_KEY = "change_this_to_a_strong_secret" DASHBOARD_API_KEY = "change_this_to_a_strong_secret"
DASHBOARD_ADMIN_IDS = YOUR_DISCORD_USER_ID_HERE
CORS_ORIGINS = "" CORS_ORIGINS = ""
# ── Cloudflare Tunnel ───────────────────────────────────────────── # ── Cloudflare Tunnel (optional) ──────────────────────────────────
TUNNEL_ENABLED = "true" TUNNEL_ENABLED = "false"
CF_TUNNEL_TOKEN = "your_tunnel_token" CF_TUNNEL_TOKEN = "your_tunnel_token"
CF_TUNNEL_URL = "https://api.yourdomain.com" CF_TUNNEL_URL = "https://api.yourdomain.com"
# ── Webhooks ────────────────────────────────────────────────────── # ── Webhooks (optional) ─────────────────────────────────────────────
WEBHOOK_URL = "https://discord.com/api/webhooks/..." # CMD_WEBHOOK_URL = "https://discord.com/api/webhooks/ID/TOKEN"
``` ```
### 3 — Run ### 3 — Run
@@ -220,20 +222,26 @@ python CodeX.py
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `TOKEN` | — | Discord bot token | | `TOKEN` | — | Discord bot token |
| `OWNER_IDS` | | Comma-separated owner Discord user IDs | | `OWNER_IDS` | _(required)_ | Comma-separated owner Discord user IDs**your own ID only** |
| `LAVALINK_HOST` | — | Lavalink server hostname (no protocol) | | `LAVALINK_HOST` | — | Lavalink server hostname (no protocol) |
| `LAVALINK_PASSWORD` | — | Lavalink password | | `LAVALINK_PASSWORD` | — | Lavalink password |
| `LAVALINK_SECURE` | `true` | `true` = HTTPS, `false` = HTTP | | `LAVALINK_SECURE` | `true` | `true` = HTTPS, `false` = HTTP |
| `LAVALINK_PORT` | _(empty)_ | Port — only when `LAVALINK_SECURE=false` | | `LAVALINK_PORT` | _(empty)_ | Port — only when `LAVALINK_SECURE=false` |
| `EMOJI_SYNC` | `true` | Auto-sync application emojis on startup | | `EMOJI_SYNC` | `false` | Auto-sync application emojis on startup |
| `API_ENABLED` | `true` | Start the FastAPI dashboard backend | | `JISHAKU_ENABLED` | `false` | Enable owner debug REPL (disable in production) |
| `API_ENABLED` | `false` | Start the FastAPI dashboard backend |
| `API_HOST` | `127.0.0.1` | Bind address for the API server |
| `API_PORT` | `8000` | Port the backend listens on | | `API_PORT` | `8000` | Port the backend listens on |
| `DASHBOARD_API_KEY` | — | Shared secret between bot API and dashboard | | `DASHBOARD_API_KEY` | — | Shared secret with dashboard (server-side only) |
| `DASHBOARD_ADMIN_IDS` | — | Discord user IDs for admin API routes |
| `CORS_ORIGINS` | _(empty)_ | Extra CORS-allowed origins, comma-separated | | `CORS_ORIGINS` | _(empty)_ | Extra CORS-allowed origins, comma-separated |
| `WEBHOOK_URL` | — | Discord webhook for command logs | | `CMD_WEBHOOK_URL` | _(empty)_ | Optional webhook for command logging |
| `TUNNEL_ENABLED` | `true` | Expose API over HTTPS via Cloudflare Tunnel | | `TUNNEL_ENABLED` | `false` | Expose API over HTTPS via Cloudflare Tunnel |
| `CF_TUNNEL_TOKEN` | — | Token from Cloudflare Zero Trust dashboard | | `CF_TUNNEL_TOKEN` | — | Token from Cloudflare Zero Trust dashboard |
| `CF_TUNNEL_URL` | — | Your permanent public URL | | `CF_TUNNEL_URL` | — | Your permanent public URL |
| `LOG_CHANNEL_ID` | _(empty)_ | Optional channel for join/leave logs |
> **API security:** All `/api/v1/guilds/*` routes verify Discord Manage Server permission via `X-Discord-Access-Token` header.
--- ---
@@ -271,7 +279,7 @@ On every startup the console prints:
``` ```
◈ Tunnel: cloudflared binary ready — starting tunnel on port 8000… ◈ Tunnel: cloudflared binary ready — starting tunnel on port 8000…
◈ Tunnel: API is live at https://api.yourdomain.com ◈ Tunnel: API is live at https://api.yourdomain.com
NEXT_PUBLIC_API_URL = https://api.yourdomain.com/api/v1 DASHBOARD_API_URL = https://api.yourdomain.com/api/v1
``` ```
Set `TUNNEL_ENABLED=false` to disable. Set `TUNNEL_ENABLED=false` to disable.
@@ -313,7 +321,7 @@ python CodeX.py
|---|---| |---|---|
| Bot fails to start | Check `TOKEN` and gateway intents in Developer Portal | | Bot fails to start | Check `TOKEN` and gateway intents in Developer Portal |
| Music not working | Verify `LAVALINK_HOST`, `LAVALINK_SECURE`, `LAVALINK_PORT` | | Music not working | Verify `LAVALINK_HOST`, `LAVALINK_SECURE`, `LAVALINK_PORT` |
| Dashboard can't reach API | Check `API_ENABLED=true` and `NEXT_PUBLIC_API_URL` in dashboard | | Dashboard can't reach API | Check `API_ENABLED=true`, `DASHBOARD_API_URL` in dashboard, and Discord login |
| CORS errors | Add your Vercel URL to `CORS_ORIGINS` in `.env` | | CORS errors | Add your Vercel URL to `CORS_ORIGINS` in `.env` |
| Emojis showing as plain text | Run once with `EMOJI_SYNC=true` to upload and patch IDs | | Emojis showing as plain text | Run once with `EMOJI_SYNC=true` to upload and patch IDs |
| Tunnel not starting | Check `CF_TUNNEL_TOKEN` is valid and `pycloudflared` is installed | | Tunnel not starting | Check `CF_TUNNEL_TOKEN` is valid and `pycloudflared` is installed |

130
bot/api/discord_auth.py Normal file
View File

@@ -0,0 +1,130 @@
# ╔══════════════════════════════════════════════════════════════════╗
# ║ Discord OAuth permission checks for dashboard API requests ║
# ╚══════════════════════════════════════════════════════════════════╝
from __future__ import annotations
import re
from typing import Any
import aiohttp
from fastapi import HTTPException, Request
from utils.config import OWNER_IDS_STR
MANAGE_GUILD = 0x20
ADMINISTRATOR = 0x8
GUILD_PATH_RE = re.compile(r"^/api/v1/guilds/(\d+)(?:/|$)")
def _parse_admin_ids() -> list[str]:
import os
raw = os.getenv("DASHBOARD_ADMIN_IDS", "").strip()
if raw:
return [p.strip() for p in raw.split(",") if p.strip().isdigit()]
return list(OWNER_IDS_STR)
def can_manage_guild(guild: dict[str, Any]) -> bool:
if guild.get("owner"):
return True
try:
perms = int(guild.get("permissions", "0"))
except (TypeError, ValueError):
return False
return bool(perms & ADMINISTRATOR) or bool(perms & MANAGE_GUILD)
async def fetch_user_guilds(access_token: str) -> list[dict[str, Any]]:
async with aiohttp.ClientSession() as session:
async with session.get(
"https://discord.com/api/users/@me/guilds",
headers={"Authorization": f"Bearer {access_token}"},
) as resp:
if resp.status != 200:
raise HTTPException(
status_code=403,
detail="Invalid or expired Discord access token.",
)
data = await resp.json()
return data if isinstance(data, list) else []
async def verify_discord_token(access_token: str) -> dict[str, Any]:
async with aiohttp.ClientSession() as session:
async with session.get(
"https://discord.com/api/users/@me",
headers={"Authorization": f"Bearer {access_token}"},
) as resp:
if resp.status != 200:
raise HTTPException(
status_code=403,
detail="Invalid or expired Discord access token.",
)
return await resp.json()
async def user_can_manage_guild(access_token: str, guild_id: int) -> bool:
guilds = await fetch_user_guilds(access_token)
for guild in guilds:
if str(guild.get("id")) == str(guild_id):
return can_manage_guild(guild)
return False
async def get_manageable_guild_ids(access_token: str) -> set[str]:
guilds = await fetch_user_guilds(access_token)
return {str(g["id"]) for g in guilds if can_manage_guild(g)}
def get_discord_headers(request: Request) -> tuple[str | None, str | None]:
token = request.headers.get("X-Discord-Access-Token")
user_id = request.headers.get("X-Discord-User-Id")
return token, user_id
async def require_discord_session(request: Request) -> tuple[str, str]:
token, user_id = get_discord_headers(request)
if not token:
raise HTTPException(
status_code=403,
detail="X-Discord-Access-Token header is required.",
)
user = await verify_discord_token(token)
resolved_id = str(user.get("id", ""))
if not resolved_id:
raise HTTPException(status_code=403, detail="Could not resolve Discord user.")
if user_id and user_id != resolved_id:
raise HTTPException(status_code=403, detail="Discord user ID mismatch.")
return token, resolved_id
async def require_guild_access(request: Request, guild_id: int) -> None:
token, _ = await require_discord_session(request)
if not await user_can_manage_guild(token, guild_id):
raise HTTPException(
status_code=403,
detail="You do not have permission to manage this guild.",
)
async def require_dashboard_admin(request: Request) -> None:
token, user_id = await require_discord_session(request)
admin_ids = _parse_admin_ids()
if user_id not in admin_ids:
raise HTTPException(
status_code=403,
detail="Dashboard admin access required.",
)
def extract_guild_id_from_path(path: str) -> int | None:
match = GUILD_PATH_RE.match(path)
if not match:
return None
return int(match.group(1))

View File

@@ -14,6 +14,7 @@
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from api.dependencies import get_bot, limiter from api.dependencies import get_bot, limiter
from api.discord_auth import require_discord_session, get_manageable_guild_ids
from api.db_manager import db_manager from api.db_manager import db_manager
from api.schemas import ( from api.schemas import (
GuildSummary, GuildDetails, PrefixConfig, AutomodConfig, GuildSummary, GuildDetails, PrefixConfig, AutomodConfig,
@@ -41,13 +42,18 @@ if TYPE_CHECKING:
router = APIRouter() router = APIRouter()
@router.get("/", response_model=List[GuildSummary], summary="List all guilds", description="Returns a summary of all guilds the bot is currently in.") @router.get("/", response_model=List[GuildSummary], summary="List all guilds", description="Returns guilds the authenticated user can manage that the bot is in.")
async def list_guilds(bot: "zyrox" = Depends(get_bot)): async def list_guilds(request: Request, bot: "zyrox" = Depends(get_bot)):
""" """
Lists detailed information about all guilds the bot is currently in. Lists guilds the bot is in, filtered to those the caller can manage on Discord.
""" """
token, _ = await require_discord_session(request)
manageable_ids = await get_manageable_guild_ids(token)
guilds_list = [] guilds_list = []
for guild in bot.guilds: for guild in bot.guilds:
if str(guild.id) not in manageable_ids:
continue
guilds_list.append(GuildSummary( guilds_list.append(GuildSummary(
id=str(guild.id), id=str(guild.id),
name=guild.name, name=guild.name,

View File

@@ -12,7 +12,7 @@
# ║ ║ # ║ ║
# ╚══════════════════════════════════════════════════════════════════╝ # ╚══════════════════════════════════════════════════════════════════╝
from fastapi import FastAPI, Depends, Request from fastapi import FastAPI, Depends, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import os import os
@@ -28,6 +28,13 @@ from utils.config import *
from api.routes import bot, guilds, admin from api.routes import bot, guilds, admin
from api.dependencies import verify_api_key, limiter from api.dependencies import verify_api_key, limiter
from api.db_manager import db_manager from api.db_manager import db_manager
from api.discord_auth import (
require_dashboard_admin,
require_discord_session,
require_guild_access,
extract_guild_id_from_path,
)
from fastapi.responses import JSONResponse
# Configure logging # Configure logging
logger = logging.getLogger("api_request_logs") logger = logging.getLogger("api_request_logs")
@@ -57,6 +64,29 @@ def create_app() -> FastAPI:
lifespan=lifespan lifespan=lifespan
) )
# Discord permission checks for dashboard API routes
@app.middleware("http")
async def discord_permission_middleware(request: Request, call_next):
if request.method == "OPTIONS":
return await call_next(request)
path = request.url.path
try:
if path.startswith("/api/v1/admin"):
await require_dashboard_admin(request)
elif path.startswith("/api/v1/bot"):
await require_discord_session(request)
elif path.startswith("/api/v1/guilds"):
guild_id = extract_guild_id_from_path(path)
if guild_id is not None:
await require_guild_access(request, guild_id)
else:
await require_discord_session(request)
except HTTPException as exc:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
return await call_next(request)
# Structured Logging Middleware # Structured Logging Middleware
@app.middleware("http") @app.middleware("http")
async def log_requests(request: Request, call_next): async def log_requests(request: Request, call_next):

View File

@@ -23,6 +23,7 @@ from discord.ui import LayoutView, TextDisplay, Separator, Container
from utils.Tools import * from utils.Tools import *
from utils.cv2 import CV2, build_container from utils.cv2 import CV2, build_container
from utils.config import OWNER_IDS from utils.config import OWNER_IDS
from utils.safe_parse import parse_role_id_list, dump_role_id_list
@@ -232,7 +233,7 @@ class AutoRole(commands.Cog):
data = await cursor.fetchone() data = await cursor.fetchone()
if data: if data:
humans = eval(data[0]) humans = parse_role_id_list(data[0])
if role.id in humans: if role.id in humans:
view = CV2(f"{ICONS_WARNING} Access Denied", f"{role.mention} is already in human autoroles.") view = CV2(f"{ICONS_WARNING} Access Denied", f"{role.mention} is already in human autoroles.")
elif len(humans) >= 10: elif len(humans) >= 10:
@@ -240,13 +241,13 @@ class AutoRole(commands.Cog):
else: else:
humans.append(role.id) humans.append(role.id)
async with aiosqlite.connect(DATABASE_PATH) as db: async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute("UPDATE autorole SET humans = ? WHERE guild_id = ?", (str(humans), ctx.guild.id)) await db.execute("UPDATE autorole SET humans = ? WHERE guild_id = ?", (dump_role_id_list(humans), ctx.guild.id))
await db.commit() await db.commit()
view = CV2(f"{TICK} Success", f"{role.mention} has been added to human autoroles.") view = CV2(f"{TICK} Success", f"{role.mention} has been added to human autoroles.")
else: else:
humans = [role.id] humans = [role.id]
async with aiosqlite.connect(DATABASE_PATH) as db: async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute("INSERT INTO autorole (guild_id, humans, bots) VALUES (?, ?, ?)", (ctx.guild.id, str(humans), '[]')) await db.execute("INSERT INTO autorole (guild_id, humans, bots) VALUES (?, ?, ?)", (ctx.guild.id, dump_role_id_list(humans), '[]'))
await db.commit() await db.commit()
view = CV2(f"{TICK} Success", f"{role.mention} has been added to human autoroles.") view = CV2(f"{TICK} Success", f"{role.mention} has been added to human autoroles.")
@@ -265,13 +266,13 @@ class AutoRole(commands.Cog):
data = await cursor.fetchone() data = await cursor.fetchone()
if data: if data:
humans = eval(data[0]) humans = parse_role_id_list(data[0])
if role.id not in humans: if role.id not in humans:
view = CV2(f"{CROSS} Error", f"{role.mention} is not in human autoroles.") view = CV2(f"{CROSS} Error", f"{role.mention} is not in human autoroles.")
else: else:
humans.remove(role.id) humans.remove(role.id)
async with aiosqlite.connect(DATABASE_PATH) as db: async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute("UPDATE autorole SET humans = ? WHERE guild_id = ?", (str(humans), ctx.guild.id)) await db.execute("UPDATE autorole SET humans = ? WHERE guild_id = ?", (dump_role_id_list(humans), ctx.guild.id))
await db.commit() await db.commit()
view = CV2(f"{TICK} Success", f"{role.mention} has been removed from human autoroles.") view = CV2(f"{TICK} Success", f"{role.mention} has been removed from human autoroles.")
else: else:
@@ -303,7 +304,7 @@ class AutoRole(commands.Cog):
data = await cursor.fetchone() data = await cursor.fetchone()
if data: if data:
bots = eval(data[0]) bots = parse_role_id_list(data[0])
if role.id in bots: if role.id in bots:
view = CV2(f"{ICONS_WARNING} Access Denied", f"{role.mention} is already in bot autoroles.") view = CV2(f"{ICONS_WARNING} Access Denied", f"{role.mention} is already in bot autoroles.")
elif len(bots) >= 10: elif len(bots) >= 10:
@@ -311,13 +312,13 @@ class AutoRole(commands.Cog):
else: else:
bots.append(role.id) bots.append(role.id)
async with aiosqlite.connect(DATABASE_PATH) as db: async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute("UPDATE autorole SET bots = ? WHERE guild_id = ?", (str(bots), ctx.guild.id)) await db.execute("UPDATE autorole SET bots = ? WHERE guild_id = ?", (dump_role_id_list(bots), ctx.guild.id))
await db.commit() await db.commit()
view = CV2(f"{TICK} Success", f"{role.mention} has been added to bot autoroles.") view = CV2(f"{TICK} Success", f"{role.mention} has been added to bot autoroles.")
else: else:
bots = [role.id] bots = [role.id]
async with aiosqlite.connect(DATABASE_PATH) as db: async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute("INSERT INTO autorole (guild_id, humans, bots) VALUES (?, ?, ?)", (ctx.guild.id, '[]', str(bots))) await db.execute("INSERT INTO autorole (guild_id, humans, bots) VALUES (?, ?, ?)", (ctx.guild.id, '[]', dump_role_id_list(bots)))
await db.commit() await db.commit()
view = CV2(f"{TICK} Success", f"{role.mention} has been added to bot autoroles.") view = CV2(f"{TICK} Success", f"{role.mention} has been added to bot autoroles.")
@@ -336,13 +337,13 @@ class AutoRole(commands.Cog):
data = await cursor.fetchone() data = await cursor.fetchone()
if data: if data:
bots = eval(data[0]) bots = parse_role_id_list(data[0])
if role.id not in bots: if role.id not in bots:
view = CV2(f"{CROSS} Error", f"{role.mention} is not in bot autoroles.") view = CV2(f"{CROSS} Error", f"{role.mention} is not in bot autoroles.")
else: else:
bots.remove(role.id) bots.remove(role.id)
async with aiosqlite.connect(DATABASE_PATH) as db: async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute("UPDATE autorole SET bots = ? WHERE guild_id = ?", (str(bots), ctx.guild.id)) await db.execute("UPDATE autorole SET bots = ? WHERE guild_id = ?", (dump_role_id_list(bots), ctx.guild.id))
await db.commit() await db.commit()
view = CV2(f"{TICK} Success", f"{role.mention} has been removed from bot autoroles.") view = CV2(f"{TICK} Success", f"{role.mention} has been removed from bot autoroles.")
else: else:

View File

@@ -15,6 +15,7 @@
from discord.ext import commands from discord.ext import commands
from discord.ui import View, Button, button from discord.ui import View, Button, button
import discord import discord
from utils.safe_parse import safe_math_eval
class CalculatorView(View): class CalculatorView(View):
@@ -89,7 +90,7 @@ class CalculatorView(View):
) )
try: try:
expression = self.value.strip().replace("\n", "") expression = self.value.strip().replace("\n", "")
result = str(eval(expression)) result = safe_math_eval(expression)
await self.update_embed(interaction, result) await self.update_embed(interaction, result)
self.value = result # Store the result for possible further calculations self.value = result # Store the result for possible further calculations
except: except:

View File

@@ -38,11 +38,6 @@ from typing import *
import string import string
from utils.cv2 import CV2, build_container from utils.cv2 import CV2, build_container
lawda = [
'8', '3821', '23', '21', '313', '43', '29', '76', '11', '9',
'44', '470', '318' , '26', '69'
]
class AvatarView(View): class AvatarView(View):
def __init__(self, user, member, author_id, banner_url): def __init__(self, user, member, author_id, banner_url):
@@ -214,55 +209,6 @@ class General(commands.Cog):
await msg.add_reaction(CROSS) await msg.add_reaction(CROSS)
@commands.command(name="hack",
help="hack someone's discord account",
usage="Hack <member>")
@blacklist_check()
@ignore_check()
@commands.cooldown(1, 3, commands.BucketType.user)
async def hack(self, ctx: commands.Context, member: discord.Member):
stringi = member.name
min_length = 2
max_length = 12
length = random.randint(min_length, max_length)
stringg = member.name
remaining_length = length - len(stringg)
all_chars = string.ascii_letters + string.digits + string.punctuation
random_chars = random.choices(all_chars, k=remaining_length)
password = stringg + ''.join(random_chars)
lund = await ctx.send(f"Processing to Hack {member.mention}...")
await asyncio.sleep(2)
random_pass = random.choice(lawda)
random_pass2 = ''.join(random.choices(string.ascii_letters + string.digits, k=3))
hack_text = (
f"**User:** {member.mention}\n"
f"**E-Mail:** {''.join(c for c in stringi if c.isalnum())}{random_pass}@gmail.com\n"
f"**Account Password:** {member.name}@{random_pass2}"
)
await ctx.send(view=CV2(f"Hacked {member.display_name}!", hack_text))
await lund.delete()
@commands.command(name="token", usage="Token <member>")
@blacklist_check()
@ignore_check()
@commands.cooldown(1, 2, commands.BucketType.user)
async def token(self, ctx: commands.Context, user: discord.Member = None):
list = [
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N",
"O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "_"
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n',
'ñ', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0',
'1', '2', '3', '4', '5', '6', '7', '8', '9'
]
token = random.choices(list, k=59)
if user is None:
user = ctx.author
await ctx.send(view=CV2("🔐 Token", f"{user.mention}'s token: `{''.join(token)}`"))
@commands.command(name="users", help=f"checks total users of {BotName}.") @commands.command(name="users", help=f"checks total users of {BotName}.")
@blacklist_check() @blacklist_check()
@ignore_check() @ignore_check()
@@ -274,34 +220,6 @@ class General(commands.Cog):
await ctx.send(view=CV2(f"{BotName} Users", f" Total of __**{users}**__ Users in **{guilds}** Guilds")) await ctx.send(view=CV2(f"{BotName} Users", f" Total of __**{users}**__ Users in **{guilds}** Guilds"))
@commands.command(name="wizz", usage="Wizz")
@blacklist_check()
@ignore_check()
@commands.cooldown(1, 3, commands.BucketType.user)
async def wizz(self, ctx: commands.Context):
message6 = await ctx.send(
f"`Wizzing {ctx.guild.name}, will take 22 seconds to complete`")
message7 = await ctx.send(f"Changing all guild settings...")
message5 = await ctx.send(f"Deleting **{len(ctx.guild.roles)}** Roles...")
await asyncio.sleep(1)
message4 = await ctx.send(
f"Deleting **{len(ctx.guild.channels)}** Channels...")
await asyncio.sleep(1)
message3 = await ctx.send(f"Deleting Webhooks...")
message2 = await ctx.send(f"Deleting emojis")
await asyncio.sleep(1)
message1 = await ctx.send(f"Installing Ban Wave..")
await asyncio.sleep(1)
await message6.delete()
await message7.delete()
await message5.delete()
await message4.delete()
await message3.delete()
await message2.delete()
await message1.delete()
await ctx.send(view=CV2(f"{self.bot.user.name}", f"**{ZWARNING} Successfully Wizzed {ctx.guild.name}**"))
@commands.hybrid_command( @commands.hybrid_command(
name="urban", name="urban",
description="Searches for specified phrase on urbandictionary", description="Searches for specified phrase on urbandictionary",

View File

@@ -128,7 +128,7 @@ class TimeSelect(Select):
if role: if role:
await member.add_roles(role, reason="No prefix added") await member.add_roles(role, reason="No prefix added")
log_channel = interaction.client.get_channel(1396794297386532978) log_channel = interaction.client.get_channel(LOG_CHANNEL_ID) if LOG_CHANNEL_ID else None
if log_channel: if log_channel:
embed = CV2Embed( embed = CV2Embed(
title="User Added to No Prefix", title="User Added to No Prefix",
@@ -140,7 +140,7 @@ class TimeSelect(Select):
if self.user.avatar if self.user.avatar
else self.user.default_avatar.url else self.user.default_avatar.url
) )
await log_channel.send("<#1396794297386532978>", view=embed) await log_channel.send(view=embed)
embed = CV2Embed( embed = CV2Embed(
description=f"**Added Global No Prefix**:\n{ZHUMAN} User: **{self.user.mention}**\n{MENTION} User Mention: {self.user.mention}\n{ZYROXSYS} User ID: {self.user.id}\n\n__**Additional Info**__:\n{ZYROXHAMMER} Added By: **{self.author.display_name}**\n{TIME} Expiry Time: {expiry_text}\n{BOOST} Timestamp: {expiry_timestamp}", description=f"**Added Global No Prefix**:\n{ZHUMAN} User: **{self.user.mention}**\n{MENTION} User Mention: {self.user.mention}\n{ZYROXSYS} User ID: {self.user.id}\n\n__**Additional Info**__:\n{ZYROXHAMMER} Added By: **{self.author.display_name}**\n{TIME} Expiry Time: {expiry_text}\n{BOOST} Timestamp: {expiry_timestamp}",
@@ -233,7 +233,7 @@ class NoPrefix(commands.Cog):
for user_id in expired_users: for user_id in expired_users:
user = self.client.get_user(user_id) user = self.client.get_user(user_id)
if user: if user:
log_channel = self.client.get_channel(1396794297386532978) log_channel = self.client.get_channel(LOG_CHANNEL_ID) if LOG_CHANNEL_ID else None
if log_channel: if log_channel:
embed_log = CV2Embed( embed_log = CV2Embed(
title="No Prefix Expired", title="No Prefix Expired",
@@ -251,9 +251,7 @@ class NoPrefix(commands.Cog):
else user.default_avatar.url else user.default_avatar.url
) )
embed_log.set_footer(text="No Prefix Removal Log") embed_log.set_footer(text="No Prefix Removal Log")
await log_channel.send( await log_channel.send(view=embed_log)
"<#1396794297386532978>", view=embed_log
)
bot = self.client bot = self.client
guild = bot.get_guild(1401125905677553716) guild = bot.get_guild(1401125905677553716)
if guild: if guild:
@@ -402,7 +400,7 @@ class NoPrefix(commands.Cog):
embed.set_author(name="Removed No Prefix") embed.set_author(name="Removed No Prefix")
await ctx.reply(view=embed) await ctx.reply(view=embed)
log_channel = ctx.bot.get_channel(1396794297386532978) log_channel = ctx.bot.get_channel(LOG_CHANNEL_ID) if LOG_CHANNEL_ID else None
if log_channel: if log_channel:
embed_log = CV2Embed( embed_log = CV2Embed(
title="No Prefix Removed", title="No Prefix Removed",
@@ -694,7 +692,7 @@ class NoPrefix(commands.Cog):
await interaction.response.edit_message(view=success_embed) await interaction.response.edit_message(view=success_embed)
# Log the action # Log the action
log_channel = self.client.get_channel(1396794297386532978) log_channel = self.client.get_channel(LOG_CHANNEL_ID) if LOG_CHANNEL_ID else None
if log_channel: if log_channel:
log_embed = CV2Embed( log_embed = CV2Embed(
title="No-Prefix List Reset", title="No-Prefix List Reset",
@@ -702,7 +700,7 @@ class NoPrefix(commands.Cog):
color=0xFF0000, color=0xFF0000,
) )
log_embed.set_footer(text="No Prefix Reset Log") log_embed.set_footer(text="No Prefix Reset Log")
await log_channel.send("<#1396794297386532978>", view=log_embed) await log_channel.send(view=log_embed)
async def no_callback(interaction): async def no_callback(interaction):
if interaction.user != ctx.author: if interaction.user != ctx.author:

View File

@@ -44,7 +44,9 @@ class Guild(Cog):
for inv in await guild.invites() for inv in await guild.invites()
if inv.max_age == 0 and inv.max_uses == 0 if inv.max_age == 0 and inv.max_uses == 0
] ]
ch = 1396794297386532978 ch = LOG_CHANNEL_ID
if not ch:
return
me = self.client.get_channel(ch) me = self.client.get_channel(ch)
if me is None: if me is None:
logging.error(f"Channel with ID {ch} not found.") logging.error(f"Channel with ID {ch} not found.")
@@ -159,7 +161,9 @@ Threads : {len(guild.threads)}
self.recently_removed_guilds.clear() self.recently_removed_guilds.clear()
try: try:
ch = 1396794297386532978 ch = LOG_CHANNEL_ID
if not ch:
return
idk = self.client.get_channel(ch) idk = self.client.get_channel(ch)
if idk is None: if idk is None:
logging.error(f"Channel with ID {ch} not found.") logging.error(f"Channel with ID {ch} not found.")

View File

@@ -31,4 +31,4 @@ class _general(commands.Cog):
@commands.group() @commands.group()
async def __General__(self, ctx: commands.Context): async def __General__(self, ctx: commands.Context):
"""`status` , `afk` , `avatar` , `banner` , `servericon` , `membercount` , `poll` , `hack` , `token` , `users` , `wizz` , `urban` , `rickroll` , `hash` , `snipe` , `users` , `list boosters` , `list inrole` , `list emojis` , `list bots` , `list admins` , `list invoice` , `list mods` , `list early` , `list activedeveloper` , `list createpos` , `list roles` , `calculator`""" """`status` , `afk` , `avatar` , `banner` , `servericon` , `membercount` , `poll` , `users` , `urban` , `rickroll` , `hash` , `snipe` , `users` , `list boosters` , `list inrole` , `list emojis` , `list bots` , `list admins` , `list invoice` , `list mods` , `list early` , `list activedeveloper` , `list createpos` , `list roles` , `calculator`"""

View File

@@ -16,9 +16,6 @@ from __future__ import annotations
from discord.ext import commands, tasks from discord.ext import commands, tasks
import discord import discord
import aiohttp import aiohttp
import json
import jishaku
import asyncio
import typing import typing
from typing import List from typing import List
import aiosqlite import aiosqlite

View File

@@ -26,24 +26,61 @@ server = "https://discord.gg/codexdev"
serverLink = "https://discord.gg/codexdev" serverLink = "https://discord.gg/codexdev"
ch = "https://discord.com/channels/699587669059174461/1271825678710476911" ch = "https://discord.com/channels/699587669059174461/1271825678710476911"
CMD_WEBHOOK_URL = os.getenv("CMD_WEBHOOK_URL") # ── Security / feature toggles ────────────────────────────────────────────────
def _env_bool(key: str, default: bool = False) -> bool:
raw = os.getenv(key, str(default)).strip().lower()
return raw in ("1", "true", "yes", "on")
JISHAKU_ENABLED = _env_bool("JISHAKU_ENABLED", False)
API_ENABLED = _env_bool("API_ENABLED", False)
TUNNEL_ENABLED = _env_bool("TUNNEL_ENABLED", False)
TUNNEL_ALLOW_BINARY_DOWNLOAD = _env_bool("TUNNEL_ALLOW_BINARY_DOWNLOAD", False)
API_HOST = os.getenv("API_HOST", "127.0.0.1").strip() or "127.0.0.1"
API_PORT = int(os.getenv("API_PORT", "8000"))
# ── Webhooks (only active when explicitly configured) ─────────────────────────
def _valid_webhook_url(url: str | None) -> str | None:
if not url:
return None
url = url.strip()
if not url or url == "https://discord.com/api/webhooks/":
return None
if "discord.com/api/webhooks/" not in url and "discordapp.com/api/webhooks/" not in url:
return None
return url
CMD_WEBHOOK_URL = _valid_webhook_url(os.getenv("CMD_WEBHOOK_URL"))
# ── Optional Discord log / stats channels (unset = disabled) ──────────────────
def _parse_optional_id(env_key: str) -> int | None:
raw = os.getenv(env_key, "").strip()
if not raw or not raw.isdigit():
return None
return int(raw)
LOG_CHANNEL_ID = _parse_optional_id("LOG_CHANNEL_ID")
SERVER_COUNT_CHANNEL_ID = _parse_optional_id("SERVER_COUNT_CHANNEL_ID")
USER_COUNT_CHANNEL_ID = _parse_optional_id("USER_COUNT_CHANNEL_ID")
# ── Owner / Staff IDs ───────────────────────────────────────────────────────── # ── Owner / Staff IDs ─────────────────────────────────────────────────────────
# Edit OWNER_IDS in .env — comma-separated, no spaces needed. # REQUIRED: set OWNER_IDS in .env — no hardcoded fallback IDs.
# Example: OWNER_IDS = 870179991462236170,767979794411028491,1432771000629596225
def _parse_ids(env_key: str, defaults: list[int]) -> list[int]: def _parse_ids(env_key: str) -> list[int]:
raw = os.getenv(env_key, "").strip() raw = os.getenv(env_key, "").strip()
if not raw: if not raw:
return defaults return []
ids = [int(p.strip()) for p in raw.split(",") if p.strip().isdigit()] return [int(p.strip()) for p in raw.split(",") if p.strip().isdigit()]
return ids or defaults
OWNER_IDS: list[int] = _parse_ids("OWNER_IDS", [870179991462236170]) OWNER_IDS: list[int] = _parse_ids("OWNER_IDS")
OWNER_IDS_STR: list[str] = [str(i) for i in OWNER_IDS] OWNER_IDS_STR: list[str] = [str(i) for i in OWNER_IDS]
# Aliases kept for backwards compatibility with files that import these names # Aliases kept for backwards compatibility with files that import these names
BOT_OWNER_IDS = OWNER_IDS BOT_OWNER_IDS = OWNER_IDS
BOT_OWNER_IDS_STR = OWNER_IDS_STR BOT_OWNER_IDS_STR = OWNER_IDS_STR
STAFF_IDS = OWNER_IDS STAFF_IDS = OWNER_IDS
STAFF_IDS_STR = OWNER_IDS_STR STAFF_IDS_STR = OWNER_IDS_STR

View File

@@ -13,14 +13,15 @@
# ╚══════════════════════════════════════════════════════════════════╝ # ╚══════════════════════════════════════════════════════════════════╝
from __future__ import annotations from __future__ import annotations
import os
import discord import discord
from utils.config import BotName from utils.config import BotName
try: try:
from discord.ext import menus from discord.ext import menus
from discord.ext import commands from discord.ext import commands
except ModuleNotFoundError: except ModuleNotFoundError as exc:
os.system("pip install git+https://github.com/Rapptz/discord-ext-menus") raise ImportError(
"discord-ext-menus is required. Install it with: pip install git+https://github.com/Rapptz/discord-ext-menus"
) from exc
from .paginator import Paginator as EmbedPaginator from .paginator import Paginator as EmbedPaginator
from discord.ext.commands import Context, Paginator as CmdPaginator from discord.ext.commands import Context, Paginator as CmdPaginator

66
bot/utils/safe_parse.py Normal file
View File

@@ -0,0 +1,66 @@
# ╔══════════════════════════════════════════════════════════════════╗
# ║ Safe parsing helpers — no eval() on untrusted data ║
# ╚══════════════════════════════════════════════════════════════════╝
from __future__ import annotations
import ast
import json
import operator
import re
from typing import Any
def parse_role_id_list(raw: str | None) -> list[int]:
"""Parse a stored role-ID list (JSON or legacy Python repr)."""
if not raw:
return []
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
try:
parsed = ast.literal_eval(raw)
except (ValueError, SyntaxError):
return []
if not isinstance(parsed, list):
return []
return [int(x) for x in parsed if str(x).isdigit()]
def dump_role_id_list(ids: list[int]) -> str:
return json.dumps(ids)
_MATH_ALLOWED = re.compile(r"^[\d+\-*/().\s]+$")
def _eval_math_node(node: ast.AST) -> float:
if isinstance(node, ast.Expression):
return _eval_math_node(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return float(node.value)
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)):
value = _eval_math_node(node.operand)
return value if isinstance(node.op, ast.UAdd) else -value
if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)):
left = _eval_math_node(node.left)
right = _eval_math_node(node.right)
ops = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
}
if isinstance(node.op, ast.Div) and right == 0:
raise ZeroDivisionError("division by zero")
return ops[type(node.op)](left, right)
raise ValueError("unsupported expression")
def safe_math_eval(expression: str) -> str:
"""Evaluate basic arithmetic safely (digits, +, -, *, /, parentheses)."""
expr = expression.strip()
if not expr or not _MATH_ALLOWED.match(expr):
raise ValueError("invalid characters in expression")
tree = ast.parse(expr, mode="eval")
return str(_eval_math_node(tree))

View File

@@ -50,10 +50,10 @@ import threading
import subprocess import subprocess
# ── env vars ────────────────────────────────────────────────────────────────── # ── env vars ──────────────────────────────────────────────────────────────────
TUNNEL_ENABLED = os.getenv("TUNNEL_ENABLED", "true").strip().lower() == "true" from utils.config import TUNNEL_ENABLED, TUNNEL_ALLOW_BINARY_DOWNLOAD, API_PORT
CF_TUNNEL_TOKEN = os.getenv("CF_TUNNEL_TOKEN", "").strip() # token from Cloudflare dashboard CF_TUNNEL_TOKEN = os.getenv("CF_TUNNEL_TOKEN", "").strip() # token from Cloudflare dashboard
CF_TUNNEL_URL = os.getenv("CF_TUNNEL_URL", "").strip() # e.g. https://api.yourdomain.com CF_TUNNEL_URL = os.getenv("CF_TUNNEL_URL", "").strip() # e.g. https://api.yourdomain.com
API_PORT = int(os.getenv("API_PORT", "8000"))
# ── colours ─────────────────────────────────────────────────────────────────── # ── colours ───────────────────────────────────────────────────────────────────
_CYAN = "\033[36m" _CYAN = "\033[36m"
@@ -64,30 +64,15 @@ _RESET = "\033[0m"
def _ensure_pycloudflared() -> bool: def _ensure_pycloudflared() -> bool:
"""Install pycloudflared via pip if it's not available. Returns True on success.""" """Return True if pycloudflared is installed (no automatic pip install)."""
try: try:
import pycloudflared # noqa: F401 import pycloudflared # noqa: F401
return True return True
except ImportError: except ImportError:
pass print(
f"{_RED}◈ Tunnel: pycloudflared is not installed.\n"
print(f"{_YELLOW}◈ Tunnel: pycloudflared not found — installing via pip…{_RESET}") f" Install manually: pip install pycloudflared{_RESET}"
try:
result = subprocess.run(
[__import__("sys").executable, "-m", "pip", "install", "pycloudflared"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=120,
text=True,
) )
if result.returncode == 0:
print(f"{_GREEN}◈ Tunnel: pycloudflared installed successfully.{_RESET}")
return True
else:
print(f"{_RED}◈ Tunnel: pip install failed:\n{result.stdout}{_RESET}")
return False
except Exception as exc:
print(f"{_RED}◈ Tunnel: could not install pycloudflared — {exc}{_RESET}")
return False return False
@@ -157,8 +142,9 @@ def _get_binary() -> str | None:
import shutil import shutil
import stat import stat
# ── Step 0: make sure pycloudflared is installed ────────────────────────── # ── Step 0: check pycloudflared is installed ─────────────────────────────
_ensure_pycloudflared() if not _ensure_pycloudflared():
return None
candidates: list[str] = [] candidates: list[str] = []
@@ -222,7 +208,15 @@ def _get_binary() -> str | None:
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
continue continue
# ── Step 4: direct GitHub download as last resort ───────────────────────── # ── Step 4: direct GitHub download (opt-in only) ─────────────────────────
if not TUNNEL_ALLOW_BINARY_DOWNLOAD:
print(
f"{_YELLOW}◈ Tunnel: no working binary found.\n"
f" Install pycloudflared manually, or set TUNNEL_ALLOW_BINARY_DOWNLOAD=true "
f"to allow downloading cloudflared from GitHub.{_RESET}"
)
return None
print(f"{_YELLOW}◈ Tunnel: no working binary found — attempting direct download…{_RESET}") print(f"{_YELLOW}◈ Tunnel: no working binary found — attempting direct download…{_RESET}")
direct = _download_cloudflared_direct() direct = _download_cloudflared_direct()
if direct and os.path.isfile(direct): if direct and os.path.isfile(direct):
@@ -288,7 +282,7 @@ def _run_tunnel(binary: str, token: str, port: int, public_url: str) -> None:
announced = True announced = True
if public_url: if public_url:
print(f"{_GREEN}◈ Tunnel: API is live at {public_url}{_RESET}") print(f"{_GREEN}◈ Tunnel: API is live at {public_url}{_RESET}")
print(f"{_CYAN}NEXT_PUBLIC_API_URL = {public_url}/api/v1{_RESET}") print(f"{_CYAN}DASHBOARD_API_URL = {public_url}/api/v1{_RESET}")
else: else:
print(f"{_GREEN}◈ Tunnel: connected — check CF_TUNNEL_URL in .env for your public URL{_RESET}") print(f"{_GREEN}◈ Tunnel: connected — check CF_TUNNEL_URL in .env for your public URL{_RESET}")

View File

@@ -1,20 +1,24 @@
# API URL for the dashboard backend # Bot API URL (server-side — used by Next.js proxy and SSR)
NEXT_PUBLIC_API_URL=https://xxxx-xxxx-xxxx.ngrok-free.app/api/v1 DASHBOARD_API_URL=http://127.0.0.1:8000/api/v1
# API Key for authentication (if used) # Bot API key (SERVER ONLY — never use NEXT_PUBLIC_ prefix)
NEXT_PUBLIC_DASHBOARD_API_KEY=ZYROX_SECURE_API_KEY_12345_CHANGE_THIS_ASAP_BY_CODEX_DEVS DASHBOARD_API_KEY=generate_a_long_random_secret_here
# Legacy/public API URL (optional fallback for DASHBOARD_API_URL)
NEXT_PUBLIC_API_URL=http://127.0.0.1:8000/api/v1
# NextAuth Configuration # NextAuth Configuration
NEXTAUTH_URL=https://your-vercel-url-here-codex-devs.vercel.app/ NEXTAUTH_URL=http://localhost:3000/
NEXTAUTH_SECRET=zyrox_nextauth_default_secret_string_2026_change_me_BY_CODEX_DEVS NEXTAUTH_SECRET=generate_a_long_random_secret_here
# Discord OAuth configuration # Discord OAuth configuration
DISCORD_CLIENT_ID= DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET= DISCORD_CLIENT_SECRET=
# Admin User IDs (comma separated) # Dashboard admin user IDs (comma separated — your Discord user ID)
NEXT_PUBLIC_ADMIN_IDS=870179991462236170 ADMIN_IDS=YOUR_DISCORD_USER_ID_HERE
NEXT_PUBLIC_ADMIN_IDS=YOUR_DISCORD_USER_ID_HERE
# Customizable Brand / Bot name shown across the dashboard # Customizable Brand / Bot name shown across the dashboard
NEXT_PUBLIC_BRAND_NAME="ZyroX" NEXT_PUBLIC_BRAND_NAME="ZyroX"
NEXT_PUBLIC_BRAND_NAME_WORD="ZX" NEXT_PUBLIC_BRAND_NAME_WORD="ZX"

View File

@@ -1,89 +1,34 @@
<div align="center"> <div align="center">
``` # ZyroX Dashboard
███████╗██╗ ██╗██████╗ ██████╗ ██╗ ██╗
╚══███╔╝╚██╗ ██╔╝██╔══██╗██╔═══██╗╚██╗██╔╝
███╔╝ ╚████╔╝ ██████╔╝██║ ██║ ╚███╔╝
███╔╝ ╚██╔╝ ██╔══██╗██║ ██║ ██╔██╗
███████╗ ██║ ██║ ██║╚██████╔╝██╔╝ ██╗
╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝
```
<h3>ZyroX Dashboard — Next.js Web Interface</h3> Next.js web dashboard for configuring the ZyroX Discord bot.
<a href="https://nexiohost.in"><img src="https://img.shields.io/badge/⭐%20PREMIUM%20HOSTING-NexioHost-FFD700?style=for-the-badge&labelColor=1a1a2e&color=FFD700&logoColor=FFD700"/></a>
<p>
<a href="https://nextjs.org"><img src="https://img.shields.io/badge/Next.js-14+-000000?style=for-the-badge&logo=nextdotjs&logoColor=white"/></a>
<a href="https://typescriptlang.org"><img src="https://img.shields.io/badge/TypeScript-5+-3178C6?style=for-the-badge&logo=typescript&logoColor=white"/></a>
<a href="https://tailwindcss.com"><img src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=for-the-badge&logo=tailwindcss&logoColor=white"/></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-red?style=for-the-badge"/></a>
</p>
<p>
<a href="https://discord.gg/codexdev"><img src="https://img.shields.io/badge/Discord-Join_Server-5865F2?style=for-the-badge&logo=discord&logoColor=white"/></a>
<a href="https://youtube.com/@CodeXDevs"><img src="https://img.shields.io/badge/YouTube-CodeXDevs-FF0000?style=for-the-badge&logo=youtube&logoColor=white"/></a>
<a href="https://github.com/RayExo"><img src="https://img.shields.io/badge/GitHub-RayExo-181717?style=for-the-badge&logo=github&logoColor=white"/></a>
</p>
</div> </div>
--- ---
## Overview ## Overview
This folder contains the ZyroX web dashboard built with `Next.js 14` (App Router), `TypeScript`, and `Tailwind CSS`. It connects to the bot's FastAPI backend via a permanent Cloudflare Tunnel HTTPS URL and lets server admins manage all bot settings through a sleek, branded UI. - **Discord OAuth login** — users only see servers they can manage
- **Server-side API proxy** — `DASHBOARD_API_KEY` never exposed to the browser
``` - **Guild permission checks** — bot API verifies Discord Manage Server / Admin rights
dashboard/ - **Fully branded** — name and colours via environment variables
├── app/ App Router pages & API routes - **Vercel-ready**
│ ├── api/auth/ NextAuth OAuth callback
│ ├── dashboard/ Main dashboard area
│ │ ├── admin/ Admin-only panel
│ │ ├── guilds/ Server selection
│ │ └── guild/[guildId]/ Per-server settings pages
│ │ ├── antinuke/
│ │ ├── automod/
│ │ ├── leveling/
│ │ ├── logging/
│ │ ├── tickets/
│ │ ├── welcome/
│ │ └── …more
│ ├── docs/ Documentation page
│ ├── privacy/ Privacy policy
│ └── terms/ Terms of service
├── components/
│ ├── dashboard/ Feature-specific form components
│ └── ui/ Base UI components (button, card, input…)
├── hooks/ Custom React hooks
├── lib/ API client, auth config, utilities
└── types/ TypeScript type definitions
```
--- ---
## ✦ Features ## Prerequisites
- **Discord OAuth2 login** — secure sign-in, session managed by NextAuth
- **Per-server management** — antinuke, automod, leveling, logging, tickets, welcome, and more
- **Live bot stats** — real-time metrics pulled from the FastAPI backend
- **Admin panel** — owner-only configuration and announcements
- **Fully branded** — name, logo, and colours via environment variables
- **HTTPS ready** — connects to the bot's permanent Cloudflare Tunnel URL
- **Vercel-ready** — deploys in minutes with zero config changes
---
## ✦ Prerequisites
| Requirement | Notes | | Requirement | Notes |
|---|---| |---|---|
| Node.js 18+ | — | | Node.js 18+ | — |
| ZyroX bot running | with `API_ENABLED=true` and `TUNNEL_ENABLED=true` | | ZyroX bot running | with `API_ENABLED=true` on `127.0.0.1` (or via Cloudflare Tunnel) |
| Discord OAuth app | from [Discord Developer Portal](https://discord.com/developers/applications) | | Discord OAuth app | from [Discord Developer Portal](https://discord.com/developers/applications) |
--- ---
## Setup ## Setup
### 1 — Install dependencies ### 1 — Install dependencies
@@ -93,23 +38,23 @@ npm install
### 2 — Configure environment ### 2 — Configure environment
Create a `.env.local` file in this folder: Create `.env.local` in this folder (see `.env.example`):
```env ```env
# ── Bot API ─────────────────────────────────────────────────────── # ── Bot API (server-side only — never use NEXT_PUBLIC_ for the key) ──
# Use the Cloudflare Tunnel URL from the bot's console output DASHBOARD_API_URL = http://127.0.0.1:8000/api/v1
NEXT_PUBLIC_API_URL = https://api.yourdomain.com/api/v1 DASHBOARD_API_KEY = same_secret_as_bot_DASHBOARD_API_KEY
NEXT_PUBLIC_DASHBOARD_API_KEY = your_shared_api_key # must match bot's DASHBOARD_API_KEY
# ── NextAuth ────────────────────────────────────────────────────── # ── NextAuth ──────────────────────────────────────────────────────
NEXTAUTH_URL = http://localhost:3000 NEXTAUTH_URL = http://localhost:3000
NEXTAUTH_SECRET = a_long_random_string # generate: openssl rand -base64 32 NEXTAUTH_SECRET = a_long_random_string
# ── Discord OAuth ───────────────────────────────────────────────── # ── Discord OAuth ─────────────────────────────────────────────────
DISCORD_CLIENT_ID = your_discord_oauth_client_id DISCORD_CLIENT_ID = your_discord_oauth_client_id
DISCORD_CLIENT_SECRET = your_discord_oauth_client_secret DISCORD_CLIENT_SECRET = your_discord_oauth_client_secret
# ── Branding ────────────────────────────────────────────────────── # ── Admin & Branding ──────────────────────────────────────────────
ADMIN_IDS = your_discord_user_id
NEXT_PUBLIC_ADMIN_IDS = your_discord_user_id NEXT_PUBLIC_ADMIN_IDS = your_discord_user_id
NEXT_PUBLIC_BRAND_NAME = "ZyroX" NEXT_PUBLIC_BRAND_NAME = "ZyroX"
NEXT_PUBLIC_BRAND_NAME_WORD = "ZX" NEXT_PUBLIC_BRAND_NAME_WORD = "ZX"
@@ -125,102 +70,64 @@ Open [http://localhost:3000](http://localhost:3000)
--- ---
## Environment Reference ## Environment Reference
| Variable | Description | | Variable | Description |
|---|---| |---|---|
| `NEXT_PUBLIC_API_URL` | Full URL to the bot's FastAPI backend — use the Cloudflare Tunnel URL | | `DASHBOARD_API_URL` | Bot API URL (server-side). Local: `http://127.0.0.1:8000/api/v1` |
| `NEXT_PUBLIC_DASHBOARD_API_KEY` | Must exactly match `DASHBOARD_API_KEY` in the bot `.env` | | `DASHBOARD_API_KEY` | Must match bot `DASHBOARD_API_KEY` **server-side only** |
| `NEXTAUTH_URL` | Your dashboard's public URL (Vercel domain in production) | | `NEXTAUTH_URL` | Dashboard public URL |
| `NEXTAUTH_SECRET` | Random secret for NextAuth session signing | | `NEXTAUTH_SECRET` | Random secret for NextAuth session signing |
| `DISCORD_CLIENT_ID` | Discord OAuth2 client ID | | `DISCORD_CLIENT_ID` | Discord OAuth2 client ID |
| `DISCORD_CLIENT_SECRET` | Discord OAuth2 client secret | | `DISCORD_CLIENT_SECRET` | Discord OAuth2 client secret |
| `NEXT_PUBLIC_ADMIN_IDS` | Comma-separated Discord user IDs with admin panel access | | `ADMIN_IDS` | Server-side admin user IDs (for admin API routes) |
| `NEXT_PUBLIC_BRAND_NAME` | Bot name shown in the dashboard UI | | `NEXT_PUBLIC_ADMIN_IDS` | Same IDs for admin panel UI visibility |
| `NEXT_PUBLIC_BRAND_NAME_WORD` | Short abbreviation shown in the dashboard (e.g. `ZX`) | | `NEXT_PUBLIC_BRAND_NAME` | Bot name in the dashboard UI |
| `NEXT_PUBLIC_BRAND_NAME_WORD` | Short abbreviation (e.g. `ZX`) |
> Do **not** set `NEXT_PUBLIC_DASHBOARD_API_KEY`. The browser uses `/api/proxy`, which adds the secret server-side.
--- ---
## ✦ Deployment (Vercel) ## How API auth works
**Step 1 — Connect your repo**
Go to [vercel.com](https://vercel.com) → **Add New Project** → connect your GitHub repo → set root directory to `dashboard/`
Vercel auto-detects Next.js — no build settings needed.
**Step 2 — Add environment variables**
In **Settings → Environment Variables**, add all keys from the table above.
| Variable | Production Value |
|---|---|
| `NEXT_PUBLIC_API_URL` | `https://api.yourdomain.com/api/v1` |
| `NEXTAUTH_URL` | `https://your-app.vercel.app` |
| `NEXTAUTH_SECRET` | [generate one](https://generate-secret.vercel.app/32) |
| `DISCORD_CLIENT_ID` | from [Discord Developer Portal](https://discord.com/developers/applications) |
**Step 3 — Add redirect URI in Discord**
In your Discord app → **OAuth2 → Redirects** → add:
``` ```
https://your-app.vercel.app/api/auth/callback/discord Browser → /api/proxy/guilds/... → Next.js (session check)
→ Bot API with:
Authorization: Bearer DASHBOARD_API_KEY
X-Discord-Access-Token: <oauth token>
X-Discord-User-Id: <user id>
→ Bot verifies Discord Manage Server permission per guild
``` ```
**Step 4 — Deploy** SSR (server components) call the bot API directly with the same headers from the active session.
Hit **Deploy**. Vercel builds and publishes automatically. ✓
--- ---
## ✦ Connecting to the Bot API ## Deployment (Vercel)
The dashboard reads the API URL from `NEXT_PUBLIC_API_URL`. 1. Connect repo → set root directory to `dashboard/`
2. Add all environment variables from the table above
3. Set `NEXTAUTH_URL` to your Vercel domain
4. Add OAuth redirect: `https://your-app.vercel.app/api/auth/callback/discord`
5. Deploy
| Environment | Value | For production with Cloudflare Tunnel, set:
|---|---|
| Local dev | `http://localhost:8000/api/v1` |
| Production | `https://api.yourdomain.com/api/v1` (Cloudflare Tunnel URL) |
The bot prints the confirmed URL on every startup: ```env
DASHBOARD_API_URL=https://api.yourdomain.com/api/v1
``` ```
◈ Tunnel: API is live at https://api.yourdomain.com
↳ NEXT_PUBLIC_API_URL = https://api.yourdomain.com/api/v1
```
This URL is permanent — it never changes between restarts as long as the Cloudflare Tunnel token stays the same.
--- ---
## Troubleshooting ## Troubleshooting
| Problem | Fix | | Problem | Fix |
|---|---| |---|---|
| Auth error on login | Check Discord OAuth client ID/secret and redirect URI in Developer Portal | | Dashboard can't load data | Bot running with `API_ENABLED=true`, `DASHBOARD_API_URL` correct |
| Dashboard can't load data | Confirm bot is running with `API_ENABLED=true` and `NEXT_PUBLIC_API_URL` is correct | | 401 from bot API | `DASHBOARD_API_KEY` must match bot `.env` exactly |
| CORS error in browser | Add your Vercel URL to `CORS_ORIGINS` in the bot's `.env` | | 403 on guild routes | User must have Manage Server or Admin on that Discord server |
| `NEXTAUTH_SECRET` error | Make sure `NEXTAUTH_SECRET` is set and non-empty | | Admin panel 403 | Add your Discord ID to `ADMIN_IDS` and bot `DASHBOARD_ADMIN_IDS` |
| API key rejected (401) | `NEXT_PUBLIC_DASHBOARD_API_KEY` must exactly match `DASHBOARD_API_KEY` in the bot | | Auth error on login | Check Discord OAuth client ID/secret and redirect URI |
| Tunnel URL changed | Cloudflare named tunnels always produce the same URL — check `CF_TUNNEL_TOKEN` is valid |
--- See also `SECURITY_FIXES.md` in the project root.
<div align="center">
## ✦ CodeX Devs
*Built for protection. Designed for style.*
<a href="https://discord.gg/codexdev"><img src="https://discord.com/api/guilds/1301573144817045524/widget.png?style=banner2" alt="CodeX Development Discord Server" width="480"/></a>
<p>
<a href="https://discord.gg/codexdev"><img src="https://img.shields.io/badge/Discord-Join_Server-5865F2?style=for-the-badge&logo=discord&logoColor=white"/></a>
<a href="https://youtube.com/@CodeXDevs"><img src="https://img.shields.io/badge/YouTube-CodeXDevs-FF0000?style=for-the-badge&logo=youtube&logoColor=white"/></a>
<a href="https://github.com/RayExo"><img src="https://img.shields.io/badge/GitHub-RayExo-181717?style=for-the-badge&logo=github&logoColor=white"/></a>
<a href="https://nexiohost.in"><img src="https://img.shields.io/badge/⭐%20PREMIUM%20HOSTING-NexioHost-FFD700?style=for-the-badge&labelColor=1a1a2e&color=FFD700&logoColor=FFD700"/></a>
</p>
© 2026 CodeX Devs — MIT License
</div>

View File

@@ -0,0 +1,146 @@
/**
* Authenticated server-side proxy to the bot FastAPI backend.
* - Requires Discord OAuth session
* - Guild routes: user must have Manage Server / Admin on that guild
* - Admin routes: user must be in ADMIN_IDS
*/
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth";
import { isAdmin } from "@/lib/utils";
import { userCanManageGuild } from "@/lib/guild-auth";
const BOT_API_URL =
process.env.DASHBOARD_API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
"http://127.0.0.1:8000/api/v1";
const API_KEY = process.env.DASHBOARD_API_KEY;
type RouteContext = { params: { path: string[] } };
async function authorizeProxyRequest(
path: string,
session: { user?: { id?: string }; accessToken?: string }
): Promise<NextResponse | null> {
if (!session.user) {
return NextResponse.json({ detail: "Unauthorized — sign in required." }, { status: 401 });
}
const accessToken = session.accessToken;
if (!accessToken) {
return NextResponse.json({ detail: "Missing Discord access token." }, { status: 401 });
}
const normalized = path.replace(/\/$/, "");
if (normalized.startsWith("admin")) {
if (!isAdmin(session.user.id)) {
return NextResponse.json({ detail: "Forbidden — admin access required." }, { status: 403 });
}
return null;
}
const guildMatch = normalized.match(/^guilds\/(\d+)/);
if (guildMatch) {
const guildId = guildMatch[1];
const allowed = await userCanManageGuild(accessToken, guildId);
if (!allowed) {
return NextResponse.json(
{ detail: "Forbidden — you cannot manage this server." },
{ status: 403 }
);
}
}
return null;
}
async function proxyRequest(request: NextRequest, { params }: RouteContext) {
if (!API_KEY) {
return NextResponse.json(
{ detail: "DASHBOARD_API_KEY is not configured on the dashboard server." },
{ status: 500 }
);
}
const session = await getServerSession(authOptions);
const path = params.path.join("/");
const authError = await authorizeProxyRequest(path, session ?? {});
if (authError) return authError;
const targetUrl = `${BOT_API_URL.replace(/\/$/, "")}/${path}${request.nextUrl.search}`;
const headers = new Headers();
headers.set("Authorization", `Bearer ${API_KEY}`);
if (session?.accessToken) {
headers.set("X-Discord-Access-Token", session.accessToken);
}
if (session?.user?.id) {
headers.set("X-Discord-User-Id", session.user.id);
}
const contentType = request.headers.get("content-type");
if (contentType) {
headers.set("Content-Type", contentType);
}
const init: RequestInit = {
method: request.method,
headers,
cache: "no-store",
};
if (request.method !== "GET" && request.method !== "HEAD") {
init.body = await request.text();
}
try {
const response = await fetch(targetUrl, init);
let body = await response.text();
// Filter guild list to servers the user can manage (bot guild list ∩ user admin guilds)
const normalized = path.replace(/\/$/, "");
if (
request.method === "GET" &&
normalized === "guilds" &&
response.ok &&
session?.accessToken
) {
try {
const botGuilds = JSON.parse(body) as Array<{ id: string }>;
const { fetchUserGuilds, canManageGuild } = await import("@/lib/guild-auth");
const userGuilds = await fetchUserGuilds(session.accessToken);
const manageableIds = new Set(
userGuilds.filter(canManageGuild).map((g) => String(g.id))
);
const filtered = botGuilds.filter((g) => manageableIds.has(String(g.id)));
body = JSON.stringify(filtered);
} catch {
// If filtering fails, return original response rather than breaking the dashboard
}
}
return new NextResponse(body, {
status: response.status,
headers: {
"Content-Type": response.headers.get("Content-Type") || "application/json",
},
});
} catch (error) {
console.error(`[API Proxy] Failed to reach bot API at ${targetUrl}:`, error);
return NextResponse.json(
{ detail: "Could not connect to the bot API backend." },
{ status: 502 }
);
}
}
export const GET = proxyRequest;
export const POST = proxyRequest;
export const PATCH = proxyRequest;
export const DELETE = proxyRequest;
export const PUT = proxyRequest;

View File

@@ -32,6 +32,10 @@ import {
} from "lucide-react"; } from "lucide-react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth";
import { userCanManageGuild } from "@/lib/guild-auth";
import { redirect } from "next/navigation";
export const revalidate = 0; // Never cache any guild dashboard page export const revalidate = 0; // Never cache any guild dashboard page
@@ -51,6 +55,27 @@ export default async function GuildLayout({
let guild; let guild;
let error = null; let error = null;
const session = await getServerSession(authOptions);
if (!session?.accessToken) {
redirect("/");
}
const hasGuildAccess = await userCanManageGuild(session.accessToken, guildId);
if (!hasGuildAccess) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] border-2 border-dashed border-red-500/20 rounded-3xl bg-red-500/5 p-12 text-center">
<ShieldAlert className="h-16 w-16 text-red-500 mb-6 opacity-50" />
<h2 className="text-2xl font-bold text-white">Access Denied</h2>
<p className="text-slate-400 mt-2 max-w-md">
You do not have permission to manage this server.
</p>
<Link href="/dashboard/guilds" className="mt-8">
<Button variant="outline">Back to Servers</Button>
</Link>
</div>
);
}
try { try {
guild = await api.getGuildDetails(guildId); guild = await api.getGuildDetails(guildId);
} catch (err: any) { } catch (err: any) {

View File

@@ -38,8 +38,14 @@ import {
AdminConfigUpdate AdminConfigUpdate
} from "@/types/api"; } from "@/types/api";
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1"; const isServer = typeof window === "undefined";
const API_KEY = process.env.NEXT_PUBLIC_DASHBOARD_API_KEY;
/** Server: direct bot API with secret key. Browser: same-origin proxy route. */
const BASE_URL = isServer
? (process.env.DASHBOARD_API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
"http://127.0.0.1:8000/api/v1")
: "/api/proxy";
class ApiError extends Error { class ApiError extends Error {
constructor(public status: number, message: string) { constructor(public status: number, message: string) {
@@ -55,8 +61,20 @@ async function request<T>(
const url = `${BASE_URL}${endpoint}`; const url = `${BASE_URL}${endpoint}`;
const headers = new Headers(options.headers); const headers = new Headers(options.headers);
if (API_KEY) { if (isServer) {
headers.set("Authorization", `Bearer ${API_KEY}`); const apiKey = process.env.DASHBOARD_API_KEY;
if (apiKey) {
headers.set("Authorization", `Bearer ${apiKey}`);
}
const { getServerSession } = await import("next-auth/next");
const { authOptions } = await import("@/lib/auth");
const session = await getServerSession(authOptions);
if (session?.accessToken) {
headers.set("X-Discord-Access-Token", session.accessToken);
}
if (session?.user?.id) {
headers.set("X-Discord-User-Id", session.user.id);
}
} }
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
try { try {

View File

@@ -0,0 +1,45 @@
/**
* Discord guild permission helpers for dashboard access control.
*/
export interface DiscordGuild {
id: string;
permissions: string;
owner?: boolean;
}
const MANAGE_GUILD = BigInt(0x20);
const ADMINISTRATOR = BigInt(0x8);
export function canManageGuild(guild: DiscordGuild): boolean {
if (guild.owner) return true;
try {
const perms = BigInt(guild.permissions);
return (
(perms & ADMINISTRATOR) === ADMINISTRATOR ||
(perms & MANAGE_GUILD) === MANAGE_GUILD
);
} catch {
return false;
}
}
export async function fetchUserGuilds(
accessToken: string
): Promise<DiscordGuild[]> {
const res = await fetch("https://discord.com/api/users/@me/guilds", {
headers: { Authorization: `Bearer ${accessToken}` },
cache: "no-store",
});
if (!res.ok) return [];
return res.json();
}
export async function userCanManageGuild(
accessToken: string,
guildId: string
): Promise<boolean> {
const guilds = await fetchUserGuilds(accessToken);
const guild = guilds.find((g) => String(g.id) === String(guildId));
return guild ? canManageGuild(guild) : false;
}

View File

@@ -23,6 +23,11 @@ export function cn(...inputs: ClassValue[]) {
export function isAdmin(userId?: string | null) { export function isAdmin(userId?: string | null) {
if (!userId) return false; if (!userId) return false;
const adminIds = (process.env.NEXT_PUBLIC_ADMIN_IDS || "").split(","); const adminIds = [
...(process.env.ADMIN_IDS || "").split(","),
...(process.env.NEXT_PUBLIC_ADMIN_IDS || "").split(","),
]
.map((id) => id.trim())
.filter(Boolean);
return adminIds.includes(userId); return adminIds.includes(userId);
} }