diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 20f5ea9..1213de3 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -5,6 +5,7 @@ on: branches: - main - "revival/**" + - "hotfix/**" pull_request: permissions: @@ -41,6 +42,32 @@ jobs: - name: Compile Python sources run: python -m compileall -q bot.py main.py cogs core cytechlink utils web + web-syntax: + name: JavaScript syntax + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Check browser scripts + run: | + node --check web/script.js + node --check web/settings.js + node --check web/security-bootstrap.js + + - name: Check Cloudflare Pages modules + run: | + node --input-type=module --check < web/functions/_middleware.js + node --input-type=module --check < web/functions/api/proxy.js + node --input-type=module --check < web/functions/api/gateway.js + node --input-type=module --check < web/functions/api/recommended.js + security-audit: name: Dependency audit runs-on: ubuntu-latest diff --git a/core/database.py b/core/database.py index c464acf..72a5ce2 100644 --- a/core/database.py +++ b/core/database.py @@ -1,9 +1,42 @@ import asyncio import time -from typing import Dict, Any, Optional, Union +from typing import Dict, Any, Union from motor.motor_asyncio import AsyncIOMotorCollection +class _RootSafeCollectionProxy: + """Delegate collection access while making empty root selectors fail-safe. + + This collection stores both the root config document and standalone user + documents. Accidental find_one({}) / update_one({}) calls can otherwise + select an arbitrary user document. Empty selectors are rewritten to the + resolved root _id. + """ + + def __init__(self, manager: "DatabaseManager", collection: AsyncIOMotorCollection): + self._manager = manager + self._collection = collection + + def __getattr__(self, name): + return getattr(self._collection, name) + + async def find_one(self, filter=None, *args, **kwargs): + if filter == {}: + await self._manager._fetch_root() + if self._manager._root_id is None: + return None + filter = {"_id": self._manager._root_id} + return await self._collection.find_one(filter, *args, **kwargs) + + async def update_one(self, filter, update, *args, **kwargs): + if filter == {}: + await self._manager._fetch_root() + if self._manager._root_id is None: + raise RuntimeError("Refusing update_one({}): root document could not be resolved") + filter = {"_id": self._manager._root_id} + return await self._collection.update_one(filter, update, *args, **kwargs) + + class DatabaseManager: """ Centralized Database Manager with In-Memory Caching. @@ -11,15 +44,16 @@ class DatabaseManager: """ def __init__(self, collection: AsyncIOMotorCollection): - self.collection = collection + self._raw_collection = collection self._cache_data: Dict[str, Any] = {} self._last_fetch_time: float = 0 self._cache_ttl: int = 60 # Cache duration (seconds) self._lock = asyncio.Lock() self._root_id = None + self.collection = _RootSafeCollectionProxy(self, collection) async def _fetch_root(self, force: bool = False) -> Dict[str, Any]: - """Fetch the root document (often empty query `{}`) and cache it.""" + """Fetch the root document and cache it.""" current_time = time.time() # Return cache if valid if ( @@ -39,7 +73,7 @@ async def _fetch_root(self, force: bool = False) -> Dict[str, Any]: return self._cache_data try: - # 🛡️ Protection: Exclude user docs strictly, find the actual root config doc + # Protection: Exclude user docs strictly, find the actual root config doc data = await self.collection.find_one( {"user_id": {"$exists": False}, "guilds": {"$exists": True}} ) @@ -102,7 +136,7 @@ async def update_guild( set_payload = {f"guilds.{gid}.{k}": v for k, v in update_dict.items()} try: - # 🛡️ Protection: Only update the explicitly cached root document + # Protection: Only update the explicitly cached root document query = ( {"_id": self._root_id} if self._root_id diff --git a/main.py b/main.py index 4b48970..331ea72 100644 --- a/main.py +++ b/main.py @@ -3,6 +3,9 @@ from discord.ext import commands from bot import Cyori from utils.config import BOT_TOKEN +from utils.dashboard_security import make_dashboard_security_middleware +from utils.runtime_config import make_runtime_config_middleware +from utils.command_security import install_settings_permission_guard logging.basicConfig(level=logging.INFO) logging.getLogger("discord.http").setLevel(logging.CRITICAL) @@ -19,11 +22,19 @@ async def get_prefix(bot, message: discord.Message): return commands.when_mentioned_or(prefix)(bot, message) +class SecuredCyori(Cyori): + async def setup_hook(self): + await super().setup_hook() + install_settings_permission_guard(self) + if self.web_app is not None: + self.web_app.middlewares.append(make_dashboard_security_middleware(self)) + self.web_app.middlewares.append(make_runtime_config_middleware(self)) + intents = discord.Intents.default() intents.members = True intents.message_content = True -bot = Cyori( +bot = SecuredCyori( command_prefix=get_prefix, activity=discord.Activity(type=discord.ActivityType.listening, name="Starting..."), help_command=None, diff --git a/utils/command_security.py b/utils/command_security.py new file mode 100644 index 0000000..e932436 --- /dev/null +++ b/utils/command_security.py @@ -0,0 +1,67 @@ +"""Defense-in-depth permission guards for sensitive hybrid settings commands.""" +import discord + + +RESTRICTED_MANAGE_GUILD_COMMANDS = {"djrole", "djmode", "vote_mode"} + + +def _has_manage_guild(member) -> bool: + permissions = getattr(member, "guild_permissions", None) + return bool( + permissions + and (permissions.administrator or permissions.manage_guild) + ) + + +def install_settings_permission_guard(bot) -> None: + """Protect both prefix and application-command entry points.""" + + async def prefix_settings_check(ctx): + command = getattr(ctx, "command", None) + if not command or command.qualified_name not in RESTRICTED_MANAGE_GUILD_COMMANDS: + return True + if ctx.guild and _has_manage_guild(ctx.author): + return True + try: + await ctx.send( + "You need Manage Server permission to change this setting.", + delete_after=10, + ) + except Exception: + pass + return False + + bot.add_check(prefix_settings_check) + + previous_interaction_check = bot.tree.interaction_check + + async def secured_interaction_check(interaction: discord.Interaction) -> bool: + if previous_interaction_check is not None: + allowed = await previous_interaction_check(interaction) + if not allowed: + return False + + command = getattr(interaction, "command", None) + if not command or command.qualified_name not in RESTRICTED_MANAGE_GUILD_COMMANDS: + return True + + member = interaction.user + if interaction.guild and _has_manage_guild(member): + return True + + try: + if not interaction.response.is_done(): + await interaction.response.send_message( + "You need Manage Server permission to change this setting.", + ephemeral=True, + ) + else: + await interaction.followup.send( + "You need Manage Server permission to change this setting.", + ephemeral=True, + ) + except Exception: + pass + return False + + bot.tree.interaction_check = secured_interaction_check diff --git a/utils/config.py b/utils/config.py index 8b3a0e8..666128d 100644 --- a/utils/config.py +++ b/utils/config.py @@ -1,19 +1,17 @@ +import base64 import os import pathlib +import sys + from dotenv import load_dotenv # --- WINDOWS UNICODE FIX --- -import sys - if sys.platform == "win32": - # Force UTF-8 encoding for standard output and error - # This is critical for Thai characters and emojis in the console. import io sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") -# Load .env file BASE_DIR = pathlib.Path(__file__).parent.parent env_file = os.getenv("ENV_FILE", ".env") load_dotenv(dotenv_path=BASE_DIR / env_file) @@ -26,124 +24,82 @@ SPOTIFY_CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET") STRIPE_API_KEY = os.getenv("STRIPE_API_KEY") STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET") -# Optional: Proxy URL for Stripe Webhooks (HTTPS) STRIPE_PROXY_URL = os.getenv("STRIPE_PROXY_URL") -DOMAIN_URL = os.getenv("DOMAIN_URL", "http://localhost:3000") -# Optional: Discord Webhook for error logging -ERROR_LOG_WEBHOOK = os.getenv("ERROR_LOG_WEBHOOK", None) - +DOMAIN_URL = os.getenv("DOMAIN_URL", "http://localhost:3000").rstrip("/") +ERROR_LOG_WEBHOOK = os.getenv("ERROR_LOG_WEBHOOK") WEB_PORT = int(os.getenv("WEB_PORT", 3000)) -# Migration Settings -NEW_BOT_ID = os.getenv("NEW_BOT_ID", "1469606905948405833") - -# --- AUTOMATIC ROLE DETECTION --- -# Only the bot with NEW_BOT_ID should be ACTIVE. -# All other bots must run in DEPRECATED_MODE. -try: - if BOT_TOKEN: - # Extract ID from token - _token_id_part = BOT_TOKEN.split(".")[0] - _token_id_part += "=" * (-len(_token_id_part) % 4) - import base64 - - _client_id = str(int(base64.b64decode(_token_id_part).decode("utf-8"))) - - # We trust .env if it explicitly sets DEPRECATED_MODE - env_dep = os.getenv("DEPRECATED_MODE") - if env_dep is not None: - DEPRECATED_MODE = env_dep.lower() == "true" - else: - DEPRECATED_MODE = _client_id != NEW_BOT_ID - - _bot_role = "MIGRATOR/OLD" if DEPRECATED_MODE else "MAIN/NEW" - print( - f"[*] Identity: {_client_id} ({_bot_role}) " - f"| DEPRECATED_MODE = {DEPRECATED_MODE}", - flush=True, - ) - else: - DEPRECATED_MODE = False - print( - "[!] Warning: No BOT_TOKEN found. " "Defaulting to Active Mode.", flush=True - ) -except Exception as e: + +def _client_id_from_token(token: str | None) -> str | None: + """Derive the public Discord application/user ID without exposing token contents.""" + if not token: + return None + try: + part = token.split(".", 1)[0] + part += "=" * (-len(part) % 4) + decoded = base64.b64decode(part).decode("utf-8") + return str(int(decoded)) + except Exception: + return None + + +# Community builds must never default to Cyori's production application ID. +TOKEN_CLIENT_ID = _client_id_from_token(BOT_TOKEN) +NEW_BOT_ID = os.getenv("NEW_BOT_ID") or TOKEN_CLIENT_ID or "" + +# Migration mode is opt-in for Community deployments. If NEW_BOT_ID is explicitly +# set to a different bot, automatic detection can still be used for migrations. +env_dep = os.getenv("DEPRECATED_MODE") +if env_dep is not None: + DEPRECATED_MODE = env_dep.lower() == "true" +elif TOKEN_CLIENT_ID and NEW_BOT_ID: + DEPRECATED_MODE = TOKEN_CLIENT_ID != NEW_BOT_ID +else: DEPRECATED_MODE = False + +if TOKEN_CLIENT_ID: + role = "MIGRATOR/OLD" if DEPRECATED_MODE else "MAIN/ACTIVE" print( - f"[!] Warning: Identity detection failed ({e}). " "Defaulting to Active Mode.", + f"[*] Identity: {TOKEN_CLIENT_ID} ({role}) | DEPRECATED_MODE = {DEPRECATED_MODE}", flush=True, ) +else: + print("[!] Warning: No BOT_TOKEN found.", flush=True) -# Smart Dev Mode +# Development DEV_MODE = os.getenv("DEV_MODE", "false").lower() == "true" IMPERSONATE_MAIN = os.getenv("IMPERSONATE_MAIN", "false").lower() == "true" TEST_GUILD_ID = int(os.getenv("TEST_GUILD_ID", 0)) - # Database MONGO_URI = os.getenv("MONGO_URI") - -# Auto DB Name Logic -import base64 # noqa: E402 -import json # noqa: E402 -import urllib.request # noqa: E402 - -_auto_db_name = "Komo" # Final fallback - -# 1. Try to fetch Username from Discord API (User Request: "bot.name") -try: - _url = "https://discord.com/api/v10/users/@me" - _req = urllib.request.Request( - _url, headers={"Authorization": f"Bot {BOT_TOKEN}", "User-Agent": "Cyori/1"} - ) - with urllib.request.urlopen(_req, timeout=5) as _res: - _data = json.load(_res) - _bot_username = _data.get("username", "") - if _bot_username: - # Sanitize (Alphanumeric only to be safe for DB name) - _clean_name = "".join(c for c in _bot_username if c.isalnum()) - if _clean_name: - _auto_db_name = _clean_name -except Exception: - # 2. Fallback to Client ID from Token - try: - _token_id_part = BOT_TOKEN.split(".")[0] - _token_id_part += "=" * (-len(_token_id_part) % 4) - _client_id = str(int(base64.b64decode(_token_id_part).decode("utf-8"))) - _auto_db_name = f"Komo_{_client_id}" - except Exception: - # 3. Fallback to Folder Name - _folder_name = os.path.basename(pathlib.Path(__file__).parent.parent) - _auto_db_name = "".join(c for c in _folder_name if c.isalnum()) - if not _auto_db_name: - _auto_db_name = "Komo" - -DB_NAME = os.getenv("DB_NAME", _auto_db_name) - -# LAVALINK +if os.getenv("DB_NAME"): + DB_NAME = os.getenv("DB_NAME") +elif TOKEN_CLIENT_ID: + DB_NAME = f"CytechMusic_{TOKEN_CLIENT_ID}" +else: + folder_name = "".join(c for c in BASE_DIR.name if c.isalnum()) + DB_NAME = folder_name or "CytechMusic" + +# Lavalink LAVALINK_HOST = os.getenv("LAVALINK_HOST") LAVALINK_PORT = int(os.getenv("LAVALINK_PORT", 2333)) LAVALINK_PASS = os.getenv("LAVALINK_PASS") -LAVALINK_ID = os.getenv("LAVALINK_ID") +LAVALINK_ID = os.getenv("LAVALINK_ID", "main") # Other DEFAULT_PREFIX = os.getenv("DEFAULT_PREFIX", "cm!") _owner_ids_str = os.getenv("OWNER_IDS", "") OWNER_IDS = [int(x.strip()) for x in _owner_ids_str.split(",") if x.strip()] -VERSION = os.getenv("VERSION") -# ID for error logs +VERSION = os.getenv("VERSION", "community") LOG_CHANNEL_ID = int(os.getenv("LOG_CHANNEL_ID", 0)) -# Webhook for error logs LOG_WEBHOOK_URL = os.getenv("LOG_WEBHOOK_URL") - # UI Configuration -# Colors EMBED_COLOR = int(os.getenv("EMBED_COLOR", "0xFFD700"), 16) ERROR_COLOR = int(os.getenv("ERROR_COLOR", "0xFF0000"), 16) SUCCESS_COLOR = int(os.getenv("SUCCESS_COLOR", "0x00FF00"), 16) -# Warning Sounds (Direct Link to .mp3) WARNING_SOUND_URL_TH = os.getenv( "WARNING_SOUND_URL_TH", "https://raw.githubusercontent.com/CytechNaRak/Cytech-Cloud/main/warning_TH_sound.mp3", @@ -152,17 +108,21 @@ "WARNING_SOUND_URL_EN", "https://raw.githubusercontent.com/CytechNaRak/Cytech-Cloud/main/warning_EN_sound.mp3", ) +BANNER_URL = os.getenv( + "BANNER_URL", + "https://raw.githubusercontent.com/Cytech-Team/CytechMusic/main/web/logo-circle.png", +) -# Images -BANNER_URL = os.getenv("BANNER_URL", "https://i.postimg.cc/5y5pk1bL/Cyori-Banner.png") - -# Links -SUPPORT_URL = os.getenv("SUPPORT_URL", "https://discord.gg/jcJ2P6Bh2p") +# Community-safe links. Deployers can override every value through environment variables. +SUPPORT_URL = os.getenv("SUPPORT_URL", "https://github.com/Cytech-Team/CytechMusic") DASHBOARD_URL = os.getenv("DASHBOARD_URL", f"{DOMAIN_URL}/dashboard") INVITE_URL = os.getenv("INVITE_URL", f"{DOMAIN_URL}/invite") -DONATE_URL = os.getenv("DONATE_URL", "https://easydonate.app/NamoPlayZone") -VOTE_URL = os.getenv("VOTE_URL", f"https://top.gg/bot/{NEW_BOT_ID}") +DONATE_URL = os.getenv("DONATE_URL", "https://github.com/Cytech-Team/CytechMusic") +VOTE_URL = os.getenv( + "VOTE_URL", + f"https://top.gg/bot/{NEW_BOT_ID}" if NEW_BOT_ID else "https://top.gg/", +) # Stripe URLs -STRIPE_SUCCESS_URL = os.getenv("STRIPE_SUCCESS_URL", "https://cyori.pages.dev/success.html") -STRIPE_CANCEL_URL = os.getenv("STRIPE_CANCEL_URL", "https://cyori.pages.dev/premium.html") +STRIPE_SUCCESS_URL = os.getenv("STRIPE_SUCCESS_URL", f"{DOMAIN_URL}/success.html") +STRIPE_CANCEL_URL = os.getenv("STRIPE_CANCEL_URL", f"{DOMAIN_URL}/premium.html") diff --git a/utils/dashboard_security.py b/utils/dashboard_security.py new file mode 100644 index 0000000..03086ca --- /dev/null +++ b/utils/dashboard_security.py @@ -0,0 +1,252 @@ +"""Fail-closed security perimeter for the self-hosted CytechMusic dashboard.""" +from __future__ import annotations + +import json +import time +from pathlib import Path + +import aiohttp +import discord +from aiohttp import web + +from utils import config + +_TOKEN_CACHE: dict[str, tuple[float, dict]] = {} +_GUILD_CACHE: dict[str, tuple[float, list[dict]]] = {} +_TOKEN_TTL = 300 +_GUILD_TTL = 180 +_WEB_DIR = Path(__file__).parent.parent / "web" + + +async def _discord_get(token: str, path: str): + if not token or len(token) < 20: + return None + try: + timeout = aiohttp.ClientTimeout(total=5) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get( + f"https://discord.com/api{path}", + headers={"Authorization": f"Bearer {token}"}, + ) as response: + if response.status == 200: + return await response.json() + except Exception: + return None + return None + + +async def _user(token: str) -> dict | None: + now = time.time() + cached = _TOKEN_CACHE.get(token) + if cached and cached[0] > now: + return cached[1] + user = await _discord_get(token, "/users/@me") + if isinstance(user, dict) and user.get("id"): + _TOKEN_CACHE[token] = (now + _TOKEN_TTL, user) + return user + return None + + +async def _guilds(token: str) -> list[dict] | None: + now = time.time() + cached = _GUILD_CACHE.get(token) + if cached and cached[0] > now: + return cached[1] + guilds = await _discord_get(token, "/users/@me/guilds?limit=200") + if isinstance(guilds, list): + _GUILD_CACHE[token] = (now + _GUILD_TTL, guilds) + return guilds + return None + + +async def _guild_access(token: str, guild_id: int) -> dict | None: + target = str(int(guild_id)) + guilds = await _guilds(token) or [] + return next((guild for guild in guilds if str(guild.get("id")) == target), None) + + +def _can_manage_guild(guild: dict | None) -> bool: + if not guild: + return False + if guild.get("owner") is True: + return True + try: + permissions = discord.Permissions(int(guild.get("permissions", 0))) + except (TypeError, ValueError): + return False + return permissions.administrator or permissions.manage_guild + + +def _bearer(request: web.Request, data: dict | None = None) -> str | None: + if data: + token = data.get("access_token") + if token: + return str(token) + token = request.query.get("access_token") + if token: + return token + auth = request.headers.get("Authorization", "") + if auth.startswith("Bearer "): + return auth[7:].strip() + return None + + +def _same_voice(bot, guild_id: int, user_id: int) -> bool: + guild = bot.get_guild(int(guild_id)) + if not guild: + return False + member = guild.get_member(int(user_id)) + if not member or not member.voice or not member.voice.channel: + return False + player = guild.voice_client + if not player: + return True + player_channel = getattr(player, "channel", None) + return bool(player_channel and player_channel.id == member.voice.channel.id) + + +def _html_path(path: str) -> Path | None: + if path == "/": + candidate = _WEB_DIR / "index.html" + elif path == "/dashboard": + candidate = _WEB_DIR / "dashboard.html" + elif path.endswith(".html"): + candidate = (_WEB_DIR / path.lstrip("/")).resolve() + try: + candidate.relative_to(_WEB_DIR.resolve()) + except ValueError: + return None + else: + return None + return candidate if candidate.is_file() else None + + +def make_dashboard_security_middleware(bot): + @web.middleware + async def dashboard_security(request: web.Request, handler): + # Inject the auth bootstrap into embedded HTML without duplicating it in every page. + if request.method == "GET": + html_path = _html_path(request.path) + if html_path: + html = html_path.read_text(encoding="utf-8-sig") + marker = '' + bootstrap = '' + if bootstrap not in html: + if marker in html: + html = html.replace(marker, f"{bootstrap}\n {marker}", 1) + else: + html = html.replace("", f" {bootstrap}\n", 1) + return web.Response(text=html, content_type="text/html", charset="utf-8") + + # The legacy gateway accepted arbitrary user_id values in control frames. Disable it + # until the gateway itself binds a verified Discord identity to the socket. + if request.path == "/api/gateway": + return web.json_response( + {"error": "realtime_temporarily_disabled", "fallback": "polling"}, + status=503, + ) + + # Stripe events must never reach the legacy handler unsigned or unconfigured. + if request.path == "/stripe/webhook": + if not config.STRIPE_WEBHOOK_SECRET: + return web.json_response({"error": "stripe_webhook_not_configured"}, status=503) + if not request.headers.get("Stripe-Signature"): + return web.json_response({"error": "missing_stripe_signature"}, status=400) + return await handler(request) + + # Old direct state-changing endpoints are no longer browser-facing. The local poller + # still uses /api/control from loopback, so retain that single internal path. + if request.method == "POST" and request.path in { + "/api/control", + "/api/guild_settings", + "/api/playlist", + }: + if request.path == "/api/control" and request.remote in {"127.0.0.1", "::1"}: + return await handler(request) + return web.json_response({"error": "use_authenticated_proxy"}, status=410) + + # Old direct privacy-sensitive reads are retired in favour of /api/proxy. + if request.method == "GET" and request.path in { + "/api/status", + "/api/user_info", + "/api/find_voice", + "/api/guild_settings", + "/api/bot_guilds", + }: + return web.json_response({"error": "use_authenticated_proxy"}, status=410) + + if request.path != "/api/proxy" or request.method == "OPTIONS": + return await handler(request) + + if request.method == "GET": + action = request.query.get("action") + secure_actions = { + "status", + "user_info", + "find_voice", + "guild_settings", + "bot_guilds", + } + if action not in secure_actions: + return await handler(request) + + token = _bearer(request) + user = await _user(token or "") + if not user: + return web.json_response({"error": "unauthorized"}, status=401) + + requested_uid = request.query.get("user_id") + if requested_uid and str(requested_uid) != str(user["id"]): + return web.json_response({"error": "forbidden"}, status=403) + + if action == "bot_guilds": + user_guilds = await _guilds(token) or [] + visible_ids = {str(guild.get("id")) for guild in user_guilds if guild.get("id")} + mutual = [str(guild.id) for guild in bot.guilds if str(guild.id) in visible_ids] + return web.json_response({"guilds": mutual}) + + guild_id = request.query.get("guild_id") + if action in {"status", "guild_settings"}: + if not guild_id or not str(guild_id).isdigit(): + return web.json_response({"error": "no_guild"}, status=400) + if not await _guild_access(token, int(guild_id)): + return web.json_response({"error": "forbidden"}, status=403) + + return await handler(request) + + if request.method != "POST": + return await handler(request) + + try: + data = await request.json() + except Exception: + return web.json_response({"error": "invalid_json"}, status=400) + + token = _bearer(request, data) + user = await _user(token or "") + if not user: + return web.json_response({"error": "unauthorized"}, status=401) + + # Never trust the user_id supplied by browser JavaScript. + uid = int(user["id"]) + data["user_id"] = str(uid) + action = data.get("action") + guild_raw = data.get("guild_id") + guild_id = int(guild_raw) if str(guild_raw or "").isdigit() else None + + if action == "proxy_control" and data.get("cmd_action") == "guild_settings_save": + if not guild_id or not _can_manage_guild(await _guild_access(token, guild_id)): + return web.json_response({"error": "manage_guild_required"}, status=403) + + if action == "playlist" and data.get("playlist_action") in {"save_queue", "play_playlist"}: + if not guild_id or not _same_voice(bot, guild_id, uid): + return web.json_response({"error": "same_voice_required"}, status=403) + elif guild_id and action not in {"playlist", "proxy_control"}: + if not _same_voice(bot, guild_id, uid): + return web.json_response({"error": "same_voice_required"}, status=403) + + # aiohttp caches the request body; replace it so downstream code sees verified identity. + request._read_bytes = json.dumps(data, separators=(",", ":")).encode("utf-8") + return await handler(request) + + return dashboard_security diff --git a/utils/runtime_config.py b/utils/runtime_config.py new file mode 100644 index 0000000..b12170e --- /dev/null +++ b/utils/runtime_config.py @@ -0,0 +1,23 @@ +"""Public, non-secret runtime configuration for Community web pages.""" +from aiohttp import web + +from utils import config + + +def make_runtime_config_middleware(bot): + @web.middleware + async def runtime_config(request: web.Request, handler): + if ( + request.method == "GET" + and request.path == "/api/proxy" + and request.query.get("action") == "runtime_config" + ): + client_id = "" + if getattr(bot, "user", None): + client_id = str(bot.user.id) + elif getattr(config, "NEW_BOT_ID", None): + client_id = str(config.NEW_BOT_ID) + return web.json_response({"client_id": client_id}) + return await handler(request) + + return runtime_config diff --git a/web/functions/_middleware.js b/web/functions/_middleware.js new file mode 100644 index 0000000..9fb2fc5 --- /dev/null +++ b/web/functions/_middleware.js @@ -0,0 +1,15 @@ +export async function onRequest(context) { + const response = await context.next(); + const contentType = response.headers.get("content-type") || ""; + if (!contentType.toLowerCase().includes("text/html")) { + return response; + } + + return new HTMLRewriter() + .on("head", { + element(element) { + element.append('', { html: true }); + }, + }) + .transform(response); +} diff --git a/web/functions/api/gateway.js b/web/functions/api/gateway.js index 18c0143..2e05b3d 100644 --- a/web/functions/api/gateway.js +++ b/web/functions/api/gateway.js @@ -1,83 +1,22 @@ /** - * CYORI WEBSOCKET GATEWAY PROXY - * Proxies WSS (from browser) → WS (to bot VPS) - * Uses Cloudflare WebSocketPair API for proper bidirectional proxying. + * Realtime gateway is intentionally disabled in the Community security hotfix. + * + * The legacy gateway accepted control frames before binding them to a verified + * Discord identity. Dashboard clients automatically fall back to authenticated + * HTTP polling/control until a token-bound WebSocket protocol is implemented. */ - -const BOT_WS_URL = "ws://bkk.fe-grp.com:11050/api/gateway"; - -export async function onRequest(context) { - const { request } = context; - - // Only handle WebSocket upgrade requests - const upgradeHeader = request.headers.get("Upgrade"); - if (!upgradeHeader || upgradeHeader.toLowerCase() !== "websocket") { - return new Response("Expected a WebSocket upgrade request", { status: 426 }); - } - - // Create a WebSocketPair: [client-facing, worker-facing] - const [clientSocket, workerSocket] = Object.values(new WebSocketPair()); - - // Handle the proxy in background (non-blocking) - proxyWebSocket(workerSocket, request).catch((err) => { - console.error("[Gateway] Proxy error:", err); - try { workerSocket.close(1011, "Proxy error"); } catch (_) { } - }); - - // Return 101 Switching Protocols with the client-facing socket - return new Response(null, { - status: 101, - webSocket: clientSocket, - }); -} - -async function proxyWebSocket(workerSocket, request) { - workerSocket.accept(); - - // Connect to backend bot WS - const backendResponse = await fetch(BOT_WS_URL, { - headers: { - "Upgrade": "websocket", - "Connection": "Upgrade", - "Sec-WebSocket-Version": request.headers.get("Sec-WebSocket-Version") || "13", - "Sec-WebSocket-Key": request.headers.get("Sec-WebSocket-Key") || "", +export async function onRequest() { + return new Response( + JSON.stringify({ + error: "realtime_temporarily_disabled", + fallback: "authenticated_http", + }), + { + status: 503, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, }, - }); - - const backendSocket = backendResponse.webSocket; - if (!backendSocket) { - workerSocket.close(1014, "Backend did not accept WebSocket"); - return; - } - - backendSocket.accept(); - - // Pipe: Client → Backend - workerSocket.addEventListener("message", (event) => { - try { backendSocket.send(event.data); } catch (_) { } - }); - - // Pipe: Backend → Client - backendSocket.addEventListener("message", (event) => { - try { workerSocket.send(event.data); } catch (_) { } - }); - - // Handle close from client - workerSocket.addEventListener("close", (event) => { - try { backendSocket.close(event.code || 1000, event.reason || ""); } catch (_) { } - }); - - // Handle close from backend - backendSocket.addEventListener("close", (event) => { - try { workerSocket.close(event.code || 1000, event.reason || ""); } catch (_) { } - }); - - // Handle errors - workerSocket.addEventListener("error", () => { - try { backendSocket.close(1011, "Client error"); } catch (_) { } - }); - - backendSocket.addEventListener("error", () => { - try { workerSocket.close(1011, "Backend error"); } catch (_) { } - }); + ); } diff --git a/web/functions/api/proxy.js b/web/functions/api/proxy.js index 34b97ce..98883d8 100644 --- a/web/functions/api/proxy.js +++ b/web/functions/api/proxy.js @@ -1,21 +1,10 @@ /** - * CYORI CLOUDFLARE PAGES PROXY - * ============================= - * Routes all dashboard requests to the VPS bot API. + * CytechMusic Cloudflare Pages API proxy. * - * Flow: - * Browser → /api/proxy (Cloudflare) → VPS /api/proxy → Bot - * Browser ← response ← VPS response ← Bot - * - * Special cases handled IN this file (not forwarded): - * - Stripe checkout (needs STRIPE_SECRET_KEY env var) - * - GitHub / Stripe webhooks - * - register_bot / poll (stubs) + * Community builds are fail-closed: no Cyori/Cytech production endpoint is used + * unless the deployer explicitly sets VPS_API_URL (or BOT_API_URL). */ -const VPS_API = "http://bkk.fe-grp.com:11050"; -const SUCCESS_URL = "https://cyori.pages.dev/success.html"; - const PLANS = { "1_month": { price: 2900, name: "Premium (1 Month)" }, "3_months": { price: 7900, name: "Premium (3 Months)" }, @@ -24,55 +13,126 @@ const PLANS = { "lifetime": { price: 78900, name: "Premium (Lifetime)" }, }; -const CORS_HEADERS = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", -}; +function backendBase(env) { + const value = env.VPS_API_URL || env.BOT_API_URL || ""; + return value ? value.replace(/\/+$/, "") : null; +} -function jsonResponse(data, status = 200) { +function corsHeaders(request, env) { + const requestOrigin = request.headers.get("Origin"); + const allowedOrigin = env.DASHBOARD_ORIGIN || new URL(request.url).origin; + const origin = !requestOrigin || requestOrigin === allowedOrigin ? allowedOrigin : "null"; + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Vary": "Origin", + }; +} + +function jsonResponse(request, env, data, status = 200) { return new Response(JSON.stringify(data), { status, - headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + headers: { "Content-Type": "application/json", ...corsHeaders(request, env) }, }); } +function bearerToken(request) { + const auth = request.headers.get("Authorization") || ""; + return auth.startsWith("Bearer ") ? auth.slice(7).trim() : ""; +} + +async function verifyDiscordUser(request) { + const token = bearerToken(request); + if (!token) return null; + try { + const response = await fetch("https://discord.com/api/users/@me", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) return null; + const user = await response.json(); + return user && user.id ? user : null; + } catch (_) { + return null; + } +} + +function forwardedHeaders(request, env, { webhook = false } = {}) { + const headers = new Headers(); + const contentType = request.headers.get("Content-Type"); + if (contentType) headers.set("Content-Type", contentType); + + const auth = request.headers.get("Authorization"); + if (auth) headers.set("Authorization", auth); + + if (env.DASHBOARD_SECRET) { + headers.set("X-Dashboard-Secret", env.DASHBOARD_SECRET); + } + + if (webhook) { + for (const name of ["stripe-signature", "x-github-event", "x-hub-signature-256", "x-github-delivery"]) { + const value = request.headers.get(name); + if (value) headers.set(name, value); + } + } + return headers; +} + export async function onRequest(context) { const { request, env } = context; const url = new URL(request.url); const method = request.method; + const cors = corsHeaders(request, env); - // ── 0. CORS Preflight ───────────────────────────────────────────────── if (method === "OPTIONS") { - return new Response(null, { headers: CORS_HEADERS }); + return new Response(null, { status: 204, headers: cors }); } - // ── 1. Read body (once, prevents "Body already consumed") ─────────────── let bodyText = ""; let bodyData = null; - if (method === "POST") { + if (["POST", "PUT", "PATCH"].includes(method)) { try { bodyText = await request.text(); - bodyData = JSON.parse(bodyText); - } catch (e) { /* empty or non-JSON body */ } + if (bodyText) bodyData = JSON.parse(bodyText); + } catch (_) { + return jsonResponse(request, env, { error: "invalid_json" }, 400); + } } const action = url.searchParams.get("action") || (bodyData && bodyData.action); + const backend = backendBase(env); - // ── 2. Stripe Checkout ──────────────────────────────────────────────── + // Public, non-secret deployment identity used by security-bootstrap.js. + if (action === "runtime_config") { + const clientId = /^\d{15,22}$/.test(env.DISCORD_CLIENT_ID || "") + ? String(env.DISCORD_CLIENT_ID) + : ""; + return jsonResponse(request, env, { client_id: clientId }); + } + + // Stripe checkout is handled at the edge, but identity comes only from Discord. if (action === "create_checkout") { + const verifiedUser = await verifyDiscordUser(request); + if (!verifiedUser) { + return jsonResponse(request, env, { error: "unauthorized" }, 401); + } + const stripeKey = env.STRIPE_SECRET_KEY; if (!stripeKey) { - return jsonResponse({ status: "error", message: "Stripe key missing" }, 500); + return jsonResponse(request, env, { error: "stripe_not_configured" }, 503); + } + + const planId = bodyData && bodyData.plan_id; + const plan = PLANS[planId]; + if (!plan) { + return jsonResponse(request, env, { error: "invalid_plan" }, 400); } - const { user_id, plan_id } = bodyData || {}; - const plan = PLANS[plan_id]; - if (!plan) return jsonResponse({ status: "error", message: "Invalid plan" }, 400); try { + const successUrl = env.SUCCESS_URL || `${url.origin}/success.html`; const stripeParams = new URLSearchParams({ "ui_mode": "embedded", - "return_url": `${SUCCESS_URL}?session_id={CHECKOUT_SESSION_ID}`, + "return_url": `${successUrl}?session_id={CHECKOUT_SESSION_ID}`, "mode": "payment", "payment_method_types[0]": "card", "payment_method_types[1]": "promptpay", @@ -80,73 +140,92 @@ export async function onRequest(context) { "line_items[0][price_data][product_data][name]": plan.name, "line_items[0][price_data][unit_amount]": plan.price.toString(), "line_items[0][quantity]": "1", - "metadata[user_id]": String(user_id), - "metadata[plan_id]": plan_id, - "metadata[days]": String(getDaysFromPlan(plan_id)), + "metadata[user_id]": String(verifiedUser.id), + "metadata[plan_id]": planId, + "metadata[days]": String(getDaysFromPlan(planId)), }); + const stripeRes = await fetch("https://api.stripe.com/v1/checkout/sessions", { method: "POST", - headers: { Authorization: `Bearer ${stripeKey}`, "Content-Type": "application/x-www-form-urlencoded" }, + headers: { + Authorization: `Bearer ${stripeKey}`, + "Content-Type": "application/x-www-form-urlencoded", + }, body: stripeParams.toString(), }); const session = await stripeRes.json(); - if (session.error) throw new Error(session.error.message); - return jsonResponse({ status: "success", clientSecret: session.client_secret }); - } catch (err) { - return jsonResponse({ status: "error", message: "Stripe: " + err.message }, 500); + if (!stripeRes.ok || session.error) { + throw new Error(session.error?.message || `Stripe HTTP ${stripeRes.status}`); + } + return jsonResponse(request, env, { + status: "success", + clientSecret: session.client_secret, + }); + } catch (error) { + return jsonResponse(request, env, { error: "stripe_error", message: String(error.message || error) }, 502); } } - // ── 3. Webhooks (GitHub / Stripe) ──────────────────────────────────── + // Never silently accept or drop webhook authenticity headers. const isGithub = request.headers.get("x-github-event"); - const isStripe = request.headers.get("stripe-signature"); - if (isGithub || isStripe) { + const stripeSignature = request.headers.get("stripe-signature"); + if (isGithub || stripeSignature) { + if (!backend) { + return jsonResponse(request, env, { error: "backend_not_configured" }, 503); + } const target = isGithub - ? `${VPS_API}/api/webhook/github` - : `${VPS_API}/stripe/webhook`; + ? `${backend}/api/webhook/github` + : `${backend}/stripe/webhook`; try { - await fetch(target, { + const response = await fetch(target, { method: "POST", - headers: { "Content-Type": request.headers.get("Content-Type") || "application/json" }, + headers: forwardedHeaders(request, env, { webhook: true }), body: bodyText, }); - return new Response("OK", { status: 200 }); - } catch (e) { - return new Response("Broadcast Failed", { status: 502 }); + return new Response(await response.text(), { + status: response.status, + headers: { "Content-Type": response.headers.get("Content-Type") || "text/plain", ...cors }, + }); + } catch (_) { + return jsonResponse(request, env, { error: "backend_unreachable" }, 502); } } - // ── 4. register_bot / poll (stubs — Cloudflare is static) ────────── if (action === "register_bot") { - return jsonResponse({ status: "success", info: "Cloudflare proxy – static" }); + return jsonResponse(request, env, { status: "success", info: "static proxy" }); } if (action === "poll") { - return jsonResponse([]); + return jsonResponse(request, env, []); + } + + if (!backend) { + return jsonResponse(request, env, { + error: "backend_not_configured", + message: "Set VPS_API_URL (or BOT_API_URL) for this Community deployment.", + }, 503); } - // ── 5. Forward everything else → VPS /api/proxy ────────────────────── - // The VPS handles all action routing internally. - const targetUrl = new URL(`${VPS_API}/api/proxy`); - // Preserve query params (e.g. ?action=status&guild_id=xxx on GET requests) - url.searchParams.forEach((v, k) => targetUrl.searchParams.set(k, v)); + const targetUrl = new URL(`${backend}/api/proxy`); + url.searchParams.forEach((value, key) => targetUrl.searchParams.set(key, value)); try { - const vpsRes = await fetch(targetUrl.toString(), { + const response = await fetch(targetUrl.toString(), { method, - headers: { "Content-Type": "application/json" }, - body: (method === "POST" || method === "PUT") ? bodyText : null, + headers: forwardedHeaders(request, env), + body: ["POST", "PUT", "PATCH"].includes(method) ? bodyText : null, }); - const respText = await vpsRes.text(); - return new Response(respText, { - status: vpsRes.status, - headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + return new Response(await response.text(), { + status: response.status, + headers: { + "Content-Type": response.headers.get("Content-Type") || "application/json", + ...cors, + }, }); - } catch (err) { - return jsonResponse({ error: "VPS Offline or unreachable" }, 502); + } catch (_) { + return jsonResponse(request, env, { error: "backend_unreachable" }, 502); } } -// ── Helpers ───────────────────────────────────────────────────────────── function getDaysFromPlan(planId) { const map = { "1_month": 30, "3_months": 90, "6_months": 180, "1_year": 365, "lifetime": 36500 }; return map[planId] ?? 0; diff --git a/web/functions/api/recommended.js b/web/functions/api/recommended.js index 097b5e1..4000e1a 100644 --- a/web/functions/api/recommended.js +++ b/web/functions/api/recommended.js @@ -1,38 +1,41 @@ /** - * CYORI RECOMMENDED API PROXY - * Direct Entry Point for Recommended Tracks + * Recommended tracks proxy for Community deployments. + * No Cyori/Cytech production backend is used by default. */ -const VPS_API = "http://bkk.fe-grp.com:11050/api/recommended"; - export async function onRequest(context) { - const { request } = context; - const url = new URL(request.url); + const { request, env } = context; + const backend = (env.VPS_API_URL || env.BOT_API_URL || "").replace(/\/+$/, ""); + if (!backend) { + return new Response(JSON.stringify({ error: "backend_not_configured" }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }); + } - // Construct target URL with same query params - const targetUrl = new URL(VPS_API); - url.searchParams.forEach((v, k) => { - targetUrl.searchParams.set(k, v); + const sourceUrl = new URL(request.url); + const targetUrl = new URL(`${backend}/api/proxy`); + targetUrl.searchParams.set("action", "recommended"); + sourceUrl.searchParams.forEach((value, key) => { + if (key !== "action") targetUrl.searchParams.set(key, value); }); try { const response = await fetch(targetUrl.toString(), { method: "GET", - headers: { "Content-Type": "application/json" } + headers: { "Content-Type": "application/json" }, }); - - const data = await response.text(); - - return new Response(data, { + return new Response(await response.text(), { status: response.status, headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*" - } + "Content-Type": response.headers.get("Content-Type") || "application/json", + "Access-Control-Allow-Origin": env.DASHBOARD_ORIGIN || sourceUrl.origin, + "Vary": "Origin", + }, }); - } catch (err) { - return new Response(JSON.stringify({ status: "error", message: "VPS Offline" }), { + } catch (_) { + return new Response(JSON.stringify({ error: "backend_unreachable" }), { status: 502, - headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } + headers: { "Content-Type": "application/json" }, }); } } diff --git a/web/invite.html b/web/invite.html index d416f04..57a062f 100644 --- a/web/invite.html +++ b/web/invite.html @@ -1,10 +1,10 @@ - + - Join & Invite - Cytech Music + Invite - Cytech Music
-
Initializing...
+
Preparing invite...
- \ No newline at end of file + diff --git a/web/script.js b/web/script.js index 1b2e584..c9a4ba1 100644 --- a/web/script.js +++ b/web/script.js @@ -8,7 +8,7 @@ // ========================================== // Change this to your Bot Client ID -const CLIENT_ID = "1469606905948405833"; +const CLIENT_ID = window.CYTECHMUSIC_CLIENT_ID || ""; // SMART API ENDPOINT DETECTION // Since the bot now serves its own static files, we can reliably use relative paths @@ -20,9 +20,7 @@ const LEGACY_GAS_API = API_BASE; // Auto-detect Redirect URI (Must match Discord Dev Portal exactly) // Allow dynamic domain callback if hosted on bot -const REDIRECT_URI = window.location.hostname.includes("cyori.pages.dev") - ? "https://cyori.pages.dev/" - : window.location.origin + window.location.pathname; +const REDIRECT_URI = window.CYTECHMUSIC_REDIRECT_URI || (window.location.origin + window.location.pathname); // Global State (Initialize from Storage immediately to prevent flicker) window.accessToken = localStorage.getItem('access_token'); @@ -35,7 +33,11 @@ window.currentLang = localStorage.getItem('preferred-lang') || 'en'; // ========================================== function login() { - const scope = "identify guilds guilds.join"; + if (!CLIENT_ID) { + alert("Discord Client ID is not configured for this Community deployment."); + return; + } + const scope = "identify guilds"; // Save current page to return to after login localStorage.setItem('login_target', window.location.href); @@ -138,9 +140,7 @@ async function fetchUserData() { // when premium data arrives. This is acceptable for progressive loading. fetchPremiumStatus(); - // 5. Auto-Join (Background) - // Change "1413525842490953891" to your support/official server ID if needed - autoJoinServer("1413525842490953891", data.id, window.accessToken); + // Community builds never auto-join users to a maintainer-owned server. } catch (e) { console.error("[Auth] Data Fetch Error:", e); diff --git a/web/security-bootstrap.js b/web/security-bootstrap.js new file mode 100644 index 0000000..1ef1bb0 --- /dev/null +++ b/web/security-bootstrap.js @@ -0,0 +1,82 @@ +/* Runtime identity + bearer-token bootstrap for Community dashboard pages. */ +(() => { + const LEGACY_CYORI_CLIENT_ID = '1469606905948405833'; + + // Load deployment identity before script.js/settings.js evaluate their constants. + if (!window.CYTECHMUSIC_CLIENT_ID) { + try { + const xhr = new XMLHttpRequest(); + xhr.open('GET', '/api/proxy?action=runtime_config', false); + xhr.send(null); + if (xhr.status >= 200 && xhr.status < 300) { + const runtime = JSON.parse(xhr.responseText || '{}'); + if (runtime.client_id) { + window.CYTECHMUSIC_CLIENT_ID = String(runtime.client_id); + } + } + } catch (_) { + // Fail closed: login/invite code will refuse to use an unknown client ID. + } + } + + function rewriteLegacyAppUrl(rawUrl) { + if (!rawUrl || typeof rawUrl !== 'string' || !rawUrl.includes(LEGACY_CYORI_CLIENT_ID)) { + return rawUrl; + } + const clientId = String(window.CYTECHMUSIC_CLIENT_ID || ''); + if (!/^\d{15,22}$/.test(clientId)) { + return null; + } + return rawUrl.split(LEGACY_CYORI_CLIENT_ID).join(clientId); + } + + // Old Community HTML/JS still contains some Cyori invite/vote links. Never allow + // those stale links to send a Community deployer's users to the production Cyori app. + const nativeOpen = window.open.bind(window); + window.open = function securedOpen(url, ...args) { + const safeUrl = rewriteLegacyAppUrl(String(url || '')); + if (!safeUrl) { + alert('Discord Client ID is not configured for this Community deployment.'); + return null; + } + return nativeOpen(safeUrl, ...args); + }; + + document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('a[href]').forEach((anchor) => { + const rewritten = rewriteLegacyAppUrl(anchor.href); + if (rewritten === null) { + anchor.removeAttribute('href'); + anchor.setAttribute('aria-disabled', 'true'); + } else if (rewritten !== anchor.href) { + anchor.href = rewritten; + } + }); + }, { once: true }); + + const nativeFetch = window.fetch.bind(window); + + window.fetch = function securedFetch(input, init = {}) { + let url; + try { + url = new URL(typeof input === 'string' ? input : input.url, window.location.href); + } catch (_) { + return nativeFetch(input, init); + } + + if (url.origin === window.location.origin && url.pathname.startsWith('/api/')) { + const headers = new Headers( + init.headers || (typeof Request !== 'undefined' && input instanceof Request ? input.headers : undefined) + ); + if (window.accessToken && !headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${window.accessToken}`); + } + if (typeof init.body === 'string' && init.body.length && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + init = { ...init, headers }; + } + + return nativeFetch(input, init); + }; +})();