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

@@ -26,24 +26,61 @@ server = "https://discord.gg/codexdev"
serverLink = "https://discord.gg/codexdev"
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 ─────────────────────────────────────────────────────────
# Edit OWNER_IDS in .env — comma-separated, no spaces needed.
# Example: OWNER_IDS = 870179991462236170,767979794411028491,1432771000629596225
# REQUIRED: set OWNER_IDS in .env — no hardcoded fallback IDs.
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()
if not raw:
return defaults
ids = [int(p.strip()) for p in raw.split(",") if p.strip().isdigit()]
return ids or defaults
return []
return [int(p.strip()) for p in raw.split(",") if p.strip().isdigit()]
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]
# Aliases kept for backwards compatibility with files that import these names
BOT_OWNER_IDS = OWNER_IDS
BOT_OWNER_IDS_STR = OWNER_IDS_STR
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
import os
import discord
from utils.config import BotName
try:
from discord.ext import menus
from discord.ext import commands
except ModuleNotFoundError:
os.system("pip install git+https://github.com/Rapptz/discord-ext-menus")
except ModuleNotFoundError as exc:
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 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
# ── 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_URL = os.getenv("CF_TUNNEL_URL", "").strip() # e.g. https://api.yourdomain.com
API_PORT = int(os.getenv("API_PORT", "8000"))
# ── colours ───────────────────────────────────────────────────────────────────
_CYAN = "\033[36m"
@@ -64,30 +64,15 @@ _RESET = "\033[0m"
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:
import pycloudflared # noqa: F401
return True
except ImportError:
pass
print(f"{_YELLOW}◈ Tunnel: pycloudflared not found — installing via pip…{_RESET}")
try:
result = subprocess.run(
[__import__("sys").executable, "-m", "pip", "install", "pycloudflared"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=120,
text=True,
print(
f"{_RED}◈ Tunnel: pycloudflared is not installed.\n"
f" Install manually: pip install pycloudflared{_RESET}"
)
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
@@ -157,8 +142,9 @@ def _get_binary() -> str | None:
import shutil
import stat
# ── Step 0: make sure pycloudflared is installed ──────────────────────────
_ensure_pycloudflared()
# ── Step 0: check pycloudflared is installed ─────────────────────────────
if not _ensure_pycloudflared():
return None
candidates: list[str] = []
@@ -222,7 +208,15 @@ def _get_binary() -> str | None:
except (OSError, subprocess.TimeoutExpired):
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}")
direct = _download_cloudflared_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
if public_url:
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:
print(f"{_GREEN}◈ Tunnel: connected — check CF_TUNNEL_URL in .env for your public URL{_RESET}")