diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,1871 +1,1010 @@ -import json +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + import os -import logging -import threading -import time -from datetime import datetime -from flask import Flask, render_template_string, request, redirect, url_for, session, flash, send_file, jsonify, Response -from flask_caching import Cache -from huggingface_hub import HfApi, hf_hub_download, utils as hf_utils -from werkzeug.utils import secure_filename -import requests -from io import BytesIO -import uuid +from flask import Flask, request, Response, render_template_string, jsonify, redirect, url_for, make_response import hmac import hashlib -from urllib.parse import unquote, parse_qs +import json +from urllib.parse import unquote, parse_qs, quote +import time +from datetime import datetime +import logging +import threading +from huggingface_hub import HfApi, hf_hub_download, list_repo_files +from huggingface_hub.utils import RepositoryNotFoundError, EntryNotFoundError +import io # --- Configuration --- -app = Flask(__name__) -app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersecretkey_folders_unique_tma") -BOT_TOKEN = "6750208873:AAE2hvPlJ99dBdhGa_Brre0IIpUdOvXxHt4" # Your Telegram Bot Token -DATA_FILE = 'cloudeng_data_tma.json' -REPO_ID = "Eluza133/Z1e1u" # Your Hugging Face Repo ID -HF_TOKEN_WRITE = os.getenv("HF_TOKEN") -HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") or HF_TOKEN_WRITE -ADMIN_TELEGRAM_IDS_STR = os.getenv("ADMIN_TELEGRAM_IDS", "") # Comma-separated list of admin Telegram IDs -ADMIN_TELEGRAM_IDS = set(int(tid.strip()) for tid in ADMIN_TELEGRAM_IDS_STR.split(',') if tid.strip().isdigit()) -UPLOAD_FOLDER = 'uploads_tma' -os.makedirs(UPLOAD_FOLDER, exist_ok=True) - -cache = Cache(app, config={'CACHE_TYPE': 'simple'}) -logging.basicConfig(level=logging.INFO) +BOT_TOKEN = os.getenv("BOT_TOKEN", "6750208873:AAE2hvPlJ99dBdhGa_Brre0IIpUdOvXxHt4") +HOST = '0.0.0.0' +PORT = 7860 -# --- Helper Functions --- - -def find_node_by_id(filesystem, node_id): - if not filesystem or not isinstance(filesystem, dict): - return None, None - if filesystem.get('id') == node_id: - return filesystem, None - - queue = [(filesystem, None)] - while queue: - current_node, parent = queue.pop(0) - if current_node.get('type') == 'folder' and 'children' in current_node: - for i, child in enumerate(current_node.get('children', [])): - if not isinstance(child, dict): continue # Skip invalid children - if child.get('id') == node_id: - return child, current_node - if child.get('type') == 'folder': - queue.append((child, current_node)) - return None, None - -def add_node(filesystem, parent_id, node_data): - parent_node, _ = find_node_by_id(filesystem, parent_id) - if parent_node and parent_node.get('type') == 'folder': - if 'children' not in parent_node or not isinstance(parent_node['children'], list): - parent_node['children'] = [] - parent_node['children'].append(node_data) - return True - return False - -def remove_node(filesystem, node_id): - node_to_remove, parent_node = find_node_by_id(filesystem, node_id) - if node_to_remove and parent_node and 'children' in parent_node and isinstance(parent_node['children'], list): - parent_node['children'] = [child for child in parent_node['children'] if isinstance(child, dict) and child.get('id') != node_id] - return True - return False - -def get_node_path_string(filesystem, node_id): - path_list = [] - current_id = node_id - - while current_id: - node, parent = find_node_by_id(filesystem, current_id) - if not node: - break - if node.get('id') != 'root': - path_list.append(node.get('name', node.get('original_filename', ''))) - if not parent: - break - current_id = parent.get('id') if parent else None - - return " / ".join(reversed(path_list)) or "Root" - - -def initialize_user_filesystem(user_data): - # user_data is already specific to one user - if 'filesystem' not in user_data or not isinstance(user_data.get('filesystem'), dict): - user_data['filesystem'] = { - "type": "folder", - "id": "root", - "name": "root", - "children": [] - } - # Migration logic (optional, based on old structure if needed) - if 'files' in user_data and isinstance(user_data['files'], list): - telegram_id = user_data.get('telegram_id') # Assuming telegram_id is stored here - if telegram_id: - for old_file in user_data['files']: - file_id = old_file.get('id', uuid.uuid4().hex) - original_filename = old_file.get('filename', 'unknown_file') - name_part, ext_part = os.path.splitext(original_filename) - unique_suffix = uuid.uuid4().hex[:8] - unique_filename = f"{name_part}_{unique_suffix}{ext_part}" - hf_path = f"cloud_files/{str(telegram_id)}/root/{unique_filename}" # Use telegram_id - - file_node = { - 'type': 'file', - 'id': file_id, - 'original_filename': original_filename, - 'unique_filename': unique_filename, - 'path': hf_path, - 'file_type': get_file_type(original_filename), - 'upload_date': old_file.get('upload_date', datetime.now().strftime('%Y-%m-%d %H:%M:%S')) - } - add_node(user_data['filesystem'], 'root', file_node) - del user_data['files'] # Remove old structure - -@cache.memoize(timeout=300) -def load_data(): - try: - download_db_from_hf() - with open(DATA_FILE, 'r', encoding='utf-8') as file: - try: - data = json.load(file) - except json.JSONDecodeError: - logging.error(f"Error decoding JSON from {DATA_FILE}. Initializing empty database.") - return {'users': {}} - - if not isinstance(data, dict): - logging.warning("Data is not in dict format, initializing empty database") - return {'users': {}} - - # Ensure 'users' key exists and is a dictionary - if 'users' not in data or not isinstance(data['users'], dict): - logging.warning("Corrupted or missing 'users' structure, re-initializing.") - data['users'] = {} - - # Convert keys to integers (Telegram IDs) and initialize filesystem - converted_users = {} - for user_id_str, user_data in data['users'].items(): - try: - user_id_int = int(user_id_str) - if isinstance(user_data, dict): - initialize_user_filesystem(user_data) # Ensure filesystem exists - converted_users[user_id_int] = user_data - else: - logging.warning(f"Skipping invalid user data for key {user_id_str}") - except ValueError: - logging.warning(f"Skipping non-integer user ID key: {user_id_str}") - - data['users'] = converted_users - logging.info("Data successfully loaded and initialized") - return data - except FileNotFoundError: - logging.warning(f"{DATA_FILE} not found. Initializing empty database.") - return {'users': {}} - except Exception as e: - logging.error(f"Error loading data: {e}") - return {'users': {}} +# Hugging Face Settings +REPO_ID = "Eluza133/Z1e1u" +HF_UPLOAD_FOLDER = "uploads" # Base folder for user uploads within the HF repo +HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE") # Token with write access +HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") # Token with read access (can be same as write) +app = Flask(__name__) +logging.basicConfig(level=logging.INFO) +app.secret_key = os.urandom(24) + +# --- Hugging Face API Initialization --- +hf_api = None +if HF_TOKEN_WRITE: + hf_api = HfApi(token=HF_TOKEN_WRITE) + logging.info("Hugging Face API initialized with WRITE token.") +elif HF_TOKEN_READ: + hf_api = HfApi(token=HF_TOKEN_READ) + logging.info("Hugging Face API initialized with READ token (Uploads disabled).") +else: + logging.warning("HF_TOKEN_WRITE and HF_TOKEN_READ not set. Hugging Face operations will fail.") + + +# --- Telegram Verification --- +def verify_telegram_data(init_data_str): + if not init_data_str: + logging.warning("Verification attempt with empty initData.") + return None, False, "Missing initData" -def save_data(data): - try: - # Ensure all user keys are strings before saving to JSON - string_key_users = {str(k): v for k, v in data.get('users', {}).items()} - data_to_save = {'users': string_key_users} - - with open(DATA_FILE, 'w', encoding='utf-8') as file: - json.dump(data_to_save, file, ensure_ascii=False, indent=4) - upload_db_to_hf() - cache.clear() - logging.info("Data saved and uploaded to HF") - except Exception as e: - logging.error(f"Error saving data: {e}") - raise - -def upload_db_to_hf(): - if not HF_TOKEN_WRITE: - logging.warning("HF_TOKEN_WRITE not set, skipping database upload.") - return - try: - api = HfApi() - api.upload_file( - path_or_fileobj=DATA_FILE, - path_in_repo=DATA_FILE, - repo_id=REPO_ID, - repo_type="dataset", - token=HF_TOKEN_WRITE, - commit_message=f"Backup {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" - ) - logging.info("Database uploaded to Hugging Face") - except Exception as e: - logging.error(f"Error uploading database: {e}") - -def download_db_from_hf(): - if not HF_TOKEN_READ: - logging.warning("HF_TOKEN_READ not set, skipping database download.") - if not os.path.exists(DATA_FILE): - with open(DATA_FILE, 'w', encoding='utf-8') as f: - json.dump({'users': {}}, f) - return - try: - hf_hub_download( - repo_id=REPO_ID, - filename=DATA_FILE, - repo_type="dataset", - token=HF_TOKEN_READ, - local_dir=".", - local_dir_use_symlinks=False, - force_filename=DATA_FILE # Ensure correct filename - ) - logging.info("Database downloaded from Hugging Face") - except hf_utils.RepositoryNotFoundError: - logging.error(f"Repository {REPO_ID} not found.") - if not os.path.exists(DATA_FILE): - with open(DATA_FILE, 'w', encoding='utf-8') as f: - json.dump({'users': {}}, f) - except hf_utils.EntryNotFoundError: - logging.warning(f"{DATA_FILE} not found in repository {REPO_ID}. Initializing empty database.") - if not os.path.exists(DATA_FILE): - with open(DATA_FILE, 'w', encoding='utf-8') as f: - json.dump({'users': {}}, f) - except Exception as e: - logging.error(f"Error downloading database: {e}") - if not os.path.exists(DATA_FILE): - with open(DATA_FILE, 'w', encoding='utf-8') as f: - json.dump({'users': {}}, f) - -def periodic_backup(): - while True: - time.sleep(1800) # Backup every 30 minutes - logging.info("Starting periodic backup...") - try: - # Ensure data is loaded before saving (important if app restarts) - current_data = load_data() - save_data(current_data) - except Exception as e: - logging.error(f"Error during periodic backup: {e}") - - -def get_file_type(filename): - filename_lower = filename.lower() - if filename_lower.endswith(('.mp4', '.mov', '.avi', '.webm', '.mkv')): - return 'video' - elif filename_lower.endswith(('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg')): - return 'image' - elif filename_lower.endswith('.pdf'): - return 'pdf' - elif filename_lower.endswith('.txt'): - return 'text' - return 'other' - -def verify_telegram_auth(init_data_str, bot_token): try: parsed_data = parse_qs(init_data_str) - received_hash = parsed_data.get('hash', [None])[0] + received_hash = parsed_data.pop('hash', [None])[0] if not received_hash: - logging.warning("Hash missing in initData") - return None + logging.warning("Verification failed: Hash missing from initData.") + return None, False, "Hash missing" - data_check_string_parts = [] + data_check_list = [] for key, value in sorted(parsed_data.items()): - if key != 'hash': - # Values are lists from parse_qs, take the first element - data_check_string_parts.append(f"{key}={value[0]}") - - data_check_string = "\n".join(data_check_string_parts) + # Make sure values are handled correctly, especially if multiple exist (though unlikely for standard fields) + data_check_list.append(f"{key}={value[0]}") + data_check_string = "\n".join(data_check_list) - secret_key = hmac.new("WebAppData".encode(), bot_token.encode(), hashlib.sha256).digest() + secret_key = hmac.new("WebAppData".encode(), BOT_TOKEN.encode(), hashlib.sha256).digest() calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest() if calculated_hash == received_hash: - user_data_json = parsed_data.get('user', [None])[0] - if user_data_json: + auth_date = int(parsed_data.get('auth_date', [0])[0]) + current_time = int(time.time()) + # Check if data is reasonably fresh (e.g., within 24 hours) + if current_time - auth_date > 86400: + logging.warning(f"Verification Warning: Telegram InitData is older than 24 hours (Auth Date: {auth_date}, Current: {current_time}). Allowing access.") + # return parsed_data, False, "Data expired" # Uncomment to enforce expiry + + user_data = None + if 'user' in parsed_data: try: - # Decode URL-encoded JSON string - user_data = json.loads(unquote(user_data_json)) - if 'id' in user_data: - logging.info(f"Telegram auth successful for user ID: {user_data['id']}") - return user_data - else: - logging.error("User ID missing in user data") - return None - except (json.JSONDecodeError, KeyError) as e: - logging.error(f"Error parsing user data from initData: {e}") - return None + user_json_str = unquote(parsed_data['user'][0]) + user_data = json.loads(user_json_str) + except Exception as e: + logging.error(f"Could not parse user JSON from initData: {e}") + return None, False, "User data parsing failed" else: - logging.error("User data missing in initData") - return None - else: - logging.warning(f"Hash mismatch. Calculated: {calculated_hash}, Received: {received_hash}") - return None + logging.warning("User data missing in parsed initData.") + return None, False, "User data missing" + + if not user_data or 'id' not in user_data: + logging.error("Verification failed: User ID missing after parsing.") + return None, False, "User ID missing" + logging.info(f"Verification successful for user ID: {user_data.get('id')}") + return user_data, True, "Verified" + else: + logging.warning(f"Verification failed: Hash mismatch. User: {parsed_data.get('user')}") + return None, False, "Invalid hash" except Exception as e: - logging.error(f"Exception during Telegram auth verification: {e}") - return None - -def is_admin(): - # Check if the logged-in user's Telegram ID is in the admin list - return 'telegram_id' in session and session['telegram_id'] in ADMIN_TELEGRAM_IDS - - -# --- HTML / CSS / JS --- - -BASE_STYLE = ''' -:root { - --primary: #ff4d6d; --secondary: #00ddeb; --accent: #8b5cf6; - --background-light: #f5f6fa; --background-dark: #1a1625; - --card-bg: rgba(255, 255, 255, 0.95); --card-bg-dark: rgba(40, 35, 60, 0.95); - --text-light: #2a1e5a; --text-dark: #e8e1ff; --shadow: 0 10px 30px rgba(0, 0, 0, 0.2); - --glass-bg: rgba(255, 255, 255, 0.15); --transition: all 0.3s ease; --delete-color: #ff4444; - --folder-color: #ffc107; - /* Telegram Theme Integration */ - --tg-theme-bg-color: var(--background-light); - --tg-theme-text-color: var(--text-light); - --tg-theme-hint-color: #aaa; - --tg-theme-link-color: var(--accent); - --tg-theme-button-color: var(--primary); - --tg-theme-button-text-color: #ffffff; - --tg-theme-secondary-bg-color: var(--card-bg); -} -html.dark { - --tg-theme-bg-color: var(--background-dark); - --tg-theme-text-color: var(--text-dark); - --tg-theme-hint-color: #777; - --tg-theme-link-color: var(--accent); - --tg-theme-button-color: var(--primary); - --tg-theme-button-text-color: #ffffff; - --tg-theme-secondary-bg-color: var(--card-bg-dark); -} - -* { margin: 0; padding: 0; box-sizing: border-box; } -body { - font-family: 'Inter', sans-serif; - background-color: var(--tg-theme-bg-color); - color: var(--tg-theme-text-color); - line-height: 1.6; - transition: background-color 0.3s ease, color 0.3s ease; -} -.container { margin: 10px auto; max-width: 1200px; padding: 15px; background: var(--tg-theme-secondary-bg-color); border-radius: 15px; box-shadow: var(--shadow); overflow-x: hidden; } -h1 { font-size: 1.8em; font-weight: 800; text-align: center; margin-bottom: 20px; background: linear-gradient(135deg, var(--primary), var(--accent)); -webkit-background-clip: text; color: transparent; } -h2 { font-size: 1.4em; margin-top: 25px; color: var(--tg-theme-text-color); } -h4 { font-size: 1.1em; margin-top: 15px; margin-bottom: 5px; color: var(--accent); } -ol, ul { margin-left: 20px; margin-bottom: 15px; } -li { margin-bottom: 5px; } -input, textarea { width: 100%; padding: 12px; margin: 10px 0; border: none; border-radius: 12px; background: var(--glass-bg); color: var(--tg-theme-text-color); font-size: 1em; box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.1); } -input:focus, textarea:focus { outline: none; box-shadow: 0 0 0 3px var(--primary); } -.btn { padding: 12px 24px; background: var(--tg-theme-button-color); color: var(--tg-theme-button-text-color); border: none; border-radius: 12px; cursor: pointer; font-size: 1em; font-weight: 600; transition: var(--transition); box-shadow: var(--shadow); display: inline-block; text-decoration: none; margin-top: 5px; margin-right: 5px; } -.btn:hover { transform: scale(1.03); filter: brightness(1.1); } -.download-btn { background: var(--secondary); color: white; } -.download-btn:hover { background: #00b8c5; } -.delete-btn { background: var(--delete-color); color: white; } -.delete-btn:hover { background: #cc3333; } -.folder-btn { background: var(--folder-color); color: white; } -.folder-btn:hover { background: #e6a000; } -.flash { color: var(--secondary); text-align: center; margin-bottom: 15px; padding: 10px; background: rgba(0, 221, 235, 0.1); border-radius: 10px; } -.flash.error { color: var(--delete-color); background: rgba(255, 68, 68, 0.1); } -.file-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; margin-top: 20px; } -.user-list { margin-top: 20px; } -.user-item { padding: 15px; background: var(--tg-theme-secondary-bg-color); border-radius: 16px; margin-bottom: 10px; box-shadow: var(--shadow); transition: var(--transition); } -.user-item:hover { transform: translateY(-5px); } -.user-item a { color: var(--tg-theme-link-color); text-decoration: none; font-weight: 600; } -.user-item a:hover { filter: brightness(1.2); } -.item { background: var(--tg-theme-secondary-bg-color); padding: 10px; border-radius: 12px; box-shadow: var(--shadow); text-align: center; transition: var(--transition); display: flex; flex-direction: column; justify-content: space-between; } -.item:hover { transform: translateY(-3px); } -.item-preview { max-width: 100%; height: 100px; object-fit: cover; border-radius: 8px; margin-bottom: 8px; cursor: pointer; display: block; margin-left: auto; margin-right: auto;} -.item.folder .item-preview { object-fit: contain; font-size: 50px; color: var(--folder-color); line-height: 100px; } -.item p { font-size: 0.85em; margin: 3px 0; word-break: break-all; } -.item a { color: var(--tg-theme-link-color); text-decoration: none; } -.item a:hover { filter: brightness(1.2); } -.item-actions { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 5px; justify-content: center; } -.item-actions .btn { font-size: 0.8em; padding: 5px 8px; } -.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.85); z-index: 2000; justify-content: center; align-items: center; } -.modal-content { max-width: 95%; max-height: 95%; background: var(--tg-theme-secondary-bg-color); padding: 10px; border-radius: 15px; overflow: auto; position: relative; } -.modal img, .modal video, .modal iframe, .modal pre { max-width: 100%; max-height: 85vh; display: block; margin: auto; border-radius: 10px; } -.modal iframe { width: 90vw; height: 85vh; border: none; } -.modal pre { background: rgba(0,0,0,0.1); color: var(--tg-theme-text-color); padding: 15px; border-radius: 8px; white-space: pre-wrap; word-wrap: break-word; text-align: left; max-height: 85vh; overflow-y: auto;} -.modal-close-btn { position: absolute; top: 10px; right: 15px; font-size: 24px; color: var(--tg-theme-hint-color); cursor: pointer; background: rgba(0,0,0,0.3); border-radius: 50%; width: 25px; height: 25px; line-height: 25px; text-align: center; } -#progress-container { width: 100%; background: var(--glass-bg); border-radius: 10px; margin: 15px 0; display: none; position: relative; height: 20px; } -#progress-bar { width: 0%; height: 100%; background: var(--tg-theme-button-color); border-radius: 10px; transition: width 0.3s ease; } -#progress-text { position: absolute; width: 100%; text-align: center; line-height: 20px; color: var(--tg-theme-button-text-color); font-size: 0.9em; font-weight: bold; text-shadow: 1px 1px 1px rgba(0,0,0,0.5); } -.breadcrumbs { margin-bottom: 15px; font-size: 1em; color: var(--tg-theme-hint-color); } -.breadcrumbs a { color: var(--tg-theme-link-color); text-decoration: none; } -.breadcrumbs a:hover { text-decoration: underline; } -.breadcrumbs span { margin: 0 5px; } -.folder-actions { margin-top: 15px; margin-bottom: 10px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } -.folder-actions input[type=text] { width: auto; flex-grow: 1; margin: 0; min-width: 150px; } -.folder-actions .btn { margin: 0; flex-shrink: 0;} -.auth-container { text-align: center; padding: 50px 20px; } -.auth-container p { margin-bottom: 20px; font-size: 1.1em; } -.spinner { border: 4px solid rgba(0, 0, 0, 0.1); width: 36px; height: 36px; border-radius: 50%; border-left-color: var(--tg-theme-button-color); animation: spin 1s ease infinite; margin: 20px auto; } -@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } -@media (max-width: 768px) { - .file-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); } - .folder-actions { flex-direction: column; align-items: stretch; } - .folder-actions input[type=text] { width: 100%; } - .item-preview { height: 80px; } - .item.folder .item-preview { font-size: 40px; line-height: 80px; } - h1 { font-size: 1.6em; } - .btn { padding: 10px 20px; font-size: 0.9em; } - .item-actions .btn { padding: 4px 8px; font-size: 0.75em;} -} -''' - -INITIAL_AUTH_HTML = ''' - -Zeus Cloud - - -
-

