diff --git a/main.py b/main.py index 55daac0..44a4699 100644 --- a/main.py +++ b/main.py @@ -3,7 +3,7 @@ ======================================== Copyright (c) 2025 OPPRO.NET Network -Version: 2.0.0 +Version: 2.1.0 """ # ============================================================================= @@ -50,7 +50,7 @@ # API Routes & Translation from src.api.dashboard.routes import set_bot_instance, dashboard_main_router, router_public -from mx_handler import TranslationHandler +from mxmariadb import TranslationHandler, BlacklistDatabase colorama_init(autoreset=True) @@ -111,8 +111,8 @@ async def start_webserver(): log_level=BotConfig.api.log_level ) server = Server(server_config) - await server.serve() logger.success("API", f"FastAPI-Server läuft auf http://{BotConfig.api.host}:{BotConfig.api.port}") + await server.serve() # ============================================================================= # MAIN EXECUTION @@ -146,16 +146,18 @@ async def start_webserver(): dashboard = DashboardTask(bot, BASEDIR) dashboard.register() + # Dashboard starten + dashboard.start() + + # --- NEU: Webserver direkt beim Start in den Loop hängen --- + # Wir starten den Webserver, bevor der Bot den Loop blockiert + bot.loop.create_task(start_webserver()) + logger.info("API", "Webserver-Task im Hintergrund gestartet") + @bot.event async def on_ready(): logger.success("BOT", f"Logged in as {bot.user.name}") - # --- NEU: Status API & Webserver starten --- - bot.loop.create_task(start_webserver()) - - # Dashboard starten - dashboard.start() - # Bot-Status if BotConfig.features.get('bot_status', True): await bot.change_presence( @@ -174,6 +176,37 @@ async def on_ready(): logger.info("LIMITS", f"Discord-API Slots belegt: {len(root_slots)} / 100") # --- LIMIT CHECK ENDE --- + @bot.check + async def global_blacklist_check(ctx: discord.ApplicationContext): + """Checks if the user is on the global blacklist.""" + # Bot owners are always exempt + if ctx.author.id in BotConfig.security.bot_owners: + return True + + try: + db = BlacklistDatabase() + await db.ensure_connection() + ban_info = await db.is_blacklisted(str(ctx.author.id)) + + if ban_info: + embed = discord.Embed( + title="🚫 Globaler Ausschluss", + description=( + f"Du wurdest von der Nutzung von **{bot.user.name}** ausgeschlossen.\n\n" + f"**Grund:** `{ban_info['reason']}`\n" + f"**Datum:** `{ban_info['created_at'].strftime('%d.%m.%Y')}`\n\n" + "Solltest du glauben, dass dies ein Fehler ist, wende dich bitte an unseren Support." + ), + color=discord.Color.from_rgb(244, 63, 94) # Rose-500 + ) + embed.set_footer(text="System: Global Blacklist") + await ctx.respond(embed=embed, ephemeral=True) + return False + except Exception as e: + logger.error("BLACKLIST", f"Fehler beim Blacklist-Check: {e}") + + return True + @bot.before_invoke async def maintenance_check(ctx: discord.ApplicationContext): """Global check for maintenance mode.""" diff --git a/mxmariadb/__init__.py b/mxmariadb/__init__.py index 6d9baad..396cf57 100644 --- a/mxmariadb/__init__.py +++ b/mxmariadb/__init__.py @@ -22,3 +22,5 @@ from .economy_db import EconomyDatabase from .management_db import ManagementDatabase from .cms_db import CMSDatabase +from .blacklist_db import BlacklistDatabase +from mx_handler import TranslationHandler diff --git a/mxmariadb/blacklist_db.py b/mxmariadb/blacklist_db.py new file mode 100644 index 0000000..8a4aa3c --- /dev/null +++ b/mxmariadb/blacklist_db.py @@ -0,0 +1,43 @@ +from .connector import MariaConnector +from typing import List, Dict, Any +import time + +class BlacklistDatabase(MariaConnector): + async def init_db(self): + """Initialisiert die Blacklist-Tabelle.""" + query = """ + CREATE TABLE IF NOT EXISTS global_blacklist ( + user_id VARCHAR(25) PRIMARY KEY, + reason TEXT, + admin_id VARCHAR(25), + admin_name VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_VALUE + ) + """ + # Hinweis: CURRENT_TIMESTAMP wird automatisch gesetzt + await self.execute_query(""" + CREATE TABLE IF NOT EXISTS global_blacklist ( + user_id VARCHAR(25) PRIMARY KEY, + reason TEXT, + admin_id VARCHAR(25), + admin_name VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + async def add_to_blacklist(self, user_id: str, reason: str, admin_id: str, admin_name: str) -> bool: + query = "INSERT INTO global_blacklist (user_id, reason, admin_id, admin_name) VALUES (%s, %s, %s, %s) ON DUPLICATE KEY UPDATE reason=%s" + return await self.execute_query(query, (user_id, reason, admin_id, admin_name, reason)) + + async def remove_from_blacklist(self, user_id: str) -> bool: + query = "DELETE FROM global_blacklist WHERE user_id = %s" + return await self.execute_query(query, (user_id,)) + + async def is_blacklisted(self, user_id: str) -> Dict[str, Any]: + query = "SELECT * FROM global_blacklist WHERE user_id = %s" + result = await self.fetch_all(query, (user_id,)) + return result[0] if result else None + + async def get_all_blacklisted(self) -> List[Dict[str, Any]]: + query = "SELECT * FROM global_blacklist ORDER BY created_at DESC" + return await self.fetch_all(query) diff --git a/mxmariadb/cms_db.py b/mxmariadb/cms_db.py index 74abf16..3a27cd5 100644 --- a/mxmariadb/cms_db.py +++ b/mxmariadb/cms_db.py @@ -1,6 +1,7 @@ # Copyright (c) 2025 OPPRO.NET Network import aiomysql import logging +from typing import List, Dict, Any, Optional from mxmariadb.connector import MariaConnector logger = logging.getLogger(__name__) @@ -49,6 +50,17 @@ async def init_db(self): except Exception: pass # Column already exists or unsupported syntax + # Tags table + await cur.execute(""" + CREATE TABLE IF NOT EXISTS cms_tags ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) UNIQUE NOT NULL, + slug VARCHAR(60) UNIQUE NOT NULL, + color VARCHAR(20) DEFAULT '#3498db', + emoji VARCHAR(10) DEFAULT '' + ) + """) + # Media/uploads table await cur.execute(""" CREATE TABLE IF NOT EXISTS cms_media ( @@ -265,3 +277,68 @@ async def get_changelog(self, limit: int = 50): LIMIT %s """, (limit,)) return await cur.fetchall() + + # ───────────────────────────────────────── + # TAGS + # ───────────────────────────────────────── + + async def get_tags(self) -> List[Dict[str, Any]]: + await self.ensure_connection() + try: + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute("SELECT * FROM cms_tags ORDER BY name") + return await cur.fetchall() + except Exception as e: + logger.error(f"Error fetching tags: {e}") + return [] + + async def create_tag(self, name: str, slug: str, color: str = "#3498db", emoji: str = ""): + await self.ensure_connection() + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "INSERT INTO cms_tags (name, slug, color, emoji) VALUES (%s, %s, %s, %s)", + (name, slug, color, emoji) + ) + await conn.commit() + return True + except Exception as e: + logger.error(f"Error creating tag: {e}") + return False + + async def update_tag(self, tag_id: int, **kwargs): + await self.ensure_connection() + if not kwargs: return False + + fields = [] + values = [] + for k, v in kwargs.items(): + fields.append(f"{k} = %s") + values.append(v) + + values.append(tag_id) + query = f"UPDATE cms_tags SET {', '.join(fields)} WHERE id = %s" + + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(query, tuple(values)) + await conn.commit() + return True + except Exception as e: + logger.error(f"Error updating tag: {e}") + return False + + async def delete_tag(self, tag_id: int): + await self.ensure_connection() + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute("DELETE FROM cms_tags WHERE id = %s", (tag_id,)) + await conn.commit() + return True + except Exception as e: + logger.error(f"Error deleting tag: {e}") + return False diff --git a/mxmariadb/levelsystem_db.py b/mxmariadb/levelsystem_db.py index 9f225af..d3533ca 100644 --- a/mxmariadb/levelsystem_db.py +++ b/mxmariadb/levelsystem_db.py @@ -56,6 +56,7 @@ async def _ensure_initialized(self): async def init_db(self): """Create tables and load caches.""" + await self.ensure_connection() async with self.pool.acquire() as conn: async with conn.cursor() as cur: await cur.execute(''' diff --git a/mxmariadb/stats_db.py b/mxmariadb/stats_db.py index 56cc36d..1908aa1 100644 --- a/mxmariadb/stats_db.py +++ b/mxmariadb/stats_db.py @@ -97,6 +97,17 @@ async def init_db(self): icon VARCHAR(10) DEFAULT '🏆' ) ''') + await cur.execute(''' + CREATE TABLE IF NOT EXISTS command_usage ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + guild_id BIGINT NOT NULL, + command_name VARCHAR(100) NOT NULL, + used_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_cmd (command_name), + INDEX idx_guild (guild_id) + ) + ''') + await cur.execute(''' CREATE TABLE IF NOT EXISTS active_voice_sessions ( user_id BIGINT PRIMARY KEY, @@ -204,6 +215,38 @@ async def _end_voice_internal(self, cur, user_id: int): await cur.execute('DELETE FROM active_voice_sessions WHERE user_id = %s', (user_id,)) + async def log_command(self, guild_id: int, command_name: str): + await self.ensure_connection() + async with self.lock: + try: + async with self.pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(''' + INSERT INTO command_usage (guild_id, command_name) + VALUES (%s, %s) + ''', (guild_id, command_name)) + await conn.commit() + except Exception as e: + logger.error(f"log_command fehlgeschlagen: {e}") + + async def get_top_commands(self, limit: int = 5) -> List[Dict]: + await self.ensure_connection() + async with self.lock: + try: + async with self.pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(''' + SELECT command_name, COUNT(*) as usage_count + FROM command_usage + GROUP BY command_name + ORDER BY usage_count DESC + LIMIT %s + ''', (limit,)) + return await cur.fetchall() + except Exception as e: + logger.error(f"get_top_commands fehlgeschlagen: {e}") + return [] + # ------------------------------------------------------------------ # XP (unverändert, nur ensure_connection nicht nötig — läuft intern) # ------------------------------------------------------------------ diff --git a/mxmariadb/welcome_db.py b/mxmariadb/welcome_db.py index 85970c2..186e7d0 100644 --- a/mxmariadb/welcome_db.py +++ b/mxmariadb/welcome_db.py @@ -72,6 +72,7 @@ async def set_welcome_message(self, guild_id: int, message: str) -> bool: # --- Core CRUD --- async def update_welcome_settings(self, guild_id: int, **kwargs) -> bool: + await self.ensure_connection() try: async with self.pool.acquire() as conn: async with conn.cursor() as cur: @@ -114,6 +115,7 @@ async def update_welcome_settings(self, guild_id: int, **kwargs) -> bool: return False async def get_welcome_settings(self, guild_id: int) -> Optional[Dict[str, Any]]: + await self.ensure_connection() try: async with self.pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: @@ -127,6 +129,7 @@ async def get_welcome_settings(self, guild_id: int) -> Optional[Dict[str, Any]]: return None async def delete_welcome_settings(self, guild_id: int) -> bool: + await self.ensure_connection() try: async with self.pool.acquire() as conn: async with conn.cursor() as cur: @@ -155,6 +158,7 @@ async def toggle_welcome(self, guild_id: int) -> Optional[bool]: async def update_welcome_stats(self, guild_id: int, joins: int = 0, leaves: int = 0): + await self.ensure_connection() try: date = datetime.now().strftime('%Y-%m-%d') async with self.pool.acquire() as conn: @@ -171,6 +175,7 @@ async def update_welcome_stats(self, guild_id: int, logger.error(f"Stats update error: {e}") async def get_weekly_stats(self, guild_id: int) -> List[Dict]: + await self.ensure_connection() try: async with self.pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: diff --git a/src/api/dashboard/auth_routes.py b/src/api/dashboard/auth_routes.py index e5dec6a..938ef3f 100644 --- a/src/api/dashboard/auth_routes.py +++ b/src/api/dashboard/auth_routes.py @@ -6,11 +6,17 @@ import time from urllib.parse import urlencode +from pydantic import BaseModel + router = APIRouter( prefix="/auth", tags=["auth"] ) +class EmailLoginRequest(BaseModel): + email: str + password: str + # JWT Setup JWT_SECRET = os.getenv("JWT_SECRET", "fallback-secret") ALGORITHM = "HS256" @@ -66,6 +72,90 @@ async def login(): print(f"[DEBUG] Generated Discord URL: {url}") return {"url": url} +# Brute Force Protection +login_attempts = {} # {ip: {"count": 0, "last_attempt": 0}} + +@router.post("/login/email") +async def login_email(request: Request, data: EmailLoginRequest): + """CMS Admin Login using Email and Password with Brute Force protection.""" + client_ip = request.client.host + now = time.time() + + # 1. Check Rate Limit + if client_ip in login_attempts: + attempt_data = login_attempts[client_ip] + # If more than 5 failed attempts in the last 15 minutes + if attempt_data["count"] >= 5 and (now - attempt_data["last_attempt"]) < 900: + wait_time = int(900 - (now - attempt_data["last_attempt"])) + raise HTTPException( + status_code=429, + detail=f"Zu viele Fehlversuche. Bitte warte {wait_time // 60} Minuten." + ) + # Reset if the last attempt was long ago + if (now - attempt_data["last_attempt"]) > 900: + login_attempts[client_ip] = {"count": 0, "last_attempt": now} + + admin_email = os.getenv("CMS_ADMIN_EMAIL") + admin_pass = os.getenv("CMS_ADMIN_PASSWORD") + + if data.email == admin_email and data.password == admin_pass: + # Success: Clear attempts + if client_ip in login_attempts: + del login_attempts[client_ip] + + # Generate JWT for the admin + jwt_token = create_access_token({ + "sub": "cms_admin", + "username": "Lenny (CMS Admin)", + "avatar": "https://cdn.discordapp.com/embed/avatars/0.png" + }) + + # 4. Security Alert: Notify owners via Discord + try: + from src.api.dashboard.routes import bot_instance + from src.bot.core.config import BotConfig + owners = getattr(BotConfig.security, 'bot_owners', []) + + if bot_instance: + alert_msg = ( + "⚠️ **Sicherheits-Alarm: Admin-Login** ⚠️\n\n" + f"Ein Login in die Admin-Zentrale wurde soeben durchgeführt.\n" + f"**E-Mail:** `{data.email}`\n" + f"**IP-Adresse:** `{client_ip}`\n" + f"**Zeitpunkt:** \n\n" + "Falls du das nicht warst, ändere sofort dein Passwort in der `.env`!" + ) + for owner_id in owners: + owner = bot_instance.get_user(int(owner_id)) + if owner: + await owner.send(alert_msg) + except Exception as e: + print(f"[ERROR] Failed to send security alert: {e}") + + return { + "access_token": jwt_token, + "token_type": "bearer", + "user": { + "id": "cms_admin", + "username": "Lenny (CMS Admin)", + "avatar": "https://cdn.discordapp.com/embed/avatars/0.png", + "isAdmin": True + } + } + + # 2. Failure Logic + # Update attempts + if client_ip not in login_attempts: + login_attempts[client_ip] = {"count": 0, "last_attempt": now} + + login_attempts[client_ip]["count"] += 1 + login_attempts[client_ip]["last_attempt"] = now + + # 3. Synthetic Delay (Brakes for Bots) + time.sleep(1.5) + + raise HTTPException(status_code=401, detail="Ungültige E-Mail oder Passwort") + @router.post("/callback") async def callback(request: Request): """Exchanges code for a token and creates a JWT session.""" @@ -127,16 +217,28 @@ async def callback(request: Request): async def get_me(request: Request, user: dict = Depends(get_current_user)): """Returns the user along with guilds they manage that the bot is also in.""" from src.api.dashboard.routes import bot_instance + from src.bot.core.config import BotConfig + + # Global Admin Check + is_bot_admin = False + if user.get("id") == "cms_admin": + is_bot_admin = True + else: + owners = getattr(BotConfig.security, 'bot_owners', []) + try: + uid = int(user.get("id", 0)) + if uid in owners: + is_bot_admin = True + except: + pass + + # Update user object with admin status + user["isAdmin"] = is_bot_admin auth_header = request.headers.get("Authorization") if not auth_header: raise HTTPException(status_code=401) - # In a real app, we'd store the Discord Access Token in a session or database. - # For now, let's assume the client might send it or we fetch it if we had it. - # To make this "really work" without a DB yet, we expect a 'X-Discord-Token' header - # or just use the one from the callback if we were to store it. - discord_token = request.headers.get("X-Discord-Token") user_guilds = [] @@ -148,12 +250,10 @@ async def get_me(request: Request, user: dict = Depends(get_current_user)): if guilds_res.status_code == 200: all_guilds = guilds_res.json() for g in all_guilds: - # check permissions (Manage Guild = 0x20) perms = int(g.get("permissions", 0)) - is_admin = (perms & 0x20) == 0x20 or (perms & 0x8) == 0x8 + is_manageable = (perms & 0x20) == 0x20 or (perms & 0x8) == 0x8 - if is_admin: - # Check if bot is in guild + if is_manageable: guild_id = int(g.get("id")) if bot_instance and bot_instance.get_guild(guild_id): user_guilds.append({ diff --git a/src/api/dashboard/cms_routes.py b/src/api/dashboard/cms_routes.py index 1fc1c0b..95969e9 100644 --- a/src/api/dashboard/cms_routes.py +++ b/src/api/dashboard/cms_routes.py @@ -53,26 +53,45 @@ def is_admin(request: Request, user: dict = None) -> bool: if bypass_enabled and client_ip in ["127.0.0.1", "localhost"]: x_user_id = request.headers.get("X-User-ID") if x_user_id: - user_id = int(x_user_id) - owners = getattr(BotConfig.security, 'bot_owners', []) - if user_id in owners: - print(f"[DEBUG] CMS Access granted via Localhost Bypass for ID {user_id}") + if x_user_id == "cms_admin": return True + try: + user_id = int(x_user_id) + owners = getattr(BotConfig.security, 'bot_owners', []) + if user_id in owners: + print(f"[DEBUG] CMS Access granted via Localhost Bypass for ID {user_id}") + return True + except (ValueError, TypeError): + pass if not user: return False - user_id = int(user["id"]) - owners = getattr(BotConfig.security, 'bot_owners', []) - cms_admins = getattr(BotConfig, 'cms', {}).get('admins', []) if hasattr(BotConfig, 'cms') else [] - return user_id in owners or user_id in cms_admins + uid = user["id"] + if uid == "cms_admin": + return True + + try: + user_id = int(uid) + owners = getattr(BotConfig.security, 'bot_owners', []) + return user_id in owners + except (ValueError, TypeError): + return False def get_requester_info(request: Request, user: dict) -> tuple[int, str]: """Returns (user_id, username) from JWT or fallback header.""" if user: - return int(user["id"]), user.get("username", "Unknown") + try: + return int(user["id"]), user.get("username", "Unknown") + except (ValueError, TypeError): + # Special case for non-numeric IDs like 'cms_admin' + return 0, user.get("username", "Unknown") + x_user_id = request.headers.get("X-User-ID") - return int(x_user_id) if x_user_id else 0, "Admin" + try: + return int(x_user_id) if x_user_id else 0, "Admin" + except (ValueError, TypeError): + return 0, "Admin" async def get_cms_db() -> CMSDatabase: db = CMSDatabase() @@ -344,3 +363,59 @@ async def delete_media(media_id: int, request: Request, user: dict = Depends(get return {"success": True} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + +# ───────────────────────────────────────── +# ADMIN – TAGS +# ───────────────────────────────────────── + +@router.get("/tags") +async def list_tags(db: CMSDatabase = Depends(get_cms_db)): + """Public/Admin: list all tags.""" + try: + tags = await db.get_tags() + return {"success": True, "data": tags} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/tags") +async def create_tag(request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: create a new tag.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + data = await request.json() + name = data.get("name") + if not name: + raise HTTPException(status_code=400, detail="Name is required") + + slug = data.get("slug") or slugify(name) + color = data.get("color", "#3498db") + emoji = data.get("emoji", "") + + success = await db.create_tag(name=name, slug=slug, color=color, emoji=emoji) + if not success: + raise HTTPException(status_code=500, detail="Failed to create tag") + return {"success": True} + +@router.put("/tags/{tag_id}") +async def update_tag(tag_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: update an existing tag.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + data = await request.json() + success = await db.update_tag(tag_id, **data) + if not success: + raise HTTPException(status_code=500, detail="Failed to update tag") + return {"success": True} + +@router.delete("/tags/{tag_id}") +async def delete_tag(tag_id: int, request: Request, user: dict = Depends(get_maybe_user), db: CMSDatabase = Depends(get_cms_db)): + """Admin: delete a tag.""" + if not is_admin(request, user): + raise HTTPException(status_code=403, detail="Not authorized") + + success = await db.delete_tag(tag_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete tag") + return {"success": True} diff --git a/src/api/dashboard/routes.py b/src/api/dashboard/routes.py index 5b9b130..0e43f51 100644 --- a/src/api/dashboard/routes.py +++ b/src/api/dashboard/routes.py @@ -88,6 +88,7 @@ async def get_leaderboard(limit: int = 50): try: stats_db = StatsDB() + blacklist_db = BlacklistDatabase() # get_leaderboard returns user_id, global_level, global_xp, total_messages, total_voice_minutes rows = await stats_db.get_leaderboard(limit=limit) @@ -152,7 +153,208 @@ async def get_api_key(api_key_header: str = Security(API_KEY_HEADER)): tags=["dashboard"] ) -# Public sub-routers (no global X-API-KEY required, they manage their own like JWT) +@dashboard_main_router.get("/admin/global-stats") +async def get_admin_global_stats(user: dict = Depends(get_current_user)): + """Fetches global bot stats and CMS stats for the admin dashboard.""" + # Auth check: Nur cms_admin oder bot owners + from .cms_routes import is_admin + # We need the request object for is_admin, but for now we simplify + # as we already have the user from get_current_user + is_bot_admin = False + if user.get("id") == "cms_admin": + is_bot_admin = True + else: + from src.bot.core.config import BotConfig + owners = getattr(BotConfig.security, 'bot_owners', []) + try: + if int(user.get("id", 0)) in owners: + is_bot_admin = True + except: + pass + + if not is_bot_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + if bot_instance is None: + raise HTTPException(status_code=503, detail="Bot-Verbindung nicht verfügbar") + + try: + from mxmariadb import CMSDatabase + db = CMSDatabase() + await db.ensure_connection() + posts = await db.get_posts(published_only=False) + + return { + "success": True, + "data": { + "totalGuilds": len(bot_instance.guilds), + "totalUsers": len(bot_instance.users), + "totalPosts": len(posts), + "apiLatency": f"{round(bot_instance.latency * 1000)}ms", + "uptime": str(discord.utils.utcnow() - getattr(bot_instance, 'start_time', discord.utils.utcnow())).split('.')[0] + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@dashboard_main_router.get("/admin/blacklist") +async def get_admin_blacklist(user: dict = Depends(get_current_user)): + # Auth check: Nur cms_admin oder bot owners + user_id = user.get("id") + is_bot_admin = False + if user_id == "cms_admin": + is_bot_admin = True + else: + from src.bot.core.config import BotConfig + owners = getattr(BotConfig.security, 'bot_owners', []) + try: + if int(user_id) in owners: + is_bot_admin = True + except: + pass + + if not is_bot_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + from mxmariadb import BlacklistDatabase + db = BlacklistDatabase() + await db.ensure_connection() + data = await db.get_all_blacklisted() + return {"success": True, "data": data} + +@dashboard_main_router.post("/admin/blacklist") +async def add_admin_blacklist(request: Request, user: dict = Depends(get_current_user)): + user_id = user.get("id") + is_bot_admin = False + if user_id == "cms_admin": + is_bot_admin = True + else: + from src.bot.core.config import BotConfig + owners = getattr(BotConfig.security, 'bot_owners', []) + try: + if int(user_id) in owners: + is_bot_admin = True + except: + pass + + if not is_bot_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + data = await request.json() + target_id = data.get("user_id") + reason = data.get("reason", "Kein Grund angegeben") + + if not target_id: + raise HTTPException(status_code=400, detail="Target User ID is required") + + from mxmariadb import BlacklistDatabase + db = BlacklistDatabase() + await db.ensure_connection() + success = await db.add_to_blacklist(target_id, reason, user_id, user.get("username", "Admin")) + return {"success": success} + +@dashboard_main_router.delete("/admin/blacklist/{target_id}") +async def remove_admin_blacklist(target_id: str, user: dict = Depends(get_current_user)): + user_id = user.get("id") + is_bot_admin = False + if user_id == "cms_admin": + is_bot_admin = True + else: + from src.bot.core.config import BotConfig + owners = getattr(BotConfig.security, 'bot_owners', []) + try: + if int(user_id) in owners: + is_bot_admin = True + except: + pass + + if not is_bot_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + from mxmariadb import BlacklistDatabase + db = BlacklistDatabase() + await db.ensure_connection() + success = await db.remove_from_blacklist(target_id) + return {"success": True} + +# --- GLOBAL CHAT CONTROL --- + +@dashboard_main_router.get("/admin/global-chat/logs") +async def get_global_chat_logs(user: dict = Depends(get_current_user)): + user_id = user.get("id") + is_bot_admin = False + if user_id == "cms_admin": + is_bot_admin = True + else: + from src.bot.core.config import BotConfig + owners = getattr(BotConfig.security, 'bot_owners', []) + try: + if int(user_id) in owners: + is_bot_admin = True + except: + pass + + if not is_bot_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + from mxmariadb import GlobalChatDatabase + db = GlobalChatDatabase() + await db.ensure_connection() + # Letzte 50 Nachrichten + query = "SELECT * FROM message_log ORDER BY timestamp DESC LIMIT 50" + data = await db.fetch_all(query) + return {"success": True, "data": data} + +@dashboard_main_router.get("/admin/global-chat/blacklist") +async def get_global_chat_blacklist(user: dict = Depends(get_current_user)): + user_id = user.get("id") + is_bot_admin = False + if user_id == "cms_admin": + is_bot_admin = True + else: + from src.bot.core.config import BotConfig + owners = getattr(BotConfig.security, 'bot_owners', []) + try: + if int(user_id) in owners: + is_bot_admin = True + except: + pass + + if not is_bot_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + from mxmariadb import GlobalChatDatabase + db = GlobalChatDatabase() + await db.ensure_connection() + query = "SELECT * FROM globalchat_blacklist ORDER BY banned_at DESC" + data = await db.fetch_all(query) + return {"success": True, "data": data} + +@dashboard_main_router.get("/admin/top-commands") +async def get_admin_top_commands(user: dict = Depends(get_current_user)): + user_id = user.get("id") + is_bot_admin = False + if user_id == "cms_admin": + is_bot_admin = True + else: + from src.bot.core.config import BotConfig + owners = getattr(BotConfig.security, 'bot_owners', []) + try: + if int(user_id) in owners: + is_bot_admin = True + except: + pass + + if not is_bot_admin: + raise HTTPException(status_code=403, detail="Not authorized") + + from mxmariadb import StatsDB + db = StatsDB() + await db.ensure_connection() + data = await db.get_top_commands(limit=5) + return {"success": True, "data": data} + +# Public sub-routers @dashboard_main_router.get("/guilds/{guild_id}/channels") async def get_guild_channels(guild_id: int, user: dict = Depends(get_current_user)): """Fetches text channels for a specific guild.""" diff --git a/src/api/dashboard/settings_routes.py b/src/api/dashboard/settings_routes.py index 028ce62..0b955c0 100644 --- a/src/api/dashboard/settings_routes.py +++ b/src/api/dashboard/settings_routes.py @@ -125,6 +125,7 @@ async def get_welcome_settings(guild_id: int, user: dict = Depends(get_current_u """Fetch welcome-specific settings.""" db = WelcomeDatabase() try: + await db.init_db() # Sicherstellen dass Tabellen existieren settings = await db.get_welcome_settings(guild_id) if settings and "channel_id" in settings and settings["channel_id"]: settings["channel_id"] = str(settings["channel_id"]) @@ -147,17 +148,16 @@ async def update_welcome_settings(guild_id: int, request: Request, user: dict = data["auto_role_id"] = int(data["auto_role_id"]) try: + await db.init_db() success = await db.update_welcome_settings(guild_id, **data) if success: user_name = user.get("username", "Unbekannter User") - # Invalidate cache if possible from src.api.dashboard.routes import bot_instance if bot_instance: cog = bot_instance.get_cog("WelcomeSystem") if cog and hasattr(cog, 'invalidate_cache'): cog.invalidate_cache(guild_id) - # Send notification to the welcome channel if configured channel_id = data.get("channel_id") await send_dashboard_notification(guild_id, "Welcome System", user_name, channel_id) @@ -172,7 +172,8 @@ async def get_antispam_settings(guild_id: int, user: dict = Depends(get_current_ """Fetch AntiSpam-specific settings.""" db = AntiSpamDatabase() try: - settings = db.get_spam_settings(guild_id) + await db.init_db() + settings = await db.get_spam_settings(guild_id) if settings and "log_channel_id" in settings and settings["log_channel_id"]: settings["log_channel_id"] = str(settings["log_channel_id"]) return {"success": True, "data": settings or {}} @@ -189,8 +190,8 @@ async def update_antispam_settings(guild_id: int, request: Request, user: dict = data["log_channel_id"] = int(data["log_channel_id"]) try: - # Use set_spam_settings with direct kwargs if possible, or mapping - success = db.set_spam_settings( + await db.init_db() + success = await db.set_spam_settings( guild_id, max_messages=data.get("max_messages", 5), time_frame=data.get("time_frame", 10), @@ -198,11 +199,6 @@ async def update_antispam_settings(guild_id: int, request: Request, user: dict = ) if success: user_name = user.get("username", "Unbekannter User") - from src.api.dashboard.routes import bot_instance - if bot_instance: - cog = bot_instance.get_cog("AntiSpam") - # Add cache invalidation if AntiSpam cog supports it - await send_dashboard_notification(guild_id, "Anti-Spam", user_name, data.get("log_channel_id")) return {"success": success} @@ -216,8 +212,9 @@ async def get_globalchat_settings(guild_id: int, user: dict = Depends(get_curren """Fetch GlobalChat-specific settings.""" db = GlobalChatDatabase() try: - settings = db.get_guild_settings(guild_id) - channel_id = db.get_globalchat_channel(guild_id) + await db.init_db() + settings = await db.get_guild_settings(guild_id) + channel_id = await db.get_globalchat_channel(guild_id) settings["channel_id"] = str(channel_id) if channel_id else None return {"success": True, "data": settings or {}} except Exception as e: @@ -230,18 +227,17 @@ async def update_globalchat_settings(guild_id: int, request: Request, user: dict db = GlobalChatDatabase() try: + await db.init_db() success = True user_name = user.get("username", "Unbekannter User") - # Handle channel_id separately new_channel_id = data.get("channel_id") if new_channel_id: - success = db.set_globalchat_channel(guild_id, int(new_channel_id)) + success = await db.set_globalchat_channel(guild_id, int(new_channel_id)) - # Update other settings for key in ["filter_enabled", "nsfw_filter", "embed_color"]: if key in data: - db.update_guild_setting(guild_id, key, data[key]) + await db.update_guild_setting(guild_id, key, data[key]) if success: await send_dashboard_notification(guild_id, "Global Chat", user_name, int(new_channel_id) if new_channel_id else None) @@ -257,7 +253,9 @@ async def get_level_settings(guild_id: int, user: dict = Depends(get_current_use """Fetch LevelSystem settings.""" db = LevelDatabase() try: - settings = db.get_guild_settings(guild_id) + await db.init_db() + # Hinweis: LevelDatabase verwendet get_guild_config statt get_guild_settings + settings = await db.get_guild_config(guild_id) return {"success": True, "data": settings or {}} except Exception as e: raise HTTPException(status_code=500, detail=f"Database error: {e}") @@ -268,11 +266,12 @@ async def update_level_settings(guild_id: int, request: Request, user: dict = De data = await request.json() db = LevelDatabase() try: - success = db.update_guild_settings(guild_id, **data) - if success: - user_name = user.get("username", "Unbekannter User") - await send_dashboard_notification(guild_id, "Level-System", user_name) - return {"success": success} + await db.init_db() + # Hinweis: LevelDatabase verwendet set_guild_config statt update_guild_settings + await db.set_guild_config(guild_id, **data) + user_name = user.get("username", "Unbekannter User") + await send_dashboard_notification(guild_id, "Level-System", user_name) + return {"success": True} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to save level settings: {e}") @@ -283,10 +282,11 @@ async def get_logging_settings(guild_id: int, user: dict = Depends(get_current_u """Fetch Logging settings.""" db = LoggingDatabase() try: - settings = db.get_guild_settings(guild_id) - if settings and "channel_id" in settings and settings["channel_id"]: - settings["channel_id"] = str(settings["channel_id"]) - return {"success": True, "data": settings or {}} + await db.init_db() + # LoggingDatabase liefert ein Dict von Typ -> ChannelID + channels = await db.get_all_log_channels(guild_id) + settings = {"channel_id": str(channels.get("general")) if channels.get("general") else None} + return {"success": True, "data": settings} except Exception as e: raise HTTPException(status_code=500, detail=f"Database error: {e}") @@ -296,15 +296,14 @@ async def update_logging_settings(guild_id: int, request: Request, user: dict = data = await request.json() db = LoggingDatabase() - if "channel_id" in data and data["channel_id"]: - data["channel_id"] = int(data["channel_id"]) - try: - success = db.update_guild_settings(guild_id, **data) - if success: - user_name = user.get("username", "Unbekannter User") - await send_dashboard_notification(guild_id, "Server-Log", user_name, data.get("channel_id")) - return {"success": success} + await db.init_db() + if "channel_id" in data and data["channel_id"]: + await db.set_log_channel(guild_id, int(data["channel_id"])) + + user_name = user.get("username", "Unbekannter User") + await send_dashboard_notification(guild_id, "Server-Log", user_name, int(data["channel_id"]) if data.get("channel_id") else None) + return {"success": True} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to save logging settings: {e}") @@ -315,10 +314,15 @@ async def get_autorole_settings(guild_id: int, user: dict = Depends(get_current_ """Fetch AutoRole settings.""" db = AutoRoleDatabase() try: - settings = db.get_guild_settings(guild_id) - if settings and "role_id" in settings and settings["role_id"]: - settings["role_id"] = str(settings["role_id"]) - return {"success": True, "data": settings or {}} + await db.init_db() + # AutoRole liefert eine Liste von Rollen + roles = await db.get_all_autoroles(guild_id) + settings = {} + if roles: + settings["role_id"] = str(roles[0]["role_id"]) + settings["enabled"] = roles[0]["enabled"] + + return {"success": True, "data": settings} except Exception as e: raise HTTPException(status_code=500, detail=f"Database error: {e}") @@ -328,15 +332,18 @@ async def update_autorole_settings(guild_id: int, request: Request, user: dict = data = await request.json() db = AutoRoleDatabase() - if "role_id" in data and data["role_id"]: - data["role_id"] = int(data["role_id"]) - try: - success = db.update_guild_settings(guild_id, **data) - if success: - user_name = user.get("username", "Unbekannter User") - await send_dashboard_notification(guild_id, "Auto-Role", user_name) - return {"success": success} + await db.init_db() + if "role_id" in data and data["role_id"]: + # Existierende entfernen und neu setzen (vereinfacht für Dashboard) + roles = await db.get_all_autoroles(guild_id) + for r in roles: + await db.remove_autorole(r["autorole_id"]) + await db.add_autorole(guild_id, int(data["role_id"])) + + user_name = user.get("username", "Unbekannter User") + await send_dashboard_notification(guild_id, "Auto-Role", user_name) + return {"success": True} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to save autorole settings: {e}") @@ -347,7 +354,9 @@ async def get_autodelete_settings(guild_id: int, user: dict = Depends(get_curren """Fetch AutoDelete settings.""" db = AutoDeleteDB() try: - settings = db.get_guild_settings(guild_id) + await db.init_db() + settings = await db.get_all() + # Filter für die aktuelle Guild (Note: autodelete table might need guild_id for better filtering) return {"success": True, "data": settings or []} except Exception as e: raise HTTPException(status_code=500, detail=f"Database error: {e}") @@ -358,14 +367,23 @@ async def update_autodelete_settings(guild_id: int, request: Request, user: dict data = await request.json() db = AutoDeleteDB() try: - # Assuming db.update_guild_settings(guild_id, data) where data is a list of channel configs - success = db.update_guild_settings(guild_id, data) - if success: - user_name = user.get("username", "Unbekannter User") - await send_dashboard_notification(guild_id, "Auto-Delete", user_name) - return {"success": success} + await db.init_db() + # In MariaDB Version heißt es add_autodelete + for item in data: + if "channel_id" in item and "duration" in item: + await db.add_autodelete( + int(item["channel_id"]), + int(item["duration"]), + item.get("exclude_pinned", True), + item.get("exclude_bots", False) + ) + + user_name = user.get("username", "Unbekannter User") + await send_dashboard_notification(guild_id, "Auto-Delete", user_name) + return {"success": True} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to save autodelete settings: {e}") + # --- TempVC Module Routes --- @router.get("/{guild_id}/tempvc") @@ -373,9 +391,9 @@ async def get_tempvc_settings(guild_id: int, user: dict = Depends(get_current_us """Fetch TempVC-specific settings.""" db = TempVCDatabase() try: - settings = db.get_tempvc_settings(guild_id) + await db.init_db() + settings = await db.get_tempvc_settings(guild_id) if settings: - # result is tuple: (creator_channel_id, category_id, auto_delete_time) data = { "creator_channel_id": str(settings[0]), "category_id": str(settings[1]), @@ -384,8 +402,7 @@ async def get_tempvc_settings(guild_id: int, user: dict = Depends(get_current_us else: data = {} - # Get UI settings - ui_settings = db.get_ui_settings(guild_id) + ui_settings = await db.get_ui_settings(guild_id) if ui_settings: data["ui_enabled"] = bool(ui_settings[0]) data["ui_prefix"] = ui_settings[1] @@ -404,18 +421,17 @@ async def update_tempvc_settings(guild_id: int, request: Request, user: dict = D db = TempVCDatabase() try: - # Update main settings + await db.init_db() creator_channel_id = int(data.get("creator_channel_id")) if data.get("creator_channel_id") else 0 category_id = int(data.get("category_id")) if data.get("category_id") else 0 auto_delete_time = int(data.get("auto_delete_time", 0)) if creator_channel_id and category_id: - db.set_tempvc_settings(guild_id, creator_channel_id, category_id, auto_delete_time) + await db.set_tempvc_settings(guild_id, creator_channel_id, category_id, auto_delete_time) - # Update UI settings ui_enabled = bool(data.get("ui_enabled", False)) ui_prefix = data.get("ui_prefix", "🔧") - db.set_ui_settings(guild_id, ui_enabled, ui_prefix) + await db.set_ui_settings(guild_id, ui_enabled, ui_prefix) user_name = user.get("username", "Unbekannter User") await send_dashboard_notification(guild_id, "TempVC System", user_name, creator_channel_id or None) diff --git a/src/web/App.tsx b/src/web/App.tsx index d2527f6..28433fb 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -18,13 +18,14 @@ const TeamPage = lazy(() => import("./pages/TeamPage")); const RoadmapPage = lazy(() => import("./pages/RoadmapPage")); const LeaderboardPage = lazy(() => import("./pages/LeaderboardPage")); const License = lazy(() => import("./pages/License").then(module => ({ default: module.License }))); -const LoginPage = lazy(() => import("./dashboard/LoginPage")); -const SettingsPage = lazy(() => import("./dashboard/SettingsPage")); -const UserSettingsPage = lazy(() => import("./dashboard/UserSettingsPage")); -const GuildSelectionPage = lazy(() => import("./dashboard/GuildSelectionPage")); +const LoginPage = lazy(() => import("./dashboard/auth/LoginPage")); +const SettingsPage = lazy(() => import("./dashboard/settings/SettingsPage")); +const UserSettingsPage = lazy(() => import("./dashboard/settings/UserSettingsPage")); +const GuildSelectionPage = lazy(() => import("./dashboard/settings/GuildSelectionPage")); const AuthCallback = lazy(() => import("./pages/AuthCallback")); const BlogPage = lazy(() => import("./pages/BlogPage")); -const CMSPage = lazy(() => import("./dashboard/CMSPage")); +const CMSPage = lazy(() => import("./dashboard/cms/CMSPage")); +const AdminPage = lazy(() => import("./dashboard/admin/AdminPage")); const queryClient = new QueryClient(); @@ -69,11 +70,9 @@ const DashboardRoutes = () => { } /> } /> } /> + } /> + } /> } /> - } /> - } /> - } /> - } /> @@ -114,7 +113,8 @@ const MainRoutes = () => { } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/src/web/components/core/AuthProvider.tsx b/src/web/components/core/AuthProvider.tsx index 24217ca..fbbe67f 100644 --- a/src/web/components/core/AuthProvider.tsx +++ b/src/web/components/core/AuthProvider.tsx @@ -6,7 +6,7 @@ interface AuthContextType { guilds: any[]; selectedGuildId: string | null; isAuthenticated: boolean; - login: (token: string, user: any, discordToken?: string) => void; + login: (token: string, user: any, discordToken?: string, isSessionOnly?: boolean) => void; logout: () => void; setSelectedGuildId: (id: string) => void; } @@ -18,9 +18,8 @@ import { API_URL } from "../../lib/api"; export const AuthProvider = ({ children }: { children: ReactNode }) => { const getSafeItem = (key: string) => { try { - return localStorage.getItem(key); + return localStorage.getItem(key) || sessionStorage.getItem(key); } catch (e) { - console.warn(`Error reading ${key} from localStorage:`, e); return null; } }; @@ -31,8 +30,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { try { return JSON.parse(item); } catch (e) { - console.error(`Error parsing ${key} from localStorage:`, e); - localStorage.removeItem(key); // Clear corrupted data return null; } }; @@ -42,15 +39,17 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const [guilds, setGuilds] = useState([]); const [selectedGuildId, setSelectedGuildId] = useState(getSafeItem("selectedGuildId")); - const login = (newToken: string, newUser: any, newDiscordToken?: string) => { + const login = (newToken: string, newUser: any, newDiscordToken?: string, isSessionOnly: boolean = false) => { setToken(newToken); setUser(newUser); + const storage = isSessionOnly ? sessionStorage : localStorage; try { - localStorage.setItem("token", newToken); - localStorage.setItem("user", JSON.stringify(newUser)); - if (newDiscordToken) localStorage.setItem("discord_token", newDiscordToken); + storage.setItem("token", newToken); + storage.setItem("user", JSON.stringify(newUser)); + storage.setItem("is_session_only", isSessionOnly ? "true" : "false"); + if (newDiscordToken) storage.setItem("discord_token", newDiscordToken); } catch (e) { - console.error("Error saving to localStorage:", e); + console.error("Error saving to storage:", e); } }; @@ -61,8 +60,9 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { setSelectedGuildId(null); try { localStorage.clear(); + sessionStorage.clear(); } catch (e) { - console.error("Error clearing localStorage:", e); + console.error("Error clearing storage:", e); } }; diff --git a/src/web/components/layout/Navbar.tsx b/src/web/components/layout/Navbar.tsx index 855c7e7..1ba5abe 100644 --- a/src/web/components/layout/Navbar.tsx +++ b/src/web/components/layout/Navbar.tsx @@ -9,17 +9,17 @@ import { cn } from "../../lib/utils"; import { useAuth } from "../core/AuthProvider"; const mainLinks = [ - { label: "Features", href: "/#features", icon: Sparkles }, { label: "Commands", href: "/commands", icon: Terminal }, + { label: "Blog", href: "/blog", icon: Newspaper }, { label: "Leaderboard", href: "/leaderboard", icon: Trophy }, ]; const dropdownLinks = [ - { label: "Roadmap", href: "/roadmap", icon: Milestone }, - { label: "Team", href: "/team", icon: Users }, + { label: "Features", href: "/#features", icon: Sparkles }, { label: "Plugins", href: "/plugins", icon: Puzzle }, { label: "Status", href: "/status", icon: Activity }, - { label: "Blog", href: "/blog", icon: Newspaper }, + { label: "Roadmap", href: "/roadmap", icon: Milestone }, + { label: "Team", href: "/team", icon: Users }, ]; function NavDropdown({ label, links, location }: { label: string, links: any[], location: any }) { @@ -94,7 +94,7 @@ export function Navbar() { const [isScrolled, setIsScrolled] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const location = useLocation(); - const { isAuthenticated } = useAuth(); + const { isAuthenticated, user } = useAuth(); useEffect(() => { const handleScroll = () => { @@ -155,7 +155,7 @@ export function Navbar() { {link.label} ))} - + @@ -170,6 +170,15 @@ export function Navbar() { > + {user?.isAdmin && ( + + + + )} ([]); - const [loading, setLoading] = useState(true); - - const fetchChangelog = async () => { - setLoading(true); - try { - const res = await fetch(`${API_URL}/dashboard/cms/changelog`, { - headers: { - "Authorization": `Bearer ${token}`, - "X-User-ID": user?.id || "1427994077332373554" - } - }); - const data = await res.json(); - if (data.success) { - setEntries(data.data); - } - } catch (err) { - toast.error("Fehler beim Laden des Changelogs"); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchChangelog(); - }, [token, user]); - - return ( -
-
-
-

Changelog Vorschau

-

Diese Einträge werden öffentlich im Changelog-Feed angezeigt.

-
- -
- - {loading ? ( -
-
-
- ) : entries.length > 0 ? ( -
- {entries.map((entry) => ( -
-
-

{entry.title}

-
- - - {new Date(entry.created_at).toLocaleDateString()} - - - - {entry.author_name} - - /{entry.slug} -
-
- - - Ansehen - -
- ))} -
- ) : ( -
- -

Noch keine Changelog-Einträge

-

Erstelle einen neuen Beitrag mit dem Typ "Changelog".

-
- )} -
- ); -} diff --git a/src/web/dashboard/admin/AdminAnalytics.tsx b/src/web/dashboard/admin/AdminAnalytics.tsx new file mode 100644 index 0000000..a67db97 --- /dev/null +++ b/src/web/dashboard/admin/AdminAnalytics.tsx @@ -0,0 +1,115 @@ +import { useState, useEffect } from "react"; +import { BarChart3, TrendingUp, Zap, MousePointer2, X, RefreshCw } from "lucide-react"; +import { toast } from "sonner"; +import { API_URL } from "../../lib/api"; +import { useAuth } from "../../components/core/AuthProvider"; +import { cn } from "../../lib/utils"; + +interface CommandStat { + command_name: string; + usage_count: number; +} + +interface AdminAnalyticsProps { + onClose: () => void; +} + +export default function AdminAnalytics({ onClose }: AdminAnalyticsProps) { + const { token } = useAuth(); + const [stats, setStats] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchStats(); + }, []); + + const fetchStats = async () => { + setLoading(true); + try { + const res = await fetch(`${API_URL}/dashboard/admin/top-commands`, { + headers: { "Authorization": `Bearer ${token}` } + }); + const json = await res.json(); + if (json.success) setStats(json.data); + } catch (err) { + toast.error("Statistiken konnten nicht geladen werden"); + } finally { + setLoading(false); + } + }; + + const maxUsage = Math.max(...stats.map(s => s.usage_count), 1); + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

