diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -16,154 +16,80 @@ import hmac import hashlib from urllib.parse import unquote -from telegram import Update, WebAppInfo, InlineKeyboardButton, InlineKeyboardMarkup -from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes - -# --- CONFIGURATION --- -BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "6750208873:AAE2hvPlJ99dBdhGa_Brre0IIpUdOvXxHt4") -ADMIN_TELEGRAM_ID = os.getenv("ADMIN_TELEGRAM_ID", "YOUR_ADMIN_TELEGRAM_ID_HERE") # Replace with actual admin Telegram ID -FLASK_SECRET_KEY = os.getenv("FLASK_SECRET_KEY", "supersecretkey_telegram_mini_app_unique") -DATA_FILE = 'cloudeng_tg_data.json' -REPO_ID = os.getenv("HF_REPO_ID", "Eluza133/Z1e1u") # Default to your repo -HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE") -HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") or HF_TOKEN_WRITE -UPLOAD_FOLDER = 'uploads_tg' -WEB_APP_URL = os.getenv("WEB_APP_URL") # e.g., https://yourdomain.com +app = Flask(__name__) +app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersecretkey_folders_unique_telegram") + +# --- Telegram Configuration --- +BOT_API_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "6750208873:AAE2hvPlJ99dBdhGa_Brre0IIpUdOvXxHt4") +ADMIN_TELEGRAM_IDS = [int(admin_id) for admin_id in os.getenv("ADMIN_TELEGRAM_IDS", "123456789,987654321").split(',') if admin_id.strip()] # Example: "123456,789012" +# For local testing, you can mock a Telegram user. +# Example: http://localhost:7860/auth/telegram?mock_user_id=YOUR_TELEGRAM_ID (replace YOUR_TELEGRAM_ID) +MOCK_TELEGRAM_MODE = os.getenv("MOCK_TELEGRAM_MODE", "False").lower() == "true" + +DATA_FILE = 'cloudeng_data_tg.json' +REPO_ID = "Eluza133/Z1e1u" # Replace with your actual Repo ID +HF_TOKEN_WRITE = os.getenv("HF_TOKEN") +HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") or HF_TOKEN_WRITE +UPLOAD_FOLDER = 'uploads' os.makedirs(UPLOAD_FOLDER, exist_ok=True) -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') -logger = logging.getLogger(__name__) -app = Flask(__name__) -app.secret_key = FLASK_SECRET_KEY cache = Cache(app, config={'CACHE_TYPE': 'simple'}) +logging.basicConfig(level=logging.INFO) -# --- STYLES --- -BASE_STYLE = ''' -:root { - --primary: #0088cc; --secondary: #00ab6c; --accent: #536de6; - --background-light: #ffffff; --background-dark: #1c1c1e; - --card-bg: rgba(240, 240, 245, 0.95); --card-bg-dark: rgba(44, 44, 46, 0.95); - --text-light: #000000; --text-dark: #ffffff; --shadow: 0 8px 25px rgba(0, 0, 0, 0.15); - --glass-bg: rgba(200, 200, 200, 0.2); --transition: all 0.3s ease; --delete-color: #ff3b30; - --folder-color: #ff9500; -} -* { margin: 0; padding: 0; box-sizing: border-box; } -body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: var(--background-light); color: var(--text-light); line-height: 1.5; -webkit-font-smoothing: antialiased; } -body.dark { background: var(--background-dark); color: var(--text-dark); } -.container { margin: 0 auto; max-width: 100%; min-height: 100vh; padding: 15px; background: var(--background-light); overflow-x: hidden; } -body.dark .container { background: var(--background-dark); } -h1 { font-size: 1.8em; font-weight: 700; text-align: center; margin-bottom: 20px; color: var(--primary); } -h2 { font-size: 1.4em; margin-top: 25px; margin-bottom:10px; color: var(--text-light); } -body.dark h2 { color: var(--text-dark); } -h4 { font-size: 1em; margin-top: 12px; margin-bottom: 4px; color: var(--accent); } -ol, ul { margin-left: 20px; margin-bottom: 12px; } -li { margin-bottom: 4px; } -input, textarea { width: 100%; padding: 12px; margin: 10px 0; border: 1px solid #ccc; border-radius: 10px; background: var(--glass-bg); color: var(--text-light); font-size: 1em; } -body.dark input, body.dark textarea { border-color: #444; color: var(--text-dark); background: rgba(70,70,70,0.3); } -input:focus, textarea:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 2px var(--primary); } -.btn { padding: 12px 24px; background: var(--primary); color: white; border: none; border-radius: 10px; cursor: pointer; font-size: 1em; font-weight: 600; transition: var(--transition); box-shadow: 0 4px 10px rgba(0,0,0,0.1); display: inline-block; text-decoration: none; margin-top: 4px; margin-right: 4px; text-align: center; } -.btn:hover { transform: translateY(-2px); background: #0077b3; box-shadow: 0 6px 15px rgba(0,0,0,0.15); } -.download-btn { background: var(--secondary); } -.download-btn:hover { background: #00965e; } -.delete-btn { background: var(--delete-color); } -.delete-btn:hover { background: #e03024; } -.folder-btn { background: var(--folder-color); } -.folder-btn:hover { background: #e08300; } -.flash { color: var(--primary); text-align: center; margin-bottom: 12px; padding: 8px; background: rgba(0, 136, 204, 0.1); border-radius: 8px; border: 1px solid var(--primary); } -.flash.error { color: var(--delete-color); background: rgba(255, 59, 48, 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; } -.user-list { margin-top: 15px; } -.user-item { padding: 12px; background: var(--card-bg); border-radius: 12px; margin-bottom: 8px; box-shadow: var(--shadow); transition: var(--transition); } -body.dark .user-item { background: var(--card-bg-dark); } -.user-item:hover { transform: translateY(-3px); } -.user-item a { color: var(--primary); text-decoration: none; font-weight: 600; } -.user-item a:hover { color: var(--accent); } -.item { background: var(--card-bg); padding: 12px; border-radius: 12px; box-shadow: var(--shadow); text-align: center; transition: var(--transition); display: flex; flex-direction: column; justify-content: space-between; } -body.dark .item { background: var(--card-bg-dark); } -.item:hover { transform: translateY(-3px); } -.item-preview { width: 100%; height: 100px; object-fit: cover; border-radius: 8px; margin-bottom: 8px; cursor: pointer; display: block; margin-left: auto; margin-right: auto;} -.item.folder .item-preview { object-fit: contain; font-size: 50px; color: var(--folder-color); line-height: 100px; } -.item p { font-size: 0.85em; margin: 4px 0; word-break: break-all; } -.item a { color: var(--primary); text-decoration: none; } -.item a:hover { color: var(--accent); } -.item-actions { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 4px; justify-content: center; } -.item-actions .btn { font-size: 0.8em; padding: 6px 10px; } -.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-light); padding: 10px; border-radius: 12px; overflow: auto; position: relative; } -body.dark .modal-content { background: var(--card-bg-dark); } -.modal img, .modal video, .modal iframe, .modal pre { max-width: 100%; max-height: 85vh; display: block; margin: auto; border-radius: 8px; } -.modal iframe { width: 90vw; height: 85vh; border: none; } -.modal pre { background: #eee; color: #333; padding: 12px; border-radius: 6px; white-space: pre-wrap; word-wrap: break-word; text-align: left; max-height: 85vh; overflow-y: auto;} -body.dark .modal pre { background: #2b2a33; color: var(--text-dark); } -.modal-close-btn { position: absolute; top: 10px; right: 15px; font-size: 24px; color: #aaa; cursor: pointer; background: rgba(0,0,0,0.3); border-radius: 50%; width: 28px; height: 28px; line-height: 28px; text-align: center; } -body.dark .modal-close-btn { color: #555; background: rgba(255,255,255,0.15); } -#progress-container { width: 100%; background: var(--glass-bg); border-radius: 8px; margin: 12px 0; display: none; position: relative; height: 18px; } -#progress-bar { width: 0%; height: 100%; background: var(--primary); border-radius: 8px; transition: width 0.3s ease; } -#progress-text { position: absolute; width: 100%; text-align: center; line-height: 18px; color: var(--text-dark); font-size: 0.8em; font-weight: bold; text-shadow: 1px 1px 1px rgba(0,0,0,0.2); } -.breadcrumbs { margin-bottom: 15px; font-size: 1em; } -.breadcrumbs a { color: var(--accent); text-decoration: none; } -.breadcrumbs a:hover { text-decoration: underline; } -.breadcrumbs span { margin: 0 4px; color: #999; } -.folder-actions { margin-top: 15px; margin-bottom: 8px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } -.folder-actions input[type=text] { width: auto; flex-grow: 1; margin: 0; min-width: 120px; } -.folder-actions .btn { margin: 0; flex-shrink: 0;} -#auth-message { text-align: center; padding: 20px; font-size: 1.2em; } -@media (max-width: 480px) { - .container { padding: 10px; } - .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; } - h1 { font-size: 1.6em; } - .btn { padding: 10px 20px; font-size: 0.9em; } - .item-actions .btn { padding: 5px 8px; font-size: 0.75em;} - .folder-actions { flex-direction: column; align-items: stretch; } -} -''' -# --- HELPER FUNCTIONS --- -def verify_telegram_data(init_data_str, bot_token_val): - if not init_data_str or not bot_token_val: - return False - - parsed_data = {} - for pair in init_data_str.split('&'): - if '=' in pair: - key, value = pair.split('=', 1) - parsed_data[key] = unquote(value) - - received_hash = parsed_data.pop('hash', None) - if not received_hash: - return False - - data_check_arr = [f"{key}={value}" for key, value in sorted(parsed_data.items())] - data_check_string = "\n".join(data_check_arr) - - secret_key = hmac.new("WebAppData".encode(), bot_token_val.encode(), hashlib.sha256).digest() - calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest() - - return calculated_hash == received_hash +def check_telegram_authorization(init_data_str: str, bot_token: str) -> dict | None: + """ + Validates the initData string from Telegram Web App. + Returns user data dictionary if valid, None otherwise. + """ + try: + # Telegram.WebApp.initData is URL-encoded, ensure it's decoded if necessary. + # Flask's request.get_json() or request.form should handle decoding if initData is part of JSON/form. + # If initData_str is directly from JS, it might already be decoded. + # For safety, let's try to unquote it if it looks like a query string. + if '%' in init_data_str: # Basic check for URL encoding + init_data_str = unquote(init_data_str) + + params = {} + for item in init_data_str.split('&'): + key, value = item.split('=', 1) + params[key] = value + + hash_to_check = params.pop('hash', None) + if not hash_to_check: + logging.warning("No hash found in initData") + return None + + data_check_arr = [f"{k}={v}" for k, v in sorted(params.items())] + data_check_string = "\n".join(data_check_arr) + + 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_to_check: + user_data = params.get("user") + if user_data: + return json.loads(user_data) # user_data is a JSON string + return {} # Should contain user data if auth is valid + else: + logging.warning(f"Telegram auth hash mismatch. Calculated: {calculated_hash}, Received: {hash_to_check}") + return None + except Exception as e: + logging.error(f"Error in check_telegram_authorization: {e}", exc_info=True) + return None -def get_tg_user_display_name(tg_user_obj): - if not tg_user_obj: return "Unknown User" - first_name = tg_user_obj.get('first_name', '') - last_name = tg_user_obj.get('last_name', '') - username = tg_user_obj.get('username') - - if first_name and last_name: return f"{first_name} {last_name}" - if first_name: return first_name - if username: return username - return f"User {tg_user_obj.get('id')}" def find_node_by_id(filesystem, node_id): if not filesystem: return None, None if filesystem.get('id') == node_id: return filesystem, None + queue = [(filesystem, None)] while queue: current_node, parent = queue.pop(0) if current_node.get('type') == 'folder' and 'children' in current_node: - for child in current_node['children']: + for i, child in enumerate(current_node['children']): if child.get('id') == node_id: return child, current_node if child.get('type') == 'folder': @@ -184,46 +110,66 @@ def remove_node(filesystem, node_id): if node_to_remove and parent_node and 'children' in parent_node: parent_node['children'] = [child for child in parent_node['children'] if child.get('id') != node_id] return True - # Handle root node removal attempt if it's the target (should not happen for files/folders within root) - elif node_to_remove and node_id == filesystem.get('id') and not parent_node: - logger.error("Attempted to remove root node itself via remove_node, which is not supported this way.") - return False # Or handle as a special case if needed + # Special case: removing root's child directly if filesystem is root and parent_node is None + if node_to_remove and filesystem.get('id') == 'root' and parent_node is None: + if 'children' in filesystem: + filesystem['children'] = [child for child in filesystem['children'] if child.get('id') != node_id] + return True return False def get_node_path_string(filesystem, node_id): path_list = [] current_id = node_id + while current_id: node, parent = find_node_by_id(filesystem, current_id) - if not node: break + if not node: + break if node.get('id') != 'root': path_list.append(node.get('name', node.get('original_filename', ''))) - if not parent: break + if not parent: + break current_id = parent.get('id') if parent else None return " / ".join(reversed(path_list)) or "Root" -def initialize_user_filesystem(user_data): - if 'filesystem' not in user_data: - user_data['filesystem'] = { - "type": "folder", "id": "root", "name": "root", "children": [] + +def initialize_user_filesystem(user_data_storage_entry): # Expects the value part of data['users'][telegram_id_str] + if 'filesystem' not in user_data_storage_entry: + user_data_storage_entry['filesystem'] = { + "type": "folder", + "id": "root", + "name": "root", # Could be user's TG name, but "root" is fine + "children": [] } + # Migration logic for old 'files' list, if any, could go here + # For simplicity, new TG users start with an empty root folder. + @cache.memoize(timeout=300) def load_data(): try: - download_db_from_hf() + if os.path.exists(DATA_FILE): # Only download if it exists on HF or local is missing + download_db_from_hf() + + if not os.path.exists(DATA_FILE): # If still not exists, create empty + with open(DATA_FILE, 'w', encoding='utf-8') as f: + json.dump({'users': {}}, f) + logging.info(f"Created empty local database file: {DATA_FILE}") + with open(DATA_FILE, 'r', encoding='utf-8') as file: data = json.load(file) - if not isinstance(data, dict): - data = {'users': {}} - data.setdefault('users', {}) - for user_id, user_data_val in data['users'].items(): - initialize_user_filesystem(user_data_val) - logger.info("Data successfully loaded and initialized") - return data + if not isinstance(data, dict): + logging.warning("Data is not in dict format, initializing empty database") + return {'users': {}} + data.setdefault('users', {}) + # User data is now keyed by str(telegram_user_id) + for tg_user_id_str, user_storage_entry in data['users'].items(): + initialize_user_filesystem(user_storage_entry) # Pass the dict value directly + logging.info("Data successfully loaded and initialized for Telegram users") + return data except Exception as e: - logger.error(f"Error loading data: {e}") + logging.error(f"Error loading data: {e}") return {'users': {}} def save_data(data): @@ -232,163 +178,492 @@ def save_data(data): json.dump(data, file, ensure_ascii=False, indent=4) upload_db_to_hf() cache.clear() - logger.info("Data saved and uploaded to HF") + logging.info("Data saved and uploaded to HF") except Exception as e: - logger.error(f"Error saving data: {e}") + logging.error(f"Error saving data: {e}") raise def upload_db_to_hf(): if not HF_TOKEN_WRITE: - logger.warning("HF_TOKEN_WRITE not set, skipping database upload.") + logging.warning("HF_TOKEN_WRITE not set, skipping database upload.") return try: api = HfApi() api.upload_file( - path_or_fileobj=DATA_FILE, path_in_repo=DATA_FILE, repo_id=REPO_ID, - repo_type="dataset", token=HF_TOKEN_WRITE, + path_or_fileobj=DATA_FILE, + path_in_repo=DATA_FILE, + repo_id=REPO_ID, + repo_type="dataset", + token=HF_TOKEN_WRITE, commit_message=f"Backup {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" ) - logger.info("Database uploaded to Hugging Face") + logging.info("Database uploaded to Hugging Face") except Exception as e: - logger.error(f"Error uploading database: {e}") + logging.error(f"Error uploading database: {e}") def download_db_from_hf(): if not HF_TOKEN_READ: - logger.warning("HF_TOKEN_READ not set, skipping database download.") + logging.warning("HF_TOKEN_READ not set, skipping database download.") if not os.path.exists(DATA_FILE): - with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'users': {}}, f) + with open(DATA_FILE, 'w', encoding='utf-8') as f: + json.dump({'users': {}}, f) return try: hf_hub_download( - repo_id=REPO_ID, filename=DATA_FILE, repo_type="dataset", - token=HF_TOKEN_READ, local_dir=".", local_dir_use_symlinks=False + repo_id=REPO_ID, + filename=DATA_FILE, + repo_type="dataset", + token=HF_TOKEN_READ, + local_dir=".", + local_dir_use_symlinks=False, + force_download=True # Ensure we get the latest if available ) - logger.info("Database downloaded from Hugging Face") - except (hf_utils.RepositoryNotFoundError, hf_utils.EntryNotFoundError): - logger.warning(f"{DATA_FILE} or repo {REPO_ID} not found. Initializing empty database.") + logging.info("Database downloaded from Hugging Face") + except hf_utils.RepositoryNotFoundError: + logging.warning(f"Repository {REPO_ID} not found.") + if not os.path.exists(DATA_FILE): + with open(DATA_FILE, 'w', encoding='utf-8') as f: + json.dump({'users': {}}, f) + except hf_utils.EntryNotFoundError: + logging.warning(f"{DATA_FILE} not found in repository {REPO_ID}. Initializing empty database if local also missing.") if not os.path.exists(DATA_FILE): - with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'users': {}}, f) + with open(DATA_FILE, 'w', encoding='utf-8') as f: + json.dump({'users': {}}, f) except Exception as e: - logger.error(f"Error downloading database: {e}") + logging.error(f"Error downloading database: {e}") if not os.path.exists(DATA_FILE): - with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'users': {}}, f) + with open(DATA_FILE, 'w', encoding='utf-8') as f: + json.dump({'users': {}}, f) def periodic_backup(): while True: - time.sleep(1800) + time.sleep(1800) # Backup every 30 minutes + logging.info("Attempting periodic backup...") + data = load_data() # Load current data before saving (or just save current state if save_data handles loading) + # save_data implicitly uploads, so this might be redundant if data is saved frequently + # The main purpose of save_data(data) is to ensure data is written to disk then uploaded. + # A direct call to upload_db_to_hf() might be better if we only want to backup the existing disk file. + # However, frequent load/save operations are handled by endpoints. This is more of a fallback. upload_db_to_hf() + def get_file_type(filename): filename_lower = filename.lower() - if filename_lower.endswith(('.mp4', '.mov', '.avi', '.webm', '.mkv')): return 'video' - elif filename_lower.endswith(('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg')): return 'image' - elif filename_lower.endswith('.pdf'): return 'pdf' - elif filename_lower.endswith('.txt'): return 'text' + if filename_lower.endswith(('.mp4', '.mov', '.avi', '.webm', '.mkv')): + return 'video' + elif filename_lower.endswith(('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg')): + return 'image' + elif filename_lower.endswith('.pdf'): + return 'pdf' + elif filename_lower.endswith('.txt'): + return 'text' return 'other' -def is_admin_user(): - return session.get('telegram_user_id') and str(session.get('telegram_user_id')) == str(ADMIN_TELEGRAM_ID) - -# --- HTML TEMPLATES --- -APP_SHELL_HTML = """ - -
- -Аутентификация через Telegram...