Zeus Cloud

Инициализация и проверка авторизации...

-
- -
- + + + +
-

Zeus Cloud

-

Пользователь: {{ user_info.get('first_name', 'Неизвестно') }} {{ user_info.get('last_name', '') }} (ID: {{ telegram_id }})

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

Содержимое папки: {{ current_folder.name if current_folder_id != 'root' else 'Главная' }}

-
- {% for item in items %} -
- {% if item.type == 'folder' %} - 📁 -

{{ item.name }}

-
- Открыть -
- - -
-
- {% elif item.type == 'file' %} - {% set previewable = item.file_type in ['image', 'video', 'pdf', 'text'] %} - {% if item.file_type == 'image' %} - {{ item.original_filename }} - {% elif item.file_type == 'video' %} - - {% elif item.file_type == 'pdf' %} -
📄
- {% elif item.file_type == 'text' %} -
📝
- {% else %} -
- {% endif %} -

{{ item.original_filename | truncate(25, True) }}

-

{{ item.upload_date }}

-
- Скачать - {% if previewable %} - - {% endif %} -
- - -
-
- {% endif %} -
- {% endfor %} - {% if not items %}

Эта папка пуста.

{% endif %} -
- -
- - - - - -''' - - -ADMIN_BASE_HTML = ''' - -{% block title %}Админ-панель{% endblock %} - -

