diff --git "a/Optomshoptxt.txt" "b/Optomshoptxt.txt" new file mode 100644--- /dev/null +++ "b/Optomshoptxt.txt" @@ -0,0 +1,2468 @@ + +# -*- coding: utf-8 -*- +import json +import os +import logging +import threading +import time +from datetime import datetime +from huggingface_hub import HfApi, hf_hub_download, utils as hf_utils +from werkzeug.utils import secure_filename +import requests +from io import BytesIO +import uuid +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 +UPLOAD_FOLDER = 'uploads_tma' +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') + +# --- Data Handling Functions --- + +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)] + 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: + return child, current_node + if child.get('type') == 'folder' and child_id not in visited: + queue.append((child, current_node)) + visited.add(child_id) + return None, None + +def add_node(filesystem, parent_id, node_data): + parent_node, _ = find_node_by_id(filesystem, parent_id) + if parent_node and parent_node.get('type') == 'folder': + if 'children' not in parent_node: + 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 + parent_node['children'].append(node_data) + return True + logging.error(f"Could not find parent folder with ID {parent_id} to add node.") + 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: + 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 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: + 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': + break + current_id = parent.get('id') + + return " / ".join(reversed(path_list)) if path_list else "Главная" + + +def initialize_user_filesystem(user_data): + if 'filesystem' not in user_data or not isinstance(user_data['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 +def load_data(): + try: + download_db_from_hf() + with open(DATA_FILE, 'r', encoding='utf-8') as file: + data = json.load(file) + if not isinstance(data, dict): + 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': {}} + for tg_id_str, user_data in data['users'].items(): + 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 + except FileNotFoundError: + logging.warning(f"{DATA_FILE} not found locally. 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 + return {'users': {}} + except Exception as e: + logging.error(f"Unexpected 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()} + + 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.") + except Exception as e: + logging.error(f"Error saving data: {e}", exc_info=True) + raise # Re-raise to signal failure + +# --- Hugging Face Hub Interaction --- + +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.") + return + try: + api = HfApi() + api.upload_file( + path_or_fileobj=DATA_FILE, + path_in_repo=DATA_FILE, + repo_id=REPO_ID, + repo_type="dataset", + token=HF_TOKEN_WRITE, + commit_message=f"Backup TMA {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}") + +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, + repo_type="dataset", + 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 + ) + logging.info("Database downloaded successfully from Hugging Face") + except hf_utils.EntryNotFoundError: + logging.warning(f"{DATA_FILE} not found in repository {REPO_ID}. Initializing empty local file.") + 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 + 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}") + +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 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'): + return 'video' + elif ext in ('jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'): + return 'image' + elif ext == 'pdf': + return 'pdf' + elif ext in ('txt', 'md', 'log', 'csv', 'json', 'xml', 'html', 'css', 'js', 'py', 'java', 'c', 'cpp', 'sh'): + 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 + 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 + + if "hash" not in parsed_data: + logging.warning("Hash not found in initData") + return None, False + + hash_received = parsed_data.pop("hash") + data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(parsed_data.items())) + + 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 + +# --- Admin Check --- +def is_admin(telegram_id): + # Ensure comparison is between strings + return str(telegram_id) in [admin_id.strip() for admin_id in ADMIN_TELEGRAM_IDS if admin_id.strip()] + +# --- Periodic Backup --- +def periodic_backup(): + while True: + time.sleep(1800) # Sleep for 30 minutes + try: + logging.info("Starting periodic backup...") + # Load data first to ensure we back up the current state if save failed recently + # but don't save it back immediately, just use it for backup upload. + # This prevents overwriting a valid HF backup with potentially corrupt local data. + current_data_for_backup = load_data() # Load fresh data + # Check if loaded data seems valid before attempting save/upload + if isinstance(current_data_for_backup, dict) and 'users' in current_data_for_backup: + # Save current state locally first (optional, depends on strategy) + # save_data(current_data_for_backup) # Careful: this might overwrite if loaded data was bad + # Upload the *currently existing* local file + upload_db_to_hf() + logging.info("Periodic backup completed.") + else: + logging.error("Periodic backup skipped: loaded data is invalid.") + except Exception as e: + logging.error(f"Error during periodic backup: {e}", exc_info=True) + + +# --- Flask Routes --- + +# Serve the main Mini App HTML +@app.route('/') +def index(): + return render_template_string(HTML_TEMPLATE, bot_token=BOT_TOKEN) # Pass BOT_TOKEN if needed by frontend JS (unlikely now) + +# Validate Telegram InitData and return user info +@app.route('/validate_auth', methods=['POST']) +def validate_auth(): + init_data_str = request.data.decode('utf-8') + if not init_data_str: + return jsonify({"status": "error", "message": "Missing initData"}), 400 + + user_info, is_valid = validate_telegram_data(init_data_str, BOT_TOKEN) + + if not is_valid or not user_info or 'id' not in user_info: + logging.warning(f"Invalid Telegram auth attempt. Data: {init_data_str[:100]}...") + return jsonify({"status": "error", "message": "Invalid Telegram data"}), 403 + + telegram_id_str = str(user_info['id']) + logging.info(f"Successfully validated Telegram user: {telegram_id_str} ({user_info.get('username', 'N/A')})") + + data = load_data() + user_exists = telegram_id_str in data['users'] + + if not user_exists: + logging.info(f"New user detected: {telegram_id_str}. Creating profile.") + data['users'][telegram_id_str] = { + 'telegram_id': telegram_id_str, + 'first_name': user_info.get('first_name', ''), + 'last_name': user_info.get('last_name', ''), + 'username': user_info.get('username', ''), + 'created_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'filesystem': { + "type": "folder", + "id": "root", + "name": "root", + "children": [] + } + } + try: + save_data(data) + logging.info(f"Successfully created and saved profile for {telegram_id_str}") + except Exception as e: + logging.error(f"Failed to save new user data for {telegram_id_str}: {e}") + # Decide how to handle this - maybe let them proceed but data isn't saved? Or return error? + return jsonify({"status": "error", "message": "Could not save user profile"}), 500 + else: + # Optionally update user details (name, username) on login + update_needed = False + if data['users'][telegram_id_str].get('first_name') != user_info.get('first_name'): + data['users'][telegram_id_str]['first_name'] = user_info.get('first_name', '') + update_needed = True + # Add similar checks for last_name, username if needed + if update_needed: + try: + # Avoid full save_data if only minor details changed & no filesystem interaction yet + # For simplicity, we'll save, but could optimize later. + save_data(data) + except Exception as e: + logging.error(f"Failed to update user details for {telegram_id_str}: {e}") + # Non-critical error, proceed anyway + + + # Return necessary info for the frontend to proceed + return jsonify({ + "status": "success", + "user": { + "id": telegram_id_str, + "first_name": user_info.get('first_name'), + "username": user_info.get('username') + # Add other needed fields + }, + "initData": init_data_str # Send back the validated initData for subsequent requests + }) + + +# Get dashboard/folder content +@app.route('/get_folder_content', methods=['POST']) +def get_folder_content(): + req_data = request.json + init_data_str = req_data.get('initData') + folder_id = req_data.get('folderId', 'root') + + 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']) + data = load_data() + user_data = data['users'].get(telegram_id_str) + + if not user_data or 'filesystem' not in user_data: + logging.error(f"User data or filesystem not found for {telegram_id_str}") + return jsonify({"status": "error", "message": "User data not found"}), 404 + + current_folder, parent_folder = find_node_by_id(user_data['filesystem'], folder_id) + + if not current_folder or current_folder.get('type') != 'folder': + logging.warning(f"Folder {folder_id} not found for user {telegram_id_str}. Returning root.") + folder_id = 'root' # Default to root if folder not found + current_folder, parent_folder = find_node_by_id(user_data['filesystem'], folder_id) + if not current_folder: # Should not happen if root exists + logging.critical(f"CRITICAL: Root folder not found for user {telegram_id_str}") + return jsonify({"status": "error", "message": "Critical error: Root folder missing"}), 500 + + items_in_folder = sorted( + current_folder.get('children', []), + key=lambda x: (x['type'] != 'folder', x.get('name', x.get('original_filename', '')).lower()) + ) + + # Generate breadcrumbs + breadcrumbs = [] + temp_id = folder_id + while temp_id: + node, parent = find_node_by_id(user_data['filesystem'], temp_id) + if not node: break + is_link = (node['id'] != folder_id) + breadcrumbs.append({'id': node['id'], 'name': node.get('name', 'Главная'), 'is_link': is_link}) + if not parent or node.get('id') == 'root': break + temp_id = parent.get('id') + breadcrumbs.reverse() + if not breadcrumbs: # Ensure root breadcrumb exists if list is empty + breadcrumbs.append({'id': 'root', 'name': 'Главная', 'is_link': False}) + + + # Prepare file URLs (Important: Use relative paths or a function to build full URLs) + base_hf_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/" + for item in items_in_folder: + if item.get('type') == 'file' and item.get('path'): + item['preview_url'] = base_hf_url + item['path'] + item['download_url'] = f"/download/{item['id']}" # Use server route for download + if item['file_type'] == 'text': + item['preview_url'] = f"/get_text_content/{item['id']}" # Special URL for text preview + elif item['file_type'] == 'pdf': + # PDFs often need the download=true or specific handling for iframe + item['preview_url'] = base_hf_url + item['path'] # May need '?download=true' depending on browser/HF setup + elif item['file_type'] == 'video': + item['preview_url_thumb'] = base_hf_url + item['path'] + "#t=0.5" # For thumbnail hint + + + return jsonify({ + "status": "success", + "currentFolderId": folder_id, + "currentFolderName": current_folder.get('name', 'Главная') if folder_id != 'root' else 'Главная', + "items": items_in_folder, + "breadcrumbs": breadcrumbs, + "repoId": REPO_ID # Send repoId for potential direct links if needed + }) + + +@app.route('/upload', methods=['POST']) +def upload_files(): + init_data_str = request.form.get('initData') + target_folder_id = request.form.get('currentFolderId', 'root') + + 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 HF_TOKEN_WRITE: + logging.error("Upload attempt failed: HF_TOKEN_WRITE not configured.") + return jsonify({"status": "error", "message": "Upload unavailable: Server configuration error."}), 503 + + files = request.files.getlist('files') + if not files or all(not f.filename for f in files): + return jsonify({"status": "error", "message": "No files selected for upload."}), 400 + + if len(files) > 20: # Limit number of files per upload + return jsonify({"status": "error", "message": "Maximum 20 files allowed per upload."}), 413 + + 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 + + target_folder_node, _ = find_node_by_id(user_data['filesystem'], target_folder_id) + if not target_folder_node or target_folder_node.get('type') != 'folder': + logging.error(f"Target folder {target_folder_id} not found or invalid for user {telegram_id_str}") + return jsonify({"status": "error", "message": "Target upload folder not found."}), 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) + if not original_filename: # Skip if filename becomes empty after securing + errors.append(f"Skipped file due to invalid name: {file.filename}") + continue + + name_part, ext_part = os.path.splitext(original_filename) + ext_part = ext_part.lower() # Standardize extension case + unique_suffix = uuid.uuid4().hex[:8] + unique_filename = f"{name_part}_{unique_suffix}{ext_part}" + file_id = uuid.uuid4().hex + + # Construct HF path relative to user and target folder ID + hf_path = f"cloud_files/{telegram_id_str}/{target_folder_id}/{unique_filename}" + temp_path = os.path.join(UPLOAD_FOLDER, f"{file_id}_{unique_filename}") + + try: + file.save(temp_path) + # Consider adding file size check here before uploading + # file_size = os.path.getsize(temp_path) + # if file_size > MAX_FILE_SIZE: ... + + 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 {telegram_id_str} uploaded {original_filename} to folder {target_folder_id}" + ) + logging.info(f"Successfully uploaded {hf_path} for user {telegram_id_str}") + + file_info = { + 'type': 'file', + 'id': file_id, + 'original_filename': original_filename, + 'unique_filename': unique_filename, + 'path': hf_path, + 'file_type': get_file_type(original_filename), + 'upload_date': 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) + + 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 + 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}") + + if save_needed: + try: + save_data(data) + 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 + + 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 + + +@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() + + 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 + + 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 + + # 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 + + + folder_id = uuid.uuid4().hex + folder_data = { + 'type': 'folder', + 'id': folder_id, + 'name': folder_name, + 'children': [] + # 'created_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S') # Optional + } + + if add_node(user_data['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}) + 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 + 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 + + +@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' + + 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 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 + + 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 + + item_node, parent_node = find_node_by_id(user_data['filesystem'], item_id) + + 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 + + item_name = item_node.get('name', item_node.get('original_filename', 'item')) + + # --- 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 + + + # 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('/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. + + init_data_str = request.args.get('initData') + admin_override = request.args.get('admin_token') # Add a way for admin downloads + + 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) + + # 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) + + 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.") + + + 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) + + hf_path = file_node.get('path') + original_filename = file_node.get('original_filename', 'downloaded_file') + + 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})") + + try: + headers = {} + if HF_TOKEN_READ: + headers["authorization"] = f"Bearer {HF_TOKEN_READ}" + + # 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) + + # 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 + ) + + 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) + + +@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') + + 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) + + 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) + + 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) + + hf_path = file_node.get('path') + if not hf_path: + return Response("Error: File path missing.", 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.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) + + + # Try decoding with UTF-8 first, then fallback + try: + text_content = response.content.decode('utf-8') + 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.") + 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) + + return Response(text_content, mimetype='text/plain; charset=utf-8') # Specify UTF-8 for browser + + 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) + 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) + + +# --- Admin Routes (/admhosto) --- + +@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) + + data = load_data() + users = data.get('users', {}) + user_details = [] + + 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', [])) + + 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 + }) + + # 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 + + 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 + + if not admin_user_id: + return Response("Access Denied", status=403) + + 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) + + all_files = [] + # Use a stack for depth-first traversal to easily track path + stack = [(user_data.get('filesystem', {}), [])] # (node, path_list_of_names) + + while stack: + current_node, current_path_names = stack.pop() + if not current_node: continue + + node_type = current_node.get('type') + node_id = current_node.get('id') + node_name = current_node.get('name', current_node.get('original_filename')) + + if node_type == 'file': + file_info = current_node.copy() # Create a copy to add path string + file_info['parent_path_str'] = " / ".join(current_path_names) if current_path_names else "Главная" + # Add URLs for admin view + base_hf_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/" + if file_info.get('path'): + file_info['preview_url'] = base_hf_url + file_info['path'] + # Pass admin ID for download/text view routes + file_info['download_url'] = f"/download/{file_info['id']}?admin_token={admin_user_id}" + if file_info['file_type'] == 'text': + file_info['preview_url'] = f"/get_text_content/{file_info['id']}?admin_token={admin_user_id}" + elif file_info['file_type'] == 'pdf': + file_info['preview_url'] = base_hf_url + file_info['path'] # Admin likely has access + + all_files.append(file_info) + + elif node_type == 'folder' and 'children' in current_node: + # Add current folder name to path for children (unless it's root) + new_path_names = current_path_names + ([node_name] if node_id != 'root' else []) + # Add children to stack in reverse order for natural processing order if needed + for child in reversed(current_node.get('children', [])): + stack.append((child, new_path_names)) + + + all_files.sort(key=lambda x: x.get('upload_date', ''), reverse=True) + + # Render template for admin view of files + user_display_name = user_data.get('username') or user_data.get('first_name') or user_id + return render_template_string(ADMIN_USER_FILES_TEMPLATE, + target_user_id=user_id, + target_username=user_display_name, + files=all_files, + repo_id=REPO_ID, + admin_id=admin_user_id) + + +@app.route('/admhosto/delete_user/', methods=['POST']) +def admin_delete_user(user_id): + # Authentication check + req_data = request.json + init_data_str = req_data.get('initData') # Assuming initData passed in body for POST + 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 + + admin_id_str = str(admin_user_info['id']) + target_user_id_str = str(user_id) # User to delete + + if admin_id_str == target_user_id_str: + return jsonify({"status": "error", "message": "Admin cannot delete themselves."}), 400 + + if not HF_TOKEN_WRITE: + return jsonify({"status": "error", "message": "Deletion unavailable: Server configuration error."}), 503 + + data = load_data() + if target_user_id_str not in data['users']: + return jsonify({"status": "error", "message": "User not found."}), 404 + + logging.warning(f"ADMIN ACTION by {admin_id_str}: Attempting to delete user {target_user_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 --- + 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."}) + 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 + + +@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 + + admin_id_str = str(admin_user_info['id']) + target_user_id_str = str(user_id) + + if not HF_TOKEN_WRITE: + return jsonify({"status": "error", "message": "Deletion unavailable: Server configuration error."}), 503 + + 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 + + file_node, parent_node = find_node_by_id(user_data['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 + + 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}.") + + 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. + + # Remove file from database structure + if remove_node(user_data['filesystem'], file_id): + 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."}) + else: + return jsonify({"status": "success", "message": f"File '{original_filename}' deleted successfully."}) + 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() + + # Start periodic backup thread only 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.") + + # 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 + app.run(debug=False, host='0.0.0.0', port=7860)