diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,12 +1,11 @@ -# --- START OF FILE app.py --- -from flask import Flask, render_template_string, request, redirect, url_for, session, flash, send_file, jsonify, Response -from flask_caching import Cache import json import os import logging import threading import time -from datetime import datetime, timedelta +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 @@ -14,104 +13,58 @@ from io import BytesIO import uuid import hmac import hashlib -from urllib.parse import parse_qsl, unquote +from urllib.parse import unquote, parse_qs # --- 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" # Replace with your HF repo ID if different +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' -TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "6750208873:AAE2hvPlJ99dBdhGa_Brre0IIpUdOvXxHt4") # Your Bot Token -ADMIN_TELEGRAM_ID = int(os.getenv("ADMIN_TELEGRAM_ID", "0")) # Set your numeric Telegram User ID as env var - os.makedirs(UPLOAD_FOLDER, exist_ok=True) + cache = Cache(app, config={'CACHE_TYPE': 'simple'}) logging.basicConfig(level=logging.INFO) # --- Helper Functions --- -def check_telegram_authorization(auth_data_str, bot_token): - """Validates the integrity of data received from the Telegram Web App.""" - if not auth_data_str or not bot_token: - logging.warning("Auth check failed: Missing auth_data or bot_token") - return None - - try: - auth_data = dict(parse_qsl(auth_data_str)) - received_hash = auth_data.pop('hash', None) - if not received_hash: - logging.warning("Auth check failed: No hash found in auth_data") - return None - - # Check data freshness (e.g., within 1 hour) - auth_date_ts = int(auth_data.get('auth_date', 0)) - if abs(time.time() - auth_date_ts) > 3600: - logging.warning(f"Auth check failed: Data is outdated (timestamp: {auth_date_ts})") - # Allow for testing, remove in production if strict check needed - # return None - pass # Bypass time check for easier testing - - data_check_string_parts = [] - for key in sorted(auth_data.keys()): - data_check_string_parts.append(f"{key}={auth_data[key]}") - data_check_string = "\n".join(data_check_string_parts) - - 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.loads(unquote(auth_data.get('user', '{}'))) - if not user_data.get('id'): - logging.warning("Auth check failed: User ID missing in parsed user data") - return None - logging.info(f"Telegram Auth Success for User ID: {user_data.get('id')}") - return user_data - else: - logging.warning(f"Auth check failed: Hash mismatch. Calculated: {calculated_hash}, Received: {received_hash}") - return None - except Exception as e: - logging.error(f"Error during Telegram auth check: {e}", exc_info=True) - return None - def find_node_by_id(filesystem, node_id): - if not filesystem: return None, None + 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 and current_node.get('type') == 'folder' and 'children' in current_node: + if current_node.get('type') == 'folder' and 'children' in current_node: for i, child in enumerate(current_node.get('children', [])): - if child and child.get('id') == node_id: + if not isinstance(child, dict): continue # Skip invalid children + if child.get('id') == node_id: return child, current_node - if child and child.get('type') == 'folder': + 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: + if 'children' not in parent_node or not isinstance(parent_node['children'], list): parent_node['children'] = [] - # Ensure children list exists and is a list - if not isinstance(parent_node.get('children'), list): - parent_node['children'] = [] parent_node['children'].append(node_data) return True - logging.warning(f"Could not add node. Parent folder {parent_id} not found or not a folder.") 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.get('children'), list): - original_len = len(parent_node['children']) - parent_node['children'] = [child for child in parent_node['children'] if child.get('id') != node_id] - return len(parent_node['children']) < original_len - logging.warning(f"Could not remove node {node_id}. Node or parent not found, or parent has no children list.") + 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): @@ -124,14 +77,15 @@ def get_node_path_string(filesystem, node_id): break if node.get('id') != 'root': path_list.append(node.get('name', node.get('original_filename', ''))) - # Prevent infinite loop if structure is broken - if not parent or parent.get('id') == current_id: + 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", @@ -139,64 +93,93 @@ def initialize_user_filesystem(user_data): "name": "root", "children": [] } - # Migration logic from old 'files' list removed as TMA assumes new structure - if 'files' in user_data: - del user_data['files'] # Clean up old potential structure - -# --- Data Handling & HF Sync --- + # 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: - data = json.load(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': {}} - data.setdefault('users', {}) - # Initialize filesystem for users who might lack it (e.g., old format) - for tg_id_str, user_data in data['users'].items(): - if isinstance(user_data, dict): - initialize_user_filesystem(user_data) - else: - # Handle potential malformed user data entry - logging.warning(f"Skipping malformed user data for key: {tg_id_str}") + + # 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 json.JSONDecodeError: - logging.error(f"Error decoding JSON from {DATA_FILE}. Initializing empty database.") - return {'users': {}} except Exception as e: - logging.error(f"Error loading data: {e}", exc_info=True) + logging.error(f"Error loading data: {e}") return {'users': {}} + def save_data(data): try: - # Ensure all user keys are strings (JSON requires string keys) - string_keyed_users = {str(k): v for k, v in data.get('users', {}).items()} - data['users'] = string_keyed_users + # 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, file, ensure_ascii=False, indent=4) + 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}", exc_info=True) - # Optionally, re-raise or handle more gracefully - # raise + 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 - if not os.path.exists(DATA_FILE): - logging.warning(f"Skipping DB upload: {DATA_FILE} does not exist.") - return try: api = HfApi() api.upload_file( @@ -209,7 +192,7 @@ def upload_db_to_hf(): ) logging.info("Database uploaded to Hugging Face") except Exception as e: - logging.error(f"Error uploading database: {e}", exc_info=True) + logging.error(f"Error uploading database: {e}") def download_db_from_hf(): if not HF_TOKEN_READ: @@ -226,7 +209,7 @@ def download_db_from_hf(): token=HF_TOKEN_READ, local_dir=".", local_dir_use_symlinks=False, - # force_download=True # Consider if needed + force_filename=DATA_FILE # Ensure correct filename ) logging.info("Database downloaded from Hugging Face") except hf_utils.RepositoryNotFoundError: @@ -235,12 +218,12 @@ def download_db_from_hf(): 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 locally if needed.") + 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}", exc_info=True) + 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) @@ -249,12 +232,12 @@ def periodic_backup(): while True: time.sleep(1800) # Backup every 30 minutes logging.info("Starting periodic backup...") - # Ensure data is loaded before saving (in case of long uptime without requests) try: - current_data = load_data() # Load potentially cached or latest data - save_data(current_data) # Save and upload + # 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}", exc_info=True) + logging.error(f"Error during periodic backup: {e}") def get_file_type(filename): @@ -265,18 +248,63 @@ def get_file_type(filename): return 'image' elif filename_lower.endswith('.pdf'): return 'pdf' - elif filename_lower.endswith(('.txt', '.log', '.csv', '.md', '.py', '.js', '.html', '.css', '.json')): + elif filename_lower.endswith('.txt'): return 'text' - # Add more types if needed - elif filename_lower.endswith(('.zip', '.rar', '.7z', '.tar', '.gz')): - return 'archive' - elif filename_lower.endswith(('.doc', '.docx', '.ppt', '.pptx', '.xls', '.xlsx')): - return 'document' - elif filename_lower.endswith(('.mp3', '.wav', '.ogg', '.flac')): - return 'audio' return 'other' -# --- Styles --- +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] + + if not received_hash: + logging.warning("Hash missing in initData") + return None + + data_check_string_parts = [] + 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) + + 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: + 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 + else: + logging.error("User data missing in initData") + return None + else: + logging.warning(f"Hash mismatch. Calculated: {calculated_hash}, Received: {received_hash}") + return None + + 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; @@ -285,7 +313,7 @@ BASE_STYLE = ''' --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 Variables (Defaults) */ + /* Telegram Theme Integration */ --tg-theme-bg-color: var(--background-light); --tg-theme-text-color: var(--text-light); --tg-theme-hint-color: #aaa; @@ -294,275 +322,295 @@ BASE_STYLE = ''' --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; - padding-bottom: 60px; /* Space for potential main button */ -} -/* Apply dark theme based on TG */ -body.dark-theme { - --tg-theme-bg-color: var(--background-dark); - --tg-theme-text-color: var(--text-dark); - --tg-theme-hint-color: #777; - --tg-theme-link-color: #9b7cfb; /* Lighter accent */ - --tg-theme-button-color: #ff6a88; /* Lighter primary */ - --tg-theme-button-text-color: #ffffff; - --tg-theme-secondary-bg-color: var(--card-bg-dark); + transition: background-color 0.3s ease, color 0.3s ease; } -.container { margin: 10px auto; max-width: 1200px; padding: 15px; background: transparent; border-radius: 0; box-shadow: none; overflow-x: hidden; } -h1 { font-size: 1.8em; font-weight: 800; text-align: center; margin-bottom: 20px; background: linear-gradient(135deg, var(--tg-theme-button-color), var(--tg-theme-link-color)); -webkit-background-clip: text; color: transparent; } -h2 { font-size: 1.4em; margin-top: 25px; color: var(--tg-theme-text-color); margin-bottom: 10px;} -h4 { font-size: 1.1em; margin-top: 15px; margin-bottom: 5px; color: var(--tg-theme-link-color); } +.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[type=text], input[type=password], input[type=file], textarea { - width: 100%; padding: 12px; margin: 10px 0; border: 1px solid var(--tg-theme-hint-color); border-radius: 10px; - background: var(--tg-theme-secondary-bg-color); color: var(--tg-theme-text-color); font-size: 1em; - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1); -} -input:focus, textarea:focus { outline: none; border-color: var(--tg-theme-link-color); box-shadow: 0 0 0 2px rgba(var(--tg-theme-link-color), 0.3); } -.btn { - padding: 12px 24px; background: var(--tg-theme-button-color); color: var(--tg-theme-button-text-color); border: none; border-radius: 10px; - cursor: pointer; font-size: 1em; font-weight: 600; transition: var(--transition); box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); - display: inline-block; text-decoration: none; text-align: center; margin-top: 5px; margin-right: 5px; -} -.btn:hover { opacity: 0.9; transform: translateY(-1px); } -.download-btn { background: var(--secondary); color: white; } /* Keep secondary color for download */ +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(--tg-theme-text-color); text-align: center; margin-bottom: 15px; padding: 10px; background: rgba(0, 221, 235, 0.1); border-radius: 10px; border: 1px solid var(--secondary); } -.flash.error { color: var(--tg-theme-text-color); background: rgba(255, 68, 68, 0.1); border-color: var(--delete-color); } -.file-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; margin-top: 15px; } -.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; height: 100%; } -.item:hover { transform: translateY(-3px); box-shadow: 0 12px 25px rgba(0,0,0,0.15); } -.item-preview { width: 100%; height: 100px; object-fit: cover; border-radius: 8px; margin-bottom: 8px; cursor: pointer; display: block; background-color: rgba(0,0,0,0.05); } -.item.folder .item-preview { object-fit: contain; font-size: 50px; color: var(--folder-color); line-height: 100px; background-color: transparent; } -.item.file .item-preview[data-type="pdf"] { font-size: 50px; line-height: 100px; color: var(--accent); background-color: transparent; } -.item.file .item-preview[data-type="text"] { font-size: 50px; line-height: 100px; color: var(--secondary); background-color: transparent; } -.item.file .item-preview[data-type="archive"] { font-size: 50px; line-height: 100px; color: #f0ad4e; background-color: transparent; } -.item.file .item-preview[data-type="audio"] { font-size: 50px; line-height: 100px; color: #5bc0de; background-color: transparent; } -.item.file .item-preview[data-type="document"] { font-size: 50px; line-height: 100px; color: #0275d8; background-color: transparent; } -.item.file .item-preview[data-type="other"] { font-size: 50px; line-height: 100px; color: var(--tg-theme-hint-color); background-color: transparent; } -.item p { font-size: 0.85em; margin: 3px 0; word-break: break-all; line-height: 1.3; } -.item p.filename { font-weight: 600; } -.item p.details { font-size: 0.75em; color: var(--tg-theme-hint-color); } +.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 { text-decoration: underline; } +.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: 80vw; height: 85vh; border: none; background: white; } /* Ensure iframe bg is white for PDFs */ -.modal pre { background: var(--tg-theme-bg-color); 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: 5px; right: 10px; font-size: 24px; color: var(--tg-theme-hint-color); cursor: pointer; background: rgba(0,0,0,0.2); border-radius: 50%; width: 25px; height: 25px; line-height: 25px; text-align: center; z-index: 2001;} -#progress-container { width: 100%; background: var(--tg-theme-secondary-bg-color); border-radius: 10px; margin: 15px 0; display: none; position: relative; height: 20px; border: 1px solid var(--tg-theme-hint-color);} +.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.2); } -.breadcrumbs { margin-bottom: 15px; font-size: 1em; color: var(--tg-theme-hint-color); word-break: break-word; } +#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 3px; } +.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 { padding: 20px; text-align: center; } -#loading-indicator { padding: 30px; text-align: center; font-size: 1.2em; } -.admin-panel .user-list { margin-top: 20px; } -.admin-panel .user-item { padding: 15px; background: var(--tg-theme-secondary-bg-color); border-radius: 16px; margin-bottom: 10px; box-shadow: var(--shadow); transition: var(--transition); } -.admin-panel .user-item:hover { transform: translateY(-5px); } -.admin-panel .user-item a { color: var(--tg-theme-link-color); text-decoration: none; font-weight: 600; } -.admin-panel .user-item a:hover { color: var(--primary); } /* Use primary for hover in admin */ -.admin-panel .file-item { background: var(--tg-theme-secondary-bg-color); padding: 10px; border-radius: 12px; box-shadow: var(--shadow); transition: var(--transition); display: flex; flex-direction: column; justify-content: space-between; } -.admin-panel .file-item:hover { transform: translateY(-3px); } -.admin-panel .file-preview { max-width: 100%; height: 80px; object-fit: contain; border-radius: 8px; margin-bottom: 8px; display: block; margin-left: auto; margin-right: auto; background-color: rgba(0,0,0,0.05); } -.admin-panel .admin-file-actions { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 5px; justify-content: center; } -.admin-panel .admin-file-actions .btn { font-size: 0.8em; padding: 4px 8px; margin: 0; } - -@media (max-width: 480px) { - .file-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px; } +.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; } - .item.file .item-preview[data-type="pdf"], - .item.file .item-preview[data-type="text"], - .item.file .item-preview[data-type="archive"], - .item.file .item-preview[data-type="audio"], - .item.file .item-preview[data-type="document"], - .item.file .item-preview[data-type="other"] { font-size: 40px; line-height: 80px; } - .item p { font-size: 0.8em;} - .breadcrumbs { font-size: 0.9em; } + h1 { font-size: 1.6em; } .btn { padding: 10px 20px; font-size: 0.9em; } - .item-actions .btn { padding: 4px 6px; font-size: 0.75em;} - .folder-actions { flex-direction: column; align-items: stretch; } + .item-actions .btn { padding: 4px 8px; font-size: 0.75em;} } ''' -# --- Telegram Mini App Routes --- - -@app.route('/') -def index(): - # This route serves the initial HTML shell for the TMA. - # Auth verification happens client-side via JS calling /verify_telegram_auth - initial_html = ''' - -Cloud TMA - +INITIAL_AUTH_HTML = ''' + +Zeus Cloud - - -
-
-
Инициализация...
- -
- + +
+