{% block header %}Админ-панель{% endblock %}

-{% with messages = get_flashed_messages(with_categories=true) %}{% if messages %}
{% for category, message in messages %}
{{ message }}
{% endfor %}
{% endif %}{% endwith %} -{% block content %}{% endblock %} -
- - - - -{% block extra_js %}{% endblock %} - -''' - -ADMIN_USERS_HTML = ADMIN_BASE_HTML.replace( - '{% block title %}Админ-панель{% endblock %}', 'Админ-панель - Пользователи' -).replace( - '{% block header %}Админ-панель{% endblock %}', 'Админ-панель - Пользователи' -).replace( - '{% block content %}{% endblock %}', - ''' -

Пользователи

-{% for user in user_details %} -
- {{ user.username }} (ID: {{ user.telegram_id }}) -

Зарегистрирован: {{ user.created_at }}

-

Файлов: {{ user.file_count }}

-
- -
-
-{% else %}

Пользователей нет.

{% endfor %}
-''' -) - -ADMIN_USER_FILES_HTML = ADMIN_BASE_HTML.replace( - '{% block title %}Админ-панель{% endblock %}', 'Файлы {{ user_info.username }}' -).replace( - '{% block header %}Админ-панель{% endblock %}', 'Файлы пользователя: {{ user_info.username }} (ID: {{ user_info.telegram_id }})' -).replace( - '{% block content %}{% endblock %}', - ''' -Назад к пользователям -
-{% for file in files %} -
-
- {% if file.file_type == 'image' %} - {% elif file.file_type == 'video' %} - {% elif file.file_type == 'pdf' %}
📄
- {% elif file.file_type == 'text' %}
📝
- {% else %}
{% endif %} -

