AstraOS-Panel/AstraOS/astra_os.py
elias dc180a1e8a
All checks were successful
Deploy AstraOS Panel / deploy (push) Successful in 14s
AstraOS Ver. 1.45
Hot fix Account reactivation
2026-08-18 19:07:16 +02:00

2214 lines
93 KiB
Python

import os
from flask import Flask, render_template, request, redirect, url_for, session, flash
from pymongo import MongoClient
from werkzeug.security import check_password_hash, generate_password_hash
from flask_wtf.csrf import CSRFProtect
from datetime import timedelta, datetime
from functools import wraps
from bson.objectid import ObjectId
import paramiko
from flask import jsonify, send_file
import logging
from flask_socketio import SocketIO, emit, join_room, leave_room
import subprocess
import threading
from werkzeug.utils import secure_filename
import requests
import socket
import zipfile
import asyncio
import discord
from discord.ui import Button, View
from dotenv import load_dotenv
load_dotenv()
async_mode = "eventlet"
app = Flask(__name__,
template_folder='Frontend/HTML',
static_folder='Frontend')
socketio = SocketIO(app, async_mode=async_mode)
BOT_UPLOAD_FOLDER = '/home/astraos/Astra-OS-Panel/bots'
try:
os.makedirs(BOT_UPLOAD_FOLDER, exist_ok=True)
try:
os.chmod(BOT_UPLOAD_FOLDER, 0o755)
except Exception:
pass
except Exception as e:
BOT_UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'Bots')
os.makedirs(BOT_UPLOAD_FOLDER, exist_ok=True)
print(f"WARNUNG: Konnte '/opt/astraos/Discord_Bots' nicht erstellen ({e}), benutze lokalen Ordner: {BOT_UPLOAD_FOLDER}")
app.config['BOT_UPLOAD_FOLDER'] = BOT_UPLOAD_FOLDER
ETS2_DLC_ARCHIVE_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'ets2_dlc_archives')
os.makedirs(ETS2_DLC_ARCHIVE_FOLDER, exist_ok=True)
running_bots = {}
ets2_dlc_build_locks = {}
ets2_dlc_build_locks_guard = threading.Lock()
class SuppressServerLoadFilter(logging.Filter):
def filter(self, record):
msg = record.getMessage()
return (
"GET /api/server_load" not in msg
and "GET /api/system_status" not in msg
)
log = logging.getLogger('werkzeug')
log.addFilter(SuppressServerLoadFilter())
app.secret_key = os.environ.get('SECRET_KEY', os.urandom(32))
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=8)
app.config['SESSION_COOKIE_SECURE'] = True
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
csrf = CSRFProtect(app)
@app.context_processor
def inject_utility_processor():
maintenance_status = dashboard_collection.find_one({"key": "scheduled_maintenance"}) if dashboard_collection is not None else None
is_maintenance_active = maintenance_status and maintenance_status.get('enabled', False)
return dict(
timedelta=timedelta,
maintenance_active=is_maintenance_active
)
try:
client = MongoClient(os.environ.get("MONGO_URI"))
db = client["astra_os"]
accounts_collection = db["accounts"]
logs_collection = db["logs"]
roles_collection = db["roles"]
dashboard_collection = db["dashboard_stats"]
discord_bots_collection = db["discord_bots_data"]
ets2_dlcs_collection = db["ets2_dlcs_packages"]
ip_analyzer_results_collection = db["ip_analyzer_results"]
age_verification_collection = db["age_verification"]
notifications_collection = db["notifications"]
print("INFO: MongoDB-Verbindung erfolgreich hergestellt.")
AVAILABLE_PERMISSIONS = [
"view_dashboard",
"view_user_management",
"manage_users",
"view_role_management",
"manage_roles",
"view_logs",
"maintenance_management",
"bypass_maintenance",
"manage_discord_bots",
"view_ets2_dlcs",
"manage_ets2_dlcs",
"events",
"instranet",
"view_ip_analyzer",
"view_age_verification",
"manage_age_verification",
"view_ayrantracker",
"notifications"
]
if roles_collection.count_documents({}) == 0:
print("WARNUNG: Keine Rollen in der Datenbank gefunden. Erstelle Standard-Rollen...")
roles_collection.insert_many([
{
"name": "Owner",
"permissions": AVAILABLE_PERMISSIONS,
"is_deletable": False
},
{
"name": "Benutzer",
"permissions": ["view_dashboard"],
"is_deletable": True
}
])
print("INFO: Standard-Rollen ('Owner', 'Benutzer') wurden erstellt.")
else:
# Bestehende Owner-Rollen auf neue Permissions anheben.
roles_collection.update_many(
{"name": "Owner"},
{"$addToSet": {"permissions": {"$each": AVAILABLE_PERMISSIONS}}}
)
if accounts_collection.count_documents({}) == 0:
print("WARNUNG: Keine Benutzer in der Datenbank gefunden. Erstelle Standard-Admin-Benutzer...")
owner_role = roles_collection.find_one({"name": "Owner"})
hashed_password = generate_password_hash("1234")
accounts_collection.insert_one({
'username': 'admin',
'password': hashed_password,
'role_id': owner_role['_id'],
'creation_date': datetime.utcnow(),
'discord_id': None,
'discord_username': None,
'discord_avatar': None,
'is_active': True,
'deactivation_reason': None,
'deactivated_at': None,
'deactivated_by': None
})
print("INFO: Standard-Admin-Benutzer ('admin', Passwort: '1234') mit Rolle 'Owner' wurde erstellt.")
else:
# Bestehende User mit is_active-Feld migrieren falls fehlend
accounts_collection.update_many(
{'is_active': {'$exists': False}},
{'$set': {'is_active': True, 'deactivation_reason': None, 'deactivated_at': None, 'deactivated_by': None}}
)
print("INFO: Bestehende Accounts wurden mit is_active-Feld migriert.")
if dashboard_collection.count_documents({}) == 0:
print("INFO: Leere dashboard_stats Collection - lege Standardstatistiken an.")
dashboard_collection.insert_many([
{"key": "active_users", "value": 1, "percent": 100},
{"key": "server_load", "percent": 0, "status": "Nicht Erreichbar"},
{"key": "open_tickets", "count": 0},
{"key": "system_status", "status": "Online"},
{"key": "scheduled_maintenance", "enabled": False, "time": None, "status": "Keine", "duration_minutes": None},
{"key": "last_user_created", "time": None, "by": None}
])
print("INFO: Standard Dashboard-Statistiken wurden erstellt.")
except Exception as e:
print(f"FATAL: Verbindung zur MongoDB fehlgeschlagen: {e}")
db = None
accounts_collection = None
logs_collection = None
roles_collection = None
dashboard_collection = None
discord_bots_collection = None
ets2_dlcs_collection = None
ip_analyzer_results_collection = None
notifications_collection = None
db = None
accounts_collection = None
logs_collection = None
ets2_dlcs_collection = None
def _venv_paths():
venv_bin = "/home/astraos/Astra-OS-Panel/venv/bin"
return {
"python": os.path.join(venv_bin, "python3"),
# pip NICHT direkt verwenden (Shebang kann auf falschen Interpreter zeigen)
"pip": os.path.join(venv_bin, "pip"),
}
def _build_bot_command(filepath: str):
venv = _venv_paths()
vpy = venv["python"]
# Keine Auto-Installation bei jedem Start; nur Bot ausführen.
return f"{vpy} -u {filepath}"
def _spawn_bot_process(bot_id: str, filepath: str, bot_name: str = "N/A", emit_to_socket: bool = False):
command = _build_bot_command(filepath)
if emit_to_socket:
socketio.emit('console_output', {'type': 'system', 'data': f'[startup] Command: {command}\n'}, room=bot_id)
process = subprocess.Popen(
command,
shell=True,
executable='/bin/bash',
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding='utf-8',
errors='replace'
)
running_bots[bot_id] = {'process': process, 'history': []}
thread = threading.Thread(target=stream_bot_output, args=(bot_id, process))
thread.daemon = True
thread.start()
if discord_bots_collection is not None:
discord_bots_collection.update_one({'_id': ObjectId(bot_id)}, {'$set': {'status': 'running'}})
if emit_to_socket:
socketio.emit('status_update', {'bot_id': bot_id, 'status': 'running'}, room=bot_id)
socketio.emit('console_output', {'type': 'system', 'data': f'--- Bot-Prozess gestartet (PID: {process.pid}) ---'}, room=bot_id)
print(f"INFO: Bot '{bot_name}' (PID: {process.pid}) wurde gestartet.")
return process
def start_bots_on_startup():
if discord_bots_collection is None:
print("WARNUNG: Bot-Autostart übersprungen, da keine DB-Verbindung besteht.")
return
bots_to_start = list(discord_bots_collection.find({'status': 'running'}))
if not bots_to_start:
print("INFO: Keine Bots für den Autostart markiert.")
return
print(f"INFO: Starte {len(bots_to_start)} Bot(s) automatisch...")
for bot_info in bots_to_start:
bot_id = str(bot_info['_id'])
filepath = bot_info.get('filepath')
if not os.path.exists(filepath):
print(f"FEHLER: Bot-Datei für '{bot_info['name']}' nicht gefunden: {filepath}")
continue
try:
_spawn_bot_process(bot_id, filepath, bot_name=bot_info.get('name', 'N/A'), emit_to_socket=False)
except Exception as e:
print(f"FEHLER: Bot '{bot_info.get('name','N/A')}' konnte nicht gestartet werden: {e}")
def stream_bot_output(bot_id, process):
def stream_to_socket(pipe, log_type):
try:
for line in iter(pipe.readline, ''):
log_entry = {'type': log_type, 'data': line}
if bot_id in running_bots:
running_bots[bot_id]['history'].append(log_entry)
max_history_lines = 1000
if len(running_bots[bot_id]['history']) > max_history_lines:
running_bots[bot_id]['history'].pop(0)
socketio.emit('console_output', log_entry, room=bot_id)
except Exception as e:
print(f"Stream-Fehler für Bot {bot_id}: {e}")
finally:
if pipe:
pipe.close()
stdout_thread = threading.Thread(target=stream_to_socket, args=(process.stdout, 'stdout'))
stderr_thread = threading.Thread(target=stream_to_socket, args=(process.stderr, 'stderr'))
stdout_thread.start()
stderr_thread.start()
process.wait()
if bot_id in running_bots and running_bots.get(bot_id) is not None:
del running_bots[bot_id]
if discord_bots_collection is not None:
discord_bots_collection.update_one({'_id': ObjectId(bot_id)}, {'$set': {'status': 'stopped'}})
socketio.emit('status_update', {'bot_id': bot_id, 'status': 'stopped'}, room=bot_id)
socketio.emit('console_output', {'bot_id': bot_id, 'type': 'system', 'data': '--- Bot-Prozess beendet ---'}, room=bot_id)
# ============================================================
# Discord Notification Bot (Lightweight Thread)
# ============================================================
discord_bot_client = None
discord_bot_loop = None
def _get_owner_discord_ids():
"""Returns a list of discord_ids for all users with the Owner role."""
if accounts_collection is None or roles_collection is None:
return []
owner_role = roles_collection.find_one({'name': 'Owner'})
if not owner_role:
return []
owners = accounts_collection.find({'role_id': owner_role['_id'], 'discord_id': {'$ne': None}})
return [o['discord_id'] for o in owners if o.get('discord_id')]
def send_discord_notification(notification_id: str, title: str, message: str, notification_type: str = 'info', requires_action: bool = False):
"""Send a Discord DM to the Owner with optional action buttons."""
global discord_bot_loop
if discord_bot_loop is None or not discord_bot_loop.is_running():
print("WARNUNG: Discord Bot Loop nicht aktiv, Benachrichtigung wird nicht gesendet.")
return
asyncio.run_coroutine_threadsafe(
_async_send_notification(notification_id, title, message, notification_type, requires_action),
discord_bot_loop
)
async def _async_send_notification(notification_id: str, title: str, message: str, notification_type: str, requires_action: bool):
global discord_bot_client
if discord_bot_client is None:
return
owner_discord_ids = _get_owner_discord_ids()
if not owner_discord_ids:
print("WARNUNG: Keine Owner-Discord-ID gefunden.")
return
type_colors = {
'info': discord.Color.blurple(),
'warning': discord.Color.orange(),
'action': discord.Color.red(),
'reactivation_request': discord.Color.yellow()
}
type_emojis = {
'info': '📢',
'warning': '⚠️',
'action': '🔔',
'reactivation_request': '🔓'
}
color = type_colors.get(notification_type, discord.Color.blurple())
emoji = type_emojis.get(notification_type, '📢')
embed = discord.Embed(
title=f"{emoji} {title}",
description=message,
color=color,
timestamp=datetime.utcnow()
)
embed.set_footer(text="AstraOS Admin Panel")
for discord_id_str in owner_discord_ids:
try:
user = await discord_bot_client.fetch_user(int(discord_id_str))
if requires_action:
view = NotificationActionView(notification_id)
await user.send(embed=embed, view=view)
else:
await user.send(embed=embed)
except Exception as e:
print(f"FEHLER: Discord DM an {discord_id_str} fehlgeschlagen: {e}")
class NotificationActionView(View):
def __init__(self, notification_id: str):
super().__init__(timeout=None)
self.notification_id = notification_id
approve_btn = Button(
label="✅ Genehmigen",
style=discord.ButtonStyle.green,
custom_id=f"notif_approve_{notification_id}"
)
reject_btn = Button(
label="❌ Ablehnen",
style=discord.ButtonStyle.red,
custom_id=f"notif_reject_{notification_id}"
)
approve_btn.callback = self.approve_callback
reject_btn.callback = self.reject_callback
self.add_item(approve_btn)
self.add_item(reject_btn)
async def approve_callback(self, interaction: discord.Interaction):
await self._handle_action(interaction, 'approved')
async def reject_callback(self, interaction: discord.Interaction):
await self._handle_action(interaction, 'rejected')
async def _handle_action(self, interaction: discord.Interaction, action: str):
if notifications_collection is not None:
notifications_collection.update_one(
{'_id': ObjectId(self.notification_id)},
{'$set': {
'action_status': action,
'action_taken_by': f"Discord:{interaction.user.name}",
'action_taken_at': datetime.utcnow(),
'is_read': True
}}
)
# If it was a reactivation request, handle account activation
notif = notifications_collection.find_one({'_id': ObjectId(self.notification_id)})
if notif and notif.get('type') == 'reactivation_request' and action == 'approved':
user_id = notif.get('metadata', {}).get('user_id')
if user_id and accounts_collection is not None:
accounts_collection.update_one(
{'_id': ObjectId(user_id)},
{'$set': {'is_active': True, 'deactivation_reason': None, 'deactivated_at': None, 'deactivated_by': None}}
)
label = "✅ Genehmigt" if action == 'approved' else "❌ Abgelehnt"
color = discord.Color.green() if action == 'approved' else discord.Color.red()
result_embed = discord.Embed(
title=f"Aktion ausgeführt: {label}",
description=f"Du hast diese Anfrage **{label.lower()}**.",
color=color,
timestamp=datetime.utcnow()
)
result_embed.set_footer(text="AstraOS Admin Panel")
for child in self.children:
child.disabled = True
await interaction.response.edit_message(embed=result_embed, view=self)
def _run_discord_bot():
global discord_bot_client, discord_bot_loop
intents = discord.Intents.default()
discord_bot_client = discord.Client(intents=intents)
@discord_bot_client.event
async def on_ready():
print(f"INFO: Discord Notification Bot eingeloggt als {discord_bot_client.user}")
token = os.environ.get('DISCORD_BOT_TOKEN')
if not token:
print("WARNUNG: DISCORD_BOT_TOKEN nicht gesetzt. Discord-Bot wird nicht gestartet.")
return
try:
discord_bot_loop = asyncio.new_event_loop()
asyncio.set_event_loop(discord_bot_loop)
discord_bot_loop.run_until_complete(discord_bot_client.start(token))
except Exception as e:
print(f"FEHLER: Discord Bot konnte nicht gestartet werden: {e}")
_discord_bot_thread = threading.Thread(target=_run_discord_bot, daemon=True)
_discord_bot_thread.start()
# ============================================================
if os.environ.get('WERKZEUG_RUN_MAIN') != 'true':
with app.app_context():
start_bots_on_startup()
try:
tickets_client = MongoClient(os.environ.get("MONGO_URI"))
tickets_db = tickets_client["chainsaw_dc_data"]
tickets_collection = tickets_db["tickets"]
except Exception as e:
tickets_collection = None
print(f"FATAL: Verbindung zu Tickets-DB fehlgeschlagen: {e}")
def add_log(action, details=None):
if 'username' in session and logs_collection is not None:
log_entry = {
'username': session['username'],
'action': action,
'details': details,
'timestamp': datetime.utcnow()
}
logs_collection.insert_one(log_entry)
def _ets2_archive_filename(package_id: str, package_name: str):
safe_name = secure_filename(package_name or 'ets2_dlc_package') or 'ets2_dlc_package'
return f'{package_id}_{safe_name}.zip'
def _ets2_archive_path(package_id: str, package_name: str):
return os.path.join(ETS2_DLC_ARCHIVE_FOLDER, _ets2_archive_filename(package_id, package_name))
def _get_ets2_build_lock(package_id: str):
with ets2_dlc_build_locks_guard:
lock = ets2_dlc_build_locks.get(package_id)
if lock is None:
lock = threading.Lock()
ets2_dlc_build_locks[package_id] = lock
return lock
def _set_ets2_archive_state(package_id: str, **fields):
if ets2_dlcs_collection is None:
return
ets2_dlcs_collection.update_one({'_id': ObjectId(package_id)}, {'$set': fields})
def _build_ets2_archive(package_id: str):
if ets2_dlcs_collection is None:
return
package = ets2_dlcs_collection.find_one({'_id': ObjectId(package_id)})
if not package:
return
folder_path = package.get('folder_path')
if not folder_path or not os.path.isdir(folder_path):
_set_ets2_archive_state(
package_id,
archive_status='error',
archive_error='Ordnerpfad ist nicht mehr verfügbar.',
archive_generated_at=None,
archive_path=None
)
return
archive_path = _ets2_archive_path(package_id, package.get('name', 'ets2_dlc_package'))
temp_archive_path = f'{archive_path}.tmp'
try:
if os.path.exists(temp_archive_path):
try:
os.remove(temp_archive_path)
except Exception:
pass
_set_ets2_archive_state(
package_id,
archive_status='building',
archive_error=None,
archive_path=archive_path,
archive_generated_at=None
)
with zipfile.ZipFile(temp_archive_path, 'w', compression=zipfile.ZIP_STORED, allowZip64=True) as zipf:
has_content = False
for root, _, files in os.walk(folder_path):
for filename in files:
file_path = os.path.join(root, filename)
arcname = os.path.relpath(file_path, folder_path)
zipf.write(file_path, arcname, compress_type=zipfile.ZIP_STORED)
has_content = True
if not has_content:
zipf.writestr('EMPTY_PACKAGE.txt', 'Dieses DLC-Paket enthaelt aktuell keine Dateien.')
os.replace(temp_archive_path, archive_path)
_set_ets2_archive_state(
package_id,
archive_status='ready',
archive_error=None,
archive_path=archive_path,
archive_generated_at=datetime.utcnow()
)
except Exception as e:
try:
if os.path.exists(temp_archive_path):
os.remove(temp_archive_path)
except Exception:
pass
_set_ets2_archive_state(
package_id,
archive_status='error',
archive_error=str(e),
archive_generated_at=None,
archive_path=archive_path
)
def _schedule_ets2_archive_build(package_id: str):
lock = _get_ets2_build_lock(package_id)
if not lock.acquire(blocking=False):
return False
def runner():
try:
_build_ets2_archive(package_id)
finally:
try:
lock.release()
except Exception:
pass
with ets2_dlc_build_locks_guard:
ets2_dlc_build_locks.pop(package_id, None)
thread = threading.Thread(target=runner, daemon=True)
thread.start()
return True
def _format_ets2_dlcs_for_view(packages):
for package in packages:
dlc_date = package.get('dlc_date')
try:
package['dlc_date_display'] = datetime.strptime(dlc_date, '%Y-%m-%d').strftime('%d.%m.%Y') if dlc_date else 'N/A'
except ValueError:
package['dlc_date_display'] = str(dlc_date) if dlc_date else 'N/A'
package_id = str(package['_id'])
archive_path = package.get('archive_path') or _ets2_archive_path(package_id, package.get('name', 'ets2_dlc_package'))
archive_ready = package.get('archive_status') == 'ready' and os.path.isfile(archive_path)
folder_path = package.get('folder_path')
if not archive_ready and folder_path and os.path.isdir(folder_path):
package.setdefault('archive_status', 'pending')
package['download_ready'] = False
package['archive_status_display'] = 'Wird erstellt'
package['archive_path'] = archive_path
_schedule_ets2_archive_build(package_id)
elif archive_ready:
package['download_ready'] = True
package['archive_status_display'] = 'Bereit'
package['archive_path'] = archive_path
else:
package['download_ready'] = False
package['archive_status_display'] = 'Fehler'
package['archive_path'] = archive_path
if package.get('archive_status') == 'error' and package.get('archive_error'):
package['archive_status_display'] = f"Fehler: {package.get('archive_error')}"
return packages
def get_user_permissions(user_id):
user = accounts_collection.find_one({'_id': ObjectId(user_id)})
if not user or 'role_id' not in user:
return []
role = roles_collection.find_one({'_id': user['role_id']})
if not role:
return []
return role.get('permissions', [])
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
flash("Bitte melden Sie sich an, um diese Seite zu sehen.", "warning")
return redirect(url_for('login'))
user = accounts_collection.find_one({'_id': ObjectId(session['user_id'])})
if not user:
session.clear()
flash("Benutzer nicht gefunden.", "danger")
return redirect(url_for('login'))
if not user.get('is_active', True):
session.clear()
return redirect(url_for('account_disabled'))
if not user.get('discord_id') and not user.get('discord_override', False) and request.endpoint not in ['link_discord', 'login_discord', 'callback_discord', 'logout', 'static']:
return redirect(url_for('link_discord'))
session['permissions'] = get_user_permissions(session['user_id'])
maintenance_status = dashboard_collection.find_one({"key": "scheduled_maintenance"})
if maintenance_status and maintenance_status.get('enabled', False):
if 'bypass_maintenance' not in session['permissions']:
return redirect(url_for('maintenance'))
return f(*args, **kwargs)
return decorated_function
def permission_required(permission):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
return redirect(url_for('login'))
user_perms = get_user_permissions(session['user_id'])
if permission not in user_perms:
flash("Sie haben keine Berechtigung, diese Seite anzuzeigen.", "danger")
return redirect(url_for('dashboard'))
return f(*args, **kwargs)
return decorated_function
return decorator
@app.route('/')
def home():
if 'user_id' in session:
return redirect(url_for('dashboard'))
return redirect(url_for('login'))
@app.route('/login', methods=['GET', 'POST'])
def login():
if 'user_id' in session:
return redirect(url_for('dashboard'))
maintenance_status = dashboard_collection.find_one({"key": "scheduled_maintenance"}) if dashboard_collection is not None else None
if maintenance_status and maintenance_status.get('enabled', False):
return redirect(url_for('maintenance'))
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if not username or not password:
flash("Benutzername und Passwort sind erforderlich.", "danger")
return render_template('login.html')
if accounts_collection is None:
flash("Datenbankverbindung nicht verfügbar. Bitte versuchen Sie es später erneut.", "danger")
return render_template('login.html'), 500
user = accounts_collection.find_one({'username': username})
if user and check_password_hash(user['password'], password):
if not user.get('is_active', True):
return redirect(url_for('account_disabled'))
session.permanent = True
session['user_id'] = str(user['_id'])
session['username'] = user['username']
session['permissions'] = get_user_permissions(str(user['_id']))
add_log("Benutzer-Login")
if not user.get('discord_id') and not user.get('discord_override', False):
flash("Bitte verknüpfe deinen Discord-Account, um fortzufahren.", "info")
return redirect(url_for('link_discord'))
flash("Erfolgreich angemeldet!", "success")
return redirect(url_for('dashboard'))
else:
flash("Ungültiger Benutzername oder Passwort.", "danger")
return render_template('login.html')
return render_template('login.html')
@app.route('/api/server_load')
@login_required
def api_server_load():
try:
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(os.environ.get('SSH_HOST'), username=os.environ.get('SSH_USER'), password=os.environ.get('SSH_PASS'), timeout=10)
stdin, stdout, stderr = ssh.exec_command("top -bn1 | grep 'Cpu(s)'")
output = stdout.read().decode()
ssh.close()
import re
match = re.search(r"(\d+\.\d+)\s*id", output)
if match:
idle = float(match.group(1))
usage = 100 - idle
return jsonify({"percent": round(usage, 1), "status": "Online"})
else:
return jsonify({"percent": 0, "status": "Fehler"})
except paramiko.AuthenticationException:
print("SSH-Fehler: Authentifizierung fehlgeschlagen.")
return jsonify({"percent": 0, "status": "Auth Fehler"})
except paramiko.SSHException as e:
print(f"SSH-Fehler: {e}")
return jsonify({"percent": 0, "status": "SSH Fehler"})
except Exception as e:
print(f"Allgemeiner Fehler bei Server-Auslastung: {e}")
return jsonify({"percent": 0, "status": "Nicht erreichbar"})
@app.route('/api/system_status')
@login_required
def api_system_status():
maintenance_status = dashboard_collection.find_one({"key": "scheduled_maintenance"}) if dashboard_collection is not None else None
maintenance_active = maintenance_status and maintenance_status.get('enabled', False)
discord_bots_maintenance = maintenance_status.get('discord_bots_maintenance', False) if maintenance_status else False
scpsl_status = "offline"
try:
udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp_sock.settimeout(2)
udp_sock.sendto(b"\x00", ("89.144.42.175", 7101))
scpsl_status = "online"
udp_sock.close()
except Exception as e:
print(f"[DEBUG] SCP:SL UDP-Ping failed: {e}")
scpsl_status = "offline"
if maintenance_active:
scpsl_status = "wartung"
gamepanel_status = "offline"
try:
resp = requests.get("https://gamepanel.mrsniff.de", timeout=3)
if resp.status_code == 200:
gamepanel_status = "online"
except Exception:
gamepanel_status = "offline"
if maintenance_active:
gamepanel_status = "wartung"
discord_bots_status = "wartung" if discord_bots_maintenance or maintenance_active else "online"
db_status = "online" if db is not None else "offline"
if maintenance_active:
db_status = "wartung"
return jsonify({
"scpsl": scpsl_status,
"gamepanel": gamepanel_status,
"discord_bots": discord_bots_status,
"database": db_status,
"maintenance": maintenance_active
})
@app.route('/status')
@login_required
def status_page():
maintenance_status = dashboard_collection.find_one({"key": "scheduled_maintenance"}) if dashboard_collection is not None else None
maintenance_active = maintenance_status and maintenance_status.get('enabled', False)
return render_template('status_page.html', active_page='status_page', maintenance_active=maintenance_active)
@app.route('/dashboard')
@login_required
def dashboard():
user_count = accounts_collection.count_documents({}) if accounts_collection is not None else 1
last_user = accounts_collection.find_one(sort=[("creation_date", -1)]) if accounts_collection is not None else None
last_user_time = last_user["creation_date"] if last_user and "creation_date" in last_user else None
last_user_by = last_user["username"] if last_user and "username" in last_user else None
open_tickets = 0
if tickets_collection is not None:
open_tickets = tickets_collection.count_documents({"status": "open"})
server_load = {"percent": 0, "status": "Lädt..."}
system_status = {"status": "Online"}
try:
resp = requests.get(request.url_root.rstrip('/') + '/api/system_status', cookies=request.cookies, timeout=2)
if resp.status_code == 200:
data = resp.json()
if data.get("maintenance"):
system_status = {"status": "Online (Wartungsarbeiten)"}
elif any(data.get(k) == "wartung" for k in ["scpsl", "gamepanel", "discord_bots", "database"]):
system_status = {"status": "Systeme im Wartungsmodus"}
elif all(data.get(k) == "online" for k in ["scpsl", "gamepanel", "discord_bots", "database"]):
system_status = {"status": "Online"}
else:
system_status = {"status": "Offline"}
else:
system_status = {"status": "Offline"}
except Exception:
system_status = {"status": "Offline"}
scheduled_maintenance = dashboard_collection.find_one({"key": "scheduled_maintenance"}) if dashboard_collection is not None else None
def _dt_to_str(v):
if v is None:
return None
try:
return v.strftime("%d. %b %Y, %H:%M")
except Exception:
return str(v)
maint = scheduled_maintenance or {"time": None, "status": "Keine", "duration_minutes": None}
maint["time_str"] = _dt_to_str(maint.get("time"))
stats = {
"active_users": {"value": user_count, "percent": 0},
"server_load": server_load,
"open_tickets": {"count": open_tickets},
"system_status": system_status,
"scheduled_maintenance": maint,
"last_user_created": {
"time": last_user_time,
"by": last_user_by,
"time_str": _dt_to_str(last_user_time)
}
}
return render_template('dashboard.html', stats=stats, active_page='dashboard')
@app.route('/logout')
@login_required
def logout():
add_log("Benutzer-Logout")
session.clear()
flash("Sie wurden erfolgreich abgemeldet.", "info")
return redirect(url_for('login'))
@app.route('/user_management')
@login_required
@permission_required('view_user_management')
def user_management():
if accounts_collection is None or roles_collection is None:
flash("Datenbankverbindung nicht verfügbar.", "danger")
return redirect(url_for('dashboard'))
users_list = list(accounts_collection.find())
roles_list = list(roles_collection.find())
for role in roles_list:
role['_id'] = str(role['_id'])
role_map = {role['_id']: role['name'] for role in roles_list}
users_for_modal = {}
for user in users_list:
user_id_str = str(user['_id'])
users_for_modal[user_id_str] = {
'_id': user_id_str,
'username': user['username'],
'role_id': str(user.get('role_id')) if user.get('role_id') else None
}
users_for_table = list(accounts_collection.find())
for user in users_for_table:
user['role_name'] = role_map.get(str(user.get('role_id')), 'Unbekannt')
user['discord_linked'] = bool(user.get('discord_id'))
return render_template('user_management.html',
users_for_modal=users_for_modal,
users_for_table=users_for_table,
roles=roles_list,
active_page='user_management')
@app.route('/add_user', methods=['POST'])
@login_required
@permission_required('manage_users')
def add_user():
if accounts_collection is None:
flash("Datenbankverbindung nicht verfügbar.", "danger")
return redirect(url_for('user_management'))
username = request.form.get('username')
password = request.form.get('password')
role_id = request.form.get('role')
if not username or not password or not role_id:
flash("Alle Felder sind erforderlich.", "danger")
return redirect(url_for('user_management'))
if accounts_collection.find_one({'username': username}):
flash("Ein Benutzer mit diesem Namen existiert bereits.", "danger")
return redirect(url_for('user_management'))
hashed_password = generate_password_hash(password)
accounts_collection.insert_one({
'username': username,
'password': hashed_password,
'role_id': ObjectId(role_id),
'creation_date': datetime.utcnow(),
'discord_id': None,
'discord_username': None,
'discord_avatar': None
})
add_log("Benutzer erstellt", f"Benutzername: {username}")
if dashboard_collection is not None:
dashboard_collection.update_one(
{"key": "last_user_created"},
{"$set": {
"time": datetime.utcnow(),
"by": session.get('username')
}},
upsert=True
)
user_count = accounts_collection.count_documents({})
dashboard_collection.update_one(
{"key": "active_users"},
{"$set": {"value": user_count}},
upsert=True
)
flash(f"Benutzer '{username}' wurde erfolgreich erstellt.", "success")
return redirect(url_for('user_management'))
@app.route('/edit_user/<user_id>', methods=['POST'])
@login_required
@permission_required('manage_users')
def edit_user(user_id):
if accounts_collection is None:
flash("Datenbankverbindung nicht verfügbar.", "danger")
return redirect(url_for('user_management'))
username = request.form.get('username')
password = request.form.get('password')
role_id = request.form.get('role')
if not username or not role_id:
flash("Benutzername und Rolle sind erforderlich.", "danger")
return redirect(url_for('user_management'))
existing_user = accounts_collection.find_one({'username': username})
if existing_user and str(existing_user['_id']) != user_id:
flash("Ein anderer Benutzer mit diesem Namen existiert bereits.", "danger")
return redirect(url_for('user_management'))
update_data = {
'username': username,
'role_id': ObjectId(role_id)
}
if password:
update_data['password'] = generate_password_hash(password)
accounts_collection.update_one(
{'_id': ObjectId(user_id)},
{'$set': update_data}
)
add_log("Benutzer bearbeitet", f"Benutzername: {username}")
flash(f"Benutzer '{username}' wurde erfolgreich aktualisiert.", "success")
return redirect(url_for('user_management'))
@app.route('/delete_user/<user_id>', methods=['POST'])
@login_required
@permission_required('manage_users')
def delete_user(user_id):
if accounts_collection is None:
flash("Datenbankverbindung nicht verfügbar.", "danger")
return redirect(url_for('user_management'))
if session.get('user_id') == user_id:
flash("Sie können sich nicht selbst löschen.", "danger")
return redirect(url_for('user_management'))
user = accounts_collection.find_one({'_id': ObjectId(user_id)})
accounts_collection.delete_one({'_id': ObjectId(user_id)})
add_log("Benutzer gelöscht", f"Benutzername: {user.get('username', 'N/A')}")
flash("Benutzer wurde erfolgreich gelöscht.", "success")
return redirect(url_for('user_management'))
@app.route('/toggle_user_status/<user_id>', methods=['POST'])
@login_required
@permission_required('manage_users')
def toggle_user_status(user_id):
if accounts_collection is None:
flash("Datenbankverbindung nicht verfügbar.", "danger")
return redirect(url_for('user_management'))
if session.get('user_id') == user_id:
flash("Sie können Ihren eigenen Account nicht deaktivieren.", "danger")
return redirect(url_for('user_management'))
user = accounts_collection.find_one({'_id': ObjectId(user_id)})
if not user:
flash("Benutzer nicht gefunden.", "danger")
return redirect(url_for('user_management'))
new_status = not user.get('is_active', True)
update_data = {'is_active': new_status}
if not new_status:
reason = request.form.get('deactivation_reason', '')
update_data['deactivation_reason'] = reason if reason else None
update_data['deactivated_at'] = datetime.utcnow()
update_data['deactivated_by'] = session.get('username')
add_log("Benutzer deaktiviert", f"Benutzername: {user.get('username')}, Grund: {reason or 'Kein Grund angegeben'}")
flash(f"Benutzer '{user.get('username')}' wurde deaktiviert.", "success")
else:
update_data['deactivation_reason'] = None
update_data['deactivated_at'] = None
update_data['deactivated_by'] = None
add_log("Benutzer aktiviert", f"Benutzername: {user.get('username')}")
flash(f"Benutzer '{user.get('username')}' wurde wieder aktiviert.", "success")
accounts_collection.update_one({'_id': ObjectId(user_id)}, {'$set': update_data})
return redirect(url_for('user_management'))
@app.route('/toggle_discord_override/<user_id>', methods=['POST'])
@login_required
@permission_required('manage_users')
def toggle_discord_override(user_id):
if accounts_collection is None:
flash("Datenbankverbindung nicht verfügbar.", "danger")
return redirect(url_for('user_management'))
user = accounts_collection.find_one({'_id': ObjectId(user_id)})
if not user:
flash("Benutzer nicht gefunden.", "danger")
return redirect(url_for('user_management'))
new_override = not user.get('discord_override', False)
accounts_collection.update_one(
{'_id': ObjectId(user_id)},
{'$set': {'discord_override': new_override}}
)
state = "aktiviert" if new_override else "deaktiviert"
add_log("Discord-Override geändert", f"Benutzername: {user.get('username')}, Override: {state}")
flash(f"Discord-Override für '{user.get('username')}' wurde {state}.", "success")
return redirect(url_for('user_management'))
@app.route('/account_disabled')
def account_disabled():
return render_template('account_disabled.html')
@app.route('/request_reactivation', methods=['POST'])
def request_reactivation():
if notifications_collection is None:
flash("Systemfehler. Bitte versuche es später erneut.", "danger")
return redirect(url_for('account_disabled'))
username = request.form.get('username', '').strip()
reason = request.form.get('reason', '').strip()
if not username:
flash("Benutzername ist erforderlich.", "danger")
return redirect(url_for('account_disabled'))
user = accounts_collection.find_one({'username': username}) if accounts_collection is not None else None
if not user:
flash("Benutzername nicht gefunden.", "danger")
return redirect(url_for('account_disabled'))
if user.get('is_active', True):
flash("Dieser Account ist nicht deaktiviert.", "info")
return redirect(url_for('login'))
# Check for existing pending request
existing = notifications_collection.find_one({
'type': 'reactivation_request',
'action_status': 'pending',
'metadata.user_id': str(user['_id'])
})
if existing:
flash("Es gibt bereits eine offene Anfrage für diesen Account. Bitte warte auf eine Antwort.", "info")
return redirect(url_for('account_disabled'))
notif_doc = {
'type': 'reactivation_request',
'title': f'Reaktivierungsanfrage: {username}',
'message': f'Der Benutzer **{username}** möchte seinen Account reaktivieren.\n\n'
f'**Begründung:** {reason if reason else "Keine Begründung angegeben"}\n\n'
f'**Deaktiviert am:** {user.get("deactivated_at", "Unbekannt")}\n'
f'**Deaktiviert von:** {user.get("deactivated_by", "Unbekannt")}\n'
f'**Deaktivierungsgrund:** {user.get("deactivation_reason") or "Kein Grund angegeben"}',
'created_at': datetime.utcnow(),
'created_by': 'system',
'is_read': False,
'requires_action': True,
'action_status': 'pending',
'action_taken_by': None,
'action_taken_at': None,
'metadata': {'user_id': str(user['_id']), 'username': username},
'discord_message_id': None
}
result = notifications_collection.insert_one(notif_doc)
send_discord_notification(
str(result.inserted_id),
notif_doc['title'],
notif_doc['message'],
notification_type='reactivation_request',
requires_action=True
)
flash("Deine Anfrage wurde gesendet. Ein Administrator wird sich darum kümmern.", "success")
return redirect(url_for('account_disabled'))
# ============================================================
# Notifications Routes
# ============================================================
@app.route('/notifications')
@login_required
@permission_required('notifications')
def notifications_page():
if notifications_collection is None:
flash("Datenbankverbindung nicht verfügbar.", "danger")
return redirect(url_for('dashboard'))
all_notifications = list(notifications_collection.find().sort('created_at', -1))
for n in all_notifications:
n['_id'] = str(n['_id'])
unread_count = notifications_collection.count_documents({'is_read': False})
return render_template('notifications.html', active_page='notifications',
notifications=all_notifications, unread_count=unread_count)
@app.route('/api/notifications/unread_count')
@login_required
def api_notifications_unread_count():
if 'notifications' not in session.get('permissions', []):
return jsonify({'count': 0})
if notifications_collection is None:
return jsonify({'count': 0})
count = notifications_collection.count_documents({'is_read': False})
return jsonify({'count': count})
@app.route('/api/notifications/send', methods=['POST'])
@login_required
@permission_required('notifications')
def api_send_notification():
if notifications_collection is None:
return jsonify({'success': False, 'error': 'DB nicht verfügbar'}), 500
data = request.get_json()
title = (data.get('title') or '').strip()
message = (data.get('message') or '').strip()
notif_type = data.get('type', 'info')
if not title or not message:
return jsonify({'success': False, 'error': 'Titel und Nachricht sind erforderlich.'}), 400
if notif_type not in ['info', 'warning', 'action']:
notif_type = 'info'
requires_action = notif_type == 'action'
notif_doc = {
'type': notif_type,
'title': title,
'message': message,
'created_at': datetime.utcnow(),
'created_by': session.get('username', 'Unbekannt'),
'is_read': False,
'requires_action': requires_action,
'action_status': 'pending' if requires_action else None,
'action_taken_by': None,
'action_taken_at': None,
'metadata': {},
'discord_message_id': None
}
result = notifications_collection.insert_one(notif_doc)
add_log("Benachrichtigung gesendet", f"Titel: {title}, Typ: {notif_type}")
send_discord_notification(
str(result.inserted_id),
title,
message,
notification_type=notif_type,
requires_action=requires_action
)
return jsonify({'success': True, 'id': str(result.inserted_id)})
@app.route('/api/notifications/<notif_id>/action', methods=['POST'])
@login_required
@permission_required('notifications')
def api_notification_action(notif_id):
if notifications_collection is None:
return jsonify({'success': False, 'error': 'DB nicht verfügbar'}), 500
data = request.get_json()
action = data.get('action')
if action not in ['approved', 'rejected']:
return jsonify({'success': False, 'error': 'Ungültige Aktion'}), 400
notif = notifications_collection.find_one({'_id': ObjectId(notif_id)})
if not notif:
return jsonify({'success': False, 'error': 'Benachrichtigung nicht gefunden'}), 404
notifications_collection.update_one(
{'_id': ObjectId(notif_id)},
{'$set': {
'action_status': action,
'action_taken_by': session.get('username'),
'action_taken_at': datetime.utcnow(),
'is_read': True
}}
)
if notif.get('type') == 'reactivation_request' and action == 'approved':
user_id = notif.get('metadata', {}).get('user_id')
if user_id and accounts_collection is not None:
accounts_collection.update_one(
{'_id': ObjectId(user_id)},
{'$set': {'is_active': True, 'deactivation_reason': None, 'deactivated_at': None, 'deactivated_by': None}}
)
add_log("Account reaktiviert (via Panel)", f"User-ID: {user_id}")
add_log("Benachrichtigung bearbeitet", f"ID: {notif_id}, Aktion: {action}")
return jsonify({'success': True})
@app.route('/api/notifications/<notif_id>/read', methods=['POST'])
@login_required
@permission_required('notifications')
def api_notification_mark_read(notif_id):
if notifications_collection is None:
return jsonify({'success': False}), 500
notifications_collection.update_one(
{'_id': ObjectId(notif_id)},
{'$set': {'is_read': True}}
)
return jsonify({'success': True})
@app.route('/api/notifications/<notif_id>/delete', methods=['POST'])
@login_required
@permission_required('notifications')
def api_notification_delete(notif_id):
if notifications_collection is None:
return jsonify({'success': False}), 500
notifications_collection.delete_one({'_id': ObjectId(notif_id)})
add_log("Benachrichtigung gelöscht", f"ID: {notif_id}")
return jsonify({'success': True})
@app.route('/settings', methods=['GET'])
@login_required
def settings():
user = accounts_collection.find_one({'_id': ObjectId(session['user_id'])})
return render_template('settings.html', user=user, active_page='settings')
@app.route('/update_password', methods=['POST'])
@login_required
def update_password():
user_id = session['user_id']
current_password = request.form.get('current_password')
new_password = request.form.get('new_password')
confirm_password = request.form.get('confirm_password')
if not all([current_password, new_password, confirm_password]):
flash("Alle Passwortfelder sind erforderlich.", "danger")
return redirect(url_for('settings'))
if new_password != confirm_password:
flash("Die neuen Passwörter stimmen nicht überein.", "danger")
return redirect(url_for('settings'))
user = accounts_collection.find_one({'_id': ObjectId(user_id)})
if not user or not check_password_hash(user['password'], current_password):
flash("Das aktuelle Passwort ist nicht korrekt.", "danger")
return redirect(url_for('settings'))
hashed_password = generate_password_hash(new_password)
accounts_collection.update_one({'_id': ObjectId(user_id)}, {'$set': {'password': hashed_password}})
add_log("Passwort geändert")
flash("Dein Passwort wurde erfolgreich geändert.", "success")
return redirect(url_for('settings'))
@app.route('/unlink_discord', methods=['POST'])
@login_required
def unlink_discord():
user_id = session['user_id']
accounts_collection.update_one(
{'_id': ObjectId(user_id)},
{'$set': {'discord_id': None, 'discord_username': None, 'discord_avatar': None}}
)
add_log("Discord-Verknüpfung aufgehoben")
flash("Deine Discord-Verknüpfung wurde entfernt. Bitte verknüpfe dein Konto erneut.", "info")
return redirect(url_for('link_discord'))
@app.route('/link_discord')
@login_required
def link_discord():
return render_template('link_discord.html')
@app.route('/login/discord')
def login_discord():
client_id = os.environ.get('DISCORD_CLIENT_ID')
redirect_uri = os.environ.get('DISCORD_REDIRECT_URI')
scope = 'identify'
discord_auth_url = (
f'https://discord.com/api/oauth2/authorize?client_id={client_id}&'
f'redirect_uri={redirect_uri}&response_type=code&scope={scope}'
)
return redirect(discord_auth_url)
@app.route('/callback/discord')
def callback_discord():
code = request.args.get('code')
if not code:
flash("Discord-Authentifizierung fehlgeschlagen.", "danger")
return redirect(url_for('login'))
client_id = os.environ.get('DISCORD_CLIENT_ID')
client_secret = os.environ.get('DISCORD_CLIENT_SECRET')
redirect_uri = os.environ.get('DISCORD_REDIRECT_URI')
token_url = 'https://discord.com/api/oauth2/token'
token_data = {
'client_id': client_id,
'client_secret': client_secret,
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': redirect_uri,
'scope': 'identify'
}
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
token_r = requests.post(token_url, data=token_data, headers=headers)
if token_r.status_code != 200:
flash("Fehler beim Abrufen des Tokens von Discord.", "danger")
return redirect(url_for('login'))
access_token = token_r.json()['access_token']
user_info_url = 'https://discord.com/api/users/@me'
user_info_r = requests.get(user_info_url, headers={'Authorization': f'Bearer {access_token}'})
if user_info_r.status_code != 200:
flash("Fehler beim Abrufen der Benutzerinformationen von Discord.", "danger")
return redirect(url_for('login'))
discord_user = user_info_r.json()
discord_id = discord_user['id']
discord_username = f"{discord_user['username']}"
discord_avatar = discord_user.get('avatar')
if 'user_id' in session:
user_id = session['user_id']
existing_link = accounts_collection.find_one({'discord_id': discord_id})
if existing_link and str(existing_link['_id']) != user_id:
flash("Dieser Discord-Account ist bereits mit einem anderen Benutzer verknüpft.", "danger")
return redirect(url_for('settings'))
accounts_collection.update_one(
{'_id': ObjectId(user_id)},
{'$set': {
'discord_id': discord_id,
'discord_username': discord_username,
'discord_avatar': discord_avatar
}}
)
add_log("Discord verknüpft")
flash("Discord-Account erfolgreich verknüpft!", "success")
return redirect(url_for('dashboard'))
else:
user = accounts_collection.find_one({'discord_id': discord_id})
if user:
session.permanent = True
session['user_id'] = str(user['_id'])
session['username'] = user['username']
session['permissions'] = get_user_permissions(str(user['_id']))
add_log("Benutzer-Login via Discord")
flash("Erfolgreich mit Discord angemeldet!", "success")
return redirect(url_for('dashboard'))
else:
flash("Kein AstraOS-Benutzer mit diesem Discord-Konto gefunden. Bitte melde dich zuerst normal an und verknüpfe deinen Account.", "warning")
return redirect(url_for('login'))
@app.route('/role_management')
@login_required
@permission_required('view_role_management')
def role_management():
if roles_collection is None:
flash("Datenbankverbindung nicht verfügbar.", "danger")
return redirect(url_for('dashboard'))
roles = list(roles_collection.find())
for role in roles:
role['_id'] = str(role['_id'])
return render_template('role_management.html', roles=roles, available_permissions=AVAILABLE_PERMISSIONS, active_page='role_management')
@app.route('/add_role', methods=['POST'])
@login_required
@permission_required('manage_roles')
def add_role():
role_name = request.form.get('role_name')
permissions = request.form.getlist('permissions')
if not role_name:
flash("Rollenname ist erforderlich.", "danger")
return redirect(url_for('role_management'))
if roles_collection.find_one({'name': role_name}):
flash("Eine Rolle mit diesem Namen existiert bereits.", "danger")
return redirect(url_for('role_management'))
roles_collection.insert_one({
"name": role_name,
"permissions": permissions,
"is_deletable": True
})
add_log("Rolle erstellt", f"Rolle: {role_name}")
flash(f"Rolle '{role_name}' wurde erstellt.", "success")
return redirect(url_for('role_management'))
@app.route('/edit_role/<role_id>', methods=['POST'])
@login_required
@permission_required('manage_roles')
def edit_role(role_id):
role_name = request.form.get('role_name')
permissions = request.form.getlist('permissions')
if not role_name:
flash("Rollenname ist erforderlich.", "danger")
return redirect(url_for('role_management'))
roles_collection.update_one(
{'_id': ObjectId(role_id)},
{'$set': {'name': role_name, 'permissions': permissions}}
)
add_log("Rolle bearbeitet", f"Rolle: {role_name}")
flash(f"Rolle '{role_name}' wurde aktualisiert.", "success")
return redirect(url_for('role_management'))
@app.route('/delete_role/<role_id>', methods=['POST'])
@login_required
@permission_required('manage_roles')
def delete_role(role_id):
role = roles_collection.find_one({'_id': ObjectId(role_id)})
if role and not role.get('is_deletable', True):
flash("Diese Rolle kann nicht gelöscht werden.", "danger")
return redirect(url_for('role_management'))
roles_collection.delete_one({'_id': ObjectId(role_id)})
add_log("Rolle gelöscht", f"Rolle: {role.get('name', 'N/A')}")
flash("Rolle wurde gelöscht.", "success")
return redirect(url_for('role_management'))
def calculate_current_age(birthdate_str):
try:
birthdate_dt = datetime.strptime(birthdate_str, '%d.%m.%Y')
except ValueError:
birthdate_dt = datetime.strptime(birthdate_str, '%Y-%m-%d')
today = datetime.today()
return today.year - birthdate_dt.year - ((today.month, today.day) < (birthdate_dt.month, birthdate_dt.day))
@app.route('/age_verification', methods=['GET', 'POST'])
@login_required
@permission_required('view_age_verification')
def age_verification():
result = None
search_query = request.args.get('search', '')
if request.method == 'POST':
birthdate_str = request.form.get('birthdate')
steam_id = request.form.get('steam_id')
ingame_name = request.form.get('ingame_name')
if birthdate_str and steam_id and ingame_name:
try:
birthdate_dt = datetime.strptime(birthdate_str, '%d.%m.%Y')
today = datetime.today()
age = today.year - birthdate_dt.year - ((today.month, today.day) < (birthdate_dt.month, birthdate_dt.day))
user_data = {
'birthdate': birthdate_str,
'steam_id': steam_id,
'ingame_name': ingame_name,
'age': age,
'checked_by': session.get('username'),
'timestamp': datetime.utcnow()
}
age_verification_collection.insert_one(user_data)
result = {'age': age, 'steam_id': steam_id}
if age < 13:
thirteenth_birthday = birthdate_dt.replace(year=birthdate_dt.year + 13)
ban_duration_days = (thirteenth_birthday - today).days + 1
result['ban_duration_days'] = ban_duration_days
result['ban_command'] = f"oban {steam_id} {ban_duration_days}d"
add_log("Altersüberprüfung hinzugefügt", f"Ingame Name: {ingame_name}, SteamID: {steam_id}")
flash("Altersüberprüfung erfolgreich durchgeführt und gespeichert.", "success")
except ValueError:
flash("Ungültiges Datumsformat. Bitte DD.MM.YYYY verwenden.", "danger")
if search_query:
users = list(age_verification_collection.find({
"$or": [
{"ingame_name": {"$regex": search_query, "$options": "i"}},
{"steam_id": {"$regex": search_query, "$options": "i"}}
]
}).sort("timestamp", -1))
else:
users = list(age_verification_collection.find().sort("timestamp", -1).limit(10))
for user in users:
user['age'] = calculate_current_age(user['birthdate'])
return render_template('age_verification.html',
active_page='age_verification',
result=result,
users=users,
search_query=search_query)
@app.route('/age_verification/<user_id>')
@login_required
@permission_required('view_age_verification')
def view_user_age_details(user_id):
user = age_verification_collection.find_one({'_id': ObjectId(user_id)})
if not user:
flash("Benutzer nicht gefunden.", "danger")
return redirect(url_for('age_verification'))
user['age'] = calculate_current_age(user['birthdate'])
result = None
if user.get('age') < 13:
today = datetime.today()
try:
birthdate_dt = datetime.strptime(user['birthdate'], '%d.%m.%Y')
except ValueError:
birthdate_dt = datetime.strptime(user['birthdate'], '%Y-%m-%d')
thirteenth_birthday = birthdate_dt.replace(year=birthdate_dt.year + 13)
ban_duration_days = (thirteenth_birthday - today).days + 1
if ban_duration_days > 0:
result = {
'ban_duration_days': ban_duration_days,
'ban_command': f"oban {user['steam_id']} {ban_duration_days}d"
}
return render_template('view_user_age_details.html',
active_page='age_verification',
user=user,
result=result)
@app.route('/age_verification/delete/<user_id>', methods=['POST'])
@login_required
@permission_required('view_age_verification')
def delete_age_verification(user_id):
entry = age_verification_collection.find_one({'_id': ObjectId(user_id)})
if not entry:
flash("Eintrag nicht gefunden.", "danger")
return redirect(url_for('age_verification'))
can_delete = False
if 'manage_age_verification' in session.get('permissions', []):
can_delete = True
elif entry.get('checked_by') == session.get('username'):
can_delete = True
if not can_delete:
flash("Sie haben keine Berechtigung, diesen Eintrag zu löschen.", "danger")
return redirect(url_for('view_user_age_details', user_id=user_id))
age_verification_collection.delete_one({'_id': ObjectId(user_id)})
add_log("Altersüberprüfung gelöscht", f"Ingame Name: {entry.get('ingame_name', 'N/A')}, SteamID: {entry.get('steam_id', 'N/A')}")
flash("Eintrag zur Altersüberprüfung wurde gelöscht.", "success")
return redirect(url_for('age_verification'))
@app.route('/ip_analyzer', methods=['GET', 'POST'])
@login_required
@permission_required('view_ip_analyzer')
def ip_analyzer():
ip_data = None
error = None
similar_ips = []
if request.method == 'POST':
ip_address = request.form.get("ip_address")
is_cheater = request.form.get("is_cheater") == "on"
steam_id = request.form.get("steam_id") if is_cheater else None
if not ip_address:
error = "Bitte eine IP-Adresse eingeben."
elif is_cheater and not steam_id:
error = "SteamID ist erforderlich, wenn die IP als Cheater markiert wird."
else:
try:
api_key = os.environ.get('VPNAPI_KEY')
response = requests.get(f"https://vpnapi.io/api/{ip_address}?key={api_key}", timeout=5)
if response.status_code == 200:
ip_data = response.json()
ip_data.setdefault('security', {})
ip_data.setdefault('location', {})
ip_data.setdefault('network', {})
similar_ips = list(ip_analyzer_results_collection.find({
"$or": [
{"ip": ip_address},
{"data.location.city": ip_data.get('location', {}).get('city')},
{"data.network.autonomous_system_number": ip_data.get('network', {}).get('autonomous_system_number')}
]
}))
ip_analyzer_results_collection.insert_one({
"ip": ip_address,
"is_cheater": is_cheater,
"steam_id": steam_id,
"data": ip_data,
"added_by": session["username"],
"timestamp": datetime.utcnow()
})
add_log("IP analysiert", f"IP: {ip_address}")
flash("IP-Adresse erfolgreich analysiert und gespeichert.", "success")
else:
error = f"Fehler bei der API-Anfrage: Status {response.status_code}"
except requests.exceptions.RequestException as e:
error = f"Fehler bei der Verbindung zur API: {e}"
except Exception as e:
error = f"Ein unerwarteter Fehler ist aufgetreten: {e}"
return render_template('ip_analyzer.html',
active_page='ip_analyzer',
ip_data=ip_data,
error=error,
similar_ips=similar_ips)
@app.route('/logs')
@login_required
@permission_required('view_logs')
def logs():
page = request.args.get('page', 1, type=int)
per_page = 25
skip = (page - 1) * per_page
log_entries = list(logs_collection.find().sort('timestamp', -1).skip(skip).limit(per_page))
total_logs = logs_collection.count_documents({})
total_pages = (total_logs + per_page - 1) // per_page
return render_template('logs.html',
logs=log_entries,
page=page,
total_pages=total_pages,
active_page='logs')
@app.route('/ets2_dlcs')
@login_required
@permission_required('view_ets2_dlcs')
def ets2_dlcs():
if ets2_dlcs_collection is None:
flash("Datenbankverbindung nicht verfügbar.", "danger")
return redirect(url_for('dashboard'))
packages = _format_ets2_dlcs_for_view(list(ets2_dlcs_collection.find().sort('created_at', -1)))
return render_template(
'ets2_dlcs.html',
active_page='ets2_dlcs',
dlc_packages=packages,
can_manage_ets2_dlcs='manage_ets2_dlcs' in session.get('permissions', [])
)
@app.route('/ets2_dlcs/create', methods=['POST'])
@login_required
@permission_required('manage_ets2_dlcs')
def create_ets2_dlc_package():
if ets2_dlcs_collection is None:
flash("Datenbankverbindung nicht verfügbar.", "danger")
return redirect(url_for('ets2_dlcs'))
package_name = (request.form.get('package_name') or '').strip()
folder_path_input = (request.form.get('folder_path') or '').strip()
dlc_date = (request.form.get('dlc_date') or '').strip()
if not package_name or not folder_path_input or not dlc_date:
flash("Name, Ordnerpfad und Datum sind erforderlich.", "danger")
return redirect(url_for('ets2_dlcs'))
try:
datetime.strptime(dlc_date, '%Y-%m-%d')
except ValueError:
flash("Ungültiges Datumsformat. Bitte YYYY-MM-DD verwenden.", "danger")
return redirect(url_for('ets2_dlcs'))
folder_path = os.path.abspath(folder_path_input)
if not os.path.isdir(folder_path):
flash("Der angegebene Ordnerpfad existiert nicht oder ist kein Ordner.", "danger")
return redirect(url_for('ets2_dlcs'))
if ets2_dlcs_collection.find_one({'name': package_name}):
flash("Ein DLC-Paket mit diesem Namen existiert bereits.", "danger")
return redirect(url_for('ets2_dlcs'))
package_id = ets2_dlcs_collection.insert_one({
'name': package_name,
'folder_path': folder_path,
'dlc_date': dlc_date,
'created_by': session.get('username'),
'created_at': datetime.utcnow(),
'archive_status': 'pending',
'archive_path': None,
'archive_generated_at': None,
'archive_error': None
}).inserted_id
add_log("ETS2 DLC-Paket erstellt", f"Name: {package_name}, Pfad: {folder_path}")
_schedule_ets2_archive_build(str(package_id))
flash("DLC-Paket wurde erstellt. Das Archiv wird im Hintergrund vorbereitet.", "success")
return redirect(url_for('ets2_dlcs'))
@app.route('/ets2_dlcs/delete/<package_id>', methods=['POST'])
@login_required
@permission_required('manage_ets2_dlcs')
def delete_ets2_dlc_package(package_id):
if ets2_dlcs_collection is None:
flash("Datenbankverbindung nicht verfügbar.", "danger")
return redirect(url_for('ets2_dlcs'))
try:
package_object_id = ObjectId(package_id)
except Exception:
flash("Ungültige Paket-ID.", "danger")
return redirect(url_for('ets2_dlcs'))
package = ets2_dlcs_collection.find_one({'_id': package_object_id})
if not package:
flash("DLC-Paket nicht gefunden.", "danger")
return redirect(url_for('ets2_dlcs'))
archive_path = package.get('archive_path') or _ets2_archive_path(package_id, package.get('name', 'ets2_dlc_package'))
if package.get('archive_status') == 'ready' and os.path.isfile(archive_path):
try:
os.remove(archive_path)
except Exception:
pass
ets2_dlcs_collection.delete_one({'_id': package_object_id})
add_log("ETS2 DLC-Paket gelöscht", f"Name: {package.get('name', 'N/A')}")
flash("DLC-Paket wurde gelöscht.", "success")
return redirect(url_for('ets2_dlcs'))
@app.route('/ets2_dlcs/download/<package_id>')
@login_required
@permission_required('view_ets2_dlcs')
def download_ets2_dlc_package(package_id):
if ets2_dlcs_collection is None:
flash("Datenbankverbindung nicht verfügbar.", "danger")
return redirect(url_for('ets2_dlcs'))
try:
package_object_id = ObjectId(package_id)
except Exception:
flash("Ungültige Paket-ID.", "danger")
return redirect(url_for('ets2_dlcs'))
package = ets2_dlcs_collection.find_one({'_id': package_object_id})
if not package:
flash("DLC-Paket nicht gefunden.", "danger")
return redirect(url_for('ets2_dlcs'))
archive_path = package.get('archive_path') or _ets2_archive_path(package_id, package.get('name', 'ets2_dlc_package'))
if package.get('archive_status') != 'ready' or not os.path.isfile(archive_path):
if package.get('folder_path') and os.path.isdir(package.get('folder_path')):
_schedule_ets2_archive_build(package_id)
flash("Das DLC-Archiv wird noch erstellt. Bitte in ein paar Augenblicken erneut versuchen.", "info")
else:
flash("Das gespeicherte Archiv ist nicht verfügbar.", "danger")
return redirect(url_for('ets2_dlcs'))
safe_name = secure_filename(package.get('name', 'ets2_dlc_package')) or 'ets2_dlc_package'
add_log("ETS2 DLC-Paket heruntergeladen", f"Name: {package.get('name', 'N/A')}")
return send_file(archive_path, as_attachment=True, download_name=f"{safe_name}.zip", conditional=True)
def stream_bot_output(bot_id, process):
def stream_to_socket(pipe, log_type):
try:
for line in iter(pipe.readline, ''):
log_entry = {'type': log_type, 'data': line}
if bot_id in running_bots:
running_bots[bot_id]['history'].append(log_entry)
max_history_lines = 1000
if len(running_bots[bot_id]['history']) > max_history_lines:
running_bots[bot_id]['history'].pop(0)
socketio.emit('console_output', log_entry, room=bot_id)
except Exception as e:
print(f"Stream-Fehler für Bot {bot_id}: {e}")
finally:
if pipe:
pipe.close()
stdout_thread = threading.Thread(target=stream_to_socket, args=(process.stdout, 'stdout'))
stderr_thread = threading.Thread(target=stream_to_socket, args=(process.stderr, 'stderr'))
stdout_thread.start()
stderr_thread.start()
process.wait()
if bot_id in running_bots and running_bots.get(bot_id) is not None:
del running_bots[bot_id]
if discord_bots_collection is not None:
discord_bots_collection.update_one({'_id': ObjectId(bot_id)}, {'$set': {'status': 'stopped'}})
socketio.emit('status_update', {'bot_id': bot_id, 'status': 'stopped'}, room=bot_id)
socketio.emit('console_output', {'bot_id': bot_id, 'type': 'system', 'data': '--- Bot-Prozess beendet ---'}, room=bot_id)
@app.route('/discord_bots')
@login_required
@permission_required('manage_discord_bots')
def discord_bots():
bots = list(discord_bots_collection.find())
for bot in bots:
bot['status'] = 'running' if str(bot['_id']) in running_bots else 'stopped'
return render_template('discord_bots.html', active_page='discord_bots', bots=bots)
@app.route('/upload_bot', methods=['POST'])
@login_required
@permission_required('manage_discord_bots')
def upload_bot():
bot_name = request.form.get('bot_name')
bot_file = request.files.get('bot_file')
if not bot_name or not bot_file or bot_file.filename == '':
flash("Bot-Name und Datei sind erforderlich.", "danger")
return redirect(url_for('discord_bots'))
if discord_bots_collection.find_one({'name': bot_name}):
flash("Ein Bot mit diesem Namen existiert bereits.", "danger")
return redirect(url_for('discord_bots'))
filename = secure_filename(bot_file.filename)
filepath = os.path.join(app.config['BOT_UPLOAD_FOLDER'], filename)
bot_file.save(filepath)
discord_bots_collection.insert_one({
'name': bot_name,
'filename': filename,
'filepath': filepath,
'created_at': datetime.utcnow(),
'status': 'stopped'
})
add_log("Bot hochgeladen", f"Bot Name: {bot_name}")
flash(f"Bot '{bot_name}' wurde erfolgreich hochgeladen.", "success")
return redirect(url_for('discord_bots'))
@app.route('/delete_bot/<bot_id>', methods=['POST'])
@login_required
@permission_required('manage_discord_bots')
def delete_bot(bot_id):
bot_info = discord_bots_collection.find_one({'_id': ObjectId(bot_id)})
if not bot_info:
flash("Bot nicht gefunden.", "danger")
return redirect(url_for('discord_bots'))
if bot_id in running_bots and running_bots.get(bot_id) is not None:
process = running_bots[bot_id]['process']
try:
process.terminate()
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
del running_bots[bot_id]
socketio.emit('status_update', {'bot_id': bot_id, 'status': 'deleted'}, room=bot_id)
try:
filepath = bot_info.get('filepath')
if filepath and os.path.exists(filepath):
os.remove(filepath)
except Exception as e:
flash(f"Fehler beim Löschen der Bot-Datei: {e}", "danger")
discord_bots_collection.delete_one({'_id': ObjectId(bot_id)})
add_log("Bot gelöscht", f"Bot Name: {bot_info.get('name', 'N/A')}")
flash(f"Bot '{bot_info['name']}' wurde erfolgreich gelöscht.", "success")
return redirect(url_for('discord_bots'))
@app.route('/bot_console/<bot_id>')
@login_required
@permission_required('manage_discord_bots')
def bot_console(bot_id):
bot = discord_bots_collection.find_one({'_id': ObjectId(bot_id)})
if not bot:
flash("Bot nicht gefunden.", "danger")
return redirect(url_for('discord_bots'))
bot['status'] = 'running' if bot_id in running_bots else 'stopped'
return render_template('bot_console.html', bot=bot, active_page='discord_bots')
@socketio.on('connect')
def handle_connect():
print('Client verbunden')
@socketio.on('join_console')
def handle_join_console(data):
bot_id = data['bot_id']
join_room(bot_id)
emit('console_output', {'type': 'system', 'data': f'Verbunden mit Konsole für Bot {bot_id}.'})
if bot_id in running_bots and running_bots.get(bot_id) is not None and 'history' in running_bots[bot_id]:
for log in running_bots[bot_id]['history']:
emit('console_output', log)
@socketio.on('leave_console')
def handle_leave_console(data):
bot_id = data['bot_id']
leave_room(bot_id)
print(f'Client hat Konsole für Bot {bot_id} verlassen.')
@socketio.on('toggle_bot')
def handle_toggle_bot(data):
bot_id = data['bot_id']
action = data['action']
bot_info = discord_bots_collection.find_one({'_id': ObjectId(bot_id)})
if not bot_info:
emit('console_output', {'type': 'error', 'data': 'Bot nicht in der Datenbank gefunden.'}, room=bot_id)
return
if action == 'start' and bot_id not in running_bots:
filepath = bot_info.get('filepath')
if not os.path.exists(filepath):
emit('console_output', {'type': 'error', 'data': f'Bot-Datei nicht gefunden: {filepath}'}, room=bot_id)
return
try:
_spawn_bot_process(bot_id, filepath, bot_name=bot_info.get('name', 'N/A'), emit_to_socket=True)
add_log("Bot gestartet", f"Bot Name: {bot_info.get('name', 'N/A')}")
except Exception as e:
emit('console_output', {'type': 'error', 'data': f'Fehler beim Starten des Bots: {e}'}, room=bot_id)
elif action == 'stop' and bot_id in running_bots and running_bots.get(bot_id) is not None:
process = running_bots[bot_id]['process']
try:
process.terminate()
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
if bot_id in running_bots:
del running_bots[bot_id]
discord_bots_collection.update_one({'_id': ObjectId(bot_id)}, {'$set': {'status': 'stopped'}})
add_log("Bot gestoppt", f"Bot Name: {bot_info.get('name', 'N/A')}")
emit('status_update', {'bot_id': bot_id, 'status': 'stopped'}, room=bot_id)
emit('console_output', {'type': 'system', 'data': '--- Bot-Prozess wird gestoppt... ---'}, room=bot_id)
@app.route('/maintenance')
def maintenance():
maint_info = dashboard_collection.find_one({"key": "scheduled_maintenance"}) if dashboard_collection is not None else {}
if not maint_info or not maint_info.get('enabled', False):
return redirect(url_for('login'))
return render_template('maintenance.html', maint_info=maint_info)
@app.route('/maintenance_login', methods=['GET', 'POST'])
def maintenance_login():
if 'user_id' in session:
return redirect(url_for('dashboard'))
maintenance_status = dashboard_collection.find_one({"key": "scheduled_maintenance"}) if dashboard_collection is not None else None
if not maintenance_status or not maintenance_status.get('enabled', False):
return redirect(url_for('login'))
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if not username or not password:
flash("Benutzername und Passwort sind erforderlich.", "danger")
return render_template('maintenance_login.html')
if accounts_collection is None:
flash("Datenbankverbindung nicht verfügbar.", "danger")
return render_template('maintenance_login.html'), 500
user = accounts_collection.find_one({'username': username})
if user and check_password_hash(user['password'], password):
user_perms = get_user_permissions(str(user['_id']))
if 'bypass_maintenance' in user_perms:
session.permanent = True
session['user_id'] = str(user['_id'])
session['username'] = user['username']
session['permissions'] = user_perms
flash("Erfolgreich angemeldet!", "success")
return redirect(url_for('dashboard'))
else:
flash("Dieser Account hat keine Berechtigung für den Wartungs-Login.", "danger")
return render_template('maintenance_login.html')
else:
flash("Ungültiger Benutzername oder Passwort.", "danger")
return render_template('maintenance_login.html')
return render_template('maintenance_login.html')
@app.route('/maintenance_management')
@login_required
@permission_required('maintenance_management')
def maintenance_management():
maint_info = dashboard_collection.find_one({"key": "scheduled_maintenance"}) if dashboard_collection is not None else {}
if maint_info and maint_info.get('time'):
maint_info['time_local_str'] = maint_info['time'].strftime('%Y-%m-%dT%H:%M')
if 'discord_bots_maintenance' not in maint_info:
maint_info['discord_bots_maintenance'] = False
return render_template('maintenance_management.html', active_page='maintenance_management', maint_info=maint_info)
@app.route('/impressum')
def impressum():
# Impressumsdaten werden aus Umgebungsvariablen geladen, damit keine persönlichen Daten im Code liegen.
impressum_data = {
'name': os.environ.get('IMPRESSUM_NAME'),
'address': os.environ.get('IMPRESSUM_ADDRESS'),
'email': os.environ.get('IMPRESSUM_EMAIL'),
'phone': os.environ.get('IMPRESSUM_PHONE'),
'company_registration': os.environ.get('IMPRESSUM_COMPANY_REG')
}
return render_template('impressum.html', active_page='impressum', impressum=impressum_data)
@app.route('/datenschutz')
def datenschutz():
# Datenschutzerklärungsdaten werden aus Umgebungsvariablen geladen
datenschutz_data = {
'name': os.environ.get('IMPRESSUM_NAME', 'AstraOS'),
'email': os.environ.get('IMPRESSUM_EMAIL', 'kontakt@astra-os.de'),
'address': os.environ.get('IMPRESSUM_ADDRESS', '')
}
return render_template('datenschutz.html', active_page='datenschutz', datenschutz=datenschutz_data)
@app.route('/update_maintenance', methods=['POST'])
@login_required
@permission_required('maintenance_management')
def update_maintenance():
if dashboard_collection is None:
flash("Datenbankverbindung nicht verfügbar.", "danger")
return redirect(url_for('maintenance_management'))
is_enabled = request.form.get('maintenance_enabled') == 'on'
status_message = request.form.get('status_message', 'Keine')
duration = request.form.get('duration_minutes')
time_str = request.form.get('maintenance_time')
discord_bots_maintenance = request.form.get('discord_bots_maintenance') == 'on'
update_data = {
"enabled": is_enabled,
"status": status_message,
"duration_minutes": int(duration) if duration and duration.isdigit() else None,
"time": datetime.fromisoformat(time_str) if time_str else None,
"discord_bots_maintenance": discord_bots_maintenance
}
dashboard_collection.update_one(
{"key": "scheduled_maintenance"},
{"$set": update_data},
upsert=True
)
add_log("Wartungseinstellungen aktualisiert", f"Aktiviert: {is_enabled}, Nachricht: {status_message}")
flash("Wartungseinstellungen wurden aktualisiert.", "success")
return redirect(url_for('maintenance_management'))
@app.errorhandler(404)
def page_not_found(e):
return render_template('404.html'), 404
VALORANT_API_KEY = os.getenv("VALORANT_API_KEY", "")
VALORANT_API_BASE = "https://api.henrikdev.xyz/valorant"
@app.route('/tools/ayrantracker')
@login_required
@permission_required('view_ayrantracker')
def ayrantracker():
return render_template('ayrantracker.html', active_page='ayrantracker')
@app.route('/api/v1/profile/<name>/<tag>', methods=['GET'])
@login_required
@permission_required('view_ayrantracker')
def get_valorant_stats(name, tag):
if not VALORANT_API_KEY:
return jsonify({"status": "error", "message": "API-Key Konfiguration fehlt in der .env-Datei."}), 500
match_mode = request.args.get('mode', 'competitive')
headers = {"Authorization": VALORANT_API_KEY}
try:
add_log("Spielersuche durchgeführt", f"Spieler gesucht: {name}#{tag}")
except Exception as e:
print(f"Fehler beim Loggen der Spielersuche: {e}")
try:
# 1. Account Basis-Daten
acc_res = requests.get(f"{VALORANT_API_BASE}/v1/account/{name}/{tag}?force=true", headers=headers, timeout=10)
if acc_res.status_code != 200:
return jsonify(
{"status": "error", "message": "Spieler nicht gefunden oder API-Limit erreicht."}), acc_res.status_code
acc_data = acc_res.json().get('data', {}) or {}
region = acc_data.get("region", "eu")
account_level = acc_data.get('account_level', 1) or 1
# 2. Aktueller Rang (MMR v1)
mmr_res = requests.get(f"{VALORANT_API_BASE}/v1/mmr/{region}/{name}/{tag}", headers=headers, timeout=10)
mmr_data = mmr_res.json().get('data', {}) if mmr_res.status_code == 200 else {}
if not mmr_data: mmr_data = {}
current_tier = mmr_data.get('currenttier', 0) or 0 # Tier ID für die Smurf-Gewichtung
# 3. RR-Verlauf (MMR History)
rr_history = {}
if match_mode == 'competitive':
history_res = requests.get(f"{VALORANT_API_BASE}/v1/mmr-history/{region}/{name}/{tag}", headers=headers,
timeout=10)
if history_res.status_code == 200:
for h_item in history_res.json().get('data', []):
m_id = h_item.get('match_id')
if m_id:
rr_history[m_id] = h_item.get('mmr_change_to_last_game', 0)
# 4. Match Historie abrufen (size=20)
matches_res = requests.get(f"{VALORANT_API_BASE}/v3/matches/{region}/{name}/{tag}?size=20&mode={match_mode}",
headers=headers, timeout=10)
matches_list = matches_res.json().get('data', []) if matches_res.status_code == 200 else []
match_count = 0
wins = 0
total_kills = 0
total_deaths = 0
total_assists = 0
global_headshots = 0
global_total_hits = 0
total_mvps = 0
total_first_bloods = 0
agent_stats = {}
processed_matches = []
for match in matches_list:
if not match: continue
metadata = match.get('metadata', {})
match_id = metadata.get('matchid')
players = match.get('players', {}).get('all_players', [])
me = next((p for p in players if
p.get('name', '').lower() == name.lower() and p.get('tag', '').lower() == tag.lower()), None)
if not me: continue
match_count += 1
agent_name = me.get('character', 'Unknown') or 'Unknown'
stats = me.get('stats', {})
m_kills = stats.get('kills', 0) or 0
m_deaths = stats.get('deaths', 0) or 0
m_assists = stats.get('assists', 0) or 0
m_score = stats.get('score', 0) or 0
total_kills += m_kills
total_deaths += m_deaths
total_assists += m_assists
if agent_name not in agent_stats:
agent_stats[agent_name] = {"count": 0, "kills": 0, "deaths": 0, "assists": 0}
agent_stats[agent_name]["count"] += 1
agent_stats[agent_name]["kills"] += m_kills
agent_stats[agent_name]["deaths"] += m_deaths
agent_stats[agent_name]["assists"] += m_assists
teams = match.get('teams', {})
my_team_color = (me.get('team', '') or '').lower()
my_team_data = teams.get(my_team_color, {})
has_won = my_team_data.get('has_won') is True
if has_won: wins += 1
red_rounds = teams.get('red', {}).get('rounds_won', 0) or 0
blue_rounds = teams.get('blue', {}).get('rounds_won', 0) or 0
rounds_played = red_rounds + blue_rounds
score_str = f"{red_rounds} : {blue_rounds}" if my_team_color == 'red' else f"{blue_rounds} : {red_rounds}"
economy = me.get('economy', {})
spent_spent = economy.get('spent', {}).get('overall', 0) or 0
avg_spent = round(spent_spent / rounds_played, 0) if rounds_played > 0 else 0
headshots = stats.get('headshots', 0) or 0
bodyshots = stats.get('bodyshots', 0) or 0
legshots = stats.get('legshots', 0) or 0
total_hits = headshots + bodyshots + legshots
hs_rate = round((headshots / total_hits) * 100, 1) if total_hits > 0 else 0
global_headshots += headshots
global_total_hits += total_hits
first_bloods = 0
kills_per_round = {3: 0, 4: 0, 5: 0}
round_data = match.get('rounds', []) or []
for r in round_data:
kill_events = r.get('kill_events', []) or []
if kill_events and kill_events[0].get('killer_name', '').lower() == name.lower():
first_bloods += 1
total_first_bloods += 1
for ps in r.get('player_stats', []) or []:
if ps.get('player_name', '').lower() == name.lower():
k_count = ps.get('kills', 0) or 0
if k_count in kills_per_round:
kills_per_round[k_count] += 1
is_team_mvp = True
for p in players:
if p.get('team', '').lower() == my_team_color and (p.get('stats', {}).get('score', 0) or 0) > m_score:
is_team_mvp = False
if is_team_mvp: total_mvps += 1
mmr_change = rr_history.get(match_id, 0) if match_mode == 'competitive' else None
map_name = metadata.get('map', 'Unknown')
game_length_min = round((metadata.get('game_length', 0) or 0) / 60, 1)
processed_matches.append({
"match_id": match_id,
"map": map_name,
"mode": metadata.get('mode', 'Unknown'),
"agent": agent_name,
"kills": m_kills,
"deaths": m_deaths,
"assists": m_assists,
"score": score_str,
"has_won": has_won,
"mmr_change": mmr_change,
"details": {
"game_length": f"{game_length_min} Min",
"server": metadata.get('cluster', 'EU'),
"headshots": headshots,
"bodyshots": bodyshots,
"legshots": legshots,
"hs_rate": f"{hs_rate}%",
"combat_score": m_score,
"avg_spent": f"{int(avg_spent)} ¥",
"first_bloods": first_bloods,
"is_team_mvp": is_team_mvp,
"multikills": {"3k": kills_per_round[3], "4k": kills_per_round[4], "5k": kills_per_round[5]}
}
})
# Globale Berechnungen
kd_calculated = round(total_kills / total_deaths, 2) if total_deaths > 0 else total_kills
winrate_calculated = round((wins / match_count) * 100, 1) if match_count > 0 else 0
global_hs_calculated = round((global_headshots / global_total_hits) * 100, 1) if global_total_hits > 0 else 0
# ================= SMURF SCORE ENGINE =================
smurf_points = 0
# 1. Hebel: Account Level im Verhältnis zum Rang (Niedriges Level + Hoher Rang = Kritisch)
if account_level < 25 and current_tier >= 12: # Unter Level 25 und Gold+
smurf_points += 35
elif account_level < 50 and current_tier >= 15: # Unter Level 50 und Platin+
smurf_points += 25
elif account_level < 80 and current_tier >= 18: # Unter Level 80 und Diamond+
smurf_points += 15
elif account_level < 15: # Generell extrem frischer Account
smurf_points += 10
# 2. Hebel: K/D Ratio Performance
if kd_calculated >= 1.4:
smurf_points += 30
elif kd_calculated >= 1.25:
smurf_points += 20
elif kd_calculated >= 1.12:
smurf_points += 10
# 3. Hebel: Win Rate Dominanz
if winrate_calculated >= 65:
smurf_points += 20
elif winrate_calculated >= 56:
smurf_points += 10
# 4. Hebel: MVP & First Blood Anteil
if match_count > 0:
mvp_rate = (total_mvps / match_count) * 100
fb_per_match = total_first_bloods / match_count
if mvp_rate >= 40: smurf_points += 10 # In fast jedem zweiten Match Team-MVP
if fb_per_match >= 2.5: smurf_points += 5 # Extrem aggressiver Opening-Fragger
smurf_score = min(smurf_points, 100) # Maximal 100%
# Einstufungstext ermitteln
if smurf_score >= 70:
smurf_status = "Höchstverdächtig (Smurf)"
elif smurf_score >= 40:
smurf_status = "Auffällige Performance"
else:
smurf_status = "Unauffälliger Main Account"
# ======================================================
all_agents = []
best_agent_name = "Keiner"
highest_agent_kda = -1.0
for name_key, data in agent_stats.items():
a_kda = round((data["kills"] + data["assists"]) / data["deaths"], 2) if data["deaths"] > 0 else float(
data["kills"] + data["assists"])
all_agents.append({"name": name_key, "count": data["count"], "kda": a_kda})
if a_kda > highest_agent_kda:
highest_agent_kda = a_kda
best_agent_name = name_key
all_agents = sorted(all_agents, key=lambda x: x["count"], reverse=True)
return jsonify({
"status": "success",
"account": {
"name": acc_data.get('name'),
"tag": acc_data.get('tag'),
"level": account_level,
"card_url": acc_data.get('card', {}).get('small') if acc_data.get('card') else None
},
"rank": {
"current_tier_name": mmr_data.get('currenttierpatched', 'Unranked') or 'Unranked',
"ranking_in_tier": mmr_data.get('ranking_in_tier', 0) or 0,
"rank_image_url": mmr_data.get('images', {}).get('small') if mmr_data.get('images') else None
},
"smurf_analyzer": {
"score": smurf_score,
"status": smurf_status
},
"calculated_stats": {
"win_rate": f"{winrate_calculated}%",
"avg_hs_percentage": f"{global_hs_calculated}%",
"kd_ratio": str(kd_calculated),
"best_agent": best_agent_name,
"matches_analyzed": match_count
},
"all_agents": all_agents,
"matches": processed_matches
})
except Exception as e:
return jsonify({"status": "error", "message": f"Interner Fehler: {str(e)}"}), 500
if __name__ == '__main__':
app.config['SESSION_COOKIE_SECURE'] = False
print("INFO: Starte den Server im Entwicklungsmodus ohne HTTPS.")
socketio.run(app, host='0.0.0.0', port=5000, debug=True, allow_unsafe_werkzeug=True)