AstraOS-Panel/AstraOS/astra_os.py
MrWhiff 60f8a0a4b9
All checks were successful
Deploy AstraOS Panel / deploy (push) Successful in 28s
AstraOS Ver. 1.53.2 - Desktop Client Installer Page hinzugefügt
2026-08-30 19:43:24 +02:00

2312 lines
98 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
import time as _time
load_dotenv()
# ---- Simple login rate limiter (per IP, 0.5s min interval) ----
_login_last_attempt: dict = {} # {ip: timestamp}
_LOGIN_RATE_LIMIT_SECONDS = 0.5
def _check_login_rate_limit(ip: str) -> bool:
"""Returns True if the request is allowed, False if rate-limited."""
now = _time.time()
last = _login_last_attempt.get(ip, 0)
if now - last < _LOGIN_RATE_LIMIT_SECONDS:
return False
_login_last_attempt[ip] = now
# Cleanup old entries to prevent unbounded growth
if len(_login_last_attempt) > 10000:
cutoff = now - 60
for k in [k for k, v in list(_login_last_attempt.items()) if v < cutoff]:
_login_last_attempt.pop(k, None)
return True
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(days=30)
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.before_request
def update_last_active():
if 'user_id' in session:
accounts_collection.update_one(
{'_id': ObjectId(session['user_id'])},
{'$set': {'last_login': datetime.utcnow()}}
)
@app.route('/')
def home():
if 'user_id' in session:
return redirect(url_for('dashboard'))
return redirect(url_for('login'))
@app.route('/installer')
@login_required
def installer():
user = accounts_collection.find_one({"_id": ObjectId(session['user_id'])}) if 'user_id' in session else None
return render_template('installer.html', user=user, active_page='installer')
@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':
# Rate limit: max 1 attempt per 0.5s per IP
_client_ip = request.headers.get('X-Forwarded-For', request.remote_addr or '').split(',')[0].strip()
if not _check_login_rate_limit(_client_ip):
flash("Zu viele Anmeldeversuche. Bitte warte einen Moment.", "warning")
return render_template('login.html')
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")
# Letzten Login-Zeitpunkt speichern
accounts_collection.update_one(
{'_id': user['_id']},
{'$set': {'last_login': datetime.utcnow()}}
)
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 0
# Aktive Nutzer: letzten 7 Tage
now = datetime.utcnow()
week_ago = now - timedelta(days=7)
two_weeks_ago = now - timedelta(days=14)
if accounts_collection is not None:
active_now = accounts_collection.count_documents({'last_login': {'$gte': week_ago}})
active_prev = accounts_collection.count_documents({'last_login': {'$gte': two_weeks_ago, '$lt': week_ago}})
if active_prev > 0:
active_pct = round((active_now - active_prev) / active_prev * 100, 1)
elif active_now > 0:
active_pct = 100.0
else:
active_pct = 0.0
else:
active_now = 0
active_pct = 0.0
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"))
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
stats = {
"active_users": {"value": active_now, "percent": active_pct, "total": user_count},
"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)
}
}
# Letzten Commit von Forgejo laden
last_commit = None
try:
forgejo_token = os.environ.get('FORGEJO_API_TOKEN', '')
headers = {'Authorization': f'token {forgejo_token}'} if forgejo_token else {}
r = requests.get(
'https://git.astra-os.de/api/v1/repos/MrWhiff/AstraOS-Panel/commits?limit=1',
headers=headers, timeout=3
)
if r.status_code == 200:
commits = r.json()
if commits:
c = commits[0]
msg = (c.get('commit', {}).get('message') or '–').split('\n')[0]
author = c.get('commit', {}).get('author', {}).get('name') or c.get('author', {}).get('login') or '–'
raw_date = c.get('created') or c.get('commit', {}).get('author', {}).get('date')
if raw_date:
from datetime import timezone
dt = datetime.fromisoformat(raw_date.replace('Z', '+00:00'))
diff_min = int((datetime.now(timezone.utc) - dt).total_seconds() / 60)
if diff_min < 60:
time_str = f'vor {diff_min} Min.'
elif diff_min < 1440:
time_str = f'vor {diff_min // 60} Std.'
else:
time_str = dt.strftime('%d. %b %Y, %H:%M')
else:
time_str = '–'
last_commit = {'message': msg, 'author': author, 'time_str': time_str}
except Exception:
pass
return render_template('dashboard.html', stats=stats, active_page='dashboard', last_commit=last_commit, maintenance_active=bool(maint.get('enabled')))
@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:
try:
thirteenth_birthday = birthdate_dt.replace(year=birthdate_dt.year + 13)
except ValueError:
# 29. Feb in einem Nicht-Schaltjahr → auf 1. März ausweichen
thirteenth_birthday = birthdate_dt.replace(year=birthdate_dt.year + 13, month=3, day=1)
ban_duration_days = (thirteenth_birthday - today).days
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')
try:
thirteenth_birthday = birthdate_dt.replace(year=birthdate_dt.year + 13)
except ValueError:
# 29. Feb in einem Nicht-Schaltjahr → auf 1. März ausweichen
thirteenth_birthday = birthdate_dt.replace(year=birthdate_dt.year + 13, month=3, day=1)
ban_duration_days = (thirteenth_birthday - today).days
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)