{{ file.original_filename | truncate(30) }}

-

В папке: {{ file.parent_path_str }}

-

Загружен: {{ file.upload_date }}

-

ID: {{ file.id }}

-

Path: {{ file.path }}

-
-
- Скачать - {% set previewable = file.file_type in ['image', 'video', 'pdf', 'text'] %} - {% if previewable %} - - {% endif %} -
- -
-
-
-{% else %}

У п��льзователя нет файлов.

{% endfor %} -
-''' -) - -# --- Flask Routes --- + #loading-indicator, #error-message, #no-files-message { + text-align: center; + padding: var(--padding); + color: var(--tg-theme-hint-color); + font-size: 1.1em; + display: none; /* Hidden initially */ + } + #error-message { + color: #dc3545; /* Standard error color */ + background-color: rgba(220, 53, 69, 0.1); + border: 1px solid rgba(220, 53, 69, 0.2); + border-radius: var(--border-radius); + } + /* Modal for media */ + .media-modal { + display: none; position: fixed; z-index: 1001; + left: 0; top: 0; width: 100%; height: 100%; + overflow: auto; background-color: rgba(0,0,0,0.8); + backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px); + animation: fadeIn 0.3s ease-out; + } + @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + .media-modal-content { + margin: auto; display: block; + max-width: 90%; max-height: 85%; + position: absolute; top: 50%; left: 50%; + transform: translate(-50%, -50%); + } + .media-modal img, .media-modal video { + display: block; width: auto; height: auto; + max-width: 100%; max-height: 100%; + margin: 0 auto; + border-radius: var(--border-radius); + } + .media-modal-close { + position: absolute; top: 15px; right: 35px; + color: #f1f1f1; font-size: 40px; font-weight: bold; + transition: 0.3s; cursor: pointer; + } + .media-modal-close:hover, .media-modal-close:focus { color: #bbb; text-decoration: none; } + + /* Footer */ + .footer { + text-align: center; + margin-top: calc(var(--padding) * 2); + padding-top: var(--padding); + border-top: 1px solid var(--tg-theme-secondary-bg-color); + font-size: 0.85em; + color: var(--tg-theme-hint-color); + } -@app.route('/') -def index(): - # This route serves the initial HTML that will perform Telegram auth - return render_template_string(INITIAL_AUTH_HTML) + + + +
+
+

☁️ Zeus Cloud

+ +
-@app.route('/verify_auth', methods=['POST']) -def verify_auth_route(): - try: - payload = request.get_json() - init_data_str = payload.get('init_data') +
+ + +
+
+
+
+
+
+
- if not init_data_str: - return jsonify({'status': 'error', 'message': 'Missing init_data'}), 400 +
+

Мои файлы

+
Загрузка списка файлов...
+
+ +
У вас пока нет загруженных файлов.
+
- user_info = verify_telegram_auth(init_data_str, BOT_TOKEN) + - if user_info and 'id' in user_info: - telegram_id = int(user_info['id']) # Ensure it's an integer +
- # Add user to database if they don't exist - data = load_data() - if telegram_id not in data['users']: - data['users'][telegram_id] = { - 'telegram_id': telegram_id, - 'user_info': user_info, # Store basic info - 'created_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), - 'filesystem': {"type": "folder", "id": "root", "name": "root", "children": []} - } - try: - save_data(data) - logging.info(f"New user created: {telegram_id}") - except Exception as e: - logging.error(f"Failed to save new user data for {telegram_id}: {e}") - return jsonify({'status': 'error', 'message': 'Failed to initialize user data'}), 500 + +
+ × +
+
+
- # Store ID and info in session - session['telegram_id'] = telegram_id - session['user_info'] = user_info - session.permanent = True # Make session persistent - return jsonify({'status': 'success'}) - else: - return jsonify({'status': 'error', 'message': 'Invalid Telegram credentials'}), 401 + + + +""" +# --- Flask Routes --- +@app.route('/') +def index(): + theme_params = {} # Let JS handle theme application after tg.ready() + # Pass essential config to JS template return render_template_string( - ADMIN_USER_FILES_HTML, - user_info=user_info_for_template, - files=all_files, + TEMPLATE, + theme=theme_params, repo_id=REPO_ID, - hf_file_url=lambda path, download=False: f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{path}{'?download=true' if download else ''}", - url_for=url_for # Pass url_for to template if needed inside complex logic not handled by Jinja directly + upload_folder=HF_UPLOAD_FOLDER ) +@app.route('/list_files', methods=['POST']) +def list_files_route(): + req_data = request.get_json() + init_data_str = req_data.get('initData') -@app.route('/admhosto/delete_user/', methods=['POST']) -def admin_delete_user(telegram_id): - if not is_admin(): - flash('Доступ запрещен.', 'error') - return redirect(url_for('index')) # Redirect non-admins away - - if not HF_TOKEN_WRITE: - flash('Удаление невозможно: токен Hugging Face для записи не настроен.', 'error') - return redirect(url_for('admin_panel')) + user_data, is_valid, message = verify_telegram_data(init_data_str) - data = load_data() - if telegram_id not in data.get('users', {}): - flash(f'Пользователь с ID {telegram_id} не найден!', 'error') - return redirect(url_for('admin_panel')) + if not is_valid or not user_data: + return jsonify({"status": "error", "message": f"Аутентификация не удалась: {message}"}), 403 - user_data = data['users'][telegram_id] - user_info = user_data.get('user_info', {}) - username_display = f"{user_info.get('first_name','')} {user_info.get('last_name','')}".strip() or f"user_{telegram_id}" + user_id = user_data.get('id') + if not user_id: + return jsonify({"status": "error", "message": "Не удалось определить ID пользователя."}), 403 - logging.warning(f"ADMIN ACTION (User: {session.get('telegram_id')}): Attempting to delete user {username_display} (ID: {telegram_id}) and all their data.") + if not hf_api: + return jsonify({"status": "error", "message": "Hugging Face API не настроен на сервере."}), 500 - # --- Step 1: Delete files from Hugging Face --- - hf_deletion_successful = False + user_folder_path = f"{HF_UPLOAD_FOLDER}/{user_id}" try: - api = HfApi() - # Use telegram_id (as string) in the path - user_folder_path_on_hf = f"cloud_files/{str(telegram_id)}" - - logging.info(f"Attempting to delete HF Hub folder: {user_folder_path_on_hf} for user {telegram_id}") - # Use delete_folder, assuming it handles non-empty folders - api.delete_folder( - folder_path=user_folder_path_on_hf, + logging.info(f"Listing files for user {user_id} in repo {REPO_ID} path {user_folder_path}/") + # Use allow_patterns to list only files directly within the user's folder + repo_files = list_repo_files( repo_id=REPO_ID, repo_type="dataset", - token=HF_TOKEN_WRITE, - commit_message=f"ADMIN ACTION: Deleted all files/folders for user {telegram_id}" + token=HF_TOKEN_READ or HF_TOKEN_WRITE, # Use read or write token + paths=[user_folder_path], # Specify the directory + recursive=False # List only top-level files in the user dir ) - logging.info(f"Successfully initiated deletion of folder {user_folder_path_on_hf} on HF Hub.") - hf_deletion_successful = True # Mark HF deletion as successful (or at least attempted without fatal error) - - except hf_utils.HfHubHTTPError as e: - # Specifically check for 404 Not Found - means the folder doesn't exist, which is fine for deletion purpose - if e.response is not None and e.response.status_code == 404: - logging.warning(f"User folder {user_folder_path_on_hf} not found on HF Hub for user {telegram_id}. Skipping HF deletion, proceeding with DB removal.") - hf_deletion_successful = True # Consider it successful as there's nothing to delete - else: - # Other HTTP errors are problematic - logging.error(f"Error deleting user folder {user_folder_path_on_hf} from HF Hub for {telegram_id}: {e}") - flash(f'Ошибка при удалении файлов пользователя {username_display} с сервера: {e}. Пользователь НЕ удален из базы.', 'error') - return redirect(url_for('admin_panel')) # Stop the process if HF deletion fails unexpectedly + # The result includes the full path, which is what we want + user_files = [f for f in repo_files if f.startswith(user_folder_path + '/')] + + logging.info(f"Found {len(user_files)} files for user {user_id}.") + return jsonify({"status": "ok", "files": user_files}) + + except EntryNotFoundError: + logging.info(f"User folder '{user_folder_path}' not found for user {user_id}. Returning empty list.") + return jsonify({"status": "ok", "files": []}) # Folder doesn't exist yet = no files + except RepositoryNotFoundError: + logging.error(f"Hugging Face repository '{REPO_ID}' not found.") + return jsonify({"status": "error", "message": f"Ошибка сервера: Репозиторий '{REPO_ID}' не найден."}), 500 except Exception as e: - # Catch any other unexpected errors during HF deletion - logging.error(f"Unexpected error during HF Hub folder deletion for {telegram_id}: {e}") - flash(f'Неожиданная ошибка при удалении файлов {username_display} с сервера: {e}. Пользователь НЕ удален из базы.', 'error') - return redirect(url_for('admin_panel')) # Stop the process + logging.exception(f"Error listing files for user {user_id} from Hugging Face:") + return jsonify({"status": "error", "message": f"Ошибка сервера при получении списка файлов: {str(e)}"}), 500 - # --- Step 2: Delete user from database (only if HF deletion was successful or skipped due to 404) --- - if hf_deletion_successful: - try: - del data['users'][telegram_id] # Remove user by integer key - save_data(data) # Save the updated data - flash(f'Пользователь {username_display} (ID: {telegram_id}) и его файлы (запрос на удаление с сервера отправлен/папка не найдена) успешно удалены из базы данных!') - logging.info(f"ADMIN ACTION: Successfully deleted user {telegram_id} from database.") - except KeyError: - # Should not happen if check at the beginning passed, but handle defensively - flash(f'Ошибка: Пользователь {username_display} (ID: {telegram_id}) не найден в базе данных во время попытки удаления (возможно, удален параллельно?).', 'error') - logging.error(f"KeyError while trying to delete user {telegram_id} from DB, possibly already removed.") - except Exception as e: - # Critical error: HF files might be deleted, but DB entry remains - logging.error(f"CRITICAL: Error saving data after deleting user {telegram_id} from DB: {e}. HF folder deletion was likely attempted.") - flash(f'Файлы пользователя {username_display} удалены (или не найдены) с сервера, но произошла КРИТИЧЕСКАЯ ошибка при удалении пользователя из базы данных: {e}. Срочно проверьте консистентность!', 'error') - # Do NOT redirect immediately, admin needs to see this critical error message - # Redirect back to the admin panel after operations - return redirect(url_for('admin_panel')) +@app.route('/upload', methods=['POST']) +def upload_file_route(): + if not HF_TOKEN_WRITE: + return jsonify({"status": "error", "message": "Загрузка не разрешена: отсутствует токен записи."}), 403 + if not hf_api: + return jsonify({"status": "error", "message": "Hugging Face API не настроен для записи."}), 500 + + init_data_str = request.form.get('initData') + user_data, is_valid, message = verify_telegram_data(init_data_str) + if not is_valid or not user_data: + return jsonify({"status": "error", "message": f"Аутентификация не удалась: {message}"}), 403 -@app.route('/admhosto/delete_file//', methods=['POST']) -def admin_delete_file(telegram_id, file_id): - # Ensure the action is performed by an admin - if not is_admin(): - flash('Доступ запрещен.', 'error') - return redirect(url_for('index')) + user_id = user_data.get('id') + if not user_id: + return jsonify({"status": "error", "message": "Не удалось определить ID пользователя."}), 403 - # Check if write token is available - if not HF_TOKEN_WRITE: - flash('Удаление файла невозможно: токен Hugging Face для записи не настроен.', 'error') - return redirect(url_for('admin_user_files', telegram_id=telegram_id)) # Redirect back to user's file list + uploaded_files = request.files.getlist('files') + if not uploaded_files: + return jsonify({"status": "error", "message": "Файлы не найдены в запросе."}), 400 + + if len(uploaded_files) > 20: + return jsonify({"status": "error", "message": "Слишком много файлов. Максимум: 20."}), 400 - data = load_data() - # Get the specific user's data using the provided telegram_id - user_data = data.get('users', {}).get(telegram_id) + user_folder_path = f"{HF_UPLOAD_FOLDER}/{user_id}" + upload_success_count = 0 + upload_errors = [] + + for file in uploaded_files: + if file.filename == '': + logging.warning(f"Skipping empty filename upload for user {user_id}.") + continue + + # Sanitize filename potentially? For now, use original. + filename = file.filename + path_in_repo = f"{user_folder_path}/{filename}" + + try: + # Check if file already exists to prevent accidental overwrite? + # Or just let upload_file handle it (it overwrites by default) + logging.info(f"Uploading '{filename}' for user {user_id} to {REPO_ID}/{path_in_repo}") + + # Use a file-like object directly + file_content = file.read() # Read content into memory + file_obj = io.BytesIO(file_content) + + hf_api.upload_file( + path_or_fileobj=file_obj, + path_in_repo=path_in_repo, + repo_id=REPO_ID, + repo_type="dataset", + token=HF_TOKEN_WRITE, # Explicitly use write token + commit_message=f"User {user_id} uploaded {filename}" + # create_pr=False # Direct commit is usually desired here + ) + upload_success_count += 1 + logging.info(f"Successfully uploaded '{filename}' for user {user_id}.") + + except Exception as e: + logging.exception(f"Error uploading file '{filename}' for user {user_id} to Hugging Face:") + upload_errors.append(f"{filename}: {str(e)}") + + if not upload_errors and upload_success_count > 0: + return jsonify({"status": "ok", "message": f"{upload_success_count} файл(ов) успешно загружено."}) + elif upload_success_count > 0: + return jsonify({ + "status": "partial_error", + "message": f"{upload_success_count} файл(ов) загружено. Ошибки: {'; '.join(upload_errors)}", + "errors": upload_errors + }), 207 # Multi-Status + else: + return jsonify({ + "status": "error", + "message": f"Не удалось загрузить файлы. Ошибки: {'; '.join(upload_errors)}", + "errors": upload_errors + }), 500 - if not user_data or not isinstance(user_data, dict): - flash(f'Пользователь с ID {telegram_id} не найден.', 'error') - return redirect(url_for('admin_panel')) # Redirect to main admin panel if user not found - # Find the file within this specific user's filesystem - file_node, parent_node = find_node_by_id(user_data.get('filesystem',{}), file_id) +@app.route('/delete_file', methods=['POST']) +def delete_file_route(): + if not HF_TOKEN_WRITE: + return jsonify({"status": "error", "message": "Удаление не разрешено: отсутствует токен записи."}), 403 + if not hf_api: + return jsonify({"status": "error", "message": "Hugging Face API не настроен для записи."}), 500 - if not file_node or file_node.get('type') != 'file' or not parent_node: - flash(f'Файл с ID {file_id} не найден в структуре пользователя {telegram_id}.', 'error') - return redirect(url_for('admin_user_files', telegram_id=telegram_id)) # Redirect back to the user's file list + req_data = request.get_json() + init_data_str = req_data.get('initData') + file_path_to_delete = req_data.get('filePath') # e.g., "uploads/USER_ID/filename.ext" - hf_path = file_node.get('path') - original_filename = file_node.get('original_filename', 'файл') + if not file_path_to_delete: + return jsonify({"status": "error", "message": "Не указан путь к файлу для удаления."}), 400 - user_info = user_data.get('user_info', {}) - username_display = f"{user_info.get('first_name','')} {user_info.get('last_name','')}".strip() or f"user_{telegram_id}" + user_data, is_valid, message = verify_telegram_data(init_data_str) + if not is_valid or not user_data: + return jsonify({"status": "error", "message": f"Аутентификация не удалась: {message}"}), 403 - # Handle deletion if HF path is missing in metadata - if not hf_path: - logging.warning(f"ADMIN ACTION (User: {session.get('telegram_id')}): HF path missing for file {file_id} ({original_filename}) user {telegram_id}. Removing from DB only.") - flash(f'Предупреждение: Путь к файлу {original_filename} отсутствует в метаданных. Удаление только из базы.', 'warning') - if remove_node(user_data['filesystem'], file_id): - try: - save_data(data) - flash(f'Метаданные файла {original_filename} (пользователь {username_display}) удалены (путь отсутствовал).') - except Exception as e: - flash(f'Ошибка сохранения данных после удаления метаданных файла {original_filename} (путь отсутствовал).', 'error') - logging.error(f"Admin delete file metadata save error (no path) for user {telegram_id}, file {file_id}: {e}") - return redirect(url_for('admin_user_files', telegram_id=telegram_id)) + user_id = user_data.get('id') + if not user_id: + return jsonify({"status": "error", "message": "Не удалось определить ID пользователя."}), 403 + # Security Check: Ensure the file path belongs to the authenticated user + expected_prefix = f"{HF_UPLOAD_FOLDER}/{user_id}/" + if not file_path_to_delete.startswith(expected_prefix): + logging.warning(f"User {user_id} attempted to delete unauthorized path: {file_path_to_delete}") + return jsonify({"status": "error", "message": "Ошибка доступа к файлу."}), 403 - # --- Step 1: Delete file from Hugging Face --- - hf_deleted = False try: - api = HfApi() - api.delete_file( - path_in_repo=hf_path, + logging.info(f"Attempting to delete file '{file_path_to_delete}' for user {user_id} from repo {REPO_ID}") + hf_api.delete_file( + path_in_repo=file_path_to_delete, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE, - commit_message=f"ADMIN ACTION: Deleted file {original_filename} (ID: {file_id}) for user {telegram_id}" + commit_message=f"User {user_id} deleted {file_path_to_delete.split('/')[-1]}" ) - logging.info(f"ADMIN ACTION (User: {session.get('telegram_id')}): Deleted file {hf_path} from HF Hub for user {telegram_id}") - hf_deleted = True # Mark HF deletion successful - - except hf_utils.EntryNotFoundError: - logging.warning(f"ADMIN ACTION (User: {session.get('telegram_id')}): File {hf_path} not found on HF Hub during delete for user {telegram_id}. Removing from DB.") - flash(f'Файл {original_filename} (пользователь {username_display}) не найден на сервере Hugging Face. Удаляется из базы.', 'warning') - hf_deleted = True # Consider successful as the file is gone from HF + logging.info(f"Successfully deleted '{file_path_to_delete}' for user {user_id}.") + return jsonify({"status": "ok", "message": "Файл успешно удален."}) + except EntryNotFoundError: + logging.warning(f"File '{file_path_to_delete}' not found for deletion by user {user_id}.") + # Could argue whether this is an error or success (file is gone) + return jsonify({"status": "error", "message": "Файл не найден на сервере."}), 404 except Exception as e: - logging.error(f"ADMIN ACTION (User: {session.get('telegram_id')}): Error deleting file {hf_path} from HF for {telegram_id}: {e}") - flash(f'Ошибка удаления файла {original_filename} (пользователь {username_display}) с сервера: {e}. Файл НЕ удален из базы.', 'error') - # Do not proceed with DB deletion if HF deletion failed unexpectedly - return redirect(url_for('admin_user_files', telegram_id=telegram_id)) - - # --- Step 2: Delete file from database (if HF deletion was successful or skipped due to 404) --- - if hf_deleted: - if remove_node(user_data['filesystem'], file_id): - try: - save_data(data) - flash(f'Файл {original_filename} (пользователь {username_display}) успешно удален!') - logging.info(f"Successfully removed file {file_id} from DB for user {telegram_id}") - except Exception as e: - # Critical inconsistency - flash(f'Файл {original_filename} удален (или не найден) с сервера, но произошла КРИТИЧЕСКАЯ ошибка обновления базы данных для пользователя {username_display}.', 'error') - logging.error(f"CRITICAL: Admin delete file DB update error after HF action for user {telegram_id}, file {file_id}: {e}") - else: - # This indicates an inconsistency if file_node was found initially - flash(f'Файл {original_filename} удален (или не найден) с сервера, но НЕ НАЙДЕН в базе данных для удаления метаданных (пользователь {username_display}). Проверьте консистентность.', 'error') - logging.error(f"Inconsistency: File {file_id} deleted from HF (or not found) but failed to remove from DB structure for user {telegram_id}") - - # Redirect back to the user's file list in the admin panel - return redirect(url_for('admin_user_files', telegram_id=telegram_id)) + logging.exception(f"Error deleting file '{file_path_to_delete}' for user {user_id} from Hugging Face:") + return jsonify({"status": "error", "message": f"Ошибка сервера при удалении файла: {str(e)}"}), 500 # --- App Initialization --- - if __name__ == '__main__': - app.config['SESSION_PERMANENT'] = True - app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=30) # Example: 30 days session lifetime - - if not BOT_TOKEN: - logging.critical("BOT_TOKEN is not set. Telegram authentication will fail.") - if not HF_TOKEN_WRITE: - logging.warning("HF_TOKEN (write access) is not set. File uploads, deletions, and backups will fail.") - if not HF_TOKEN_READ: - logging.warning("HF_TOKEN_READ is not set. Falling back to HF_TOKEN. File downloads/previews might fail for private repos if HF_TOKEN is also not set.") - if not ADMIN_TELEGRAM_IDS: - logging.warning("ADMIN_TELEGRAM_IDS is not set. Admin panel access will be blocked.") - else: - logging.info(f"Admin Telegram IDs loaded: {ADMIN_TELEGRAM_IDS}") - - - # Perform initial DB download/check - if HF_TOKEN_READ or HF_TOKEN_WRITE: - logging.info("Performing initial database download/check...") - download_db_from_hf() - else: - logging.warning("No read or write token. Database operations with Hugging Face Hub are disabled.") - if not os.path.exists(DATA_FILE): - with open(DATA_FILE, 'w', encoding='utf-8') as f: - json.dump({'users': {}}, f) - logging.info(f"Created empty local database file: {DATA_FILE}") - - # Start periodic backup thread only if write token is available - if HF_TOKEN_WRITE: - backup_thread = threading.Thread(target=periodic_backup, daemon=True) - backup_thread.start() - logging.info("Periodic backup thread started.") + print("---") + print("--- ZEUS CLOUD MINI APP SERVER ---") + print("---") + print(f"Flask server starting on http://{HOST}:{PORT}") + print(f"Using Bot Token ID: {BOT_TOKEN.split(':')[0]}") + print(f"Hugging Face Repo: {REPO_ID}") + print(f"HF Upload Folder: {HF_UPLOAD_FOLDER}") + if not hf_api: + print("---") + print("--- WARNING: HUGGING FACE API NOT INITIALIZED ---") + print("--- Set HF_TOKEN_READ and/or HF_TOKEN_WRITE environment variables.") + print("--- File listing requires READ, Upload/Delete require WRITE.") + print("---") + elif not HF_TOKEN_WRITE: + print("---") + print("--- WARNING: HF_TOKEN_WRITE NOT SET ---") + print("--- File uploads and deletions will be disabled.") + print("---") else: - logging.warning("Periodic backup disabled because HF_TOKEN (write access) is not set.") + print("--- Hugging Face WRITE token found. Uploads/Deletes enabled.") - # Run the Flask app - # Use Gunicorn or another production server instead of app.run for deployment - # Example for local testing: - # from datetime import timedelta - app.run(debug=False, host='0.0.0.0', port=7861) # Use a different port, e.g., 7861 \ No newline at end of file + print("--- Server Ready ---") + # Use a production server like Waitress or Gunicorn instead of app.run() for deployment + # from waitress import serve + # serve(app, host=HOST, port=PORT) + app.run(host=HOST, port=PORT, debug=False) # debug=False recommended for production \ No newline at end of file