Zeus Cloud

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

+
+
- - - + +''' + +DASHBOARD_HTML = ''' + +Панель управления - 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 %} +
+ +
+ + + + + +''' - // Handle Back Button clicks - tg.BackButton.onClick(closeModalManual); // Close modal if open, otherwise maybe navigate back? + +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 %} - '''.format(style=BASE_STYLE, repo_id=REPO_ID, HF_TOKEN_READ=HF_TOKEN_READ or '') # Pass style and config - return render_template_string(initial_html) - -@app.route('/verify_telegram_auth', methods=['POST']) -def verify_telegram_auth(): - if not TELEGRAM_BOT_TOKEN: - return jsonify({'status': 'error', 'message': 'Telegram Bot Token не настроен на сервере.'}), 500 - - req_data = request.json - init_data_str = req_data.get('initData') - - if not init_data_str: - return jsonify({'status': 'error', 'message': 'initData не получены.'}), 400 - - user_data = check_telegram_authorization(init_data_str, TELEGRAM_BOT_TOKEN) - - if user_data and 'id' in user_data: - tg_id = user_data['id'] - tg_first_name = user_data.get('first_name', '') - tg_username = user_data.get('username') - tg_lang = user_data.get('language_code', 'en') - - session['tg_id'] = tg_id - session['tg_first_name'] = tg_first_name - session['tg_username'] = tg_username - session['tg_lang'] = tg_lang - session.permanent = True # Make session last longer - - # Load persistent data and check/create user entry - data = load_data() - users = data.setdefault('users', {}) - tg_id_str = str(tg_id) # Use string keys for JSON compatibility - - if tg_id_str not in users: - logging.info(f"New user from Telegram: ID={tg_id}, Name={tg_first_name}, Username={tg_username}") - users[tg_id_str] = { - 'tg_first_name': tg_first_name, - 'tg_username': tg_username, - 'created_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), - 'filesystem': { - "type": "folder", "id": "root", "name": "root", "children": [] +''' + +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 --- + +@app.route('/') +def index(): + # This route serves the initial HTML that will perform Telegram auth + return render_template_string(INITIAL_AUTH_HTML) + +@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) - except Exception as e: - logging.error(f"Failed to save data for new user {tg_id}: {e}") - return jsonify({'status': 'error', 'message': 'Ошибка сохранения данных нового пользователя.'}), 500 + 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: - # Optionally update user info if changed - changed = False - if users[tg_id_str].get('tg_first_name') != tg_first_name: - users[tg_id_str]['tg_first_name'] = tg_first_name - changed = True - if users[tg_id_str].get('tg_username') != tg_username: - users[tg_id_str]['tg_username'] = tg_username - changed = True - # Ensure filesystem exists for existing users - if 'filesystem' not in users[tg_id_str] or not isinstance(users[tg_id_str]['filesystem'], dict): - initialize_user_filesystem(users[tg_id_str]) - changed = True - - if changed: - try: - save_data(data) - except Exception as e: - logging.warning(f"Failed to update user data for {tg_id}: {e}") - # Non-critical error, proceed with auth - - return jsonify({'status': 'success', 'user': user_data}) - else: - logging.warning("Telegram authentication failed verification.") - return jsonify({'status': 'error', 'message': 'Ошибка проверки данных Telegram.'}), 403 + return jsonify({'status': 'error', 'message': 'Invalid Telegram credentials'}), 401 + except Exception as e: + logging.error(f"Error in /verify_auth: {e}") + return jsonify({'status': 'error', 'message': 'Internal server error during authentication'}), 500 -# This route provides the *content* of the dashboard, loaded via fetch -@app.route('/dashboard_content') -def dashboard_content(): - if 'tg_id' not in session: - return Response("Не авторизован", status=401) - tg_id = session['tg_id'] - tg_id_str = str(tg_id) - display_name = session.get('tg_first_name', f"User {tg_id}") +@app.route('/dashboard') +def dashboard(): + if 'telegram_id' not in session or 'user_info' not in session: + # If not authenticated, redirect to the initial page which handles auth + return redirect(url_for('index')) + telegram_id = session['telegram_id'] + user_info = session['user_info'] data = load_data() - if tg_id_str not in data.get('users', {}): - session.clear() - return Response("Пользователь не найден", status=404) - user_data = data['users'][tg_id_str] - # Ensure filesystem exists - if 'filesystem' not in user_data: - initialize_user_filesystem(user_data) - # Attempt to save immediately if initialized - try: save_data(data) - except Exception as e: logging.error(f"Failed saving after init for {tg_id}: {e}") + if telegram_id not in data['users']: + # If user somehow disappeared from DB after login, clear session and redirect + session.clear() + flash('Ошибка: данные пользователя не найдены. Пожалуйста, авторизуйтесь снова.', 'error') + return redirect(url_for('index')) + user_data = data['users'][telegram_id] + # Ensure filesystem exists (might be redundant if load_data handles it well) + initialize_user_filesystem(user_data) current_folder_id = request.args.get('folder_id', 'root') - current_folder, parent_folder = find_node_by_id(user_data.get('filesystem'), current_folder_id) + current_folder, parent_folder = find_node_by_id(user_data.get('filesystem', {}), current_folder_id) if not current_folder or current_folder.get('type') != 'folder': - logging.warning(f"Folder {current_folder_id} not found or not a folder for user {tg_id}. Falling back to root.") - # Don't flash here, just redirect logic to root + flash('Запрошенная папка не найдена!', 'error') current_folder_id = 'root' - current_folder, parent_folder = find_node_by_id(user_data.get('filesystem'), current_folder_id) + current_folder, parent_folder = find_node_by_id(user_data.get('filesystem', {}), current_folder_id) if not current_folder: - logging.error(f"CRITICAL: Root folder not found for user {tg_id}") + logging.error(f"CRITICAL: Root folder not found for user {telegram_id}") + flash('Критическая ошибка: корневая папка не найдена.', 'error') session.clear() - return Response('Критическая ошибка: корневая папка не найдена.', status=500) + return redirect(url_for('index')) - items_in_folder = sorted(current_folder.get('children', []), key=lambda x: (x.get('type', '') != 'folder', x.get('name', x.get('original_filename', '')).lower())) + items_in_folder = sorted(current_folder.get('children', []), key=lambda x: (x.get('type', 'file') != 'folder', x.get('name', x.get('original_filename', '')).lower())) - # Generate Breadcrumbs + # Calculate breadcrumbs breadcrumbs = [] temp_id = current_folder_id - fs = user_data.get('filesystem') - # Limit breadcrumb depth to prevent infinite loops on corrupted data - depth = 0 - max_depth = 20 - while temp_id and depth < max_depth: + fs = user_data.get('filesystem', {}) + while temp_id: node, parent = find_node_by_id(fs, temp_id) if not node: break is_link = (node.get('id') != current_folder_id) - name = node.get('name', 'Unknown') if node.get('type') == 'folder' else node.get('original_filename', 'Unknown') - if node.get('id') == 'root': name = 'Главная' - breadcrumbs.append({'id': node.get('id'), 'name': name, 'is_link': is_link}) - if not parent or parent.get('id') == temp_id: break # Stop if no parent or self-reference + breadcrumbs.append({'id': node.get('id'), 'name': node.get('name', 'Root'), 'is_link': is_link}) + if not parent: break temp_id = parent.get('id') - depth += 1 - if depth == max_depth: - logging.warning(f"Max breadcrumb depth reached for user {tg_id}, folder {current_folder_id}") breadcrumbs.reverse() - - dashboard_html = ''' -
-

