diff --git a/app.py b/app.py index 15e3fc7..1e81655 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,4 @@ -from flask import Flask, render_template, request, jsonify, flash, redirect, url_for +from flask import Flask, render_template, request, jsonify, flash, redirect, url_for, session import requests import os import subprocess @@ -10,11 +10,19 @@ import urllib.request import urllib.error import tempfile import socket +import stat from urllib.parse import urlparse import docker import yaml from datetime import datetime +# User Management System importieren +from user_management import ( + user_manager, UserRole, Permission, + login_required, role_required, permission_required, + api_login_required, api_permission_required +) + app = Flask(__name__) app.secret_key = 'your-secret-key-change-this' @@ -30,6 +38,212 @@ CONFIG_FILE = 'config.json' PROJECTS_DIR = 'projects' APPS_DIR = 'apps' +class FileManager: + """File Manager für Dateioperationen im Web-Interface""" + + def __init__(self, base_path=None): + self.base_path = base_path or os.path.abspath('.') + self.allowed_extensions = { + 'text': {'.txt', '.md', '.py', '.js', '.html', '.css', '.json', '.xml', '.yml', '.yaml', '.sh', '.bat', '.cfg', '.ini', '.conf'}, + 'image': {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.svg', '.ico'}, + 'archive': {'.zip', '.tar', '.gz', '.rar', '.7z'}, + 'document': {'.pdf', '.doc', '.docx', '.xls', '.xlsx'} + } + + def get_safe_path(self, path): + """Normalisiere und validiere Pfad""" + if not path: + return self.base_path + + # Relative Pfade relativ zur base_path auflösen + if not os.path.isabs(path): + full_path = os.path.join(self.base_path, path) + else: + full_path = path + + # Normalisiere den Pfad + full_path = os.path.normpath(full_path) + + # Sicherheitscheck: Pfad muss innerhalb der base_path liegen + if not full_path.startswith(self.base_path): + raise ValueError("Zugriff außerhalb des erlaubten Bereichs") + + return full_path + + def list_directory(self, path=""): + """Liste Dateien und Ordner in einem Verzeichnis""" + try: + safe_path = self.get_safe_path(path) + + if not os.path.exists(safe_path): + return {'error': 'Verzeichnis existiert nicht'} + + if not os.path.isdir(safe_path): + return {'error': 'Pfad ist kein Verzeichnis'} + + items = [] + for item in os.listdir(safe_path): + item_path = os.path.join(safe_path, item) + rel_path = os.path.relpath(item_path, self.base_path).replace('\\', '/') + + item_info = { + 'name': item, + 'path': rel_path, + 'is_directory': os.path.isdir(item_path), + 'size': 0, + 'modified': 0, + 'type': 'folder' if os.path.isdir(item_path) else self.get_file_type(item) + } + + if not os.path.isdir(item_path): + try: + stat = os.stat(item_path) + item_info['size'] = stat.st_size + item_info['modified'] = stat.st_mtime + except: + pass + + items.append(item_info) + + # Sortiere: Ordner zuerst, dann Dateien + items.sort(key=lambda x: (not x['is_directory'], x['name'].lower())) + + # Aktueller Pfad relativ zur base_path + current_path = os.path.relpath(safe_path, self.base_path).replace('\\', '/') + if current_path == '.': + current_path = '' + + # Parent-Pfad für Navigation + parent_path = '' + if current_path: + parent_path = os.path.dirname(current_path).replace('\\', '/') + if parent_path == '.': + parent_path = '' + + return { + 'success': True, + 'current_path': current_path, + 'parent_path': parent_path, + 'items': items + } + + except Exception as e: + return {'error': str(e)} + + def get_file_type(self, filename): + """Bestimme Dateityp anhand der Dateiendung""" + ext = os.path.splitext(filename)[1].lower() + + for file_type, extensions in self.allowed_extensions.items(): + if ext in extensions: + return file_type + + return 'unknown' + + def read_file(self, path): + """Lese Dateiinhalt""" + try: + safe_path = self.get_safe_path(path) + + if not os.path.exists(safe_path): + return {'error': 'Datei existiert nicht'} + + if os.path.isdir(safe_path): + return {'error': 'Pfad ist ein Verzeichnis'} + + # Prüfe Dateigröße (max 10MB für Text-Dateien) + file_size = os.path.getsize(safe_path) + if file_size > 10 * 1024 * 1024: + return {'error': 'Datei zu groß (max 10MB)' + } + + # Versuche als Text zu lesen + try: + with open(safe_path, 'r', encoding='utf-8') as f: + content = f.read() + return { + 'success': True, + 'content': content, + 'size': file_size, + 'type': 'text' + } + except UnicodeDecodeError: + return {'error': 'Datei ist nicht als Text lesbar'} + + except Exception as e: + return {'error': str(e)} + + def write_file(self, path, content): + """Schreibe Dateiinhalt""" + try: + safe_path = self.get_safe_path(path) + + # Erstelle Verzeichnis falls nicht vorhanden + os.makedirs(os.path.dirname(safe_path), exist_ok=True) + + with open(safe_path, 'w', encoding='utf-8') as f: + f.write(content) + + return {'success': True, 'message': 'Datei erfolgreich gespeichert'} + + except Exception as e: + return {'error': str(e)} + + def delete_item(self, path): + """Lösche Datei oder Verzeichnis""" + try: + safe_path = self.get_safe_path(path) + + if not os.path.exists(safe_path): + return {'error': 'Datei/Verzeichnis existiert nicht'} + + if os.path.isdir(safe_path): + shutil.rmtree(safe_path) + return {'success': True, 'message': 'Verzeichnis erfolgreich gelöscht'} + else: + os.remove(safe_path) + return {'success': True, 'message': 'Datei erfolgreich gelöscht'} + + except Exception as e: + return {'error': str(e)} + + def create_directory(self, path): + """Erstelle neues Verzeichnis""" + try: + safe_path = self.get_safe_path(path) + + if os.path.exists(safe_path): + return {'error': 'Verzeichnis existiert bereits'} + + os.makedirs(safe_path, exist_ok=True) + return {'success': True, 'message': 'Verzeichnis erfolgreich erstellt'} + + except Exception as e: + return {'error': str(e)} + + def rename_item(self, old_path, new_name): + """Benenne Datei oder Verzeichnis um""" + try: + safe_old_path = self.get_safe_path(old_path) + + if not os.path.exists(safe_old_path): + return {'error': 'Datei/Verzeichnis existiert nicht'} + + new_path = os.path.join(os.path.dirname(safe_old_path), new_name) + safe_new_path = self.get_safe_path(os.path.relpath(new_path, self.base_path)) + + if os.path.exists(safe_new_path): + return {'error': 'Ziel existiert bereits'} + + os.rename(safe_old_path, safe_new_path) + return {'success': True, 'message': 'Erfolgreich umbenannt'} + + except Exception as e: + return {'error': str(e)} + +# File Manager Instanz +file_manager = FileManager() + class ProjectManager: def __init__(self): self.docker_client = None @@ -1011,7 +1225,7 @@ class ProjectManager: if alternative_port: return False, f"Port {blocked_port} ist bereits belegt. Alternativer freier Port: {alternative_port}" else: - return False, f"Port {blocked_blocked} ist bereits belegt und keine Alternative gefunden." + return False, f"Port {blocked_port} ist bereits belegt und keine Alternative gefunden." else: return False, f"Port-Konflikt: {error_msg}" else: @@ -1257,7 +1471,174 @@ class ProjectManager: # Globale Instanz project_manager = ProjectManager() +# ===== USER MANAGEMENT ROUTES ===== + +@app.route('/login', methods=['GET', 'POST']) +def login(): + """Login-Seite""" + if request.method == 'POST': + username = request.form.get('username', '').strip() + password = request.form.get('password', '') + + if not username or not password: + flash('Benutzername und Passwort sind erforderlich', 'danger') + return render_template('login.html') + + success, result = user_manager.authenticate_user(username, password) + + if success: + # Erstelle Session + session_token = user_manager.create_session(username) + session['session_token'] = session_token + session['username'] = username + session['user_role'] = result['role'] + + flash(f'Willkommen, {result.get("first_name", username)}!', 'success') + + # Weiterleitung zur ursprünglich angeforderten Seite + next_page = request.args.get('next') + if next_page: + return redirect(next_page) + return redirect(url_for('index')) + else: + flash(result, 'danger') + + return render_template('login.html') + +@app.route('/logout') +def logout(): + """Logout""" + session_token = session.get('session_token') + if session_token: + user_manager.destroy_session(session_token) + + session.clear() + flash('Sie wurden erfolgreich abgemeldet', 'info') + return redirect(url_for('login')) + +@app.route('/profile') +@login_required +def profile(): + """Benutzer-Profil""" + user = request.current_user + return render_template('profile.html', user=user) + +@app.route('/change_password', methods=['POST']) +@login_required +def change_password(): + """Passwort ändern""" + old_password = request.form.get('old_password', '') + new_password = request.form.get('new_password', '') + confirm_password = request.form.get('confirm_password', '') + + if not old_password or not new_password or not confirm_password: + flash('Alle Felder sind erforderlich', 'danger') + return redirect(url_for('profile')) + + if new_password != confirm_password: + flash('Neue Passwörter stimmen nicht überein', 'danger') + return redirect(url_for('profile')) + + if len(new_password) < 6: + flash('Passwort muss mindestens 6 Zeichen lang sein', 'danger') + return redirect(url_for('profile')) + + username = request.current_user['username'] + success, message = user_manager.change_password(username, old_password, new_password) + + if success: + flash(message, 'success') + else: + flash(message, 'danger') + + return redirect(url_for('profile')) + +@app.route('/users') +@role_required(UserRole.ADMIN) +def users_management(): + """Benutzerverwaltung""" + users = user_manager.get_all_users() + roles = UserRole.get_all_roles() + return render_template('users_management.html', users=users, roles=roles) + +@app.route('/users/create', methods=['POST']) +@role_required(UserRole.ADMIN) +def create_user(): + """Benutzer erstellen""" + username = request.form.get('username', '').strip() + email = request.form.get('email', '').strip() + password = request.form.get('password', '') + role = request.form.get('role', UserRole.USER) + first_name = request.form.get('first_name', '').strip() + last_name = request.form.get('last_name', '').strip() + + if not username or not email or not password: + flash('Benutzername, E-Mail und Passwort sind erforderlich', 'danger') + return redirect(url_for('users_management')) + + if len(password) < 6: + flash('Passwort muss mindestens 6 Zeichen lang sein', 'danger') + return redirect(url_for('users_management')) + + success, message = user_manager.create_user( + username=username, + email=email, + password=password, + role=role, + first_name=first_name, + last_name=last_name + ) + + if success: + flash(message, 'success') + else: + flash(message, 'danger') + + return redirect(url_for('users_management')) + +@app.route('/users//edit', methods=['POST']) +@role_required(UserRole.ADMIN) +def edit_user(username): + """Benutzer bearbeiten""" + email = request.form.get('email', '').strip() + role = request.form.get('role', UserRole.USER) + first_name = request.form.get('first_name', '').strip() + last_name = request.form.get('last_name', '').strip() + enabled = request.form.get('enabled') == 'on' + + success, message = user_manager.update_user( + username=username, + email=email, + role=role, + first_name=first_name, + last_name=last_name, + enabled=enabled + ) + + if success: + flash(message, 'success') + else: + flash(message, 'danger') + + return redirect(url_for('users_management')) + +@app.route('/users//delete', methods=['POST']) +@role_required(UserRole.ADMIN) +def delete_user(username): + """Benutzer löschen""" + success, message = user_manager.delete_user(username) + + if success: + flash(message, 'success') + else: + flash(message, 'danger') + + return redirect(url_for('users_management')) + +# ===== PROTECTED ROUTES - Bestehende Routes mit Authentifizierung ===== + @app.route('/') +@login_required def index(): """Hauptseite mit Projektübersicht""" config = project_manager.load_config() @@ -1275,6 +1656,7 @@ def index(): return render_template('index.html', projects=projects, config=config) @app.route('/refresh_projects') +@permission_required(Permission.PROJECT_VIEW) def refresh_projects(): """Aktualisiere Projektliste von URL""" config = project_manager.load_config() @@ -1291,6 +1673,7 @@ def refresh_projects(): return redirect(url_for('index')) @app.route('/available_projects') +@permission_required(Permission.PROJECT_VIEW) def available_projects(): """Zeige verfügbare Projekte zum Installieren""" config = project_manager.load_config() @@ -1322,6 +1705,7 @@ def available_projects(): return render_template('available_projects.html', projects=projects) @app.route('/install_project', methods=['POST']) +@permission_required(Permission.PROJECT_CREATE) def install_project(): """Installiere Projekt""" project_url = request.form.get('project_url') @@ -1480,6 +1864,7 @@ def install_project(): return jsonify({'success': success, 'message': message, 'method': installation_method}) @app.route('/build_project/') +@permission_required(Permission.PROJECT_UPDATE) def build_project(project_name): """Baue Projekt""" success, message = project_manager.build_project(project_name) @@ -1487,6 +1872,7 @@ def build_project(project_name): return redirect(url_for('index')) @app.route('/start_project/') +@permission_required(Permission.PROJECT_START) def start_project(project_name): """Starte Projekt""" port = request.args.get('port', None) @@ -1515,6 +1901,7 @@ def start_project(project_name): return redirect(url_for('project_details', project_name=project_name)) @app.route('/stop_project/') +@permission_required(Permission.PROJECT_STOP) def stop_project(project_name): """Stoppe Projekt""" success, message = project_manager.stop_project(project_name) @@ -1522,6 +1909,7 @@ def stop_project(project_name): return redirect(url_for('index')) @app.route('/remove_project/') +@permission_required(Permission.PROJECT_DELETE) def remove_project(project_name): """Entferne Projekt""" success, message = project_manager.remove_project(project_name) @@ -1529,6 +1917,7 @@ def remove_project(project_name): return redirect(url_for('index')) @app.route('/config', methods=['GET', 'POST']) +@permission_required(Permission.SYSTEM_CONFIG) def config(): """Konfigurationsseite""" try: @@ -1590,6 +1979,7 @@ def config(): return render_template('config.html', config=fallback_config) @app.route('/project_details/') +@permission_required(Permission.PROJECT_VIEW) def project_details(project_name): """Projektdetails und Konfiguration""" info = project_manager.get_project_info(project_name) @@ -1611,6 +2001,7 @@ def project_details(project_name): return render_template('project_details.html', project=info, env_content=env_content) @app.route('/save_env/', methods=['POST']) +@permission_required(Permission.PROJECT_FILES) def save_env(project_name): """Speichere .env Konfiguration""" env_content = request.form.get('env_content', '') @@ -1627,6 +2018,7 @@ def save_env(project_name): return redirect(url_for('project_details', project_name=project_name)) @app.route('/api/system_status') +@api_permission_required(Permission.SYSTEM_MONITORING) def api_system_status(): """API Endpoint für Systemstatus""" status = { @@ -1692,10 +2084,12 @@ def api_system_status(): return jsonify(status) @app.route('/api/test_connection', methods=['POST']) +@api_permission_required(Permission.SYSTEM_CONFIG) def api_test_connection(): """Test Verbindung zu Projektliste URL""" data = request.get_json() url = data.get('url', '') + if not url: return jsonify({'success': False, 'error': 'Keine URL angegeben'}) @@ -1711,6 +2105,7 @@ def api_test_connection(): return jsonify({'success': False, 'error': str(e)}) @app.route('/api/container_logs/') +@api_permission_required(Permission.PROJECT_LOGS) def api_container_logs(project_name): """Hole Container-Logs für Debugging""" try: @@ -1733,6 +2128,7 @@ def api_container_logs(project_name): return jsonify({'success': False, 'error': str(e)}) @app.route('/api/container_inspect/') +@api_permission_required(Permission.PROJECT_VIEW) def api_container_inspect(project_name): """Detaillierte Container-Informationen für Debugging""" try: @@ -1766,6 +2162,7 @@ def api_container_inspect(project_name): return jsonify({'success': False, 'error': str(e)}) @app.route('/api/container_stats/') +@api_permission_required(Permission.PROJECT_VIEW) def api_container_stats(project_name): """Container Statistiken abrufen""" try: @@ -1801,6 +2198,7 @@ def api_container_stats(project_name): return jsonify({'success': False, 'error': str(e)}) @app.route('/api/restart_project/', methods=['POST']) +@api_permission_required(Permission.PROJECT_RESTART) def api_restart_project(project_name): """Projekt Container neustarten""" try: @@ -1814,6 +2212,7 @@ def api_restart_project(project_name): return jsonify({'success': False, 'error': str(e)}) @app.route('/api/clear_cache', methods=['POST']) +@api_permission_required(Permission.SYSTEM_CONFIG) def api_clear_cache(): """Cache leeren""" try: @@ -1823,6 +2222,7 @@ def api_clear_cache(): return jsonify({'success': False, 'message': f'Fehler: {str(e)}'}) @app.route('/api/export_config') +@api_permission_required(Permission.SYSTEM_CONFIG) def api_export_config(): """Konfiguration exportieren""" try: @@ -1838,6 +2238,7 @@ def api_export_config(): return jsonify({'success': False, 'error': str(e)}) @app.route('/api/import_config', methods=['POST']) +@api_permission_required(Permission.SYSTEM_CONFIG) def api_import_config(): """Konfiguration importieren""" try: @@ -1848,6 +2249,7 @@ def api_import_config(): return jsonify({'success': False, 'error': str(e)}) @app.route('/api/reset_config', methods=['POST']) +@api_permission_required(Permission.SYSTEM_CONFIG) def api_reset_config(): """Konfiguration zurücksetzen""" try: @@ -1863,6 +2265,7 @@ def api_reset_config(): return jsonify({'success': False, 'error': str(e)}) @app.route('/docker_status') +@permission_required(Permission.SYSTEM_DOCKER) def docker_status(): """Docker Status und Diagnose Seite""" try: @@ -1881,6 +2284,7 @@ def docker_status(): return render_template('docker_status.html', config=fallback_config) @app.route('/api/docker_diagnose') +@api_permission_required(Permission.SYSTEM_DOCKER) def api_docker_diagnose(): """Docker Diagnose API""" try: @@ -2018,6 +2422,7 @@ def api_docker_diagnose(): }), 500 @app.route('/api/check_port/') +@api_permission_required(Permission.SYSTEM_MONITORING) def api_check_port(port): """Prüfe ob ein Port verfügbar ist""" try: @@ -2036,6 +2441,7 @@ def api_check_port(port): }) @app.route('/api/find_available_port') +@api_permission_required(Permission.SYSTEM_MONITORING) def api_find_available_port(): """Finde einen verfügbaren Port""" try: @@ -2060,6 +2466,7 @@ def api_find_available_port(): }) @app.route('/api/start_project/', methods=['POST']) +@api_permission_required(Permission.PROJECT_START) def api_start_project(project_name): """API Endpoint zum Starten eines Projekts""" try: @@ -2112,6 +2519,7 @@ def api_start_project(project_name): }) @app.route('/api/start_native/', methods=['POST']) +@api_permission_required(Permission.PROJECT_START) def api_start_native(project_name): """Starte Projekt nativ (ohne Docker)""" try: @@ -2177,6 +2585,7 @@ def api_start_native(project_name): return jsonify({'success': False, 'error': f'API-Fehler: {str(e)}'}) @app.route('/api/open_terminal/', methods=['POST']) +@api_permission_required(Permission.PROJECT_CONSOLE) def api_open_terminal(project_name): """Öffne Terminal im Projektordner""" try: @@ -2197,12 +2606,14 @@ def api_open_terminal(project_name): # Test Icons Template Route @app.route('/templates/test_icons.html') +@permission_required(Permission.SYSTEM_CONFIG) def serve_test_icons(): """Serve Icon Test Template""" with open('test_icons.html', 'r', encoding='utf-8') as f: return f.read(), 200, {'Content-Type': 'text/html; charset=utf-8'} @app.route('/update_project', methods=['POST']) +@permission_required(Permission.PROJECT_UPDATE) def update_project(): """Update ein Projekt""" project_name = request.form.get('project_name') @@ -2216,6 +2627,7 @@ def update_project(): return jsonify({'success': success, 'message': message}) @app.route('/update_project_get/') +@permission_required(Permission.PROJECT_VIEW) def update_project_get(project_name): """Update Projekt via GET (Weiterleitung)""" success, message = project_manager.update_project(project_name) @@ -2223,6 +2635,7 @@ def update_project_get(project_name): return redirect(url_for('index')) @app.route('/api/update_project/', methods=['POST']) +@api_permission_required(Permission.PROJECT_UPDATE) def api_update_project(project_name): """API Endpoint zum Updaten eines Projekts""" try: @@ -2238,6 +2651,7 @@ def api_update_project(project_name): }), 500 @app.route('/api/installed_projects') +@api_permission_required(Permission.PROJECT_VIEW) def api_installed_projects(): """API Endpoint für installierte Projekte mit Versionsinfo""" try: @@ -2270,6 +2684,7 @@ def api_installed_projects(): }) @app.route('/api/projects_list.json') +@api_permission_required(Permission.PROJECT_VIEW) def api_projects_list(): """API Endpoint für die lokale Projektliste (für Tests)""" try: @@ -2280,6 +2695,7 @@ def api_projects_list(): return jsonify([]), 500 @app.route('/api/refresh_projects', methods=['POST']) +@api_permission_required(Permission.PROJECT_VIEW) def api_refresh_projects(): """API Endpoint zum Aktualisieren der Projektliste via AJAX""" try: @@ -2319,6 +2735,7 @@ def api_refresh_projects(): }) @app.route('/custom_install/') +@permission_required(Permission.PROJECT_VIEW) def custom_install(project_name): """Custom Installation Seite für ein Projekt""" config = project_manager.load_config() @@ -2376,6 +2793,142 @@ def check_port_available(port): except: return False +# ===== FILE MANAGER ROUTES ===== + +@app.route('/file_manager') +@login_required +@permission_required(Permission.FILE_ACCESS) +def file_manager_view(): + """File Manager Hauptseite""" + return render_template('file_manager_new.html') + +@app.route('/api/files/list') +@api_permission_required(Permission.FILE_ACCESS) +def api_file_list(): + """API: Liste Dateien und Ordner""" + path = request.args.get('path', '') + result = file_manager.list_directory(path) + return jsonify(result) + +@app.route('/api/files/read') +@api_permission_required(Permission.FILE_ACCESS) +def api_file_read(): + """API: Lese Dateiinhalt""" + path = request.args.get('path', '') + if not path: + return jsonify({'error': 'Kein Pfad angegeben'}) + + result = file_manager.read_file(path) + return jsonify(result) + +@app.route('/api/files/write', methods=['POST']) +@api_permission_required(Permission.FILE_WRITE) +def api_file_write(): + """API: Schreibe Dateiinhalt""" + data = request.get_json() + if not data or 'path' not in data or 'content' not in data: + return jsonify({'error': 'Pfad und Inhalt erforderlich'}) + + result = file_manager.write_file(data['path'], data['content']) + return jsonify(result) + +@app.route('/api/files/delete', methods=['POST']) +@api_permission_required(Permission.FILE_WRITE) +def api_file_delete(): + """API: Lösche Datei oder Verzeichnis""" + data = request.get_json() + if not data or 'path' not in data: + return jsonify({'error': 'Pfad erforderlich'}) + + result = file_manager.delete_item(data['path']) + return jsonify(result) + +@app.route('/api/files/create_directory', methods=['POST']) +@api_permission_required(Permission.FILE_WRITE) +def api_create_directory(): + """API: Erstelle neues Verzeichnis""" + data = request.get_json() + if not data or 'path' not in data: + return jsonify({'error': 'Pfad erforderlich'}) + + result = file_manager.create_directory(data['path']) + return jsonify(result) + +@app.route('/api/files/rename', methods=['POST']) +@api_permission_required(Permission.FILE_WRITE) +def api_file_rename(): + """API: Benenne Datei oder Verzeichnis um""" + data = request.get_json() + if not data or 'path' not in data or 'new_name' not in data: + return jsonify({'error': 'Pfad und neuer Name erforderlich'}) + + result = file_manager.rename_item(data['path'], data['new_name']) + return jsonify(result) + +@app.route('/api/files/upload', methods=['POST']) +@api_permission_required(Permission.FILE_WRITE) +def api_file_upload(): + """API: Datei hochladen""" + try: + if 'file' not in request.files: + return jsonify({'error': 'Keine Datei ausgewählt'}) + + file = request.files['file'] + path = request.form.get('path', '') + + if file.filename == '': + return jsonify({'error': 'Keine Datei ausgewählt'}) + + # Sicherer Dateiname + filename = file.filename + if path: + upload_path = os.path.join(path, filename) + else: + upload_path = filename + + # Verwende FileManager für sicheren Pfad + safe_path = file_manager.get_safe_path(upload_path) + + # Erstelle Verzeichnis falls nicht vorhanden + os.makedirs(os.path.dirname(safe_path), exist_ok=True) + + # Speichere Datei + file.save(safe_path) + + return jsonify({ + 'success': True, + 'message': f'Datei {filename} erfolgreich hochgeladen' + }) + + except Exception as e: + return jsonify({'error': str(e)}) + +@app.route('/api/files/download') +@api_permission_required(Permission.FILE_ACCESS) +def api_file_download(): + """API: Datei herunterladen""" + try: + path = request.args.get('path', '') + if not path: + return jsonify({'error': 'Kein Pfad angegeben'}) + + safe_path = file_manager.get_safe_path(path) + + if not os.path.exists(safe_path): + return jsonify({'error': 'Datei existiert nicht'}) + + if os.path.isdir(safe_path): + return jsonify({'error': 'Pfad ist ein Verzeichnis'}) + + # Sende Datei zum Download + directory = os.path.dirname(safe_path) + filename = os.path.basename(safe_path) + + return send_from_directory(directory, filename, as_attachment=True) + + except Exception as e: + return jsonify({'error': str(e)}) + if __name__ == '__main__': # Finde verfügbaren Port diff --git a/file_manager.py b/file_manager.py new file mode 100644 index 0000000..4bd2fa8 --- /dev/null +++ b/file_manager.py @@ -0,0 +1,335 @@ +""" +File Management System für App Installer & Manager +Ermöglicht Pterodactyl-ähnliche Dateiverwaltung +""" + +import os +import shutil +import mimetypes +import zipfile +import tempfile +from pathlib import Path +from datetime import datetime +from werkzeug.utils import secure_filename + +class FileManager: + def __init__(self, base_path): + self.base_path = Path(base_path) + self.allowed_extensions = { + 'text': ['.txt', '.log', '.conf', '.cfg', '.ini', '.env', '.md', '.json', '.xml', '.yaml', '.yml'], + 'code': ['.py', '.js', '.html', '.css', '.php', '.java', '.cpp', '.h', '.sql'], + 'config': ['.dockerfile', '.dockerignore', '.gitignore', '.htaccess'], + 'archive': ['.zip', '.tar', '.gz', '.rar'], + 'image': ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico'] + } + + # Maximale Dateigröße (in Bytes) + self.max_file_size = 50 * 1024 * 1024 # 50MB + + def get_directory_contents(self, relative_path=""): + """Hole Verzeichnisinhalt mit Metadaten""" + try: + current_path = self.base_path / relative_path + + # Sicherheitscheck - Pfad darf nicht außerhalb der Basis liegen + if not self._is_safe_path(current_path): + raise ValueError("Zugriff außerhalb des erlaubten Bereichs") + + if not current_path.exists(): + return {'error': 'Verzeichnis nicht gefunden'} + + items = [] + + # Parent Directory Link (außer im Root) + if relative_path and relative_path != "": + parent_path = str(Path(relative_path).parent) + if parent_path == ".": + parent_path = "" + items.append({ + 'name': '..', + 'type': 'directory', + 'path': parent_path, + 'size': 0, + 'modified': '', + 'permissions': 'read' + }) + + # Verzeichnisse und Dateien auflisten + for item in sorted(current_path.iterdir()): + try: + stat_info = item.stat() + relative_item_path = str(item.relative_to(self.base_path)) + + item_data = { + 'name': item.name, + 'type': 'directory' if item.is_dir() else 'file', + 'path': relative_item_path, + 'size': stat_info.st_size if item.is_file() else 0, + 'modified': datetime.fromtimestamp(stat_info.st_mtime).strftime('%Y-%m-%d %H:%M:%S'), + 'permissions': self._get_permissions(item), + 'extension': item.suffix.lower() if item.is_file() else '', + 'mime_type': mimetypes.guess_type(str(item))[0] if item.is_file() else None + } + + items.append(item_data) + + except (PermissionError, OSError): + # Dateien ohne Berechtigung überspringen + continue + + return { + 'current_path': relative_path, + 'items': items, + 'total_items': len(items) + } + + except Exception as e: + return {'error': str(e)} + + def read_file(self, relative_path): + """Lese Dateiinhalt (nur Text-Dateien)""" + try: + file_path = self.base_path / relative_path + + if not self._is_safe_path(file_path): + raise ValueError("Zugriff außerhalb des erlaubten Bereichs") + + if not file_path.exists() or file_path.is_dir(): + raise ValueError("Datei nicht gefunden") + + # Prüfe ob es eine editierbare Datei ist + if not self._is_editable_file(file_path): + raise ValueError("Dateityp kann nicht bearbeitet werden") + + # Größencheck + if file_path.stat().st_size > self.max_file_size: + raise ValueError("Datei zu groß zum Anzeigen") + + # Versuche UTF-8, dann Latin-1 + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + except UnicodeDecodeError: + with open(file_path, 'r', encoding='latin-1') as f: + content = f.read() + + return { + 'content': content, + 'size': file_path.stat().st_size, + 'encoding': 'utf-8', + 'editable': True + } + + except Exception as e: + return {'error': str(e)} + + def write_file(self, relative_path, content): + """Schreibe Dateiinhalt""" + try: + file_path = self.base_path / relative_path + + if not self._is_safe_path(file_path): + raise ValueError("Zugriff außerhalb des erlaubten Bereichs") + + if not self._is_editable_file(file_path): + raise ValueError("Dateityp kann nicht bearbeitet werden") + + # Backup erstellen wenn Datei existiert + if file_path.exists(): + backup_path = file_path.with_suffix(file_path.suffix + '.backup') + shutil.copy2(file_path, backup_path) + + # Datei schreiben + with open(file_path, 'w', encoding='utf-8') as f: + f.write(content) + + return {'success': True, 'message': 'Datei gespeichert'} + + except Exception as e: + return {'error': str(e)} + + def create_file(self, relative_path, name): + """Erstelle neue Datei""" + try: + dir_path = self.base_path / relative_path + file_path = dir_path / secure_filename(name) + + if not self._is_safe_path(file_path): + raise ValueError("Zugriff außerhalb des erlaubten Bereichs") + + if file_path.exists(): + raise ValueError("Datei existiert bereits") + + # Leere Datei erstellen + file_path.touch() + + return {'success': True, 'message': 'Datei erstellt'} + + except Exception as e: + return {'error': str(e)} + + def create_directory(self, relative_path, name): + """Erstelle neues Verzeichnis""" + try: + dir_path = self.base_path / relative_path + new_dir_path = dir_path / secure_filename(name) + + if not self._is_safe_path(new_dir_path): + raise ValueError("Zugriff außerhalb des erlaubten Bereichs") + + if new_dir_path.exists(): + raise ValueError("Verzeichnis existiert bereits") + + new_dir_path.mkdir(parents=True) + + return {'success': True, 'message': 'Verzeichnis erstellt'} + + except Exception as e: + return {'error': str(e)} + + def delete_item(self, relative_path): + """Lösche Datei oder Verzeichnis""" + try: + item_path = self.base_path / relative_path + + if not self._is_safe_path(item_path): + raise ValueError("Zugriff außerhalb des erlaubten Bereichs") + + if not item_path.exists(): + raise ValueError("Element nicht gefunden") + + if item_path.is_dir(): + shutil.rmtree(item_path) + else: + item_path.unlink() + + return {'success': True, 'message': 'Element gelöscht'} + + except Exception as e: + return {'error': str(e)} + + def rename_item(self, relative_path, new_name): + """Benenne Datei oder Verzeichnis um""" + try: + old_path = self.base_path / relative_path + new_path = old_path.parent / secure_filename(new_name) + + if not self._is_safe_path(old_path) or not self._is_safe_path(new_path): + raise ValueError("Zugriff außerhalb des erlaubten Bereichs") + + if not old_path.exists(): + raise ValueError("Element nicht gefunden") + + if new_path.exists(): + raise ValueError("Zielname existiert bereits") + + old_path.rename(new_path) + + return {'success': True, 'message': 'Element umbenannt'} + + except Exception as e: + return {'error': str(e)} + + def upload_file(self, relative_path, file_obj): + """Lade Datei hoch""" + try: + if not file_obj or not file_obj.filename: + raise ValueError("Keine Datei ausgewählt") + + filename = secure_filename(file_obj.filename) + upload_path = self.base_path / relative_path / filename + + if not self._is_safe_path(upload_path): + raise ValueError("Zugriff außerhalb des erlaubten Bereichs") + + # Größencheck + file_obj.seek(0, 2) # Springe zum Ende + size = file_obj.tell() + file_obj.seek(0) # Zurück zum Anfang + + if size > self.max_file_size: + raise ValueError(f"Datei zu groß (max. {self.max_file_size // 1024 // 1024}MB)") + + # Datei speichern + file_obj.save(str(upload_path)) + + return {'success': True, 'message': 'Datei hochgeladen'} + + except Exception as e: + return {'error': str(e)} + + def download_file(self, relative_path): + """Bereite Datei für Download vor""" + try: + file_path = self.base_path / relative_path + + if not self._is_safe_path(file_path): + raise ValueError("Zugriff außerhalb des erlaubten Bereichs") + + if not file_path.exists() or file_path.is_dir(): + raise ValueError("Datei nicht gefunden") + + return { + 'path': str(file_path), + 'filename': file_path.name, + 'size': file_path.stat().st_size + } + + except Exception as e: + return {'error': str(e)} + + def compress_directory(self, relative_path): + """Komprimiere Verzeichnis zu ZIP""" + try: + dir_path = self.base_path / relative_path + + if not self._is_safe_path(dir_path): + raise ValueError("Zugriff außerhalb des erlaubten Bereichs") + + if not dir_path.exists() or not dir_path.is_dir(): + raise ValueError("Verzeichnis nicht gefunden") + + # Temporäre ZIP-Datei erstellen + temp_zip = tempfile.NamedTemporaryFile(delete=False, suffix='.zip') + + with zipfile.ZipFile(temp_zip.name, 'w', zipfile.ZIP_DEFLATED) as zipf: + for file_path in dir_path.rglob('*'): + if file_path.is_file(): + arcname = file_path.relative_to(dir_path) + zipf.write(file_path, arcname) + + return { + 'path': temp_zip.name, + 'filename': f"{dir_path.name}.zip" + } + + except Exception as e: + return {'error': str(e)} + + def _is_safe_path(self, path): + """Prüfe ob Pfad sicher ist (innerhalb der Basis)""" + try: + path.resolve().relative_to(self.base_path.resolve()) + return True + except ValueError: + return False + + def _is_editable_file(self, path): + """Prüfe ob Datei editierbar ist""" + suffix = path.suffix.lower() + for category in ['text', 'code', 'config']: + if suffix in self.allowed_extensions[category]: + return True + return False + + def _get_permissions(self, path): + """Ermittle Dateiberechtigungen (vereinfacht)""" + try: + if os.access(path, os.W_OK): + return 'write' + elif os.access(path, os.R_OK): + return 'read' + else: + return 'none' + except: + return 'unknown' diff --git a/requirements.txt b/requirements.txt index 8f5b356..8934162 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ requests==2.31.0 docker==6.1.3 PyYAML==6.0.1 python-dotenv==1.0.0 +secrets +hashlib diff --git a/sessions_db.json b/sessions_db.json new file mode 100644 index 0000000..4e9bcc9 --- /dev/null +++ b/sessions_db.json @@ -0,0 +1,19 @@ +{ + "sessions": { + "_gePrCgPc7vWx-zI3c_PLWSU0-1ryVMz1Wa4isufgjU": { + "username": "admin", + "created_at": "2025-07-09T20:56:42.783910", + "last_activity": "2025-07-09T23:58:11.364229", + "ip_address": "127.0.0.1", + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0" + }, + "25V3zUYYjYIoD21HlNwAsw-kLyh5Yu8bJmLtNCvHiBc": { + "username": "admin", + "created_at": "2025-07-09T23:59:01.223498", + "last_activity": "2025-07-09T23:59:01.223498", + "ip_address": "127.0.0.1", + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Code/1.101.2 Chrome/134.0.6998.205 Electron/35.5.1 Safari/537.36" + } + }, + "created_at": "2025-07-09T20:53:47.361864" +} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 9d09865..5a55b7e 100644 --- a/templates/base.html +++ b/templates/base.html @@ -39,6 +39,8 @@ backdrop-filter: blur(10px); border-radius: 10px; margin-bottom: 20px; + position: relative; + z-index: 10001 !important; /* Höchste Priorität für Navbar */ } .card { @@ -118,98 +120,107 @@ } } - /* Dropdown-Menü Styles */ + /* Dropdown-Menü Styles - Maximale Z-Index Priorität */ .dropdown-menu { border-radius: 10px; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15); border: none; + z-index: 999999 !important; + position: absolute !important; } - .dropdown-item { - padding: 10px 15px; - border-radius: 6px; - margin: 2px 5px; - transition: background-color 0.2s ease; + .dropdown { + z-index: 999998 !important; + position: relative !important; } + /* Navbar Dropdown spezifisch */ + .navbar .dropdown-menu { + z-index: 1000000 !important; + position: absolute !important; + } + + .navbar .dropdown { + z-index: 999999 !important; + position: relative !important; + } + + /* Dropdown-Menu-End spezifische Styles */ + .dropdown-menu-end { + right: 0 !important; + left: auto !important; + } + + /* Global Dropdown-Fix */ + .dropdown-menu.show { + z-index: 1000000 !important; + } + + /* Verhindere andere Elemente über Dropdowns */ + .card { + z-index: 1; + position: relative; + } + + .modal { + z-index: 10050 !important; /* Modals über Dropdowns */ + } + + .modal-backdrop { + z-index: 10040 !important; + } + + /* Spezifische Fixes für überlappende Elemente */ + .container-fluid { + z-index: 1; + position: relative; + } + + /* Alle anderen Elemente sollen unter Dropdowns bleiben */ + .project-card, .header-stats, .btn, .alert { + z-index: 1 !important; + position: relative; + } + + /* Sicherstellen, dass Dropdown-Parent korrekt positioniert ist */ + .nav-item.dropdown { + position: relative !important; + } + + /* Dropdown-Menü-Animation und bessere Unterstützung */ + .dropdown-menu { + transition: opacity 0.15s ease-in-out, transform 0.15s ease-in-out; + } + + .dropdown-menu:not(.show) { + opacity: 0; + transform: translateY(-10px); + pointer-events: none; + } + + .dropdown-menu.show { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + } + + /* Hover-Effekte für Dropdown-Items */ .dropdown-item:hover { - background-color: #f8f9fa; + background-color: rgba(0, 123, 255, 0.1); + transition: background-color 0.15s ease-in-out; } - .dropdown-item i { - width: 20px; - text-align: center; + /* Mobile-spezifische Dropdown-Styles */ + @media (max-width: 768px) { + .navbar .dropdown-menu { + position: static !important; + z-index: 1000000 !important; + box-shadow: none; + border: 1px solid rgba(0,0,0,0.15); + margin-top: 0.5rem; + } } - .dropdown-toggle-split { - border-left: 1px solid rgba(255, 255, 255, 0.2); - } - - /* Installation Button Group */ - .btn-group .btn-success { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - - .btn-group .dropdown-toggle-split { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - padding-left: 8px; - padding-right: 8px; - } - - /* Deaktivierte Dropdown-Buttons */ - .dropdown-toggle-split.disabled, - .dropdown-toggle-split:disabled { - cursor: not-allowed; - opacity: 0.6; - pointer-events: none; - } - - .dropdown-item.disabled { - opacity: 0.5; - pointer-events: none; - cursor: not-allowed; - } - - /* Tooltip für deaktivierte Buttons */ - .btn[title]:disabled { - cursor: help; - } - - /* Neue Kategorie-Statistiken */ - .category-stat { - transition: transform 0.2s ease, box-shadow 0.2s ease; - border: none; - min-height: 120px; - display: flex; - flex-direction: column; - justify-content: center; - } - - .category-stat:hover { - transform: translateY(-5px); - box-shadow: 0 8px 25px rgba(0,0,0,0.15); - } - - .stat-number-cat { - font-size: 2em; - font-weight: bold; - margin-bottom: 5px; - } - - /* Technologie-Statistiken */ - .tech-stat { - transition: all 0.2s ease; - border: 1px solid #dee2e6; - } - - .tech-stat:hover { - background-color: #f8f9fa !important; - transform: translateX(5px); - } - - /* Erweiterte Projekt-Karten */ .project-card { transition: all 0.3s ease; border-radius: 12px; @@ -493,16 +504,85 @@ Docker Status + + + {% if session.user_role in ['admin', 'moderator'] %} + + {% else %} + {% endif %} + + @@ -723,7 +803,10 @@ document.addEventListener('DOMContentLoaded', function() { checkAndFixIcons(); checkSystemHealth(); + // Dropdowns funktionieren automatisch mit Bootstrap - keine zusätzliche Initialisierung nötig }); + + // Bootstrap Dropdowns funktionieren automatisch ohne zusätzliches JavaScript {% block scripts %}{% endblock %} diff --git a/templates/file_manager.html b/templates/file_manager.html new file mode 100644 index 0000000..8a0eacb --- /dev/null +++ b/templates/file_manager.html @@ -0,0 +1,2145 @@ +{% extends "base.html" %} + +{% block title %}File Manager - {{ super() }}{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+
+
+ +
+