Bot Analytics

+

Performance & Usage Insights

+
+
+ +
+ + {/* Content */} +
+
+
+ +

Top 5 Commands

+
+ +
+ +
+ {loading ? ( +
+ ) : stats.length > 0 ? ( + stats.map((stat, index) => ( +
+
+
+ #{index + 1} + {stat.command_name} +
+
+ + {stat.usage_count}x +
+
+
+
+
+
+ )) + ) : ( +
+ +

Noch keine Daten verfügbar

+
+ )} +
+ +
+

+ * Die Statistiken werden in Echtzeit aus der Datenbank geladen. Die Command-Nutzung wird global über alle Server hinweg aggregiert. +

+
+
+
+
+ ); +} diff --git a/src/web/dashboard/admin/AdminBlacklist.tsx b/src/web/dashboard/admin/AdminBlacklist.tsx new file mode 100644 index 0000000..7de8078 --- /dev/null +++ b/src/web/dashboard/admin/AdminBlacklist.tsx @@ -0,0 +1,224 @@ +import { useState, useEffect } from "react"; +import { ShieldAlert, Trash2, UserPlus, Search, ShieldCheck, Clock, User as UserIcon, X } from "lucide-react"; +import { toast } from "sonner"; +import { API_URL } from "../../lib/api"; +import { useAuth } from "../../components/core/AuthProvider"; +import { cn } from "../../lib/utils"; + +interface BlacklistEntry { + user_id: string; + reason: string; + admin_id: string; + admin_name: string; + created_at: string; +} + +interface AdminBlacklistProps { + onClose: () => void; +} + +export default function AdminBlacklist({ onClose }: AdminBlacklistProps) { + const { user, token } = useAuth(); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [showAddForm, setShowAddForm] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + + const [formData, setFormData] = useState({ + user_id: "", + reason: "" + }); + + useEffect(() => { + fetchBlacklist(); + }, []); + + const fetchBlacklist = async () => { + try { + const res = await fetch(`${API_URL}/dashboard/admin/blacklist`, { + headers: { "Authorization": `Bearer ${token}` } + }); + const json = await res.json(); + if (json.success) setEntries(json.data); + } catch (err) { + toast.error("Blacklist konnte nicht geladen werden"); + } finally { + setLoading(false); + } + }; + + const handleAdd = async (e: React.FormEvent) => { + e.preventDefault(); + if (!formData.user_id) return toast.error("Bitte eine Discord-ID eingeben"); + + try { + const res = await fetch(`${API_URL}/dashboard/admin/blacklist`, { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(formData) + }); + const json = await res.json(); + if (json.success) { + toast.success("User wurde bot-weit gesperrt"); + setFormData({ user_id: "", reason: "" }); + setShowAddForm(false); + fetchBlacklist(); + } + } catch (err) { + toast.error("Fehler beim Sperren"); + } + }; + + const handleRemove = async (userId: string) => { + if (!confirm("Diesen User wirklich wieder entmuten?")) return; + + // Optimistic Update + const oldEntries = [...entries]; + setEntries(entries.filter(e => e.user_id !== userId)); + + try { + const res = await fetch(`${API_URL}/dashboard/admin/blacklist/${userId}`, { + method: "DELETE", + headers: { "Authorization": `Bearer ${token}` } + }); + const json = await res.json(); + if (!json.success) { + setEntries(oldEntries); + toast.error("Fehler beim Entsperren"); + } else { + toast.success("User entmutet"); + } + } catch (err) { + setEntries(oldEntries); + toast.error("Verbindungsfehler"); + } + }; + + const filteredEntries = entries.filter(e => + e.user_id.includes(searchQuery) || + e.reason.toLowerCase().includes(searchQuery.toLowerCase()) || + e.admin_name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

Global Blacklist

+

User & Security Management

+
+
+ +
+ + {/* Content */} +
+
+
+ + setSearchQuery(e.target.value)} + className="w-full bg-white/5 border border-white/10 rounded-2xl py-3 pl-12 pr-4 text-xs outline-none focus:ring-1 focus:ring-rose-500 transition-all" + /> +
+ +
+ + {showAddForm && ( +
+
+
+ + setFormData({...formData, user_id: e.target.value})} + placeholder="z.B. 1427994077332373554" + className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-xs outline-none focus:border-rose-500 transition-all" + /> +
+
+ + setFormData({...formData, reason: e.target.value})} + placeholder="Verstoß gegen Regeln..." + className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-xs outline-none focus:border-rose-500 transition-all" + /> +
+
+
+ + +
+
+ )} + +
+ {loading ? ( +
+ ) : filteredEntries.length > 0 ? ( + filteredEntries.map((entry) => ( +
+
+
+ +
+
+
+ {entry.user_id} + Banned +
+
"{entry.reason}"
+
+
+ Von {entry.admin_name} +
+
+ {new Date(entry.created_at).toLocaleDateString()} +
+
+
+
+ +
+ )) + ) : ( +
+ +

Keine User auf der Blacklist

+
+ )} +
+
+
+
+ ); +} diff --git a/src/web/dashboard/admin/AdminGlobalChat.tsx b/src/web/dashboard/admin/AdminGlobalChat.tsx new file mode 100644 index 0000000..e370ece --- /dev/null +++ b/src/web/dashboard/admin/AdminGlobalChat.tsx @@ -0,0 +1,170 @@ +import { useState, useEffect } from "react"; +import { MessageSquare, ShieldAlert, Trash2, Search, X, Clock, User as UserIcon, Globe, ShieldCheck } from "lucide-react"; +import { toast } from "sonner"; +import { API_URL } from "../../lib/api"; +import { useAuth } from "../../components/core/AuthProvider"; +import { cn } from "../../lib/utils"; + +interface ChatLog { + id: number; + user_id: string; + guild_id: string; + content: string; + timestamp: string; +} + +interface ChatBlacklist { + id: number; + entity_type: string; + entity_id: string; + reason: string; + banned_at: string; +} + +interface AdminGlobalChatProps { + onClose: () => void; +} + +export default function AdminGlobalChat({ onClose }: AdminGlobalChatProps) { + const { token } = useAuth(); + const [logs, setLogs] = useState([]); + const [blacklist, setBlacklist] = useState([]); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState<'logs' | 'blacklist'>('logs'); + + useEffect(() => { + fetchData(); + }, [activeTab]); + + const fetchData = async () => { + setLoading(true); + try { + const endpoint = activeTab === 'logs' ? 'logs' : 'blacklist'; + const res = await fetch(`${API_URL}/dashboard/admin/global-chat/${endpoint}`, { + headers: { "Authorization": `Bearer ${token}` } + }); + const json = await res.json(); + if (json.success) { + if (activeTab === 'logs') setLogs(json.data); + else setBlacklist(json.data); + } + } catch (err) { + toast.error("Daten konnten nicht geladen werden"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

Global Chat Control

+

Cross-Server Communication Moderation

+
+
+ +
+ + {/* Tabs */} +
+ + +
+ + {/* Content */} +
+ {loading ? ( +
+ ) : activeTab === 'logs' ? ( +
+ {logs.map((log) => ( +
+
+
+ +
+
+
+
+ User ID: {log.user_id} + + Guild: {log.guild_id} +
+ {new Date(log.timestamp).toLocaleTimeString()} +
+

+ {log.content} +

+
+
+
+ ))} + {logs.length === 0 && ( +
+ +

Noch keine Nachrichten im Global Chat

+
+ )} +
+ ) : ( +
+ {blacklist.map((entry) => ( +
+
+
+ +
+
+
+ {entry.entity_id} + {entry.entity_type} Banned +
+

"{entry.reason}"

+
+ Gesperrt am {new Date(entry.banned_at).toLocaleDateString()} +
+
+
+ +
+ ))} + {blacklist.length === 0 && ( +
+ +

Die Global Chat Blacklist ist leer

+
+ )} +
+ )} +
+
+
+ ); +} diff --git a/src/web/dashboard/admin/AdminPage.tsx b/src/web/dashboard/admin/AdminPage.tsx new file mode 100644 index 0000000..49dac4e --- /dev/null +++ b/src/web/dashboard/admin/AdminPage.tsx @@ -0,0 +1,260 @@ +import React, { useEffect, useState } from "react"; +import { motion } from "framer-motion"; +import { + LayoutDashboard, + Newspaper, + Users, + ShieldCheck, + BarChart3, + Settings, + MessageSquare, + Activity, + ArrowRight +} from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "../../components/core/AuthProvider"; +import { API_URL } from "../../lib/api"; +import { cn } from "../../lib/utils"; +import AdminBlacklist from "./AdminBlacklist"; +import AdminAnalytics from "./AdminAnalytics"; +import AdminGlobalChat from "./AdminGlobalChat"; + +const AdminPage = () => { + const { user, loading, token } = useAuth(); + const navigate = useNavigate(); + const [stats, setStats] = useState({ + totalGuilds: 0, + totalUsers: 0, + totalPosts: 0, + apiLatency: "Laden...", + uptime: "Laden..." + }); + + const [showBlacklist, setShowBlacklist] = useState(false); + const [showAnalytics, setShowAnalytics] = useState(false); + const [showGlobalChat, setShowGlobalChat] = useState(false); + + useEffect(() => { + // Auth Check: Nur Admins oder der cms_admin dürfen hier sein + if (loading) return; + + if (!user || !user.isAdmin) { + console.log("Admin Check failed:", { user }); + navigate("/dash/login"); + } + }, [user, loading, navigate]); + + useEffect(() => { + const fetchStats = async () => { + try { + const res = await fetch(`${API_URL}/dashboard/admin/global-stats`, { + headers: { + "Authorization": `Bearer ${token}` + } + }); + const json = await res.json(); + if (json.success) { + setStats(json.data); + } + } catch (err) { + console.error("Failed to fetch admin stats:", err); + } + }; + + if (token) { + fetchStats(); + const interval = setInterval(fetchStats, 10000); // Alle 10 Sek aktualisieren + return () => clearInterval(interval); + } + }, [token]); + + const adminModules = [ + { + title: "Content Management", + desc: "Blog-Posts verwalten, News schreiben und Tags editieren.", + icon: Newspaper, + path: "/dash/admin/cms", + color: "from-blue-500 to-cyan-500", + count: stats.totalPosts + " Posts" + }, + { + title: "User & Security", + desc: "Globale Blacklist, Team-Berechtigungen und Bot-Admins.", + icon: ShieldCheck, + path: "/dash/admin/users", + color: "from-purple-500 to-pink-500" + }, + { + title: "Bot Analytics", + desc: "Detaillierte Statistiken über Commands, Shards und Auslastung.", + icon: BarChart3, + path: "/dash/admin/stats", + color: "from-orange-500 to-red-500" + }, + { + title: "Global Chat Control", + desc: "Chat-Filter verwalten und globale Unterhaltungen moderieren.", + icon: MessageSquare, + path: "/dash/admin/global-chat", + color: "from-green-500 to-emerald-500" + } + ]; + + if (loading) return null; + + return ( +
+
+ + {/* Header */} +
+ +
+
+ +
+ + ManagerX HQ + +
+

+ Admin Zentrale +

+
+ + +
+

{user?.username}

+

System Administrator

+
+
+ {user?.username?.[0]} +
+
+
+ + {/* Stats Grid */} +
+ {[ + { label: "Server", value: stats.totalGuilds, icon: Activity }, + { label: "Nutzer", value: (stats.totalUsers / 1000).toFixed(1) + "k", icon: Users }, + { label: "Blog Posts", value: stats.totalPosts, icon: Newspaper }, + { label: "API Latenz", value: stats.apiLatency, icon: Settings }, + ].map((stat, i) => ( + +
+ + Live +
+

{stat.value}

+

{stat.label}

+
+ ))} +
+ + {/* Modules Grid */} +

+ + Verwaltungs-Module +

+ +
+ {adminModules.map((module, i) => ( + { + if (module.title === "User & Security") { + setShowBlacklist(true); + } else if (module.title === "Bot Analytics") { + setShowAnalytics(true); + } else if (module.title === "Global Chat Control") { + setShowGlobalChat(true); + } else if (module.path && !module.status) { + navigate(module.path); + } + }} + className={cn( + "group relative bg-white/5 border border-white/10 rounded-3xl p-8 cursor-pointer overflow-hidden transition-all", + module.status && module.title !== "User & Security" ? "opacity-50 grayscale" : "hover:border-primary/50" + )} + > + {/* Background Glow */} +
+ +
+
+ +
+ +
+
+

+ {module.title} +

+ {module.status && module.title !== "User & Security" ? ( + + {module.status} + + ) : ( + + )} +
+

+ {module.desc} +

+ + {module.count && ( + + + {module.count} + + )} + + {module.status && module.title !== "User & Security" && ( + + {module.status} + + )} +
+
+ + ))} +
+ + {/* Footer Info */} +
+

© 2026 ManagerX System Control

+
+ +
API Online + + Version 2.1.5 (Stable) +
+
+ +
+ {showBlacklist && setShowBlacklist(false)} />} + {showAnalytics && setShowAnalytics(false)} />} + {showGlobalChat && setShowGlobalChat(false)} />} +
+ ); +}; + +export default AdminPage; diff --git a/src/web/dashboard/LoginPage.tsx b/src/web/dashboard/auth/LoginPage.tsx similarity index 69% rename from src/web/dashboard/LoginPage.tsx rename to src/web/dashboard/auth/LoginPage.tsx index f9e81ad..09c20a1 100644 --- a/src/web/dashboard/LoginPage.tsx +++ b/src/web/dashboard/auth/LoginPage.tsx @@ -1,20 +1,23 @@ -import React from "react"; -import { motion } from "framer-motion"; -import { - Shield, +import React, { useState, useEffect } from "react"; +import { useNavigate, Link, useLocation } from "react-router-dom"; +import { + Mail, + Lock, + LayoutDashboard, + ShieldCheck, + Zap, + ArrowRight, + MessageSquare, + Globe, + Settings, Sparkles, - Lock, - LayoutDashboard, - ShieldCheck, - Zap, - ArrowRight, - MessageSquare, - Globe, - Settings + Shield } from "lucide-react"; -import { Link } from "react-router-dom"; -import { cn } from "../lib/utils"; +import { motion, AnimatePresence } from "framer-motion"; +import { cn } from "../../lib/utils"; import { toast } from "sonner"; +import { useAuth } from "../../components/core/AuthProvider"; +import { API_URL } from "../../lib/api"; const FeatureItem = ({ icon: Icon, title, description }: { icon: any, title: string, description: string }) => (
@@ -28,9 +31,22 @@ const FeatureItem = ({ icon: Icon, title, description }: { icon: any, title: str
); -import { API_URL } from "../lib/api"; - export default function LoginPage() { + const [showAdminLogin, setShowAdminLogin] = useState(false); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const { login } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + useEffect(() => { + const params = new URLSearchParams(location.search); + if (params.get("admin") === "true") { + setShowAdminLogin(true); + } + }, [location]); + const handleLogin = async () => { try { const apiUrl = `${API_URL}/dashboard/auth/login`; @@ -49,6 +65,30 @@ export default function LoginPage() { } }; + const handleEmailLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + try { + const res = await fetch(`${API_URL}/dashboard/auth/login/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }); + const data = await res.json(); + if (data.access_token) { + login(data.access_token, data.user, undefined, true); // true = session only + toast.success("Admin-Session gestartet!"); + navigate("/dash/admin"); + } else { + toast.error(data.detail || "Login fehlgeschlagen"); + } + } catch (e) { + toast.error("Verbindungsfehler"); + } finally { + setLoading(false); + } + }; + return (
{/* Background Decoration */} @@ -160,6 +200,57 @@ export default function LoginPage() { +
+
+ ODER +
+
+ + {showAdminLogin && ( + +
+
+ + setEmail(e.target.value)} + required + className="w-full bg-white/5 border border-white/10 rounded-2xl py-3.5 pl-12 pr-4 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all font-medium" + /> +
+
+
+
+ + setPassword(e.target.value)} + required + className="w-full bg-white/5 border border-white/10 rounded-2xl py-3.5 pl-12 pr-4 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all font-medium" + /> +
+
+ + {loading ? "Wird geprüft..." : "Admin Login"} + +
+ )} +
VORSCHAU MODUS diff --git a/src/web/dashboard/cms/CMSChangelogTab.tsx b/src/web/dashboard/cms/CMSChangelogTab.tsx new file mode 100644 index 0000000..add19f0 --- /dev/null +++ b/src/web/dashboard/cms/CMSChangelogTab.tsx @@ -0,0 +1,203 @@ +import { useState } from "react"; +import { Plus, Trash2, Save, FileText, Sparkles, Wrench, Zap, Eye } from "lucide-react"; +import { toast } from "sonner"; +import { API_URL } from "../../lib/api"; +import { useAuth } from "../../components/core/AuthProvider"; +import ReactMarkdown from "react-markdown"; +import { cn } from "../../lib/utils"; + +interface ChangelogItem { + id: string; + type: 'new' | 'fix' | 'improve'; + text: string; +} + +const CATEGORIES = { + new: { label: 'Neu', icon: Sparkles, color: 'text-emerald-400', emoji: '✨' }, + fix: { label: 'Fixes', icon: Wrench, color: 'text-rose-400', emoji: '🛠️' }, + improve: { label: 'Verbesserungen', icon: Zap, color: 'text-amber-400', emoji: '⚡' }, +}; + +export default function CMSChangelogTab() { + const { user, token } = useAuth(); + const [version, setVersion] = useState("v2.0.0"); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + + const addItem = (type: 'new' | 'fix' | 'improve') => { + setItems([...items, { id: Math.random().toString(36).substr(2, 9), type, text: "" }]); + }; + + const removeItem = (id: string) => { + setItems(items.filter(i => i.id !== id)); + }; + + const updateItem = (id: string, text: string) => { + setItems(items.map(i => i.id === id ? { ...i, text } : i)); + }; + + const generateMarkdown = () => { + let md = `# Changelog ${version}\n\n`; + + (['new', 'fix', 'improve'] as const).forEach(cat => { + const catItems = items.filter(i => i.type === cat); + if (catItems.length > 0) { + md += `### ${CATEGORIES[cat].emoji} ${CATEGORIES[cat].label}\n`; + catItems.forEach(item => { + if (item.text) md += `- ${item.text}\n`; + }); + md += `\n`; + } + }); + + return md; + }; + + const handleSave = async () => { + if (!version) return toast.error("Bitte Version angeben"); + if (items.length === 0) return toast.error("Bitte mindestens einen Eintrag hinzufügen"); + + setLoading(true); + try { + const content = generateMarkdown(); + const res = await fetch(`${API_URL}/dashboard/cms/posts`, { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json", + "X-User-ID": user?.id || "1427994077332373554" + }, + body: JSON.stringify({ + title: `Update ${version}`, + slug: `update-${version.replace(/\./g, '-')}`, + content: content, + post_type: 'changelog', + is_published: true, + author_id: user?.id, + tags: 'Update,Changelog' + }) + }); + + const data = await res.json(); + if (data.success) { + toast.success("Changelog veröffentlicht!"); + setItems([]); // Clear after success + } else { + toast.error(data.detail || "Fehler beim Speichern"); + } + } catch (err) { + toast.error("Verbindungsfehler"); + } finally { + setLoading(false); + } + }; + + return ( +
+ {/* Editor Side */} +
+
+
+
+

Changelog Generator

+

Einfach professionelle Updates erstellen

+
+
+ Version + setVersion(e.target.value)} + className="bg-transparent border-none outline-none text-xs font-black text-primary w-16 text-right" + /> +
+
+ + {/* Categories Grid */} +
+ {(['new', 'fix', 'improve'] as const).map((cat) => { + const Icon = CATEGORIES[cat].icon; + return ( + + ); + })} +
+ + {/* Items List */} +
+ {items.length === 0 ? ( +
+ +

Noch keine Einträge hinzugefügt

+
+ ) : ( + items.map((item) => { + const ItemIcon = CATEGORIES[item.type].icon; + return ( +
+
+ +
+
+