diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,11 +1,12 @@ - -# -*- coding: utf-8 -*- +# --- 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 +from datetime import datetime, timedelta from huggingface_hub import HfApi, hf_hub_download, utils as hf_utils from werkzeug.utils import secure_filename import requests @@ -14,25 +15,66 @@ import uuid import hmac import hashlib from urllib.parse import parse_qsl, unquote -from flask import Flask, render_template_string, request, redirect, url_for, session, flash, send_file, jsonify, Response -from flask_caching import Cache # --- Configuration --- app = Flask(__name__) -app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersecretkey_tma_unique_folders") -DATA_FILE = 'cloudeng_tma_data.json' -REPO_ID = "Eluza133/Z1e1u" # Replace with your actual Repo ID if different -HF_TOKEN_WRITE = os.getenv("HF_TOKEN") # Must be set for uploads/deletes -HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") or HF_TOKEN_WRITE # Read token -BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "6750208873:AAE2hvPlJ99dBdhGa_Brre0IIpUdOvXxHt4") # Your Bot Token -ADMIN_TELEGRAM_IDS = os.getenv("ADMIN_TELEGRAM_IDS", "").split(',') # Comma-separated list of admin Telegram IDs +app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersecretkey_folders_unique_tma") +DATA_FILE = 'cloudeng_data_tma.json' +REPO_ID = "Eluza133/Z1e1u" # Replace with your HF repo ID if different +HF_TOKEN_WRITE = os.getenv("HF_TOKEN") +HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") or HF_TOKEN_WRITE UPLOAD_FOLDER = 'uploads_tma' -os.makedirs(UPLOAD_FOLDER, exist_ok=True) +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, format='%(asctime)s - %(levelname)s - %(message)s') +logging.basicConfig(level=logging.INFO) + +# --- Helper Functions --- -# --- Data Handling 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 @@ -40,18 +82,14 @@ def find_node_by_id(filesystem, node_id): return filesystem, None queue = [(filesystem, None)] - visited = {filesystem.get('id')} - while queue: current_node, parent = queue.pop(0) - if current_node.get('type') == 'folder' and 'children' in current_node: - for i, child in enumerate(current_node['children']): - child_id = child.get('id') - if child_id == node_id: + if current_node and 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: return child, current_node - if child.get('type') == 'folder' and child_id not in visited: + if child and child.get('type') == 'folder': queue.append((child, current_node)) - visited.add(child_id) return None, None def add_node(filesystem, parent_id, node_data): @@ -59,26 +97,21 @@ def add_node(filesystem, parent_id, node_data): if parent_node and parent_node.get('type') == 'folder': if 'children' not in parent_node: parent_node['children'] = [] - # Check for duplicate name in the same folder (optional, consider case sensitivity) - # existing_names = {child.get('name', '').lower() for child in parent_node['children'] if child.get('type') == node_data['type']} - # if node_data.get('name', '').lower() in existing_names and node_data['type'] == 'folder': - # logging.warning(f"Attempted to add duplicate folder name: {node_data.get('name')} in parent {parent_id}") - # return False # Or handle differently + # 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.error(f"Could not find parent folder with ID {parent_id} to add node.") + 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: + 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 True - # Handle root node removal attempt or node not found - if node_to_remove and not parent_node and node_id != filesystem.get('id'): - logging.error(f"Found node {node_id} but no parent, cannot remove.") - elif not node_to_remove: - logging.error(f"Node {node_id} not found for removal.") + 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.") return False def get_node_path_string(filesystem, node_id): @@ -88,36 +121,31 @@ def get_node_path_string(filesystem, node_id): while current_id: node, parent = find_node_by_id(filesystem, current_id) if not node: - logging.warning(f"Node path broken at ID {current_id} when resolving path for {node_id}") break - # Don't add 'root' name to the path string if node.get('id') != 'root': path_list.append(node.get('name', node.get('original_filename', ''))) - # Stop if we reached the root or if the parent is missing (error condition) - if not parent or node.get('id') == 'root': + # Prevent infinite loop if structure is broken + if not parent or parent.get('id') == current_id: break - current_id = parent.get('id') - - return " / ".join(reversed(path_list)) if path_list else "Главная" + current_id = parent.get('id') if parent else None + return " / ".join(reversed(path_list)) or "Root" def initialize_user_filesystem(user_data): - if 'filesystem' not in user_data or not isinstance(user_data['filesystem'], dict): + if 'filesystem' not in user_data or not isinstance(user_data.get('filesystem'), dict): user_data['filesystem'] = { "type": "folder", "id": "root", "name": "root", "children": [] } - # Migration logic (if necessary from a previous structure) can be added here - # For example, if 'files' existed as a flat list previously. - # Ensure this doesn't run if 'filesystem' already exists and is valid. - if 'files' in user_data and isinstance(user_data.get('files'), list): - logging.warning(f"Found old 'files' list for user {user_data.get('telegram_id')}. Migrating to filesystem structure.") - # Implement migration logic if needed, similar to the original code - del user_data['files'] # Remove old structure after migration - -@cache.memoize(timeout=120) # Cache for 2 minutes + # 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 --- + +@cache.memoize(timeout=300) def load_data(): try: download_db_from_hf() @@ -127,53 +155,47 @@ def load_data(): logging.warning("Data is not in dict format, initializing empty database") return {'users': {}} data.setdefault('users', {}) - # Ensure users are keyed by string Telegram ID and have filesystems - valid_data = {'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): + if isinstance(user_data, dict): initialize_user_filesystem(user_data) - valid_data['users'][str(tg_id_str)] = user_data # Ensure keys are strings - else: - logging.warning(f"Invalid user data format for key {tg_id_str}. Skipping.") - # logging.info("Data successfully loaded and initialized") - return valid_data + else: + # Handle potential malformed user data entry + logging.warning(f"Skipping malformed user data for key: {tg_id_str}") + logging.info("Data successfully loaded and initialized") + return data except FileNotFoundError: - logging.warning(f"{DATA_FILE} not found locally. Initializing empty database.") + logging.warning(f"{DATA_FILE} not found. Initializing empty database.") return {'users': {}} except json.JSONDecodeError: - logging.error(f"Error decoding JSON from {DATA_FILE}. Returning empty database.") - # Consider backing up the corrupted file here + logging.error(f"Error decoding JSON from {DATA_FILE}. Initializing empty database.") return {'users': {}} except Exception as e: - logging.error(f"Unexpected error loading data: {e}", exc_info=True) + logging.error(f"Error loading data: {e}", exc_info=True) return {'users': {}} def save_data(data): try: - # Basic validation before saving - if not isinstance(data, dict) or 'users' not in data or not isinstance(data['users'], dict): - logging.error("Attempted to save invalid data structure. Aborting save.") - raise ValueError("Invalid data structure for saving.") - # Ensure all user keys are strings - data['users'] = {str(k): v for k, v in data['users'].items()} + # 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 with open(DATA_FILE, 'w', encoding='utf-8') as file: json.dump(data, file, ensure_ascii=False, indent=4) upload_db_to_hf() - cache.clear() # Clear cache after saving - # logging.info("Data saved locally and upload initiated.") + cache.clear() + logging.info("Data saved and uploaded to HF") except Exception as e: logging.error(f"Error saving data: {e}", exc_info=True) - raise # Re-raise to signal failure - -# --- Hugging Face Hub Interaction --- + # Optionally, re-raise or handle more gracefully + # 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"Data file {DATA_FILE} not found, skipping upload.") + logging.warning(f"Skipping DB upload: {DATA_FILE} does not exist.") return try: api = HfApi() @@ -183,23 +205,20 @@ def upload_db_to_hf(): repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE, - commit_message=f"Backup TMA {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" + commit_message=f"Backup {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" ) logging.info("Database uploaded to Hugging Face") except Exception as e: - logging.error(f"Error uploading database to HF: {e}") + logging.error(f"Error uploading database: {e}", exc_info=True) def download_db_from_hf(): if not HF_TOKEN_READ: logging.warning("HF_TOKEN_READ not set, skipping database download.") - # Ensure an empty file exists if none is downloaded 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}") return try: - logging.info(f"Attempting to download {DATA_FILE} from {REPO_ID}") hf_hub_download( repo_id=REPO_ID, filename=DATA_FILE, @@ -207,487 +226,988 @@ def download_db_from_hf(): token=HF_TOKEN_READ, local_dir=".", local_dir_use_symlinks=False, - force_download=True, # Ensure we get the latest version - etag_timeout=10 # Shorter timeout for checking changes + # force_download=True # Consider if needed ) - logging.info("Database downloaded successfully from Hugging Face") + logging.info("Database downloaded from Hugging Face") + except hf_utils.RepositoryNotFoundError: + logging.error(f"Repository {REPO_ID} not found.") + if not os.path.exists(DATA_FILE): + with open(DATA_FILE, 'w', encoding='utf-8') as f: + json.dump({'users': {}}, f) except hf_utils.EntryNotFoundError: - logging.warning(f"{DATA_FILE} not found in repository {REPO_ID}. Initializing empty local file.") + logging.warning(f"{DATA_FILE} not found in repository {REPO_ID}. Initializing empty database locally if needed.") 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 from HF: {e}") - # Fallback: ensure an empty file exists if download fails and file doesn't exist + logging.error(f"Error downloading database: {e}", exc_info=True) if not os.path.exists(DATA_FILE): - try: - with open(DATA_FILE, 'w', encoding='utf-8') as f: - json.dump({'users': {}}, f) - logging.info(f"Created empty local database file due to download error: {DATA_FILE}") - except Exception as fe: - logging.error(f"Failed to create fallback empty database file: {fe}") + with open(DATA_FILE, 'w', encoding='utf-8') as f: + json.dump({'users': {}}, f) -def delete_hf_file(hf_path, user_id_str, filename): - if not HF_TOKEN_WRITE: - logging.error("HF_TOKEN_WRITE not set. Cannot delete file from HF Hub.") - return False - if not hf_path: - logging.error(f"Cannot delete file with empty hf_path for user {user_id_str}, filename {filename}") - return 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"User {user_id_str} deleted file {filename}" - ) - logging.info(f"Deleted file {hf_path} from HF Hub for user {user_id_str}") - return True - except hf_utils.EntryNotFoundError: - logging.warning(f"File {hf_path} not found on HF Hub for deletion attempt by user {user_id_str}. Assuming deleted.") - return True # Treat as success if already gone - except Exception as e: - logging.error(f"Error deleting file {hf_path} from HF Hub for user {user_id_str}: {e}") - return False +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 + except Exception as e: + logging.error(f"Error during periodic backup: {e}", exc_info=True) -def delete_hf_folder(folder_path, user_id_str): - if not HF_TOKEN_WRITE: - logging.error("HF_TOKEN_WRITE not set. Cannot delete folder from HF Hub.") - return False - if not folder_path: - logging.error(f"Cannot delete folder with empty path for user {user_id_str}") - return False - try: - api = HfApi() - logging.info(f"Attempting to delete HF Hub folder: {folder_path} for user {user_id_str}") - api.delete_folder( - folder_path=folder_path, - repo_id=REPO_ID, - repo_type="dataset", - token=HF_TOKEN_WRITE, - commit_message=f"ADMIN ACTION: Deleted all files/folders for user {user_id_str}" - ) - logging.info(f"Successfully initiated deletion of folder {folder_path} on HF Hub for user {user_id_str}") - return True - except hf_utils.HfHubHTTPError as e: - if e.response.status_code == 404: - logging.warning(f"Folder {folder_path} not found on HF Hub for deletion by user {user_id_str}. Skipping.") - return True # Treat as success if already gone - else: - logging.error(f"HTTP error deleting folder {folder_path} from HF Hub for user {user_id_str}: {e}") - return False - except Exception as e: - logging.error(f"Unexpected error deleting folder {folder_path} from HF Hub for {user_id_str}: {e}") - return False -# --- File Type Helper --- def get_file_type(filename): - if not filename or '.' not in filename: - return 'other' - ext = filename.split('.')[-1].lower() - if ext in ('mp4', 'mov', 'avi', 'webm', 'mkv', 'wmv', 'flv'): + filename_lower = filename.lower() + if filename_lower.endswith(('.mp4', '.mov', '.avi', '.webm', '.mkv')): return 'video' - elif ext in ('jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'): + elif filename_lower.endswith(('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg')): return 'image' - elif ext == 'pdf': + elif filename_lower.endswith('.pdf'): return 'pdf' - elif ext in ('txt', 'md', 'log', 'csv', 'json', 'xml', 'html', 'css', 'js', 'py', 'java', 'c', 'cpp', 'sh'): + elif filename_lower.endswith(('.txt', '.log', '.csv', '.md', '.py', '.js', '.html', '.css', '.json')): return 'text' - elif ext in ('mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a'): - return 'audio' # Added audio type - elif ext in ('zip', 'rar', '7z', 'tar', 'gz', 'bz2'): - return 'archive' # Added archive type - elif ext in ('doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp'): - return 'document' # Added document type + # 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' -# --- Telegram Authentication --- - -def validate_telegram_data(init_data_str, bot_token): - try: - parsed_data = dict(parse_qsl(init_data_str)) - except Exception as e: - logging.error(f"Failed to parse initData string: {e}") - return None, False +# --- Styles --- +BASE_STYLE = ''' +:root { + --primary: #ff4d6d; --secondary: #00ddeb; --accent: #8b5cf6; + --background-light: #f5f6fa; --background-dark: #1a1625; + --card-bg: rgba(255, 255, 255, 0.95); --card-bg-dark: rgba(40, 35, 60, 0.95); + --text-light: #2a1e5a; --text-dark: #e8e1ff; --shadow: 0 10px 30px rgba(0, 0, 0, 0.2); + --glass-bg: rgba(255, 255, 255, 0.15); --transition: all 0.3s ease; --delete-color: #ff4444; + --folder-color: #ffc107; + /* Telegram Theme Variables (Defaults) */ + --tg-theme-bg-color: var(--background-light); + --tg-theme-text-color: var(--text-light); + --tg-theme-hint-color: #aaa; + --tg-theme-link-color: var(--accent); + --tg-theme-button-color: var(--primary); + --tg-theme-button-text-color: #ffffff; + --tg-theme-secondary-bg-color: var(--card-bg); +} +* { 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); +} +.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); } +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 */ +.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); } +.item a { color: var(--tg-theme-link-color); text-decoration: none; } +.item a:hover { text-decoration: underline; } +.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);} +#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; } +.breadcrumbs a { color: var(--tg-theme-link-color); text-decoration: none; } +.breadcrumbs a:hover { text-decoration: underline; } +.breadcrumbs span { margin: 0 3px; } +.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; } + .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; } + .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; } +} +''' - if "hash" not in parsed_data: - logging.warning("Hash not found in initData") - return None, False +# --- Telegram Mini App Routes --- - hash_received = parsed_data.pop("hash") - data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(parsed_data.items())) +@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 + + + + +
+
+
Инициализация...
+ +
+ +
- 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 == hash_received: - try: - user_data = json.loads(parsed_data.get("user", "{}")) - if 'id' not in user_data: - logging.error("User ID missing in parsed user data") - return None, False - # Optional: Check auth_date for freshness - # auth_date = int(parsed_data.get("auth_date", 0)) - # if time.time() - auth_date > 86400: # 24 hours expiry - # logging.warning("Telegram auth data expired") - # return None, False - return user_data, True - except json.JSONDecodeError: - logging.error("Failed to parse user JSON from initData") - return None, False - except Exception as e: - logging.error(f"Error processing user data: {e}") - return None, False - else: - logging.warning(f"Hash mismatch. Received: {hash_received}, Calculated: {calculated_hash}") - return None, False + + + '''.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": [] + } + } + 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 + 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 + + +# 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}") + + 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}") + + + current_folder_id = request.args.get('folder_id', 'root') + 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 + current_folder_id = 'root' + 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}") + session.clear() + return Response('Критическая ошибка: корневая папка не найдена.', status=500) + + items_in_folder = sorted(current_folder.get('children', []), key=lambda x: (x.get('type', '') != 'folder', x.get('name', x.get('original_filename', '')).lower())) + + # Generate 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: + 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 + 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, + '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_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 + } + 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 + + if not HF_TOKEN_WRITE: + # Return JSON for JS handler + return jsonify({'status': 'error', 'message': 'Загрузка невозможна: токен для записи не настроен.'}), 403 + + 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 + + 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] + + files = request.files.getlist('files') + if not files or all(not f.filename for f in files): + return jsonify({'status': 'error', 'message': 'Файлы для загрузки не выбраны.'}), 400 + + 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) + + if not target_folder_node or target_folder_node.get('type') != 'folder': + return jsonify({'status': 'error', 'message': 'Целевая папка для загрузки не найдена!'}), 404 + + 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}" + 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}" + temp_path = os.path.join(UPLOAD_FOLDER, f"{file_id}_{unique_filename}") + + try: + file.save(temp_path) + + api.upload_file( + path_or_fileobj=temp_path, + path_in_repo=hf_path, + 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}" + ) + + file_info = { + 'type': 'file', + 'id': file_id, + 'original_filename': original_filename, + 'unique_filename': unique_filename, # Store for potential reference + 'path': hf_path, 'file_type': get_file_type(original_filename), 'upload_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S') - # 'size': file_size # Add size if needed } if add_node(user_data['filesystem'], target_folder_id, file_info): uploaded_count += 1 save_needed = True else: - errors.append(f"Error adding metadata for {original_filename}.") - logging.error(f"Failed to add node metadata for file {file_id} to folder {target_folder_id} for user {telegram_id_str}") - # Attempt to clean up the orphaned file on HF - delete_hf_file(hf_path, telegram_id_str, original_filename) + 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 + 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 {telegram_id_str}: {e}", exc_info=True) - errors.append(f"Error uploading {original_filename}: {str(e)}") - # Clean up local temp file if upload fails + logging.error(f"Error uploading file {original_filename} for {tg_id}: {e}", exc_info=True) + errors.append(f"Ошибка загрузки файла {original_filename}: {e}") finally: if os.path.exists(temp_path): - try: - os.remove(temp_path) - except Exception as e_rem: - logging.error(f"Error removing temp file {temp_path}: {e_rem}") + os.remove(temp_path) if save_needed: try: save_data(data) + logging.info(f"{uploaded_count} files uploaded successfully for user {tg_id}.") except Exception as e: - logging.error(f"Error saving data after upload for {telegram_id_str}: {e}") - # Return success but with a warning about saving metadata - return jsonify({ - "status": "warning", - "message": f"{uploaded_count} file(s) uploaded, but failed to save metadata.", - "errors": errors - }), 500 + errors.append('Файлы загружены, но произошла ошибка сохранения метаданных.') + logging.error(f"Error saving data after upload for {tg_id}: {e}", exc_info=True) + final_message = "" + if uploaded_count > 0: + final_message += f'{uploaded_count} файл(ов) успешно загружено! ' if errors: - return jsonify({ - "status": "warning" if uploaded_count > 0 else "error", - "message": f"{uploaded_count} file(s) uploaded with {len(errors)} errors.", - "errors": errors - }), 207 # Multi-Status or use 500 if critical - elif uploaded_count > 0: - return jsonify({"status": "success", "message": f"{uploaded_count} file(s) uploaded successfully."}) - else: - # This case should ideally be caught earlier (no files selected) - return jsonify({"status": "error", "message": "Upload failed. No files were processed."}), 400 + 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" -@app.route('/create_folder', methods=['POST']) -def create_folder(): - req_data = request.json - init_data_str = req_data.get('initData') - parent_folder_id = req_data.get('parentFolderId', 'root') - folder_name = req_data.get('folderName', '').strip() + return jsonify({'status': status_str, 'message': final_message.strip()}), status_code - user_info, is_valid = validate_telegram_data(init_data_str, BOT_TOKEN) - if not is_valid or not user_info: - return jsonify({"status": "error", "message": "Invalid session"}), 403 - telegram_id_str = str(user_info['id']) - - if not folder_name: - return jsonify({"status": "error", "message": "Folder name cannot be empty."}), 400 - - # Basic validation for folder name (adjust regex as needed) - # Allow letters, numbers, spaces, underscores, hyphens - if not all(c.isalnum() or c in ' _-' for c in folder_name): - return jsonify({"status": "error", "message": "Folder name contains invalid characters."}), 400 +@app.route('/create_folder', methods=['POST']) +def create_folder(): + if 'tg_id' not in session: + return jsonify({'status': 'error', 'message': 'Не авторизован'}), 401 + tg_id = session['tg_id'] + tg_id_str = str(tg_id) data = load_data() - user_data = data['users'].get(telegram_id_str) + user_data = data['users'].get(tg_id_str) if not user_data: - return jsonify({"status": "error", "message": "User data not found."}), 404 + return jsonify({'status': 'error', 'message': 'Пользователь не найден'}), 404 + + 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() - # Check if parent folder exists - parent_node, _ = find_node_by_id(user_data['filesystem'], parent_folder_id) - if not parent_node or parent_node.get('type') != 'folder': - logging.error(f"Parent folder {parent_folder_id} not found for folder creation by user {telegram_id_str}") - return jsonify({"status": "error", "message": "Parent folder not found."}), 404 - # Optional: Check for duplicate folder name within the parent - if 'children' in parent_node: - existing_names = {child.get('name', '').lower() for child in parent_node['children'] if child.get('type') == 'folder'} - if folder_name.lower() in existing_names: - return jsonify({"status": "error", "message": f"A folder named '{folder_name}' already exists here."}), 409 # Conflict + 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 folder_id = uuid.uuid4().hex @@ -695,275 +1215,295 @@ def create_folder(): 'type': 'folder', 'id': folder_id, 'name': folder_name, - 'children': [] - # 'created_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S') # Optional + 'children': [] # Always initialize children for new folders } - if add_node(user_data['filesystem'], parent_folder_id, folder_data): + # 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): try: save_data(data) - logging.info(f"Folder '{folder_name}' (ID: {folder_id}) created by user {telegram_id_str} in parent {parent_folder_id}") - # Return the newly created folder info if needed by frontend - return jsonify({"status": "success", "message": f"Folder '{folder_name}' created.", "newFolder": folder_data}) + return jsonify({'status': 'success', 'message': f'Папка "{folder_name}" успешно создана.'}) except Exception as e: - logging.error(f"Failed to save data after creating folder '{folder_name}' for user {telegram_id_str}: {e}") - # Attempt to rollback? Difficult without transactions. Maybe remove the node added? - # remove_node(user_data['filesystem'], folder_id) # Attempt rollback (might fail if structure changed) - return jsonify({"status": "error", "message": "Failed to save data after creating folder."}), 500 + 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 else: - # This should theoretically be caught by the parent_node check earlier - logging.error(f"add_node failed for folder '{folder_name}' by user {telegram_id_str} even after parent check passed.") - return jsonify({"status": "error", "message": "Failed to add folder to filesystem."}), 500 + return jsonify({'status': 'error', 'message': 'Не удалось найти родительскую папку или добавить узел.'}), 404 +@app.route('/download/') +def download_file(file_id): + tg_id_str = None + is_admin_req = is_admin() # Check admin status early -@app.route('/delete_item', methods=['POST']) -def delete_item(): - req_data = request.json - init_data_str = req_data.get('initData') - item_id = req_data.get('itemId') - item_type = req_data.get('itemType') # 'file' or 'folder' + 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 - user_info, is_valid = validate_telegram_data(init_data_str, BOT_TOKEN) - if not is_valid or not user_info: - return jsonify({"status": "error", "message": "Invalid session"}), 403 + 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.") + 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': + 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}") + 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) - telegram_id_str = str(user_info['id']) - if not item_id or item_id == 'root': - return jsonify({"status": "error", "message": "Invalid item ID for deletion."}), 400 - if item_type not in ['file', 'folder']: - return jsonify({"status": "error", "message": "Invalid item type for deletion."}), 400 + hf_path = file_node.get('path') + original_filename = file_node.get('original_filename', 'downloaded_file') - data = load_data() - user_data = data['users'].get(telegram_id_str) - if not user_data: - return jsonify({"status": "error", "message": "User data not found."}), 404 + 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) - item_node, parent_node = find_node_by_id(user_data['filesystem'], item_id) + # 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}") - if not item_node or item_node.get('type') != item_type or not parent_node: - logging.warning(f"Item {item_id} (type {item_type}) not found or parent missing for deletion by user {telegram_id_str}") - return jsonify({"status": "error", "message": f"{item_type.capitalize()} not found or cannot be deleted."}), 404 + try: + headers = {} + # Use read token if available (necessary for private repos) + if HF_TOKEN_READ: + headers["authorization"] = f"Bearer {HF_TOKEN_READ}" - item_name = item_node.get('name', item_node.get('original_filename', 'item')) + # 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) - # --- Folder Deletion Specific Logic --- - if item_type == 'folder': - if item_node.get('children'): # Check if folder is not empty - return jsonify({"status": "error", "message": f"Folder '{item_name}' is not empty. Cannot delete."}), 400 - # Folders usually don't have direct HF representation unless we create placeholder files - # So, just remove from metadata - if remove_node(user_data['filesystem'], item_id): - try: - save_data(data) - logging.info(f"Empty folder '{item_name}' (ID: {item_id}) deleted by user {telegram_id_str}") - return jsonify({"status": "success", "message": f"Folder '{item_name}' deleted."}) - except Exception as e: - logging.error(f"Failed to save data after deleting folder {item_id} for user {telegram_id_str}: {e}") - return jsonify({"status": "error", "message": "Failed to save changes after deleting folder."}), 500 - else: - logging.error(f"remove_node failed for folder {item_id} user {telegram_id_str} despite checks.") - return jsonify({"status": "error", "message": "Internal error deleting folder from structure."}), 500 - - # --- File Deletion Specific Logic --- - elif item_type == 'file': - hf_path = item_node.get('path') - if not hf_path: - logging.warning(f"HF path missing for file {item_id} user {telegram_id_str}. Deleting metadata only.") - # Proceed to delete metadata even if HF path is missing - else: - # Attempt to delete from Hugging Face Hub first - if not delete_hf_file(hf_path, telegram_id_str, item_name): - # Decide if failure to delete from HF should prevent metadata deletion - # For now, let's proceed to delete metadata but return a warning/error - logging.error(f"Failed to delete file {hf_path} from HF Hub for user {telegram_id_str}. Proceeding with metadata removal.") - # Return error immediately? Or just warn and remove metadata? - # return jsonify({"status": "error", "message": f"Failed to delete file '{item_name}' from storage. Please try again."}), 500 + # Stream the content back to the user + 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') + } + ) + 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) + except Exception as e: + logging.error(f"Unexpected error during download ({hf_path}): {e}", exc_info=True) + return Response('Произошла непредвиденная ошибка при скачивании файла.', status=500) - # Remove file node from filesystem metadata - if remove_node(user_data['filesystem'], item_id): - try: - save_data(data) - logging.info(f"File '{item_name}' (ID: {item_id}) metadata deleted by user {telegram_id_str}") - # Check if HF deletion failed earlier to adjust message - hf_delete_failed = hf_path and not delete_hf_file # Re-check or use a flag - if hf_delete_failed: - return jsonify({"status": "warning", "message": f"File '{item_name}' metadata deleted, but failed to remove from storage."}) - else: - return jsonify({"status": "success", "message": f"File '{item_name}' deleted."}) - except Exception as e: - logging.error(f"Failed to save data after deleting file {item_id} for user {telegram_id_str}: {e}") - return jsonify({"status": "error", "message": "Failed to save changes after deleting file."}), 500 - else: - logging.error(f"remove_node failed for file {item_id} user {telegram_id_str} despite checks.") - return jsonify({"status": "error", "message": "Internal error deleting file from structure."}), 500 - # Should not reach here if item_type is validated - return jsonify({"status": "error", "message": "Unknown error during deletion."}), 500 +@app.route('/delete_file/', methods=['POST']) +def delete_file(file_id): + if 'tg_id' not in session: + return jsonify({'status': 'error', 'message': 'Не авторизован'}), 401 + tg_id = session['tg_id'] + tg_id_str = str(tg_id) + data = load_data() + user_data = data['users'].get(tg_id_str) + if not user_data: + return jsonify({'status': 'error', 'message': 'Пользователь не найден'}), 404 -@app.route('/download/') -def download_file(file_id): - # For TMA, authentication needs to be passed differently, e.g., via query parameter with initData hash - # Or rely on a server-side session established after initial validation (less ideal for stateless TMA) - # Simplest (but less secure if link shared): Check access token in query? - # Better: Require initData in query/header and validate it. + 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 - init_data_str = request.args.get('initData') - admin_override = request.args.get('admin_token') # Add a way for admin downloads + if not file_node or file_node.get('type') != 'file' or not parent_node: + return jsonify({'status': 'error', 'message': 'Файл не найден или не может быть удален.'}), 404 - user_info = None - is_valid_user = False - admin_access = False + hf_path = file_node.get('path') + original_filename = file_node.get('original_filename', 'файл') + save_needed = False + error_occurred = False + messages = [] - if init_data_str: - user_info, is_valid_user = validate_telegram_data(init_data_str, BOT_TOKEN) + # Case 1: Path is missing in metadata, just remove from DB + if not hf_path: + logging.warning(f"File ID {file_id} (User: {tg_id}) has missing path. Deleting only metadata.") + if remove_node(user_data['filesystem'], file_id): + save_needed = True + messages.append(f'Метаданные файла {original_filename} удалены (путь отсутствовал).') + else: + messages.append('Не удалось удалить метаданные файла (путь отсутствовал).') + error_occurred = True - # Temporary admin access check (replace with a proper mechanism) - if admin_override and is_admin(admin_override): # Use the ID passed in token as the admin ID to check - admin_access = True - logging.info(f"Admin access granted for download of {file_id} by admin ID {admin_override}") - # We still need to find *which* user the file belongs to if admin - elif not is_valid_user or not user_info: - return Response("Authentication required.", status=403) + # Case 2: Path exists, attempt HF deletion first + else: + if not HF_TOKEN_WRITE: + return jsonify({'status': 'error', 'message': 'Удаление невозможно: токен для записи не настроен.'}), 403 - data = load_data() - file_node = None - user_id_for_file = None - - if admin_access: - # Admin needs to find the file across all users - logging.info(f"Admin searching for file ID {file_id} across all users.") - for u_id, u_data in data.get('users', {}).items(): - node, _ = find_node_by_id(u_data.get('filesystem', {}), file_id) - if node and node.get('type') == 'file': - file_node = node - user_id_for_file = u_id - logging.info(f"Admin found file ID {file_id} belonging to user {user_id_for_file}") - break - elif is_valid_user: - telegram_id_str = str(user_info['id']) - user_data = data['users'].get(telegram_id_str) - if user_data: - file_node, _ = find_node_by_id(user_data['filesystem'], file_id) - if file_node and file_node.get('type') == 'file': - user_id_for_file = telegram_id_str - else: - logging.warning(f"User {telegram_id_str} tried to download non-existent/invalid file {file_id}") - else: - logging.error(f"User data not found for validated user {telegram_id_str} during download attempt.") + 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 - if not file_node or not user_id_for_file: - logging.warning(f"File node {file_id} not found for download (User valid: {is_valid_user}, Admin: {admin_access})") - return Response("File not found or access denied.", status=404) + 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 - hf_path = file_node.get('path') - original_filename = file_node.get('original_filename', 'downloaded_file') + 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 - if not hf_path: - logging.error(f"HF path missing for file {file_id} (User: {user_id_for_file}). Cannot download.") - return Response("Error: File path missing in metadata.", status=500) - # Generate the direct download URL for HF - # Note: If repo is private, this direct link might not work without auth baked in, - # which is insecure. Streaming through server is safer for private repos. - file_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{hf_path}?download=true" - logging.info(f"Attempting to stream download for file {file_id} from {file_url} (User: {user_id_for_file}, Admin: {admin_access})") +@app.route('/delete_folder/', methods=['POST']) +def delete_folder(folder_id): + if 'tg_id' not in session: + return jsonify({'status': 'error', 'message': 'Не авторизован'}), 401 - try: - headers = {} - if HF_TOKEN_READ: - headers["authorization"] = f"Bearer {HF_TOKEN_READ}" + if folder_id == 'root': + return jsonify({'status': 'error', 'message': 'Нельзя удалить корневую папку!'}), 400 - # Use stream=True to avoid loading large files into memory - response = requests.get(file_url, headers=headers, stream=True, timeout=30) # Add timeout - response.raise_for_status() # Check for HTTP errors (4xx, 5xx) + tg_id = session['tg_id'] + tg_id_str = str(tg_id) + data = load_data() + user_data = data['users'].get(tg_id_str) + if not user_data: + return jsonify({'status': 'error', 'message': 'Пользователь не найден'}), 404 - # Stream the response back to the client - return Response( - response.iter_content(chunk_size=8192), # Stream in chunks - content_type=response.headers.get('Content-Type', 'application/octet-stream'), - headers={ "Content-Disposition": f"attachment; filename*=UTF-8''{secure_filename(original_filename)}" } # Correct encoding for filename - ) + 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') - except requests.exceptions.RequestException as e: - logging.error(f"Error downloading file from HF ({hf_path}) for user {user_id_for_file}: {e}") - status_code = 502 # Bad Gateway if HF fails - if isinstance(e, requests.exceptions.HTTPError): - status_code = e.response.status_code if e.response is not None else 500 - if status_code == 404: status_code = 404 # Pass through 404 - return Response(f'Error downloading file: {e}', status=status_code) - except Exception as e: - logging.error(f"Unexpected error during download streaming ({hf_path}) for user {user_id_for_file}: {e}", exc_info=True) - return Response('Internal server error during download.', status=500) + if not folder_node or folder_node.get('type') != 'folder' or not parent_node: + return jsonify({'status': 'error', 'message': 'Папка не найдена или не может быть удалена.'}), 404 + folder_name = folder_node.get('name', 'папка') -@app.route('/get_text_content/') -def get_text_content(file_id): - # Similar authentication needed as for download - init_data_str = request.args.get('initData') - admin_override = request.args.get('admin_token') + # Check if folder is empty + if folder_node.get('children'): + return jsonify({'status': 'error', 'message': f'Папку "{folder_name}" можно удалить только если она пуста.'}), 400 + + # No HF deletion needed for empty folders, just remove metadata + 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 }) + 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 + else: + return jsonify({'status': 'error', 'message': 'Не удалось удалить папку из базы данных.'}), 500 - user_info = None - is_valid_user = False - admin_access = False - if init_data_str: - user_info, is_valid_user = validate_telegram_data(init_data_str, BOT_TOKEN) +@app.route('/get_text_content/') +def get_text_content(file_id): + tg_id_str = None + is_admin_req = is_admin() - if admin_override and is_admin(admin_override): - admin_access = True - elif not is_valid_user or not user_info: - return Response("Authentication required.", status=403) + if 'tg_id' in session: + tg_id_str = str(session['tg_id']) + elif not is_admin_req: + return Response("Не авторизован", status=401) data = load_data() file_node = None - user_id_for_file = None - - if admin_access: - for u_id, u_data in data.get('users', {}).items(): - node, _ = find_node_by_id(u_data.get('filesystem', {}), file_id) - if node and node.get('type') == 'file' and node.get('file_type') == 'text': - file_node = node - user_id_for_file = u_id - break - elif is_valid_user: - telegram_id_str = str(user_info['id']) - user_data = data['users'].get(telegram_id_str) - if user_data: - file_node, _ = find_node_by_id(user_data['filesystem'], file_id) - if file_node and file_node.get('type') == 'file' and node.get('file_type') == 'text': - user_id_for_file = telegram_id_str - - - if not file_node or not user_id_for_file or file_node.get('file_type') != 'text': - return Response("Text file not found or access denied.", status=404) + 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.") + 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 not file_node: # Already checked for type=file and file_type=text in the loops + return Response("Текстовый файл не найден", status=404) hf_path = file_node.get('path') if not hf_path: - return Response("Error: File path missing.", status=500) + logging.error(f"Text file ID {file_id} (User: {user_tg_id_of_file}) has missing path.") + return Response("Ошибка: путь к файлу отсутствует", status=500) file_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{hf_path}?download=true" - logging.info(f"Fetching text content for {file_id} from {file_url} (User: {user_id_for_file}, Admin: {admin_access})") try: headers = {} if HF_TOKEN_READ: headers["authorization"] = f"Bearer {HF_TOKEN_READ}" - response = requests.get(file_url, headers=headers, timeout=15) + response = requests.get(file_url, headers=headers, timeout=15) # Timeout for text files response.raise_for_status() - # Limit preview size - MAX_PREVIEW_SIZE = 2 * 1024 * 1024 # 2MB limit for text preview - if len(response.content) > MAX_PREVIEW_SIZE: - logging.warning(f"Text file {file_id} too large for preview ({len(response.content)} bytes). User: {user_id_for_file}") - # Return only the beginning? Or an error? - # return Response(response.content[:MAX_PREVIEW_SIZE] + b"\n\n--- File truncated ---", mimetype='text/plain') - return Response("File too large for preview.", status=413) - + # 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: @@ -971,1498 +1511,492 @@ def get_text_content(file_id): except UnicodeDecodeError: try: # Common fallback for Windows-created files - text_content = response.content.decode('cp1251') # Or latin-1 - logging.info(f"Decoded text file {file_id} using cp1251 fallback.") + text_content = response.content.decode('cp1251') except Exception: - logging.error(f"Could not decode text file {file_id} with UTF-8 or fallback encoding.") - return Response("Error decoding file content. Unsupported encoding?", status=500) + # 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.") + - return Response(text_content, mimetype='text/plain; charset=utf-8') # Specify UTF-8 for browser + return Response(text_content, mimetype='text/plain; charset=utf-8') # Specify UTF-8 except requests.exceptions.RequestException as e: - logging.error(f"Error fetching text content from HF ({hf_path}) for user {user_id_for_file}: {e}") - status_code = 502 - if isinstance(e, requests.exceptions.HTTPError): status_code = e.response.status_code if e.response is not None else 500 - return Response(f"Error fetching content: {e}", status=status_code) + logging.error(f"Error fetching text content from HF ({hf_path}): {e}") + status_code = e.response.status_code if e.response is not None else 502 + return Response(f"Ошибка загрузки содержимого: {status_code}", status=status_code) except Exception as e: - logging.error(f"Unexpected error fetching text content ({hf_path}) for user {user_id_for_file}: {e}", exc_info=True) - return Response("Internal server error.", status=500) + logging.error(f"Unexpected error fetching text content ({hf_path}): {e}", exc_info=True) + return Response("Внутренняя ошибка сервера", status=500) -# --- Admin Routes (/admhosto) --- +@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 @app.route('/admhosto') def admin_panel(): - auth_header = request.headers.get('X-Telegram-Init-Data') - admin_token = request.args.get('admin_token') # Allow token via query param for initial access maybe - - admin_user_id = None - if auth_header: - user_info, is_valid = validate_telegram_data(auth_header, BOT_TOKEN) - if is_valid and user_info and is_admin(user_info['id']): - admin_user_id = str(user_info['id']) - elif admin_token and is_admin(admin_token): - admin_user_id = admin_token # Use the token itself as the ID for check - - if not admin_user_id: - # Return HTML indicating access denied or redirect logic - return render_template_string(ADMIN_LOGIN_TEMPLATE) - # return Response("Access Denied", status=403) + # 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) + data = load_data() users = data.get('users', {}) + 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 u_id_str, u_data in users.items(): file_count = 0 - folder_count = 0 - total_size = 0 # Calculating size would require iterating and potentially querying HF - skip for now - - q = [(u_data.get('filesystem', {}))] # Start with root node - while q: - current_node = q.pop(0) - if not current_node: continue - - node_type = current_node.get('type') - if node_type == 'file': - file_count += 1 - # size = current_node.get('size', 0) # Add size if stored - # total_size += size - elif node_type == 'folder': - if current_node.get('id') != 'root': # Don't count root as a user folder - folder_count += 1 - if 'children' in current_node: - q.extend(current_node.get('children', [])) + # 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): + if item.get('type') == 'file': + count += 1 + elif item.get('type') == 'folder': + count += count_files_recursive(item) + return count + + file_count = count_files_recursive(udata.get('filesystem', {})) + total_files_all_users += file_count user_details.append({ - 'id': u_id_str, - 'username': u_data.get('username', 'N/A'), - 'first_name': u_data.get('first_name', 'N/A'), - 'created_at': u_data.get('created_at', 'N/A'), - 'file_count': file_count, - 'folder_count': folder_count, - # 'total_size_mb': round(total_size / (1024*1024), 2) # Add size if calculated + '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 ""), + '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 %} + +

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