+ + File Manager +

+

Verwalte deine Projektdateien

+
+ + + + + +
+ + + +
+ +
+
+ + +
+
+ + Lade Dateien... +
+
+ + +
+
+ Datei.txt +
+ + +
+
+
+ +
+
+
+
+
+
+ + + + + +
+
+ Öffnen +
+
+ Bearbeiten +
+
+ Herunterladen +
+
+ Umbenennen +
+
+ Kopieren +
+
+ Löschen +
+
+ + + + + + + + + +{% endblock %} + +{% endblock %} + +{% block content %} +
+
+
+
+ +
+

+ + File Manager +

+

Verwalte deine Projektdateien

+
+ + + + + +
+ + + +
+ +
+
+ + +
+
+ + Lade Dateien... +
+
+ + +
+
+ Datei.txt +
+ + +
+
+
+ +
+
+
+
+
+
+ + + + + +
+
+ Öffnen +
+
+ Bearbeiten +
+
+ Herunterladen +
+
+ Umbenennen +
+
+ Kopieren +
+
+ Löschen +
+
+ + + + + + + + + +{% endblock %} + +.file-icon { + width: 20px; + height: 20px; + margin-right: 10px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.file-icon.folder { color: #ffc107; } +.file-icon.code { color: #28a745; } +.file-icon.text { color: #6c757d; } +.file-icon.image { color: #17a2b8; } +.file-icon.archive { color: #fd7e14; } +.file-icon.config { color: #dc3545; } + +.file-details { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.file-name { + font-weight: 500; + flex-grow: 1; +} + +.file-meta { + font-size: 0.8rem; + color: #6c757d; + text-align: right; +} + +.action-toolbar { + background: #f8f9fa; + padding: 0.75rem 1rem; + border-top: 1px solid #e9ecef; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.editor-container { + display: none; + border-top: 1px solid #e9ecef; +} + +.code-editor { + width: 100%; + height: 500px; + border: none; + padding: 1rem; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 14px; + line-height: 1.5; + background: #2d3748; + color: #e2e8f0; + resize: vertical; +} + +.upload-zone { + border: 2px dashed #ced4da; + border-radius: 8px; + padding: 2rem; + text-align: center; + margin: 1rem; + transition: all 0.3s ease; +} + +.upload-zone:hover, .upload-zone.dragover { + border-color: #007bff; + background: #f8f9ff; +} + +.permission-badge { + font-size: 0.7rem; + padding: 0.2rem 0.4rem; + border-radius: 3px; + margin-left: 0.5rem; +} + +.permission-r { background: #d4edda; color: #155724; } +.permission-w { background: #fff3cd; color: #856404; } +.permission-x { background: #f8d7da; color: #721c24; } + +/* Context Menu */ +.context-menu { + position: absolute; + background: white; + border: 1px solid #ced4da; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + min-width: 180px; + display: none; +} + +.context-menu-item { + padding: 0.5rem 1rem; + cursor: pointer; + border-bottom: 1px solid #f8f9fa; + transition: background 0.2s ease; +} + +.context-menu-item:hover { + background: #f8f9fa; +} + +.context-menu-item:last-child { + border-bottom: none; +} + +.context-menu-item.disabled { + color: #6c757d; + cursor: not-allowed; +} + +.context-menu-item.disabled:hover { + background: transparent; +} + +/* Progress Bar für Upload */ +.upload-progress { + display: none; + margin: 1rem; +} + +/* Responsive */ +@media (max-width: 768px) { + .file-details { + flex-direction: column; + align-items: flex-start; + } + + .file-meta { + text-align: left; + margin-top: 0.25rem; + } + + .action-toolbar { + flex-direction: column; + } + + .action-toolbar .btn { + margin-bottom: 0.25rem; + } +} + +{% endblock %} + +{% block content %} +
+
+

+ + File Manager - {{ project_name }} +

+

Verwalten Sie Projektdateien direkt im Browser

+
+ + Zurück zum Projekt + +
+ +
+ +
+
+
+
+ {{ project_name }} +
+ Aktueller Pfad: {{ current_path or '/' }} +
+
+ +
+
+
+ + + + + +
+ +
+
+ Lade Dateien... +
+

Lade Dateiliste...

+
+
+ + +
+ + + + + + +
+ + +
+
+
+
+ + Editor: +
+
+ + +
+
+
+ +
+
+ + + + + +
+
+ Öffnen +
+
+ Bearbeiten +
+
+ Download +
+
+
+ Umbenennen +
+
+ Kopieren +
+
+ Verschieben +
+
+
+ Löschen +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/file_manager_new.html b/templates/file_manager_new.html new file mode 100644 index 0000000..8d35f92 --- /dev/null +++ b/templates/file_manager_new.html @@ -0,0 +1,565 @@ +{% extends "base.html" %} + +{% block title %}File Manager - {{ super() }}{% endblock %} + +{% block content %} +
+
+
+
+
+

+ + File Manager +

+

Verwalte deine Projektdateien

+
+ +
+ +
+ +
+ + +
+ +
+ + +
+ + + + + + + + + + + + + + + +
NameTypGrößeGeändertAktionen
+
+ Lade... +
+

Lade Dateien...

+
+
+
+
+
+
+
+ + + + + + + + + + + + + + +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..1e76cd7 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,156 @@ +{% extends "base.html" %} + +{% block title %}Login - App Installer{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..3567957 --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,262 @@ +{% extends "base.html" %} + +{% block title %}Benutzerprofil - App Installer{% endblock %} + +{% block content %} +
+
+
+ + + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} +
+
+ +
+ +
+
+
+
+ + Benutzer-Informationen +
+
+
+
+
+ {{ user.first_name[0] if user.first_name else user.username[0] }}{{ user.last_name[0] if user.last_name else '' }} +
+

+ {% if user.first_name or user.last_name %} + {{ user.first_name }} {{ user.last_name }} + {% else %} + {{ user.username }} + {% endif %} +

+ + {{ user.role.title() }} + +
+ +
+ +
+ Benutzername: + {{ user.username }} +
+ +
+ E-Mail: + {{ user.email }} +
+ +
+ Erstellt: + {{ user.created_at[:10] }} +
+ + {% if user.last_login %} +
+ Letzter Login: + {{ user.last_login[:16] }} +
+ {% endif %} + +
+ Login-Anzahl: + {{ user.login_count or 0 }}x +
+
+
+
+ + +
+
+
+
+ + Passwort ändern +
+
+
+
+
+
+ + +
+ +
+ + +
Mindestens 6 Zeichen
+
+ +
+ + +
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+ + Ihre Berechtigungen +
+
+
+
+ +
+
Projekt-Berechtigungen
+
+ {% set project_permissions = [ + ('project.view', 'Projekte anzeigen', 'fas fa-eye'), + ('project.create', 'Projekte erstellen', 'fas fa-plus'), + ('project.update', 'Projekte aktualisieren', 'fas fa-edit'), + ('project.delete', 'Projekte löschen', 'fas fa-trash'), + ('project.start', 'Projekte starten', 'fas fa-play'), + ('project.stop', 'Projekte stoppen', 'fas fa-stop'), + ('project.restart', 'Projekte neustarten', 'fas fa-redo'), + ('project.logs', 'Logs anzeigen', 'fas fa-file-alt'), + ('project.console', 'Konsole nutzen', 'fas fa-terminal'), + ('project.files', 'Dateien verwalten', 'fas fa-folder') + ] %} + + {% for perm, desc, icon in project_permissions %} + {% set has_perm = user.role in ['admin'] or (user.role == 'moderator' and perm != 'project.delete') or (user.role == 'user' and perm not in ['project.delete', 'project.console']) or (user.role == 'viewer' and perm in ['project.view', 'project.logs']) %} +
+ + {{ desc }} + {% if has_perm %} + + {% else %} + + {% endif %} +
+ {% endfor %} +
+
+ + +
+
System-Berechtigungen
+
+ {% set system_permissions = [ + ('system.config', 'Konfiguration', 'fas fa-cog'), + ('system.users', 'Benutzerverwaltung', 'fas fa-users'), + ('system.monitoring', 'System-Überwachung', 'fas fa-chart-line'), + ('system.backups', 'Backup-Verwaltung', 'fas fa-download'), + ('system.docker', 'Docker-Verwaltung', 'fab fa-docker') + ] %} + + {% for perm, desc, icon in system_permissions %} + {% set has_perm = user.role == 'admin' or (user.role == 'moderator' and perm in ['system.monitoring', 'system.docker']) %} +
+ + {{ desc }} + {% if has_perm %} + + {% else %} + + {% endif %} +
+ {% endfor %} +
+
+
+
+
+
+
+
+ + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/users_management.html b/templates/users_management.html new file mode 100644 index 0000000..c04822f --- /dev/null +++ b/templates/users_management.html @@ -0,0 +1,401 @@ +{% extends "base.html" %} + +{% block title %}Benutzerverwaltung - App Installer{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+
+ +
+
+

+ + Benutzerverwaltung +

+

Verwalten Sie Benutzer und deren Berechtigungen

+
+ +
+ + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} +
+
+ + +
+
+
+
+ +

{{ users|length }}

+ Benutzer gesamt +
+
+
+
+
+
+ +

{{ users.values()|selectattr('role', 'equalto', 'admin')|list|length }}

+ Administratoren +
+
+
+
+
+
+ +

{{ users.values()|selectattr('role', 'equalto', 'moderator')|list|length }}

+ Moderatoren +
+
+
+
+
+
+ +

{{ users.values()|selectattr('enabled', 'equalto', true)|list|length }}

+ Aktive Benutzer +
+
+
+
+ + +
+ {% for username, user in users.items() %} +
+
+
+
+
+ {{ user.first_name[0] if user.first_name else user.username[0] }}{{ user.last_name[0] if user.last_name else '' }} +
+
+
+ {% if user.first_name or user.last_name %} + {{ user.first_name }} {{ user.last_name }} + {% else %} + {{ user.username }} + {% endif %} +
+ @{{ user.username }} +
+ +
+ +
+ + + {{ user.role.title() }} + + {% if not user.get('enabled', true) %} + Deaktiviert + {% endif %} +
+ + +
+
+
+ + + + + + {% if user.username != 'admin' or users.values()|selectattr('role', 'equalto', 'admin')|list|length > 1 %} + + {% endif %} + {% endfor %} +
+
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/user_management.py b/user_management.py new file mode 100644 index 0000000..c6bb802 --- /dev/null +++ b/user_management.py @@ -0,0 +1,507 @@ +""" +User Management System für App Installer & Manager +Pterodactyl-ähnliches Benutzer- und Rollensystem +""" + +import os +import json +import hashlib +import secrets +import time +from datetime import datetime, timedelta +from functools import wraps +from flask import session, request, redirect, url_for, flash, jsonify + +# Benutzer-Datenbank Datei +USERS_DB_FILE = 'users_db.json' +SESSIONS_DB_FILE = 'sessions_db.json' + +class UserRole: + """Benutzerrollen-Definitionen""" + ADMIN = 'admin' + MODERATOR = 'moderator' + USER = 'user' + VIEWER = 'viewer' + + # Rollen-Hierarchie (höhere Zahl = mehr Rechte) + HIERARCHY = { + VIEWER: 1, + USER: 2, + MODERATOR: 3, + ADMIN: 4 + } + + @classmethod + def get_all_roles(cls): + return list(cls.HIERARCHY.keys()) + + @classmethod + def has_permission(cls, user_role, required_role): + """Prüfe ob Benutzerrolle ausreichende Berechtigung hat""" + user_level = cls.HIERARCHY.get(user_role, 0) + required_level = cls.HIERARCHY.get(required_role, 0) + return user_level >= required_level + +class Permission: + """Berechtigungs-Definitionen""" + # Projekt-Berechtigungen + PROJECT_VIEW = 'project.view' + PROJECT_CREATE = 'project.create' + PROJECT_UPDATE = 'project.update' + PROJECT_DELETE = 'project.delete' + PROJECT_START = 'project.start' + PROJECT_STOP = 'project.stop' + PROJECT_RESTART = 'project.restart' + PROJECT_LOGS = 'project.logs' + PROJECT_CONSOLE = 'project.console' + PROJECT_FILES = 'project.files' + + # File-Berechtigungen + FILE_ACCESS = 'file.access' + FILE_WRITE = 'file.write' + + # System-Berechtigungen + SYSTEM_CONFIG = 'system.config' + SYSTEM_USERS = 'system.users' + SYSTEM_MONITORING = 'system.monitoring' + SYSTEM_BACKUPS = 'system.backups' + SYSTEM_DOCKER = 'system.docker' + + # Rollen-zu-Berechtigungen Mapping + ROLE_PERMISSIONS = { + UserRole.VIEWER: [ + PROJECT_VIEW, + PROJECT_LOGS, + FILE_ACCESS + ], + UserRole.USER: [ + PROJECT_VIEW, + PROJECT_CREATE, + PROJECT_UPDATE, + PROJECT_START, + PROJECT_STOP, + PROJECT_RESTART, + PROJECT_LOGS, + PROJECT_FILES, + FILE_ACCESS, + FILE_WRITE + ], + UserRole.MODERATOR: [ + PROJECT_VIEW, + PROJECT_CREATE, + PROJECT_UPDATE, + PROJECT_DELETE, + PROJECT_START, + PROJECT_STOP, + PROJECT_RESTART, + PROJECT_LOGS, + PROJECT_CONSOLE, + PROJECT_FILES, + FILE_ACCESS, + FILE_WRITE, + SYSTEM_MONITORING, + SYSTEM_DOCKER + ], + UserRole.ADMIN: [ + PROJECT_VIEW, + PROJECT_CREATE, + PROJECT_UPDATE, + PROJECT_DELETE, + PROJECT_START, + PROJECT_STOP, + PROJECT_RESTART, + PROJECT_LOGS, + PROJECT_CONSOLE, + PROJECT_FILES, + FILE_ACCESS, + FILE_WRITE, + SYSTEM_CONFIG, + SYSTEM_USERS, + SYSTEM_MONITORING, + SYSTEM_BACKUPS, + SYSTEM_DOCKER + ] + } + + @classmethod + def user_has_permission(cls, user_role, permission): + """Prüfe ob Benutzer eine spezifische Berechtigung hat""" + user_permissions = cls.ROLE_PERMISSIONS.get(user_role, []) + return permission in user_permissions + +class UserManager: + """Benutzer-Management System""" + + def __init__(self): + self.users_db = self._load_users_db() + self.sessions_db = self._load_sessions_db() + self._ensure_admin_user() + + def _load_users_db(self): + """Lade Benutzer-Datenbank""" + if os.path.exists(USERS_DB_FILE): + try: + with open(USERS_DB_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + print(f"Fehler beim Laden der Benutzer-DB: {e}") + + # Standard-Struktur + return { + 'users': {}, + 'created_at': datetime.now().isoformat(), + 'version': '1.0' + } + + def _load_sessions_db(self): + """Lade Session-Datenbank""" + if os.path.exists(SESSIONS_DB_FILE): + try: + with open(SESSIONS_DB_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + print(f"Fehler beim Laden der Session-DB: {e}") + + return { + 'sessions': {}, + 'created_at': datetime.now().isoformat() + } + + def _save_users_db(self): + """Speichere Benutzer-Datenbank""" + try: + with open(USERS_DB_FILE, 'w', encoding='utf-8') as f: + json.dump(self.users_db, f, indent=2, ensure_ascii=False) + except Exception as e: + print(f"Fehler beim Speichern der Benutzer-DB: {e}") + + def _save_sessions_db(self): + """Speichere Session-Datenbank""" + try: + with open(SESSIONS_DB_FILE, 'w', encoding='utf-8') as f: + json.dump(self.sessions_db, f, indent=2, ensure_ascii=False) + except Exception as e: + print(f"Fehler beim Speichern der Session-DB: {e}") + + def _ensure_admin_user(self): + """Stelle sicher, dass ein Admin-Benutzer existiert""" + if not self.users_db['users']: + # Erstelle Standard-Admin + admin_password = self._generate_secure_password() + self.create_user( + username='admin', + email='admin@localhost', + password=admin_password, + role=UserRole.ADMIN, + first_name='System', + last_name='Administrator' + ) + print(f"🔐 Standard-Admin erstellt:") + print(f" Benutzername: admin") + print(f" Passwort: {admin_password}") + print(f" ⚠️ BITTE PASSWORT SOFORT ÄNDERN!") + + def _generate_secure_password(self, length=12): + """Generiere sicheres Passwort""" + import string + alphabet = string.ascii_letters + string.digits + "!@#$%^&*" + return ''.join(secrets.choice(alphabet) for _ in range(length)) + + def _hash_password(self, password, salt=None): + """Hash Passwort mit Salt""" + if salt is None: + salt = secrets.token_hex(32) + + # Verwende SHA-256 mit Salt + password_hash = hashlib.sha256((password + salt).encode()).hexdigest() + return password_hash, salt + + def _verify_password(self, password, stored_hash, salt): + """Verifiziere Passwort""" + password_hash, _ = self._hash_password(password, salt) + return password_hash == stored_hash + + def create_user(self, username, email, password, role=UserRole.USER, + first_name='', last_name='', enabled=True): + """Erstelle neuen Benutzer""" + if username in self.users_db['users']: + return False, f"Benutzer '{username}' existiert bereits" + + if role not in UserRole.get_all_roles(): + return False, f"Ungültige Rolle: {role}" + + # Hash Passwort + password_hash, salt = self._hash_password(password) + + # Erstelle Benutzer + user_data = { + 'username': username, + 'email': email, + 'password_hash': password_hash, + 'password_salt': salt, + 'role': role, + 'first_name': first_name, + 'last_name': last_name, + 'enabled': enabled, + 'created_at': datetime.now().isoformat(), + 'last_login': None, + 'login_count': 0, + 'projects_access': [], # Spezifische Projekt-Zugriffe + 'preferences': { + 'theme': 'light', + 'language': 'de', + 'notifications': True + } + } + + self.users_db['users'][username] = user_data + self._save_users_db() + + return True, f"Benutzer '{username}' erfolgreich erstellt" + + def authenticate_user(self, username, password): + """Authentifiziere Benutzer""" + if username not in self.users_db['users']: + return False, "Benutzername oder Passwort falsch" + + user = self.users_db['users'][username] + + # Prüfe ob Benutzer aktiviert ist + if not user.get('enabled', True): + return False, "Benutzer ist deaktiviert" + + # Verifiziere Passwort + if not self._verify_password(password, user['password_hash'], user['password_salt']): + return False, "Benutzername oder Passwort falsch" + + # Update Login-Statistiken + user['last_login'] = datetime.now().isoformat() + user['login_count'] = user.get('login_count', 0) + 1 + self._save_users_db() + + return True, user + + def create_session(self, username): + """Erstelle Session für Benutzer""" + session_token = secrets.token_urlsafe(32) + + session_data = { + 'username': username, + 'created_at': datetime.now().isoformat(), + 'last_activity': datetime.now().isoformat(), + 'ip_address': request.environ.get('REMOTE_ADDR', 'unknown'), + 'user_agent': request.environ.get('HTTP_USER_AGENT', 'unknown') + } + + self.sessions_db['sessions'][session_token] = session_data + self._save_sessions_db() + + # Cleanup alte Sessions (älter als 30 Tage) + self._cleanup_old_sessions() + + return session_token + + def validate_session(self, session_token): + """Validiere Session""" + if session_token not in self.sessions_db['sessions']: + return False, None + + session_data = self.sessions_db['sessions'][session_token] + + # Prüfe Session-Alter (24 Stunden) + created_at = datetime.fromisoformat(session_data['created_at']) + if datetime.now() - created_at > timedelta(hours=24): + self.destroy_session(session_token) + return False, None + + # Update letzte Aktivität + session_data['last_activity'] = datetime.now().isoformat() + self._save_sessions_db() + + username = session_data['username'] + user = self.users_db['users'].get(username) + + if not user or not user.get('enabled', True): + self.destroy_session(session_token) + return False, None + + return True, user + + def destroy_session(self, session_token): + """Zerstöre Session""" + if session_token in self.sessions_db['sessions']: + del self.sessions_db['sessions'][session_token] + self._save_sessions_db() + + def _cleanup_old_sessions(self): + """Cleanup alte Sessions""" + current_time = datetime.now() + sessions_to_remove = [] + + for token, session_data in self.sessions_db['sessions'].items(): + created_at = datetime.fromisoformat(session_data['created_at']) + if current_time - created_at > timedelta(days=30): + sessions_to_remove.append(token) + + for token in sessions_to_remove: + del self.sessions_db['sessions'][token] + + if sessions_to_remove: + self._save_sessions_db() + + def get_user(self, username): + """Hole Benutzer-Informationen""" + return self.users_db['users'].get(username) + + def get_all_users(self): + """Hole alle Benutzer""" + return self.users_db['users'] + + def update_user(self, username, **kwargs): + """Update Benutzer-Informationen""" + if username not in self.users_db['users']: + return False, f"Benutzer '{username}' nicht gefunden" + + user = self.users_db['users'][username] + + # Erlaubte Update-Felder + allowed_fields = ['email', 'role', 'first_name', 'last_name', 'enabled', 'projects_access', 'preferences'] + + for field, value in kwargs.items(): + if field in allowed_fields: + user[field] = value + + self._save_users_db() + return True, f"Benutzer '{username}' aktualisiert" + + def change_password(self, username, old_password, new_password): + """Ändere Benutzer-Passwort""" + if username not in self.users_db['users']: + return False, "Benutzer nicht gefunden" + + user = self.users_db['users'][username] + + # Verifiziere altes Passwort + if not self._verify_password(old_password, user['password_hash'], user['password_salt']): + return False, "Altes Passwort ist falsch" + + # Setze neues Passwort + password_hash, salt = self._hash_password(new_password) + user['password_hash'] = password_hash + user['password_salt'] = salt + + self._save_users_db() + return True, "Passwort erfolgreich geändert" + + def delete_user(self, username): + """Lösche Benutzer""" + if username not in self.users_db['users']: + return False, f"Benutzer '{username}' nicht gefunden" + + # Verhindere Löschung des letzten Admins + if self.users_db['users'][username]['role'] == UserRole.ADMIN: + admin_count = sum(1 for user in self.users_db['users'].values() + if user['role'] == UserRole.ADMIN and user.get('enabled', True)) + if admin_count <= 1: + return False, "Der letzte Administrator kann nicht gelöscht werden" + + del self.users_db['users'][username] + self._save_users_db() + + # Entferne alle Sessions des Benutzers + sessions_to_remove = [token for token, session_data in self.sessions_db['sessions'].items() + if session_data['username'] == username] + for token in sessions_to_remove: + del self.sessions_db['sessions'][token] + + if sessions_to_remove: + self._save_sessions_db() + + return True, f"Benutzer '{username}' gelöscht" + +# Globale User Manager Instanz +user_manager = UserManager() + +# Decorator für Login-Erfordernis +def login_required(f): + """Decorator: Erfordert Login""" + @wraps(f) + def decorated_function(*args, **kwargs): + session_token = session.get('session_token') + if not session_token: + flash('Sie müssen sich anmelden.', 'warning') + return redirect(url_for('login')) + + valid, user = user_manager.validate_session(session_token) + if not valid: + session.pop('session_token', None) + flash('Ihre Session ist abgelaufen.', 'warning') + return redirect(url_for('login')) + + # Füge Benutzer-Info zu Request hinzu + request.current_user = user + return f(*args, **kwargs) + + return decorated_function + +# Decorator für Rollen-Berechtigung +def role_required(required_role): + """Decorator: Erfordert spezifische Rolle""" + def decorator(f): + @wraps(f) + @login_required + def decorated_function(*args, **kwargs): + user = request.current_user + if not UserRole.has_permission(user['role'], required_role): + flash(f'Keine Berechtigung. Erforderliche Rolle: {required_role}', 'danger') + return redirect(url_for('index')) + return f(*args, **kwargs) + return decorated_function + return decorator + +# Decorator für spezifische Berechtigung +def permission_required(permission): + """Decorator: Erfordert spezifische Berechtigung""" + def decorator(f): + @wraps(f) + @login_required + def decorated_function(*args, **kwargs): + user = request.current_user + if not Permission.user_has_permission(user['role'], permission): + flash(f'Keine Berechtigung für: {permission}', 'danger') + return redirect(url_for('index')) + return f(*args, **kwargs) + return decorated_function + return decorator + +# API Helper für JSON-Antworten +def api_login_required(f): + """API Decorator: Erfordert Login für API-Calls""" + @wraps(f) + def decorated_function(*args, **kwargs): + session_token = session.get('session_token') + if not session_token: + return jsonify({'success': False, 'error': 'Login erforderlich'}), 401 + + valid, user = user_manager.validate_session(session_token) + if not valid: + session.pop('session_token', None) + return jsonify({'success': False, 'error': 'Session abgelaufen'}), 401 + + request.current_user = user + return f(*args, **kwargs) + + return decorated_function + +def api_permission_required(permission): + """API Decorator: Erfordert spezifische Berechtigung für API-Calls""" + def decorator(f): + @wraps(f) + @api_login_required + def decorated_function(*args, **kwargs): + user = request.current_user + if not Permission.user_has_permission(user['role'], permission): + return jsonify({'success': False, 'error': f'Keine Berechtigung für: {permission}'}), 403 + return f(*args, **kwargs) + return decorated_function + return decorator diff --git a/users_db.json b/users_db.json new file mode 100644 index 0000000..4f9bf26 --- /dev/null +++ b/users_db.json @@ -0,0 +1,25 @@ +{ + "users": { + "admin": { + "username": "admin", + "email": "admin@localhost", + "password_hash": "a0f677d20ac159a9914e92c2a6466fda213a344de3853587e32e3d766992603c", + "password_salt": "4fdbb3d595c5f8e2db69baa4008f196ccc9f8e980da9bc0fd8f3f249caf3efd4", + "role": "admin", + "first_name": "System", + "last_name": "Administrator", + "enabled": true, + "created_at": "2025-07-09T20:53:47.361864", + "last_login": "2025-07-09T23:59:01.222407", + "login_count": 2, + "projects_access": [], + "preferences": { + "theme": "light", + "language": "de", + "notifications": true + } + } + }, + "created_at": "2025-07-09T20:53:47.361864", + "version": "1.0" +} \ No newline at end of file