Cloud

Пользователь: {{ display_name }} (ID: {{ tg_id }})

-
- {# Flash messages will be shown via tg.showAlert in JS #} -
- - - -
-
- - - -
-
- -
- - - -
-
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'] %} - {% set icon = {'image': '🖼️', 'video': '🎬', 'pdf': '📄', 'text': '📝', 'archive': '📦', 'audio': '🎵', 'document': '📎'}.get(item.file_type, '❓') %} -
- {% if item.file_type != 'image' %} {{ icon }} {% endif %} -
- -

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

-

{{ item.upload_date }}

-
- {# Use JS to trigger download if direct link causes issues in TMA #} - Скачать - {% if previewable %} - - {% endif %} -
- - -
-
- {% endif %} -
- {% endfor %} - {% if not items %}

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

{% endif %} -
- - {# Очистить сессию #} - {# Logout doesn't make much sense in TMA context #} -
-''' template_context = { - 'tg_id': tg_id, - 'display_name': display_name, + 'telegram_id': telegram_id, + 'user_info': user_info, 'items': items_in_folder, 'current_folder_id': current_folder_id, 'current_folder': current_folder, 'breadcrumbs': breadcrumbs, 'repo_id': REPO_ID, - 'HF_TOKEN_READ': HF_TOKEN_READ, + 'HF_TOKEN_READ': HF_TOKEN_READ, # Note: Passing tokens to JS isn't secure practice 'hf_file_url': lambda path, download=False: f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{path}{'?download=true' if download else ''}", - 'os': os # Keep os if needed by templates, though unlikely now + 'os': os # os module likely not needed in template directly } - return render_template_string(dashboard_html, **template_context) - + return render_template_string(DASHBOARD_HTML, **template_context) -# --- File/Folder Operations --- @app.route('/upload', methods=['POST']) -def upload_file(): - if 'tg_id' not in session: - return jsonify({'status': 'error', 'message': 'Не авторизован'}), 401 +def upload_files(): + if 'telegram_id' not in session: + # Use 401 Unauthorized or 403 Forbidden + return jsonify({"status": "error", "message": "Не авторизован"}), 401 - if not HF_TOKEN_WRITE: - # Return JSON for JS handler - return jsonify({'status': 'error', 'message': 'Загрузка невозможна: токен для записи не настроен.'}), 403 + telegram_id = session['telegram_id'] + data = load_data() + user_data = data['users'].get(telegram_id) - tg_id = session['tg_id'] - tg_id_str = str(tg_id) - # Use tg_id for path to avoid issues with changing usernames - user_identifier_for_path = tg_id_str + if not user_data: + return jsonify({"status": "error", "message": "Пользователь не найден"}), 404 - data = load_data() - if tg_id_str not in data.get('users', {}): - return jsonify({'status': 'error', 'message': 'Пользователь не найден'}), 404 - user_data = data['users'][tg_id_str] + if not HF_TOKEN_WRITE: + flash('Загрузка невозможна: токен для записи не настроен.', 'error') + # Redirect back to the folder they were in + return redirect(url_for('dashboard', folder_id=request.form.get('current_folder_id', 'root'))) files = request.files.getlist('files') + target_folder_id = request.form.get('current_folder_id', 'root') + if not files or all(not f.filename for f in files): - return jsonify({'status': 'error', 'message': 'Файлы для загрузки не выбраны.'}), 400 + flash('Файлы для загрузки не выбраны.', 'error') + return redirect(url_for('dashboard', folder_id=target_folder_id)) if len(files) > 20: - return jsonify({'status': 'error', 'message': 'Максимум 20 файлов за раз!'}), 400 - - target_folder_id = request.form.get('current_folder_id', 'root') - target_folder_node, _ = find_node_by_id(user_data.get('filesystem'), target_folder_id) + flash('Максимум 20 файлов за раз!', 'error') + return redirect(url_for('dashboard', folder_id=target_folder_id)) + target_folder_node, _ = find_node_by_id(user_data.get('filesystem',{}), target_folder_id) if not target_folder_node or target_folder_node.get('type') != 'folder': - return jsonify({'status': 'error', 'message': 'Целевая папка для загрузки не найдена!'}), 404 + flash('Целевая папка для загрузки не найдена!', 'error') + # Redirect to root if target folder is invalid + return redirect(url_for('dashboard')) api = HfApi() uploaded_count = 0 errors = [] - save_needed = False for file in files: if file and file.filename: original_filename = secure_filename(file.filename) name_part, ext_part = os.path.splitext(original_filename) unique_suffix = uuid.uuid4().hex[:8] - # Keep filename relatively simple for HF path - unique_filename = f"{secure_filename(name_part)}_{unique_suffix}{ext_part}" + unique_filename = f"{name_part}_{unique_suffix}{ext_part}" file_id = uuid.uuid4().hex - # Construct path using tg_id and target folder id - hf_path = f"cloud_files/{user_identifier_for_path}/{target_folder_id}/{unique_filename}" + # Use telegram_id (as string) in path + hf_path = f"cloud_files/{str(telegram_id)}/{target_folder_id}/{unique_filename}" temp_path = os.path.join(UPLOAD_FOLDER, f"{file_id}_{unique_filename}") try: @@ -1132,14 +1117,14 @@ def upload_file(): repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE, - commit_message=f"User {tg_id} uploaded {original_filename} to folder {target_folder_id}" + commit_message=f"User {telegram_id} uploaded {original_filename} to folder {target_folder_id}" ) file_info = { 'type': 'file', 'id': file_id, 'original_filename': original_filename, - 'unique_filename': unique_filename, # Store for potential reference + 'unique_filename': unique_filename, 'path': hf_path, 'file_type': get_file_type(original_filename), 'upload_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S') @@ -1147,347 +1132,372 @@ def upload_file(): if add_node(user_data['filesystem'], target_folder_id, file_info): uploaded_count += 1 - save_needed = True else: - error_msg = f"Ошибка добавления метаданных для {original_filename} в папку {target_folder_id}." - errors.append(error_msg) - logging.error(f"Failed to add node metadata for file {file_id} to folder {target_folder_id} for user {tg_id}") - # Attempt to clean up orphaned file on HF + errors.append(f"Ошибка добавления метаданных для {original_filename}.") + logging.error(f"Failed to add node metadata for file {file_id} to folder {target_folder_id} for user {telegram_id}") + # Attempt to clean up orphaned HF file try: api.delete_file(path_in_repo=hf_path, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE) - logging.info(f"Cleaned up orphaned HF file: {hf_path}") except Exception as del_err: logging.error(f"Failed to delete orphaned file {hf_path} from HF Hub: {del_err}") except Exception as e: - logging.error(f"Error uploading file {original_filename} for {tg_id}: {e}", exc_info=True) + logging.error(f"Error uploading file {original_filename} for {telegram_id}: {e}") errors.append(f"Ошибка загрузки файла {original_filename}: {e}") finally: if os.path.exists(temp_path): os.remove(temp_path) - if save_needed: + if uploaded_count > 0: try: save_data(data) - logging.info(f"{uploaded_count} files uploaded successfully for user {tg_id}.") + flash(f'{uploaded_count} файл(ов) успешно загружено!') except Exception as e: - errors.append('Файлы загружены, но произошла ошибка сохранения метаданных.') - logging.error(f"Error saving data after upload for {tg_id}: {e}", exc_info=True) + flash('Файлы загружены на сервер, но произошла ошибка сохранения метаданных.', 'error') + logging.error(f"Error saving data after upload for {telegram_id}: {e}") - final_message = "" - if uploaded_count > 0: - final_message += f'{uploaded_count} файл(ов) успешно загружено! ' if errors: - final_message += "Ошибки: " + "; ".join(errors) - - status_code = 200 if uploaded_count > 0 and not errors else (500 if errors else 200) - status_str = "success" if status_code == 200 else "error" + for error_msg in errors: + flash(error_msg, 'error') - return jsonify({'status': status_str, 'message': final_message.strip()}), status_code + # Redirect back to the folder where files were uploaded + return redirect(url_for('dashboard', folder_id=target_folder_id)) @app.route('/create_folder', methods=['POST']) def create_folder(): - if 'tg_id' not in session: - return jsonify({'status': 'error', 'message': 'Не авторизован'}), 401 + if 'telegram_id' not in session: + flash('Пожалуйста, авторизуйтесь.', 'error') + return redirect(url_for('index')) - tg_id = session['tg_id'] - tg_id_str = str(tg_id) + telegram_id = session['telegram_id'] data = load_data() - user_data = data['users'].get(tg_id_str) + user_data = data['users'].get(telegram_id) if not user_data: - return jsonify({'status': 'error', 'message': 'Пользователь не найден'}), 404 + flash('Пользователь не найден!', 'error') + session.clear() + return redirect(url_for('index')) parent_folder_id = request.form.get('parent_folder_id', 'root') - # Sanitize folder name - allow letters, numbers, spaces, underscore - folder_name_raw = request.form.get('folder_name', '').strip() - folder_name = "".join(c for c in folder_name_raw if c.isalnum() or c in (' ', '_')).strip() - + folder_name = request.form.get('folder_name', '').strip() if not folder_name: - return jsonify({'status': 'error', 'message': 'Имя папки не может быть пустым или содержать недопустимые символы.'}), 400 - if len(folder_name) > 50: # Add length limit - return jsonify({'status': 'error', 'message': 'Имя папки слишком длинное (макс 50 симв).'}), 400 + flash('Имя папки не может быть пустым!', 'error') + return redirect(url_for('dashboard', folder_id=parent_folder_id)) + # Allow more characters, restrict later if needed, basic validation + import re + if not re.match(r"^[a-zA-Z0-9 _.-]+$", folder_name): + flash('Имя папки содержит недопустимые символы.', 'error') + return redirect(url_for('dashboard', folder_id=parent_folder_id)) folder_id = uuid.uuid4().hex folder_data = { 'type': 'folder', 'id': folder_id, 'name': folder_name, - 'children': [] # Always initialize children for new folders + 'children': [] } - # Ensure filesystem structure is valid before adding - if 'filesystem' not in user_data or not isinstance(user_data.get('filesystem'), dict): - initialize_user_filesystem(user_data) - - - if add_node(user_data.get('filesystem'), parent_folder_id, folder_data): + if add_node(user_data.get('filesystem',{}), parent_folder_id, folder_data): try: save_data(data) - return jsonify({'status': 'success', 'message': f'Папка "{folder_name}" успешно создана.'}) + flash(f'Папка "{folder_name}" успешно создана.') except Exception as e: - logging.error(f"Create folder save error for user {tg_id}: {e}", exc_info=True) - # Attempt to remove the added node if save fails? Maybe too complex. - return jsonify({'status': 'error', 'message': 'Ошибка сохранения данных при создании папки.'}), 500 + flash('Ошибка сохранения данных при создании папки.', 'error') + logging.error(f"Create folder save error for {telegram_id}: {e}") else: - return jsonify({'status': 'error', 'message': 'Не удалось найти родительскую папку или добавить узел.'}), 404 + flash('Не удалось найти родительскую папку.', 'error') + + return redirect(url_for('dashboard', folder_id=parent_folder_id)) + @app.route('/download/') def download_file(file_id): - tg_id_str = None - is_admin_req = is_admin() # Check admin status early + current_user_id = session.get('telegram_id') + admin_access = is_admin() # Check if current session holder is admin - if 'tg_id' in session: - tg_id_str = str(session['tg_id']) - elif not is_admin_req: - flash('Пожалуйста, авторизуйтесь.') # Flash might not be visible - return redirect(url_for('index')) # Redirect to main TMA page + if not current_user_id and not admin_access: + flash('Пожалуйста, авторизуйтесь для скачивания.') + return redirect(url_for('index')) # Redirect to auth page data = load_data() file_node = None - user_tg_id_of_file = None - - # Try finding file for logged-in user first - if tg_id_str and tg_id_str in data.get('users', {}): - user_data = data['users'][tg_id_str] - file_node, _ = find_node_by_id(user_data.get('filesystem'), file_id) - if file_node: - user_tg_id_of_file = tg_id_str - - # If not found for current user, admin can search all users - if not file_node and is_admin_req: - logging.info(f"Admin (TG ID: {session.get('tg_id')}) searching for file ID {file_id} across all users.") + owner_id = None + + # 1. Try finding the file for the current user (if logged in) + if current_user_id: + user_data = data['users'].get(current_user_id) + if user_data and 'filesystem' in user_data: + file_node, _ = find_node_by_id(user_data['filesystem'], file_id) + if file_node: + owner_id = current_user_id + + # 2. If not found for current user OR if current user is admin and wants to search all + if not file_node and admin_access: + logging.info(f"Admin {current_user_id} searching for file ID {file_id} across all users.") for user_id, udata in data.get('users', {}).items(): - if isinstance(udata, dict): - node, _ = find_node_by_id(udata.get('filesystem'), file_id) + if 'filesystem' in udata: + node, _ = find_node_by_id(udata['filesystem'], file_id) if node and node.get('type') == 'file': file_node = node - user_tg_id_of_file = user_id - logging.info(f"Admin found file ID {file_id} belonging to user TG ID {user_tg_id_of_file}") + owner_id = user_id # Found the owner + logging.info(f"Admin {current_user_id} found file ID {file_id} belonging to user {owner_id}") break if not file_node or file_node.get('type') != 'file': - # Flash might not be seen if redirected immediately - # flash('Файл не найден!', 'error') - logging.warning(f"File not found (ID: {file_id}) for user {tg_id_str} or admin search.") - # Redirect back to wherever they came from, or root dashboard - # Using referrer is unreliable; maybe redirect to root always on error? - # return redirect(request.referrer or url_for('index')) - return Response("Файл не найден", status=404) + flash('Файл не найден!', 'error') + # Redirect back to dashboard if logged in, or index if not + referer = request.referrer + if current_user_id and referer and ('dashboard' in referer or 'admhosto' in referer): + return redirect(referer) + elif current_user_id: + return redirect(url_for('dashboard')) + else: # Admin accessing directly without session? Should not happen ideally. + return redirect(url_for('admin_panel' if admin_access else 'index')) hf_path = file_node.get('path') original_filename = file_node.get('original_filename', 'downloaded_file') if not hf_path: - logging.error(f"File ID {file_id} (User: {user_tg_id_of_file}) has missing path in metadata.") - return Response("Ошибка: Путь к файлу не найден в метаданных.", status=500) + flash('Ошибка: Путь к файлу не найден в метаданных.', 'error') + referer = request.referrer + if current_user_id and referer and ('dashboard' in referer or 'admhosto' in referer): + return redirect(referer) + elif current_user_id: + return redirect(url_for('dashboard')) + else: + return redirect(url_for('admin_panel' if admin_access else 'index')) + - # Generate download URL (direct access) file_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{hf_path}?download=true" - logging.info(f"Attempting download for file ID {file_id}, Path: {hf_path}, URL: {file_url}") try: headers = {} - # Use read token if available (necessary for private repos) if HF_TOKEN_READ: headers["authorization"] = f"Bearer {HF_TOKEN_READ}" - # Stream the download - response = requests.get(file_url, headers=headers, stream=True, timeout=60) # Add timeout - response.raise_for_status() # Check for HTTP errors (4xx, 5xx) + response = requests.get(file_url, headers=headers, stream=True, timeout=60) # Added timeout + response.raise_for_status() + + # Stream download for potentially large files + # file_content = BytesIO(response.content) # This loads whole file in memory + # return send_file( + # file_content, + # as_attachment=True, + # download_name=original_filename, + # mimetype='application/octet-stream' + # ) - # Stream the content back to the user + # Use Response streaming return Response( - stream_with_context(response.iter_content(chunk_size=8192)), - headers={ - 'Content-Disposition': f'attachment; filename="{original_filename}"', - 'Content-Type': response.headers.get('Content-Type', 'application/octet-stream'), - 'Content-Length': response.headers.get('Content-Length') - } + response.iter_content(chunk_size=8192), + mimetype='application/octet-stream', + headers={'Content-Disposition': f'attachment; filename="{original_filename}"'} ) except requests.exceptions.RequestException as e: - logging.error(f"Error downloading file from HF ({hf_path}): {e}") - status_code = e.response.status_code if e.response is not None else 502 - return Response(f'Ошибка скачивания файла {original_filename} с сервера ({status_code}).', status=status_code) + logging.error(f"Error downloading file from HF ({hf_path}) for user {current_user_id or 'Admin'} (owner: {owner_id}): {e}") + flash(f'Ошибка скачивания файла {original_filename}! ({e})', 'error') + referer = request.referrer + if current_user_id and referer and ('dashboard' in referer or 'admhosto' in referer): + return redirect(referer) + elif current_user_id: + return redirect(url_for('dashboard')) + else: + return redirect(url_for('admin_panel' if admin_access else 'index')) except Exception as e: - logging.error(f"Unexpected error during download ({hf_path}): {e}", exc_info=True) - return Response('Произошла непредвиденная ошибка при скачивании файла.', status=500) + logging.error(f"Unexpected error during download ({hf_path}) for user {current_user_id or 'Admin'} (owner: {owner_id}): {e}") + flash('Произошла непредвиденная ошибка при скачивании файла.', 'error') + referer = request.referrer + if current_user_id and referer and ('dashboard' in referer or 'admhosto' in referer): + return redirect(referer) + elif current_user_id: + return redirect(url_for('dashboard')) + else: + return redirect(url_for('admin_panel' if admin_access else 'index')) @app.route('/delete_file/', methods=['POST']) def delete_file(file_id): - if 'tg_id' not in session: - return jsonify({'status': 'error', 'message': 'Не авторизован'}), 401 + if 'telegram_id' not in session: + flash('Пожалуйста, авторизуйтесь.', 'error') + return redirect(url_for('index')) - tg_id = session['tg_id'] - tg_id_str = str(tg_id) + telegram_id = session['telegram_id'] data = load_data() - user_data = data['users'].get(tg_id_str) + user_data = data['users'].get(telegram_id) if not user_data: - return jsonify({'status': 'error', 'message': 'Пользователь не найден'}), 404 + flash('Пользователь не найден!', 'error') + session.clear() + return redirect(url_for('index')) + + file_node, parent_node = find_node_by_id(user_data.get('filesystem',{}), file_id) + # Get the folder ID we should redirect back to + current_view_folder_id = request.form.get('current_view_folder_id', parent_node.get('id', 'root') if parent_node else 'root') - file_node, parent_node = find_node_by_id(user_data.get('filesystem'), file_id) - current_view_folder_id = request.form.get('current_view_folder_id', 'root') # Keep track for reload if not file_node or file_node.get('type') != 'file' or not parent_node: - return jsonify({'status': 'error', 'message': 'Файл не найден или не может быть удален.'}), 404 + flash('Файл не найден или не может быть удален.', 'error') + return redirect(url_for('dashboard', folder_id=current_view_folder_id)) hf_path = file_node.get('path') original_filename = file_node.get('original_filename', 'файл') - save_needed = False - error_occurred = False - messages = [] - # Case 1: Path is missing in metadata, just remove from DB + # Handle deletion even if HF path is missing (clean up DB) if not hf_path: - logging.warning(f"File ID {file_id} (User: {tg_id}) has missing path. Deleting only metadata.") + logging.warning(f'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): - save_needed = True - messages.append(f'Метаданные файла {original_filename} удалены (путь отсутствовал).') - else: - messages.append('Не удалось удалить метаданные файла (путь отсутствовал).') - error_occurred = True - - # Case 2: Path exists, attempt HF deletion first - else: - if not HF_TOKEN_WRITE: - return jsonify({'status': 'error', 'message': 'Удаление невозможно: токен для записи не настроен.'}), 403 + try: + save_data(data) + flash(f'Метаданные файла {original_filename} удалены (путь отсутствовал).') + except Exception as e: + flash('Ошибка сохранения данных после удаления метаданных.', 'error') + logging.error(f"Delete file metadata save error (no path) for {telegram_id}: {e}") + return redirect(url_for('dashboard', folder_id=current_view_folder_id)) - try: - api = HfApi() - api.delete_file( - path_in_repo=hf_path, - repo_id=REPO_ID, - repo_type="dataset", - token=HF_TOKEN_WRITE, - commit_message=f"User {tg_id} deleted file {original_filename} (ID: {file_id})" - ) - logging.info(f"Deleted file {hf_path} from HF Hub for user {tg_id}") - messages.append(f'Файл {original_filename} удален с сервера.') - - # Now remove from DB - if remove_node(user_data['filesystem'], file_id): - save_needed = True - messages.append('Метаданные удалены из базы.') - else: - messages.append('Файл удален с сервера, но не найден в базе для удаления метаданных.') - error_occurred = True # Metadata mismatch - - except hf_utils.EntryNotFoundError: - logging.warning(f"File {hf_path} not found on HF Hub during delete for user {tg_id}. Removing from DB.") - messages.append(f'Файл {original_filename} не найден на сервере.') - if remove_node(user_data['filesystem'], file_id): - save_needed = True - messages.append('Удален из базы.') - else: - messages.append('Не найден ни на сервере, ни в базе данных.') - error_occurred = True - except Exception as e: - logging.error(f"Error deleting file {hf_path} for {tg_id}: {e}", exc_info=True) - messages.append(f'Ошибка удаления файла {original_filename} с сервера: {e}') - error_occurred = True # Don't remove metadata if server deletion failed + # Check for write token before attempting HF deletion + if not HF_TOKEN_WRITE: + flash('Удаление невозможно: токен для записи не настроен.', 'error') + return redirect(url_for('dashboard', folder_id=current_view_folder_id)) + try: + api = HfApi() + api.delete_file( + path_in_repo=hf_path, + repo_id=REPO_ID, + repo_type="dataset", + token=HF_TOKEN_WRITE, + commit_message=f"User {telegram_id} deleted file {original_filename} (ID: {file_id})" + ) + logging.info(f"Deleted file {hf_path} from HF Hub for user {telegram_id}") - if save_needed: - try: - save_data(data) - messages.append('База данных обновлена.') - except Exception as e: - logging.error(f"Delete file DB update error for {tg_id}: {e}", exc_info=True) - messages.append('Ошибка сохранения базы данных после удаления.') - error_occurred = True + if remove_node(user_data['filesystem'], file_id): + try: + save_data(data) + flash(f'Файл {original_filename} успешно удален!') + except Exception as e: + # Data saving failed after successful HF deletion - critical inconsistency + flash('Файл удален с сервера, но произошла КРИТИЧЕСКАЯ ошибка обновления базы данных. Свяжитесь с администратором.', 'error') + logging.error(f"CRITICAL: Delete file DB update error after HF deletion for {telegram_id}, file {file_id}: {e}") + else: + # Should not happen if file_node was found earlier + flash('Файл удален с сервера, но не найден в локальной базе данных для удаления метаданных.', 'error') + logging.error(f"Inconsistency: File {file_id} deleted from HF but not found in DB for user {telegram_id}") - final_status = 'error' if error_occurred else 'success' - final_message = " ".join(messages) - return jsonify({'status': final_status, 'message': final_message}), 200 if final_status == 'success' else 500 + except hf_utils.EntryNotFoundError: + logging.warning(f"File {hf_path} not found on HF Hub during delete attempt for user {telegram_id}. Removing from DB.") + if remove_node(user_data['filesystem'], file_id): + try: + save_data(data) + flash(f'Файл {original_filename} не найден на сервере, удален из базы.') + except Exception as e: + flash('Ошибка сохранения данных после удаления метаданных (файл не найден на сервере).', 'error') + logging.error(f"Delete file metadata save error (HF not found) for {telegram_id}: {e}") + else: + flash('Файл не найден ни на сервере, ни в базе данных.', 'error') + except Exception as e: + logging.error(f"Error deleting file {hf_path} for {telegram_id}: {e}") + flash(f'Ошибка удаления файла {original_filename}: {e}', 'error') + return redirect(url_for('dashboard', folder_id=current_view_folder_id)) @app.route('/delete_folder/', methods=['POST']) def delete_folder(folder_id): - if 'tg_id' not in session: - return jsonify({'status': 'error', 'message': 'Не авторизован'}), 401 + if 'telegram_id' not in session: + flash('Пожалуйста, авторизуйтесь.', 'error') + return redirect(url_for('index')) if folder_id == 'root': - return jsonify({'status': 'error', 'message': 'Нельзя удалить корневую папку!'}), 400 + flash('Нельзя удалить корневую папку!', 'error') + # Determine current view folder to redirect back correctly + current_view_folder_id = request.form.get('current_view_folder_id', 'root') + return redirect(url_for('dashboard', folder_id=current_view_folder_id)) + - tg_id = session['tg_id'] - tg_id_str = str(tg_id) + telegram_id = session['telegram_id'] data = load_data() - user_data = data['users'].get(tg_id_str) + user_data = data['users'].get(telegram_id) if not user_data: - return jsonify({'status': 'error', 'message': 'Пользователь не найден'}), 404 + flash('Пользователь не найден!', 'error') + session.clear() + return redirect(url_for('index')) - folder_node, parent_node = find_node_by_id(user_data.get('filesystem'), folder_id) - current_view_folder_id = request.form.get('current_view_folder_id', 'root') + folder_node, parent_node = find_node_by_id(user_data.get('filesystem',{}), folder_id) + # Get the folder ID we should redirect back to (parent of the deleted folder) + redirect_to_folder_id = parent_node.get('id', 'root') if parent_node else 'root' if not folder_node or folder_node.get('type') != 'folder' or not parent_node: - return jsonify({'status': 'error', 'message': 'Папка не найдена или не может быть удалена.'}), 404 + flash('Папка не найдена или не может быть удалена.', 'error') + return redirect(url_for('dashboard', folder_id=redirect_to_folder_id)) # Redirect to parent + folder_name = folder_node.get('name', 'папка') # Check if folder is empty if folder_node.get('children'): - return jsonify({'status': 'error', 'message': f'Папку "{folder_name}" можно удалить только если она пуста.'}), 400 + flash(f'Папку "{folder_name}" можно удалить только если она пуста.', 'error') + # Redirect back to the folder containing the one attempted to delete + return redirect(url_for('dashboard', folder_id=parent_node.get('id', 'root'))) - # No HF deletion needed for empty folders, just remove metadata + # Remove the empty folder node from the parent's children list if remove_node(user_data['filesystem'], folder_id): try: save_data(data) - # Determine redirect target (parent folder) - redirect_to_folder_id = parent_node.get('id', 'root') - return jsonify({'status': 'success', 'message': f'Пустая папка "{folder_name}" успешно удалена.', 'redirect_folder': redirect_to_folder_id }) + flash(f'Пустая папка "{folder_name}" успешно удалена.') except Exception as e: - logging.error(f"Delete empty folder save error for {tg_id}: {e}", exc_info=True) - return jsonify({'status': 'error', 'message': 'Ошибка сохранения данных после удаления папки.'}), 500 + flash('Ошибка сохранения данных после удаления папки.', 'error') + logging.error(f"Delete empty folder save error for {telegram_id}: {e}") + # Attempt to revert deletion in memory? Maybe too complex, log error is primary else: - return jsonify({'status': 'error', 'message': 'Не удалось удалить папку из базы данных.'}), 500 + # This case should ideally not happen if folder_node was found + flash('Не удалось удалить папку из структуры данных.', 'error') + logging.error(f"Failed to remove folder node {folder_id} for user {telegram_id} despite finding it.") + + + # Redirect to the parent folder after deletion + return redirect(url_for('dashboard', folder_id=redirect_to_folder_id)) @app.route('/get_text_content/') def get_text_content(file_id): - tg_id_str = None - is_admin_req = is_admin() + current_user_id = session.get('telegram_id') + admin_access = is_admin() - if 'tg_id' in session: - tg_id_str = str(session['tg_id']) - elif not is_admin_req: - return Response("Не авторизован", status=401) + if not current_user_id and not admin_access: + return Response("Не авторизован", status=401, mimetype='text/plain') data = load_data() file_node = None - user_tg_id_of_file = None - - # Try finding file for logged-in user first - if tg_id_str and tg_id_str in data.get('users', {}): - user_data = data['users'][tg_id_str] - file_node, _ = find_node_by_id(user_data.get('filesystem'), file_id) - if file_node and file_node.get('type') == 'file' and file_node.get('file_type') == 'text': - user_tg_id_of_file = tg_id_str - - # If not found for current user, admin can search all users - if not file_node and is_admin_req: - logging.info(f"Admin (TG ID: {session.get('tg_id')}) searching for text file ID {file_id} across all users.") + owner_id = None + + # Find file (similar logic to download) + if current_user_id: + user_data = data['users'].get(current_user_id) + if user_data and 'filesystem' in user_data: + file_node, _ = find_node_by_id(user_data['filesystem'], file_id) + if file_node: owner_id = current_user_id + + if not file_node and admin_access: for user_id, udata in data.get('users', {}).items(): - if isinstance(udata, dict): - node, _ = find_node_by_id(udata.get('filesystem'), file_id) - if node and node.get('type') == 'file' and node.get('file_type') == 'text': - file_node = node - user_tg_id_of_file = user_id - logging.info(f"Admin found text file ID {file_id} belonging to user TG ID {user_tg_id_of_file}") - break + if 'filesystem' in udata: + node, _ = find_node_by_id(udata['filesystem'], file_id) + if node and node.get('type') == 'file' and node.get('file_type') == 'text': + file_node = node + owner_id = user_id + break - if not file_node: # Already checked for type=file and file_type=text in the loops - return Response("Текстовый файл не найден", status=404) + if not file_node or file_node.get('type') != 'file' or file_node.get('file_type') != 'text': + return Response("Текстовый файл не найден", status=404, mimetype='text/plain') hf_path = file_node.get('path') if not hf_path: - logging.error(f"Text file ID {file_id} (User: {user_tg_id_of_file}) has missing path.") - return Response("Ошибка: путь к файлу отсутствует", status=500) + logging.error(f"Path missing for text file {file_id} (owner {owner_id})") + return Response("Ошибка: путь к файлу отсутствует", status=500, mimetype='text/plain') + # Construct URL carefully file_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{hf_path}?download=true" try: @@ -1495,495 +1505,358 @@ def get_text_content(file_id): if HF_TOKEN_READ: headers["authorization"] = f"Bearer {HF_TOKEN_READ}" - response = requests.get(file_url, headers=headers, timeout=15) # Timeout for text files - response.raise_for_status() + # Use a timeout for the request + response = requests.get(file_url, headers=headers, timeout=15) + response.raise_for_status() # Check for HTTP errors + + # Limit preview size + MAX_PREVIEW_SIZE = 1 * 1024 * 1024 # 1 MB + if 'content-length' in response.headers: + try: + if int(response.headers['content-length']) > MAX_PREVIEW_SIZE: + return Response("Файл слишком большой для предпросмотра (> 1MB).", status=413, mimetype='text/plain') + except ValueError: + pass # Ignore if content-length is not a valid number + + file_content = response.content + if len(file_content) > MAX_PREVIEW_SIZE: + # Check size again in case Content-Length was missing/wrong + return Response("Файл слишком большой для предпросмотра (> 1MB).", status=413, mimetype='text/plain') - # Limit preview size (e.g., 1MB) - max_preview_size = 1 * 1024 * 1024 - if len(response.content) > max_preview_size: - # Provide truncated content instead of error? - # text_content = response.content[:max_preview_size].decode('utf-8', errors='ignore') + "\n\n[Файл слишком большой, показана только часть]" - return Response("Файл слишком большой для предпросмотра (>1MB).", status=413) # Try decoding with UTF-8 first, then fallback try: - text_content = response.content.decode('utf-8') + text_content = file_content.decode('utf-8') except UnicodeDecodeError: try: - # Common fallback for Windows-created files - text_content = response.content.decode('cp1251') - except Exception: - # Last resort: latin-1 or ignore errors - text_content = response.content.decode('latin-1', errors='ignore') - logging.warning(f"Could not determine encoding for {hf_path}. Used latin-1 with errors ignored.") - + # Try common fallback encodings + text_content = file_content.decode('cp1251') # Windows Cyrillic + except UnicodeDecodeError: + try: + text_content = file_content.decode('latin-1') # ISO-8859-1 + except Exception: + logging.warning(f"Could not decode text file {file_id} (owner {owner_id}, path {hf_path}) with known encodings.") + return Response("Не удалось определить кодировку файла для предпросмотра.", status=500, mimetype='text/plain') - return Response(text_content, mimetype='text/plain; charset=utf-8') # Specify UTF-8 + # Return plain text content + return Response(text_content, mimetype='text/plain; charset=utf-8') # Specify charset + except requests.exceptions.Timeout: + logging.error(f"Timeout fetching text content from HF ({hf_path}) for file {file_id} (owner {owner_id})") + return Response("Ошибка загрузки содержимого: время ожидания истекло.", status=504, mimetype='text/plain') except requests.exceptions.RequestException as e: - logging.error(f"Error fetching text content from HF ({hf_path}): {e}") + logging.error(f"Error fetching text content from HF ({hf_path}) for file {file_id} (owner {owner_id}): {e}") + # Provide specific error if possible, e.g., 404 status_code = e.response.status_code if e.response is not None else 502 - return Response(f"Ошибка загрузки содержимого: {status_code}", status=status_code) + return Response(f"Ошибка загрузки содержимого: {e}", status=status_code, mimetype='text/plain') except Exception as e: - logging.error(f"Unexpected error fetching text content ({hf_path}): {e}", exc_info=True) - return Response("Внутренняя ошибка сервера", status=500) - + logging.error(f"Unexpected error fetching text content ({hf_path}) for file {file_id} (owner {owner_id}): {e}") + return Response("Внутренняя ошибка сервера при получении содержимого файла.", status=500, mimetype='text/plain') -@app.route('/logout') # Kept for potential session clearing during testing -def logout(): - session.clear() - # In TMA context, redirecting to login doesn't make sense. - # Maybe redirect to the main page which forces re-auth? - return redirect(url_for('index')) - -# --- Admin Panel (Separate Access - Not directly part of TMA flow) --- - -def is_admin(): - # Check if the logged-in Telegram user's ID matches the ADMIN_TELEGRAM_ID - return 'tg_id' in session and session['tg_id'] == ADMIN_TELEGRAM_ID +# --- Admin Routes --- @app.route('/admhosto') def admin_panel(): - # Admin must access this URL in a browser AFTER authenticating via the TMA as the admin user if not is_admin(): - # flash('Доступ запрещен (Admin).', 'error') # Flash not useful here - # Redirect to main page or show an error - return Response("Доступ запрещен. Только для администратора.", status=403) - + # Maybe redirect to index or show a specific error page + flash('Доступ запрещен (Только для администраторов).', 'error') + return redirect(url_for('index')) data = load_data() - users = data.get('users', {}) + users = data.get('users', {}) # Users keyed by integer telegram_id user_details = [] - total_files_all_users = 0 - for tg_id_str, udata in users.items(): - if not isinstance(udata, dict): continue # Skip malformed data - + for tid, udata in users.items(): + if not isinstance(udata, dict): continue # Skip malformed user data file_count = 0 - # Simple recursive counter (can be slow for very deep structures) - def count_files_recursive(folder): - count = 0 - if not isinstance(folder, dict) or not isinstance(folder.get('children'), list): - return 0 - for item in folder.get('children', []): - if isinstance(item, dict): + fs = udata.get('filesystem', {}) + if fs and 'children' in fs: + q = [fs.get('children', [])] + while q: + current_level = q.pop(0) + for item in current_level: + if not isinstance(item, dict): continue # Skip invalid children if item.get('type') == 'file': - count += 1 - elif item.get('type') == 'folder': - count += count_files_recursive(item) - return count + file_count += 1 + elif item.get('type') == 'folder' and 'children' in item: + q.append(item.get('children', [])) - file_count = count_files_recursive(udata.get('filesystem', {})) - total_files_all_users += file_count + user_info = udata.get('user_info', {}) + username = user_info.get('username', f"user_{tid}") # Fallback username + full_name = f"{user_info.get('first_name','')} {user_info.get('last_name','')}".strip() + display_name = full_name if full_name else username user_details.append({ - 'tg_id': tg_id_str, - 'display_name': udata.get('tg_first_name', f"ID: {tg_id_str}") + (f" (@{udata['tg_username']})" if udata.get('tg_username') else ""), + 'telegram_id': tid, + 'username': display_name, # Use combined name or username 'created_at': udata.get('created_at', 'N/A'), 'file_count': file_count }) - user_details.sort(key=lambda x: x['display_name'].lower()) - admin_html = ''' - -Админ-панель Cloud - -

Админ-панель

-

Вошедший админ: {{ session.get('tg_first_name', '') }} (ID: {{ session.get('tg_id') }})

-

Всего пользователей: {{ user_details|length }} | Всего файлов: {{ total_files_all_users }}

-{# Add flash message display area if needed for admin actions #} -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
{{ message }}
- {% endfor %} - {% endif %} -{% endwith %} + # Sort users, e.g., by creation date or ID + user_details.sort(key=lambda x: x.get('telegram_id')) + + return render_template_string(ADMIN_USERS_HTML, user_details=user_details) -

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

-{% for user in user_details %} -
-

{{ user.display_name }} (ID: {{ user.tg_id }})

-

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

-

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

-
- Просмотр файлов -
- -
-
-
-{% else %}

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

{% endfor %}
-Вернуться в приложение -
- '''.format(style=BASE_STYLE) # Embed style - return render_template_string(admin_html, user_details=user_details, total_files_all_users=total_files_all_users, session=session) -@app.route('/admhosto/user/') -def admin_user_files(user_tg_id): +@app.route('/admhosto/user/') +def admin_user_files(telegram_id): if not is_admin(): - return Response("Доступ запрещен.", status=403) + flash('Доступ запрещен.', 'error') + return redirect(url_for('index')) - user_tg_id_str = str(user_tg_id) # Ensure string for lookup data = load_data() - user_data = data.get('users', {}).get(user_tg_id_str) + user_data = data.get('users', {}).get(telegram_id) # Get user by integer ID + if not user_data or not isinstance(user_data, dict): - flash(f'Пользователь с ID {user_tg_id} не найден.', 'error') + flash(f'Пользователь с ID {telegram_id} не найден.', 'error') return redirect(url_for('admin_panel')) - user_display_name = user_data.get('tg_first_name', f"ID: {user_tg_id_str}") + (f" (@{user_data['tg_username']})" if user_data.get('tg_username') else "") + user_info_dict = user_data.get('user_info', {}) + username = user_info_dict.get('username', f"user_{telegram_id}") + full_name = f"{user_info_dict.get('first_name','')} {user_info_dict.get('last_name','')}".strip() + display_name = full_name if full_name else username - all_files = [] - # Recursive function to collect files with their path string - def collect_files_recursive(folder, current_path_list): - if not isinstance(folder, dict) or not isinstance(folder.get('children'), list): - return - current_folder_name = folder.get('name', 'UnknownFolder') - if folder.get('id') == 'root': current_folder_name = 'Главная' - - for item in folder.get('children', []): - if not isinstance(item, dict): continue - item_name = item.get('name', item.get('original_filename', 'UnknownItem')) - new_path_list = current_path_list + [item_name] - - if item.get('type') == 'file': - # Store parent path string for display - item['display_parent_path'] = " / ".join(current_path_list) if current_path_list else 'Главная' - all_files.append(item) - elif item.get('type') == 'folder': - collect_files_recursive(item, new_path_list) - - collect_files_recursive(user_data.get('filesystem', {}), []) - # Sort by upload date descending if available - all_files.sort(key=lambda x: x.get('upload_date', '0'), reverse=True) - - files_html = ''' -Файлы {{ user_display_name }} - - -

Файлы пользователя: {{ user_display_name }}

-Назад к пользователям -{% with messages = get_flashed_messages(with_categories=true) %}{% if messages %}{% for category, message in messages %}
{{ message }}
{% endfor %}{% endif %}{% endwith %} - -
-{% for file in files %} -
-
{# Content div #} - {% set icon = {'image': '🖼️', 'video': '🎬', 'pdf': '📄', 'text': '📝', 'archive': '📦', 'audio': '🎵', 'document': '📎'}.get(file.file_type, '❓') %} -
- {% if file.file_type != 'image' %} {{ icon }} {% endif %} -
-

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

-

Папка: {{ file.display_parent_path }}

-

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

-

ID: {{ file.id }}

-

Path: {{ file.path }}

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

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

{% endfor %} -
- -{# Modal for admin preview #} - - - - - '''.format(style=BASE_STYLE) # Embed style - template_context = { - 'user_tg_id': user_tg_id, - 'user_display_name': user_display_name, - 'files': all_files, - '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 ''}" + user_info_for_template = { + 'telegram_id': telegram_id, + 'username': display_name } - return render_template_string(files_html, **template_context) - -@app.route('/admhosto/delete_user/', methods=['POST']) -def admin_delete_user(user_tg_id): + all_files = [] + fs = user_data.get('filesystem', {}) + + def collect_files(folder_node, current_path_id='root'): + if not folder_node or not isinstance(folder_node, dict) or folder_node.get('type') != 'folder': + return + + # Calculate path string for the parent folder + parent_path_str = get_node_path_string(fs, current_path_id) if current_path_id != 'root' else "Root" + + for item in folder_node.get('children', []): + if not isinstance(item, dict): continue # Skip malformed + if item.get('type') == 'file': + # Add the calculated parent path string to the file item + item['parent_path_str'] = parent_path_str + all_files.append(item) + elif item.get('type') == 'folder': + # Recursive call for subfolder, passing its ID + collect_files(item, item.get('id')) + + # Start collection from the root filesystem node + collect_files(fs, 'root') + + all_files.sort(key=lambda x: x.get('upload_date', ''), reverse=True) + + return render_template_string( + ADMIN_USER_FILES_HTML, + user_info=user_info_for_template, + files=all_files, + 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 + ) + + +@app.route('/admhosto/delete_user/', methods=['POST']) +def admin_delete_user(telegram_id): if not is_admin(): flash('Доступ запрещен.', 'error') - return redirect(url_for('admin_panel')) # Redirect to admin panel + return redirect(url_for('index')) # Redirect non-admins away + if not HF_TOKEN_WRITE: - flash('Удаление невозможно: токен для записи не настроен.', 'error') + flash('Удаление невозможно: токен Hugging Face для записи не настроен.', 'error') return redirect(url_for('admin_panel')) - user_tg_id_str = str(user_tg_id) data = load_data() - if user_tg_id_str not in data.get('users', {}): - flash('Пользователь не найден!', 'error') + if telegram_id not in data.get('users', {}): + flash(f'Пользователь с ID {telegram_id} не найден!', 'error') return redirect(url_for('admin_panel')) - user_data = data['users'][user_tg_id_str] - user_display_name = user_data.get('tg_first_name', f"ID: {user_tg_id_str}") - logging.warning(f"ADMIN ACTION (User: {session.get('tg_id')}): Attempting to delete user {user_display_name} (ID: {user_tg_id_str}) and all their data.") + 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}" + + logging.warning(f"ADMIN ACTION (User: {session.get('telegram_id')}): Attempting to delete user {username_display} (ID: {telegram_id}) and all their data.") - # Step 1: Attempt to delete user's folder on Hugging Face - hf_delete_success = False + # --- Step 1: Delete files from Hugging Face --- + hf_deletion_successful = False try: api = HfApi() - # Use tg_id in the path for consistency - user_folder_path_on_hf = f"cloud_files/{user_tg_id_str}" - - logging.info(f"ADMIN ACTION: Attempting to delete HF Hub folder: {user_folder_path_on_hf} for user {user_tg_id_str}") - # Listing files might be too slow/complex. Attempt direct folder delete. - # Note: delete_folder might fail if the folder isn't completely empty due to eventual consistency or large files. - # Consider deleting individual files first if folder deletion is unreliable. - # For simplicity, we try deleting the folder directly. - try: - api.delete_folder( - folder_path=user_folder_path_on_hf, - repo_id=REPO_ID, - repo_type="dataset", - token=HF_TOKEN_WRITE, - commit_message=f"ADMIN ACTION: Deleted all files/folders for user {user_tg_id_str}" - ) - logging.info(f"ADMIN ACTION: Successfully initiated deletion of folder {user_folder_path_on_hf} on HF Hub.") - hf_delete_success = True # Assume success if no error raised (it might be async) - except hf_utils.HfHubHTTPError as e: - if e.response.status_code == 404: - logging.warning(f"User folder {user_folder_path_on_hf} not found on HF Hub for user {user_tg_id_str}. Assuming already deleted or never existed.") - hf_delete_success = True # Treat as success if not found - else: - raise # Re-raise other HTTP errors + # 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, + repo_id=REPO_ID, + repo_type="dataset", + token=HF_TOKEN_WRITE, + commit_message=f"ADMIN ACTION: Deleted all files/folders for user {telegram_id}" + ) + 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 except Exception as e: - logging.error(f"ADMIN ACTION: Error deleting user data from HF Hub for {user_tg_id_str}: {e}", exc_info=True) - flash(f'Ошибка при удалении файлов пользователя {user_display_name} с сервера: {e}. Пользователь НЕ удален из базы.', 'error') - return redirect(url_for('admin_panel')) + # 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 - # Step 2: Delete user from local database ONLY IF HF deletion was successful or folder not found - if hf_delete_success: + # --- 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'][user_tg_id_str] - save_data(data) - flash(f'Пользователь {user_display_name} (ID: {user_tg_id_str}) удален из базы данных (запрос на удаление файлов с сервера отправлен).') - logging.info(f"ADMIN ACTION: Successfully deleted user {user_tg_id_str} from database.") + 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: - logging.error(f"ADMIN ACTION: Error saving data after deleting user {user_tg_id_str}: {e}", exc_info=True) - # This is bad: user deleted from HF but not DB. Manual cleanup needed. - flash(f'Файлы пользователя {user_display_name} удалены с сервера, но ПРОИЗОШЛА КРИТИЧЕСКАЯ ОШИБКА при удалении пользователя из базы данных: {e}. ТРЕБУЕТСЯ РУЧНОЕ ВМЕШАТЕЛЬСТВО.', 'error') - else: - # Should not happen if errors were handled correctly above, but as a safeguard: - flash(f'Удаление файлов пользователя {user_display_name} с сервера не удалось. Пользователь НЕ удален из базы.', 'error') - + # 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('/admhosto/delete_file//', methods=['POST']) -def admin_delete_file(user_tg_id, file_id): +@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') - # Redirect to admin panel for consistency - return redirect(url_for('admin_panel')) + return redirect(url_for('index')) + + # Check if write token is available if not HF_TOKEN_WRITE: - flash('Удаление невозможно: токен для записи не настроен.', 'error') - return redirect(url_for('admin_user_files', user_tg_id=user_tg_id)) + flash('Удаление файла невозможно: токен Hugging Face для записи не настроен.', 'error') + return redirect(url_for('admin_user_files', telegram_id=telegram_id)) # Redirect back to user's file list - user_tg_id_str = str(user_tg_id) data = load_data() - user_data = data.get('users', {}).get(user_tg_id_str) + # Get the specific user's data using the provided telegram_id + user_data = data.get('users', {}).get(telegram_id) + if not user_data or not isinstance(user_data, dict): - flash(f'Пользователь с ID {user_tg_id} не найден.', 'error') - return redirect(url_for('admin_panel')) + flash(f'Пользователь с ID {telegram_id} не найден.', 'error') + return redirect(url_for('admin_panel')) # Redirect to main admin panel if user not found - file_node, parent_node = find_node_by_id(user_data.get('filesystem'), file_id) + # Find the file within this specific user's filesystem + file_node, parent_node = find_node_by_id(user_data.get('filesystem',{}), file_id) if not file_node or file_node.get('type') != 'file' or not parent_node: - flash('Файл не найден в структуре пользователя.', 'error') - return redirect(url_for('admin_user_files', user_tg_id=user_tg_id)) + 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 hf_path = file_node.get('path') original_filename = file_node.get('original_filename', 'файл') - user_display_name = user_data.get('tg_first_name', f"ID: {user_tg_id_str}") - save_needed = False - error_occurred = False - admin_tg_id = session.get('tg_id') # For logging - # Log admin action - logging.warning(f"ADMIN ACTION (User: {admin_tg_id}): Attempting to delete file ID {file_id} ({original_filename}) for user {user_display_name} (ID: {user_tg_id_str}).") + 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}" + # Handle deletion if HF path is missing in metadata if not hf_path: - flash(f'Ошибка: Путь к файлу {original_filename} не найден. Удаление только из базы.', 'error') - logging.warning(f"ADMIN ACTION: Path missing for file {file_id}. Deleting metadata only.") + 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): - save_needed = True - else: - error_occurred = True - flash('Не удалось удалить метаданные файла (путь отсутствовал).') - else: - try: - api = HfApi() - api.delete_file( - path_in_repo=hf_path, - repo_id=REPO_ID, - repo_type="dataset", - token=HF_TOKEN_WRITE, - commit_message=f"ADMIN ACTION (by {admin_tg_id}): Deleted file {original_filename} (ID: {file_id}) for user {user_tg_id_str}" - ) - logging.info(f"ADMIN ACTION: Deleted file {hf_path} from HF Hub for user {user_tg_id_str}") - - if remove_node(user_data['filesystem'], file_id): - save_needed = True - else: - flash('Файл удален с сервера, но не найден в базе для удаления метаданных.', 'error') - error_occurred = True # Metadata mismatch - - except hf_utils.EntryNotFoundError: - logging.warning(f"ADMIN ACTION: File {hf_path} not found on HF Hub during delete for user {user_tg_id_str}. Removing from DB.") - flash(f'Файл {original_filename} не найден на сервере.') - if remove_node(user_data['filesystem'], file_id): - save_needed = True - flash('Удален из базы.') - else: - flash('Не найден ни на сервере, ни в базе данных.', 'error') - error_occurred = True - except Exception as e: - logging.error(f"ADMIN ACTION: Error deleting file {hf_path} for {user_tg_id_str}: {e}", exc_info=True) - flash(f'Ошибка удаления файла {original_filename} с сервера: {e}', 'error') - error_occurred = True # Do not save if HF delete failed + 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)) - if save_needed: - try: - save_data(data) - flash(f'Файл {original_filename} успешно удален (база обновлена).') - except Exception as e: - logging.error(f"ADMIN ACTION: Delete file DB update error for {user_tg_id_str}: {e}", exc_info=True) - flash('Файл удален, но произошла ошибка обновления базы данных.', 'error') - error_occurred = True - # Redirect back to the user's file list - return redirect(url_for('admin_user_files', user_tg_id=user_tg_id)) + # --- Step 1: Delete file from Hugging Face --- + hf_deleted = False + try: + api = HfApi() + api.delete_file( + path_in_repo=hf_path, + 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}" + ) + 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 + + 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)) # --- App Initialization --- if __name__ == '__main__': - print(f"--- Configuration ---") - print(f"Repo ID: {REPO_ID}") - print(f"Write Token Set: {'Yes' if HF_TOKEN_WRITE else 'No'}") - print(f"Read Token Set: {'Yes' if HF_TOKEN_READ else 'No (using Write Token if set)'}") - print(f"Telegram Bot Token Set: {'Yes' if TELEGRAM_BOT_TOKEN else 'No - TELEGRAM AUTH WILL FAIL'}") - print(f"Admin TG ID: {ADMIN_TELEGRAM_ID if ADMIN_TELEGRAM_ID else 'Not Set - ADMIN PANEL DISABLED'}") - print(f"Data File: {DATA_FILE}") - print(f"Upload Folder: {UPLOAD_FOLDER}") - print(f"---------------------") - - - if not TELEGRAM_BOT_TOKEN: - logging.error("CRITICAL: TELEGRAM_BOT_TOKEN is not set. Telegram authentication will fail.") - if not ADMIN_TELEGRAM_ID: - logging.warning("ADMIN_TELEGRAM_ID is not set. Admin panel functionality will be unavailable.") - - # Initial DB download/check + 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 before starting.") + logging.info("Performing initial database download/check...") download_db_from_hf() else: - logging.warning("No HF read/write token. Database operations with Hugging Face Hub are disabled.") - # Ensure local file exists if HF is 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}") + 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 if write token exists + # 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() @@ -1991,12 +1864,8 @@ if __name__ == '__main__': else: logging.warning("Periodic backup disabled because HF_TOKEN (write access) is not set.") - # Set session lifetime (e.g., 30 days) - app.permanent_session_lifetime = timedelta(days=30) - # Run the Flask app - # Use debug=False for production/TMA context - # Host 0.0.0.0 makes it accessible externally (needed for TMA) - app.run(debug=False, host='0.0.0.0', port=7860) - -# --- END OF FILE app.py --- \ No newline at end of file + # 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