+{% 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): + if not is_admin(): + return Response("Доступ запрещен.", status=403) + + 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) + if not user_data or not isinstance(user_data, dict): + flash(f'Пользователь с ID {user_tg_id} не найден.', 'error') + return redirect(url_for('admin_panel')) - # Sort users, e.g., by creation date or username - user_details.sort(key=lambda x: x.get('created_at', ''), reverse=True) - - return render_template_string(ADMIN_PANEL_TEMPLATE, user_details=user_details, admin_id=admin_user_id) - - -@app.route('/admhosto/user_files/') -def admin_user_files(user_id): - auth_header = request.headers.get('X-Telegram-Init-Data') - admin_token = request.args.get('admin_token') # Allow token via query param + 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 "") - admin_user_id = None - if auth_header: - user_info, is_valid = validate_telegram_data(auth_header, BOT_TOKEN) - if is_valid and user_info and is_admin(user_info['id']): - admin_user_id = str(user_info['id']) - elif admin_token and is_admin(admin_token): - admin_user_id = admin_token + 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 %} +
- if not admin_user_id: - return Response("Access Denied", status=403) +{# Modal for admin preview #} + - data = load_data() - user_data = data.get('users', {}).get(str(user_id)) # Ensure using string ID - if not user_data: - # Flash message equivalent for admin SPA? Return error JSON or redirect in JS - return Response(f"User {user_id} not found.", status=404) + + + '''.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 ''}" + } + return render_template_string(files_html, **template_context) - if admin_id_str == target_user_id_str: - return jsonify({"status": "error", "message": "Admin cannot delete themselves."}), 400 +@app.route('/admhosto/delete_user/', methods=['POST']) +def admin_delete_user(user_tg_id): + if not is_admin(): + flash('Доступ запрещен.', 'error') + return redirect(url_for('admin_panel')) # Redirect to admin panel if not HF_TOKEN_WRITE: - return jsonify({"status": "error", "message": "Deletion unavailable: Server configuration error."}), 503 + flash('Удаление невозможно: токен для записи не настроен.', 'error') + return redirect(url_for('admin_panel')) + user_tg_id_str = str(user_tg_id) data = load_data() - if target_user_id_str not in data['users']: - return jsonify({"status": "error", "message": "User not found."}), 404 + if user_tg_id_str not in data.get('users', {}): + flash('Пользователь не найден!', 'error') + return redirect(url_for('admin_panel')) - logging.warning(f"ADMIN ACTION by {admin_id_str}: Attempting to delete user {target_user_id_str} and all their data.") + 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.") - # --- Delete User's Files from Hugging Face Hub --- - # This is potentially dangerous and slow if many files. - # A safer approach might be to just delete the top-level user folder. - user_folder_path_on_hf = f"cloud_files/{target_user_id_str}" - hf_delete_success = delete_hf_folder(user_folder_path_on_hf, target_user_id_str) - - if not hf_delete_success: - # Decide whether to proceed with DB deletion if HF deletion fails - logging.error(f"Failed to delete HF folder {user_folder_path_on_hf} for user {target_user_id_str}. Aborting user deletion.") - return jsonify({"status": "error", "message": "Failed to delete user files from storage. User not deleted from database."}), 500 - - - # --- Delete User from Database --- + # Step 1: Attempt to delete user's folder on Hugging Face + hf_delete_success = False try: - del data['users'][target_user_id_str] - save_data(data) - logging.info(f"ADMIN ACTION by {admin_id_str}: Successfully deleted user {target_user_id_str} from database.") - return jsonify({"status": "success", "message": f"User {target_user_id_str} and their files (deletion requested) successfully removed."}) + 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 except Exception as e: - logging.error(f"ADMIN ACTION by {admin_id_str}: Error saving data after deleting user {target_user_id_str} from DB: {e}") - # At this point, HF files might be deleted, but user record remains. Critical state. - return jsonify({"status": "error", "message": "User files deleted from storage, but failed to remove user from database. Manual cleanup required."}), 500 + 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')) + # Step 2: Delete user from local database ONLY IF HF deletion was successful or folder not found + if hf_delete_success: + 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.") + 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') -@app.route('/admhosto/delete_file//', methods=['POST']) -def admin_delete_file(user_id, file_id): - # Authentication check - req_data = request.json - init_data_str = req_data.get('initData') - admin_user_info, is_valid_admin = validate_telegram_data(init_data_str, BOT_TOKEN) - if not is_valid_admin or not admin_user_info or not is_admin(admin_user_info['id']): - return jsonify({"status": "error", "message": "Admin authentication failed"}), 403 + return redirect(url_for('admin_panel')) - admin_id_str = str(admin_user_info['id']) - target_user_id_str = str(user_id) +@app.route('/admhosto/delete_file//', methods=['POST']) +def admin_delete_file(user_tg_id, file_id): + if not is_admin(): + flash('Доступ запрещен.', 'error') + # Redirect to admin panel for consistency + return redirect(url_for('admin_panel')) if not HF_TOKEN_WRITE: - return jsonify({"status": "error", "message": "Deletion unavailable: Server configuration error."}), 503 + flash('Удаление невозможно: токен для записи не настроен.', 'error') + return redirect(url_for('admin_user_files', user_tg_id=user_tg_id)) + user_tg_id_str = str(user_tg_id) data = load_data() - user_data = data.get('users', {}).get(target_user_id_str) - if not user_data: - return jsonify({"status": "error", "message": "Target user not found."}), 404 + user_data = data.get('users', {}).get(user_tg_id_str) + if not user_data or not isinstance(user_data, dict): + flash(f'Пользователь с ID {user_tg_id} не найден.', 'error') + return redirect(url_for('admin_panel')) - file_node, parent_node = find_node_by_id(user_data['filesystem'], file_id) + 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: - return jsonify({"status": "error", "message": "File not found in user's structure."}), 404 + flash('Файл не найден в структуре пользователя.', 'error') + return redirect(url_for('admin_user_files', user_tg_id=user_tg_id)) hf_path = file_node.get('path') - original_filename = file_node.get('original_filename', 'file') - logging.warning(f"ADMIN ACTION by {admin_id_str}: Attempting to delete file {file_id} ({original_filename}) for user {target_user_id_str}.") + 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 - hf_delete_success = True - if not hf_path: - logging.warning(f"ADMIN ACTION: HF path missing for file {file_id} user {target_user_id_str}. Deleting metadata only.") - else: - hf_delete_success = delete_hf_file(hf_path, target_user_id_str, original_filename) - if not hf_delete_success: - logging.error(f"ADMIN ACTION: Failed to delete file {hf_path} from HF Hub for user {target_user_id_str}.") - # Decide policy: stop or continue? Continue for now, but log error. + # 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}).") - # Remove file from database structure - if remove_node(user_data['filesystem'], file_id): + + if not hf_path: + flash(f'Ошибка: Путь к файлу {original_filename} не найден. Удаление только из базы.', 'error') + logging.warning(f"ADMIN ACTION: Path missing for file {file_id}. Deleting metadata only.") + if remove_node(user_data['filesystem'], file_id): + save_needed = True + else: + error_occurred = True + flash('Не удалось удалить метаданные файла (путь отсутствовал).') + else: try: - save_data(data) - logging.info(f"ADMIN ACTION by {admin_id_str}: Successfully deleted file {file_id} metadata for user {target_user_id_str}.") - if not hf_delete_success: - return jsonify({"status": "warning", "message": f"File '{original_filename}' metadata deleted, but failed to remove from storage."}) + 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: - return jsonify({"status": "success", "message": f"File '{original_filename}' deleted successfully."}) + flash('Не найден ни на сервере, ни в базе данных.', 'error') + error_occurred = True except Exception as e: - logging.error(f"ADMIN ACTION by {admin_id_str}: Error saving data after deleting file {file_id} metadata for user {target_user_id_str}: {e}") - return jsonify({"status": "error", "message": "File removed from storage (if possible), but failed to update database."}), 500 - else: - logging.error(f"ADMIN ACTION by {admin_id_str}: remove_node failed for file {file_id} user {target_user_id_str} despite checks.") - return jsonify({"status": "error", "message": "Internal error removing file from database structure."}), 500 - - -# --- HTML Templates (Stored as Strings) --- - -BASE_STYLE = ''' -:root { - --primary: #ff4d6d; --secondary: #00ddeb; --accent: #8b5cf6; - --tg-theme-bg-color: var(--tg-bg-color, #ffffff); - --tg-theme-text-color: var(--tg-text-color, #000000); - --tg-theme-hint-color: var(--tg-hint-color, #aaaaaa); - --tg-theme-link-color: var(--tg-link-color, #2481cc); - --tg-theme-button-color: var(--tg-button-color, #5288c1); - --tg-theme-button-text-color: var(--tg-button-text-color, #ffffff); - --tg-theme-secondary-bg-color: var(--tg-secondary-bg-color, #f1f1f1); - - /* Custom vars based on TG */ - --background: var(--tg-theme-bg-color); - --text-color: var(--tg-theme-text-color); - --card-bg: var(--tg-theme-secondary-bg-color); - --button-bg: var(--tg-theme-button-color); - --button-text: var(--tg-theme-button-text-color); - --link-color: var(--tg-theme-link-color); - --hint-color: var(--tg-theme-hint-color); - --delete-color: #ff4444; - --folder-color: #ffc107; /* Keep custom folder color */ - --shadow: 0 4px 15px rgba(0, 0, 0, 0.1); - --glass-bg: rgba(128, 128, 128, 0.1); /* Adjusted for potential dark mode */ - --transition: all 0.3s ease; -} -* { margin: 0; padding: 0; box-sizing: border-box; } -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; - background-color: var(--background); - color: var(--text-color); - line-height: 1.6; - padding: 10px; /* Add padding for app view */ - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} -.container { /* Removed fixed width/bg, padding handled by body */ - margin: 0 auto; - max-width: 100%; - overflow-x: hidden; -} -h1 { font-size: 1.8em; font-weight: 700; text-align: center; margin-bottom: 15px; color: var(--text-color); } -h2 { font-size: 1.3em; margin-top: 20px; margin-bottom: 10px; color: var(--text-color); border-bottom: 1px solid var(--hint-color); padding-bottom: 5px;} -h4 { font-size: 1.1em; margin-top: 15px; margin-bottom: 5px; color: var(--accent); } /* Keep accent for headings */ -p { margin-bottom: 10px; } -a { color: var(--link-color); text-decoration: none; } -a:hover { text-decoration: underline; } -input[type=text], input[type=password], input[type=file], textarea { - width: 100%; - padding: 12px; - margin: 8px 0; - border: 1px solid var(--hint-color); - border-radius: 8px; - background-color: var(--background); /* Use main background */ - color: var(--text-color); - font-size: 1em; - box-shadow: none; /* Remove inner shadow */ -} -input:focus, textarea:focus { - outline: none; - border-color: var(--link-color); /* Highlight with link color */ - box-shadow: 0 0 0 2px rgba(var(--link-color-rgb), 0.2); /* Optional subtle glow */ -} -/* Use Telegram style buttons where possible */ -.btn { - padding: 10px 20px; - background: var(--button-bg); - color: var(--button-text); - border: none; - border-radius: 8px; - cursor: pointer; - font-size: 1em; - font-weight: 600; - transition: var(--transition); - display: inline-block; - text-decoration: none; - margin-top: 5px; - margin-right: 5px; - text-align: center; - box-shadow: 0 2px 5px rgba(0,0,0,0.1); -} -.btn:hover { opacity: 0.9; transform: translateY(-1px); } -.btn:active { transform: translateY(0); opacity: 0.8; } -.download-btn { background: var(--secondary); color: white;} /* Keep custom colors for specific actions */ -.delete-btn { background: var(--delete-color); color: white; } -.folder-btn { background: var(--folder-color); color: #333; } /* Adjust text color for yellow */ - -.flash { color: var(--text-color); text-align: center; margin-bottom: 15px; padding: 10px; background: rgba(0, 122, 255, 0.1); border: 1px solid rgba(0, 122, 255, 0.3); border-radius: 8px; } -.flash.error { color: var(--delete-color); background: rgba(255, 68, 68, 0.1); border-color: rgba(255, 68, 68, 0.3); } -.flash.success { color: #34c759; background: rgba(52, 199, 89, 0.1); border-color: rgba(52, 199, 89, 0.3); } - -.file-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; margin-top: 15px; } -.item { - background: var(--card-bg); - padding: 10px; - border-radius: 12px; - box-shadow: var(--shadow); - text-align: center; - transition: var(--transition); - display: flex; - flex-direction: column; - justify-content: space-between; - min-height: 180px; /* Ensure items have some height */ - overflow: hidden; -} -.item:hover { transform: translateY(-3px); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); } -.item-preview { - width: 100%; height: 100px; - object-fit: cover; /* Changed from contain */ - border-radius: 8px; - margin-bottom: 8px; - cursor: pointer; - display: block; - margin-left: auto; margin-right: auto; - background-color: rgba(128,128,128, 0.1); /* Placeholder bg */ - display: flex; /* Center icon fallback */ - align-items: center; - justify-content: center; - font-size: 40px; /* Icon size */ -} -.item.folder .item-preview { font-size: 50px; color: var(--folder-color); object-fit: contain; } /* Folder icon */ -.item.file .item-preview { /* File type icons as fallback */ - /* Default icon */ color: var(--hint-color); content: '📄'; /* Example */ -} -.item.file.image .item-preview, .item.file.video .item-preview { background-color: transparent; } /* Images/videos shouldn't have placeholder bg */ - -/* Specific file type icons (using content or background-image) */ -.item.file.pdf .item-preview::before { content: '📊'; color: var(--accent); font-size: 50px; } /* PDF */ -.item.file.text .item-preview::before { content: '📝'; color: var(--secondary); font-size: 50px; } /* Text */ -.item.file.audio .item-preview::before { content: '🎵'; color: #007aff; font-size: 50px; } /* Audio */ -.item.file.archive .item-preview::before { content: '📦'; color: #ff9500; font-size: 50px; } /* Archive */ -.item.file.document .item-preview::before { content: '📑'; color: #5856d6; font-size: 50px; } /* Document */ -.item.file.other .item-preview::before { content: '❓'; color: var(--hint-color); font-size: 50px; } /* Other */ - - -.item p { font-size: 0.9em; margin: 3px 0; word-break: break-all; line-height: 1.3; } -.item p.filename { font-weight: 500; color: var(--text-color); } -.item p.details { font-size: 0.8em; color: var(--hint-color); } - -.item-actions { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 5px; justify-content: center; } -.item-actions .btn { font-size: 0.85em; padding: 5px 10px; } - -/* Modal styling */ -.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.7); z-index: 2000; justify-content: center; align-items: center; } -.modal-content { max-width: 95%; max-height: 95%; background: var(--background); padding: 10px; border-radius: 15px; overflow: auto; position: relative; box-shadow: 0 10px 30px rgba(0,0,0,0.3); } -.modal img, .modal video, .modal iframe, .modal pre { max-width: 100%; max-height: 85vh; display: block; margin: auto; border-radius: 10px; } -.modal iframe { width: 90vw; height: 85vh; border: 1px solid var(--hint-color); } -.modal pre { background: var(--card-bg); color: var(--text-color); padding: 15px; border-radius: 8px; white-space: pre-wrap; word-wrap: break-word; text-align: left; max-height: 85vh; overflow-y: auto; font-family: monospace;} -.modal-close-btn { position: absolute; top: 10px; right: 15px; font-size: 24px; color: var(--hint-color); cursor: pointer; background: rgba(0,0,0,0.2); border-radius: 50%; width: 30px; height: 30px; line-height: 30px; text-align: center; } - -#progress-container { width: 100%; background: var(--card-bg); border-radius: 10px; margin: 15px 0; display: none; position: relative; height: 20px; border: 1px solid var(--hint-color); } -#progress-bar { width: 0%; height: 100%; background: var(--button-bg); border-radius: 10px; transition: width 0.3s ease; } -#progress-text { position: absolute; width: 100%; text-align: center; line-height: 20px; color: var(--button-text); 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(--hint-color); white-space: nowrap; overflow-x: auto; padding-bottom: 5px;} -.breadcrumbs a { color: var(--link-color); text-decoration: none; } -.breadcrumbs a:hover { text-decoration: underline; } -.breadcrumbs span.crumb-separator { margin: 0 5px; } -.breadcrumbs span.current-crumb { color: var(--text-color); font-weight: 500;} - -.folder-actions { margin-top: 15px; margin-bottom: 10px; display: flex; gap: 10px; 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;} - -#loading-indicator { text-align: center; padding: 20px; font-size: 1.2em; color: var(--hint-color); display: none; } -#error-display { color: var(--delete-color); background: rgba(255, 68, 68, 0.1); border: 1px solid rgba(255, 68, 68, 0.3); padding: 10px; border-radius: 8px; margin-bottom: 15px; display: none; } - -.user-info { text-align: center; margin-bottom: 15px; color: var(--hint-color); font-size: 0.9em;} - -/* Responsive Adjustments */ -@media (max-width: 600px) { - .file-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px;} - .item { min-height: 160px; } - .item-preview { height: 80px; font-size: 35px; } - .item.folder .item-preview { font-size: 40px; } - .item p.filename { font-size: 0.85em; } - .item p.details { font-size: 0.75em; } - .item-actions .btn { font-size: 0.8em; padding: 4px 8px; } - h1 { font-size: 1.6em; } - h2 { font-size: 1.2em; } - .btn { padding: 8px 16px; font-size: 0.95em;} - .folder-actions { flex-direction: column; align-items: stretch; } - .folder-actions input[type=text] { width: 100%; } -} -''' - -HTML_TEMPLATE = ''' - - - - - Zeus Cloud - - - - -
-

Zeus Cloud

- -
-

Инициализация...

-
- -
- - - -
- - - - - - - -''' - -ADMIN_LOGIN_TEMPLATE = ''' - -Admin Access -

Admin Access Required

You need admin privileges to access this page.

-'''.format(style=BASE_STYLE) - - -ADMIN_PANEL_TEMPLATE = ''' - - - - - Админ-панель - - - - -
-

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

-

Вошли как Admin ID: {{ admin_id }}

- - - -

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

-
- {% for user in user_details %} -
- - -
- {% else %} -

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

- {% endfor %} -
-
- - - - -''' - -ADMIN_USER_FILES_TEMPLATE = ''' - - - - - Файлы {{ target_username }} - - - - -
- ← Назад к пользователям -

Файлы пользователя: {{ target_username }} (ID: {{ target_user_id }})

- - - - - -
- {% for file in files %} -
-
-
- {% if file.file_type == 'image' and file.preview_url %} - {{ file.original_filename }} - {% elif file.file_type == 'video' and file.preview_url %} - {% set thumb_url = file.preview_url + '#t=0.5' %} - - {% endif %} - {# Icons handled by CSS based on class #} -
-

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

-
-

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

-

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

-

ID: {{ file.id }}

-

Путь: {{ file.path }}

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

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

- {% endfor %} -
-
- - - - - - - -''' # --- App Initialization --- -if __name__ == '__main__': - if not BOT_TOKEN or len(BOT_TOKEN.split(':')) != 2: - logging.critical("FATAL: TELEGRAM_BOT_TOKEN is missing or invalid!") - exit(1) - 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 unset.") - # Perform initial database download before starting app or background tasks - logging.info("Performing initial database download...") - download_db_from_hf() +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 + if HF_TOKEN_READ or HF_TOKEN_WRITE: + logging.info("Performing initial database download/check before starting.") + 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}") - # Start periodic backup thread only if write token exists + # Start periodic backup thread if write token exists if HF_TOKEN_WRITE: backup_thread = threading.Thread(target=periodic_backup, daemon=True) backup_thread.start() logging.info("Periodic backup thread started.") else: - logging.warning("Periodic backup disabled because HF_TOKEN_WRITE is not set.") + 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 Flask App (use appropriate server for production, e.g., Gunicorn) - # For development: - # Make sure to use host='0.0.0.0' to be accessible on the network - # Use a proper WSGI server like gunicorn in production: - # gunicorn --bind 0.0.0.0:7860 your_app_file_name:app + # 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