diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -2,7 +2,8 @@ # -*- coding: utf-8 -*- import os -from flask import Flask, request, Response, render_template_string, jsonify, redirect, url_for, make_response +import flask +from flask import Flask, request, Response, render_template_string, jsonify, redirect, url_for, send_file import hmac import hashlib import json @@ -12,27 +13,195 @@ from datetime import datetime import logging import threading from huggingface_hub import HfApi, hf_hub_download, list_repo_files -from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError -from werkzeug.utils import secure_filename +from huggingface_hub.utils import RepositoryNotFoundError, EntryNotFoundError +import mimetypes +import io # --- Configuration --- -BOT_TOKEN = os.getenv("BOT_TOKEN", "6750208873:AAE2hvPlJ99dBdhGa_Brre0IIpUdOvXxHt4") +BOT_TOKEN = os.getenv("BOT_TOKEN", "6750208873:AAE2hvPlJ99dBdhGa_Brre0IIpUdOvXxHt4") # Use environment variable or default HOST = '0.0.0.0' PORT = 7860 +DATA_FILE = 'data.json' # Local file for user and file metadata # Hugging Face Settings -REPO_ID = "Eluza133/Z1e1u" -HF_UPLOAD_FOLDER = "uploads" # Base folder within the HF repo +REPO_ID = os.getenv("HF_REPO_ID", "Eluza133/Z1e1u") # Target repository +HF_DATA_FILE_PATH = "data.json" # Path for the metadata file within the HF repo +HF_UPLOAD_FOLDER = "uploads" # Folder within the HF repo to store user files HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE") # Token with write access HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") # Token with read access (can be same as write) -# File Upload Settings -MAX_FILES_PER_UPLOAD = 20 +# Constants +MAX_UPLOAD_FILES = 20 +AUTH_TIMEOUT = 86400 # 24 hours validity for initData app = Flask(__name__) -logging.basicConfig(level=logging.INFO) -app.secret_key = os.urandom(24) -hf_api = HfApi() +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +app.secret_key = os.urandom(24) # For potential future session use + +# --- Hugging Face & Data Handling --- +_data_lock = threading.Lock() +metadata_cache = {} # In-memory cache for data.json + +def get_hf_api(write=False): + token = HF_TOKEN_WRITE if write else HF_TOKEN_READ + if not token: + logging.warning(f"Hugging Face {'write' if write else 'read'} token not set.") + return None + return HfApi(token=token) + +def download_metadata_from_hf(): + global metadata_cache + api = get_hf_api(write=False) + if not api: + logging.warning("HF Read token missing. Cannot download metadata.") + return False + try: + logging.info(f"Attempting to download {HF_DATA_FILE_PATH} from {REPO_ID}...") + download_path = hf_hub_download( + repo_id=REPO_ID, + filename=HF_DATA_FILE_PATH, + repo_type="dataset", + token=api.token, + local_dir=".", + local_dir_use_symlinks=False, + force_download=True, + etag_timeout=10 + ) + logging.info("Metadata file successfully downloaded from Hugging Face.") + with _data_lock: + try: + with open(download_path, 'r', encoding='utf-8') as f: + metadata_cache = json.load(f) + logging.info("Successfully loaded downloaded metadata into cache.") + except (FileNotFoundError, json.JSONDecodeError) as e: + logging.error(f"Error reading downloaded metadata file: {e}. Resetting cache.") + metadata_cache = {} + # Clean up downloaded file? hf_hub_download uses a cache, maybe not needed. + # if os.path.exists(DATA_FILE) and download_path != DATA_FILE: + # os.remove(download_path) # Remove temp download if different name + return True + except EntryNotFoundError: + logging.warning(f"Metadata file '{HF_DATA_FILE_PATH}' not found in repo '{REPO_ID}'. Starting fresh.") + with _data_lock: + metadata_cache = {} + return True # It's not an error, just no existing data + except RepositoryNotFoundError: + logging.error(f"Hugging Face repository '{REPO_ID}' not found. Cannot download metadata.") + except Exception as e: + logging.error(f"Error downloading metadata from Hugging Face: {e}", exc_info=True) + return False + +def load_local_metadata(): + global metadata_cache + with _data_lock: + if not metadata_cache: # Only load from file if cache is empty + try: + with open(DATA_FILE, 'r', encoding='utf-8') as f: + metadata_cache = json.load(f) + logging.info("Metadata loaded from local JSON.") + except FileNotFoundError: + logging.warning(f"{DATA_FILE} not found locally. Starting with empty data.") + metadata_cache = {} + except json.JSONDecodeError: + logging.error(f"Error decoding {DATA_FILE}. Starting with empty data.") + metadata_cache = {} + except Exception as e: + logging.error(f"Unexpected error loading metadata: {e}") + metadata_cache = {} + return metadata_cache + +def save_metadata(data_to_update=None): + global metadata_cache + with _data_lock: + try: + if data_to_update: + # Deep merge might be needed if updating nested structures + # For now, simple update assumes top-level keys (user IDs) + metadata_cache.update(data_to_update) + + # Save updated cache to local file + with open(DATA_FILE, 'w', encoding='utf-8') as f: + json.dump(metadata_cache, f, ensure_ascii=False, indent=4) + logging.info(f"Metadata successfully saved locally to {DATA_FILE}.") + + # Trigger async upload to HF after successful local save + upload_metadata_to_hf_async() + return True + except Exception as e: + logging.error(f"Error saving metadata: {e}", exc_info=True) + return False + +def update_user_file_metadata(user_id, file_info_list): + user_id_str = str(user_id) + with _data_lock: + if user_id_str not in metadata_cache: + metadata_cache[user_id_str] = {"user_info": {}, "files": []} + + if "files" not in metadata_cache[user_id_str]: + metadata_cache[user_id_str]["files"] = [] + + # Add new files, potentially checking for duplicates if needed + existing_filenames = {f['filename'] for f in metadata_cache[user_id_str]["files"]} + new_files_added = 0 + for file_info in file_info_list: + if file_info['filename'] not in existing_filenames: + metadata_cache[user_id_str]["files"].append(file_info) + existing_filenames.add(file_info['filename']) + new_files_added += 1 + else: + # Handle update logic if a file with the same name is re-uploaded + # For now, we just log it. Replace or versioning could be added. + logging.warning(f"File '{file_info['filename']}' already exists for user {user_id}. Skipping add.") + # Example: Update existing entry + # for i, existing_file in enumerate(metadata_cache[user_id_str]["files"]): + # if existing_file['filename'] == file_info['filename']: + # metadata_cache[user_id_str]["files"][i] = file_info # Replace with new metadata + # break + + if new_files_added > 0: + logging.info(f"Added {new_files_added} file metadata entries for user {user_id}.") + # Save metadata (which will trigger HF upload) + if not save_metadata(): + return False # Propagate save error + else: + logging.info(f"No new file metadata added for user {user_id}.") + + return True # Indicate metadata was processed (even if no new files added) + +def _upload_metadata_to_hf_task(): + api = get_hf_api(write=True) + if not api: + logging.warning("HF Write token missing. Skipping metadata upload.") + return + if not os.path.exists(DATA_FILE): + logging.warning(f"{DATA_FILE} does not exist locally. Skipping upload.") + return + + try: + # Acquire lock only for reading the file path and ensuring it has content + with _data_lock: + if os.path.getsize(DATA_FILE) == 0: + logging.warning(f"{DATA_FILE} is empty. Skipping upload.") + # Optionally upload an empty file or a default structure like {} + # For now, skip. + return + file_to_upload = DATA_FILE # Use the local file path + + logging.info(f"Attempting to upload {file_to_upload} to {REPO_ID}/{HF_DATA_FILE_PATH}...") + api.upload_file( + path_or_fileobj=file_to_upload, + path_in_repo=HF_DATA_FILE_PATH, + repo_id=REPO_ID, + repo_type="dataset", + commit_message=f"Update metadata {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" + ) + logging.info("Metadata successfully uploaded to Hugging Face.") + except Exception as e: + logging.error(f"Error uploading metadata to Hugging Face: {e}", exc_info=True) + +def upload_metadata_to_hf_async(): + upload_thread = threading.Thread(target=_upload_metadata_to_hf_task, daemon=True) + upload_thread.start() # --- Telegram Verification --- def verify_telegram_data(init_data_str): @@ -41,50 +210,97 @@ def verify_telegram_data(init_data_str): received_hash = parsed_data.pop('hash', [None])[0] if not received_hash: - logging.warning("Verification failed: Hash missing") - return None, False, "Хэш отсутствует" + logging.warning("Verification failed: Hash missing from initData.") + return None, False, "Hash missing" data_check_list = [] + # Sort keys alphabetically for consistent string generation for key, value in sorted(parsed_data.items()): + # Make sure values are handled correctly (they are lists in parse_qs) data_check_list.append(f"{key}={value[0]}") data_check_string = "\n".join(data_check_list) + # Calculate secret key secret_key = hmac.new("WebAppData".encode(), BOT_TOKEN.encode(), hashlib.sha256).digest() + # Calculate data hash calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest() - if calculated_hash == received_hash: - auth_date = int(parsed_data.get('auth_date', [0])[0]) - current_time = int(time.time()) - # Allow data up to 24 hours old, adjust if needed - if current_time - auth_date > 86400: - logging.warning(f"Telegram InitData is older than 24 hours (Auth Date: {auth_date}, Current: {current_time}). Allowing.") - # return parsed_data, False, "Данные авторизации устарели" # Uncomment to enforce stricter timeout + # Compare hashes + if calculated_hash != received_hash: + logging.warning(f"Verification failed: Hash mismatch. Calculated: {calculated_hash}, Received: {received_hash}") + return parsed_data, False, "Invalid hash" + + # Check auth_date (timestamp) + auth_date = int(parsed_data.get('auth_date', [0])[0]) + current_time = int(time.time()) + if current_time - auth_date > AUTH_TIMEOUT: + logging.warning(f"Verification failed: initData expired. Auth time: {auth_date}, Current time: {current_time}") + return parsed_data, False, "Data expired" + + # Extract user info if present + user_info_dict = None + if 'user' in parsed_data: + try: + user_json_str = unquote(parsed_data['user'][0]) + user_info_dict = json.loads(user_json_str) + except Exception as e: + logging.error(f"Could not parse user JSON from initData: {e}") + # Continue verification, but user info might be missing + + logging.info(f"Telegram data verified successfully for user ID: {user_info_dict.get('id') if user_info_dict else 'Unknown'}") + return user_info_dict, True, "Verified" - return parsed_data, True, "Верификация успешна" - else: - logging.warning(f"Verification failed. Calculated: {calculated_hash}, Received: {received_hash}") - return parsed_data, False, "Неверный хэш" except Exception as e: - logging.error(f"Error verifying Telegram data: {e}") - return None, False, f"Ошибка верификации: {e}" + logging.error(f"Error during Telegram data verification: {e}", exc_info=True) + return None, False, "Verification exception" -def get_user_id_from_init_data(init_data_str): - parsed_data, is_valid, _ = verify_telegram_data(init_data_str) +# --- User Authentication & Data Update --- +def authenticate_and_get_user(init_data_str): + user_info, is_valid, message = verify_telegram_data(init_data_str) if not is_valid: - return None + return None, message + + user_id = user_info.get('id') if user_info else None + if not user_id: + logging.warning("Verification successful but user ID is missing in user data.") + return None, "User ID missing" + + user_id_str = str(user_id) + + # Ensure user exists in metadata cache and update basic info if needed + with _data_lock: + should_save = False + if user_id_str not in metadata_cache: + metadata_cache[user_id_str] = { + "user_info": user_info, + "files": [] + } + logging.info(f"New user registered: {user_id}") + should_save = True + else: + # Optionally update user_info if it has changed (e.g., name, username, photo) + # This requires comparing fields and deciding if an update is needed. + # Simple approach: Always update if provided. + if "user_info" not in metadata_cache[user_id_str] or metadata_cache[user_id_str]["user_info"] != user_info: + metadata_cache[user_id_str]["user_info"] = user_info + # logging.info(f"User info updated for user: {user_id}") # Can be noisy + should_save = True # Save if info changed or was missing + + # Save metadata only if the user was new or info was updated + if should_save: + # save_metadata is already thread-safe and uploads async + if not save_metadata(): + # Handle save failure, though it's unlikely to fail just for user info update + logging.error(f"Failed to save metadata after updating/adding user {user_id}") + # Decide if this should prevent the user from proceeding. For now, allow. + + return user_info, "Authenticated" - user_data_json = parsed_data.get('user', [None])[0] - if not user_data_json: - return None - try: - user_info = json.loads(unquote(user_data_json)) - return user_info.get('id') - except (json.JSONDecodeError, TypeError) as e: - logging.error(f"Could not parse user data from initData: {e}") - return None # --- HTML Templates --- -TEMPLATE = """ + +# Main User Interface Template +USER_TEMPLATE = """ @@ -94,296 +310,209 @@ TEMPLATE = """ - +
-
+

Zeus Cloud

-

Ваше персональное облачное хранилище

-
- -
+
Загрузка...
+
-

Загрузить файлы

-
- - -
- -
-
+

Загрузить файлы

+ + +
Файлы не выбраны
+ +
+
-
-

Мои файлы

-
Загрузка списка файлов...
- - +
+

Ваши файлы

+
+
    + +
+
- - + + """ +# Admin Panel Template +ADMIN_TEMPLATE = """ + + + + + + Admin - Zeus Cloud + + + + + + +
+

Zeus Cloud - Админ Панель

+
ВНИМАНИЕ: Этот раздел не защищен! Добавьте аутентификацию для реального использования.
+ +
+

Управление метаданными

+ +
+
+
+ + {% if users %} +
+ {% for user_id, data in users.items() %} +
+
+ User Avatar + +
+
+
Язык: {{ data.user_info.language_code or 'N/A' }}
+
Premium: {{ 'Да' if data.user_info and data.user_info.is_premium else 'Нет' }}
+
+
+ Файлов загружено: {{ data.files|length if data.files else 0 }} +
+ Просмотреть файлы +
+ {% endfor %} +
+ {% else %} +

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

+ {% endif %} +
+ + + +""" + +ADMIN_USER_FILES_TEMPLATE = """ + + + + + + Файлы пользователя {{ user_info.first_name or user_id }} - Admin + + + + + + +
+ ← Назад к списку пользователей +

Файлы пользователя

+
{{ user_info.first_name or '' }} {{ user_info.last_name or '' }} (ID: {{ user_id }})
+ + {% if files %} + + + + + + + + + + + + {% for file in files|sort(attribute='uploaded_at_ts', reverse=true) %} + + + + + + + + {% endfor %} + +
Имя файлаРазмерДата загрузкиТипДействия
{{ file.filename }}{{ file.size | filesizeformat if file.size else 'N/A' }}{{ file.uploaded_at_str or 'N/A' }}{{ file.content_type or 'N/A' }} + Скачать +
+ {% else %} +

У этого пользователя нет загруженных файлов.

+ {% endif %} +
+ + + +""" + +# --- Jinja Filters --- +@app.template_filter('filesizeformat') +def filesizeformat(value): + try: + bytes_val = int(value) + if bytes_val == 0: return '0 Bytes' + k = 1024 + sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'] + i = min(int(math.floor(math.log(bytes_val) / math.log(k))), len(sizes) - 1) + return f"{bytes_val / math.pow(k, i):.2f} {sizes[i]}" + except (ValueError, TypeError): + return value + except Exception: # Catch log(0) or other math errors + return 'N/A' + +import math # Needs import for the filter + # --- Flask Routes --- + @app.route('/') def index(): - theme_params = {} # Let JS handle theme application - return render_template_string(TEMPLATE, theme=theme_params, max_files=MAX_FILES_PER_UPLOAD) + # Render the main user interface + # Theme params will be applied by JS after tg.ready() + return render_template_string(USER_TEMPLATE, theme={}, max_files=MAX_UPLOAD_FILES) -@app.route('/verify', methods=['POST']) -def verify_route(): +@app.route('/files', methods=['POST']) +def get_user_files(): req_data = request.get_json() init_data_str = req_data.get('initData') if not init_data_str: - return jsonify({"status": "error", "verified": False, "message": "Missing initData"}), 400 + return jsonify({"status": "error", "message": "Missing initData"}), 400 - user_data_parsed, is_valid, message = verify_telegram_data(init_data_str) + user_info, message = authenticate_and_get_user(init_data_str) + if not user_info: + return jsonify({"status": "error", "message": message}), 403 - user_info_dict = {} - if user_data_parsed and 'user' in user_data_parsed: - try: - user_json_str = unquote(user_data_parsed['user'][0]) - user_info_dict = json.loads(user_json_str) - except Exception as e: - logging.error(f"Could not parse user JSON in /verify: {e}") - - if is_valid: - return jsonify({"status": "ok", "verified": True, "user": user_info_dict}), 200 - else: - return jsonify({"status": "error", "verified": False, "message": message, "user": user_info_dict}), 403 + user_id_str = str(user_info['id']) + with _data_lock: + user_data = metadata_cache.get(user_id_str, {}) + files = user_data.get('files', []) + return jsonify({"status": "ok", "files": files}), 200 @app.route('/upload', methods=['POST']) def upload_files(): - if not HF_TOKEN_WRITE: - return jsonify({"status": "error", "message": "Функция загрузки недоступна (ошибка конфигурации сервера)."}), 503 - init_data_str = request.form.get('initData') if not init_data_str: - return jsonify({"status": "error", "message": "Ошибка авторизации: initData отсутствует."}), 400 + return jsonify({"status": "error", "message": "Missing initData"}), 400 - user_id = get_user_id_from_init_data(init_data_str) - if not user_id: - _, _, verification_message = verify_telegram_data(init_data_str) # Get specific reason - return jsonify({"status": "error", "message": f"Ошибка авторизации: {verification_message}"}), 403 + user_info, message = authenticate_and_get_user(init_data_str) + if not user_info: + return jsonify({"status": "error", "message": message}), 403 + + user_id = user_info['id'] + user_id_str = str(user_id) - files = request.files.getlist('files') - if not files: - return jsonify({"status": "error", "message": "Файлы для загрузки не найдены."}), 400 + uploaded_files = request.files.getlist('files') # Key matches JS FormData + if not uploaded_files or len(uploaded_files) == 0: + return jsonify({"status": "error", "message": "No files selected for upload."}), 400 + if len(uploaded_files) > MAX_UPLOAD_FILES: + return jsonify({"status": "error", "message": f"Cannot upload more than {MAX_UPLOAD_FILES} files at once."}), 400 - if len(files) > MAX_FILES_PER_UPLOAD: - return jsonify({"status": "error", "message": f"Слишком много файлов. Максимум {MAX_FILES_PER_UPLOAD} за раз."}), 400 + api = get_hf_api(write=True) + if not api: + return jsonify({"status": "error", "message": "Server error: Cannot connect to storage."}), 500 - successful_uploads = 0 + successful_uploads_metadata = [] errors = [] - user_folder = f"{HF_UPLOAD_FOLDER}/{user_id}" - for file in files: - if file and file.filename: - filename = secure_filename(file.filename) - path_in_repo = f"{user_folder}/{filename}" - try: - logging.info(f"Attempting to upload '{filename}' for user {user_id} to {REPO_ID}/{path_in_repo}") - # Pass file object directly - hf_api.upload_file( - path_or_fileobj=file.stream, # Use file.stream - path_in_repo=path_in_repo, - repo_id=REPO_ID, - repo_type="dataset", - token=HF_TOKEN_WRITE, - commit_message=f"Upload {filename} by user {user_id}" - ) - successful_uploads += 1 - logging.info(f"Successfully uploaded '{filename}' for user {user_id}") - except HfHubHTTPError as e: - logging.error(f"HTTP error uploading {filename} for user {user_id}: {e}") - # Check for specific errors like 413 Payload Too Large if needed - errors.append(f"{filename}: Ошибка сервера ({e.response.status_code if e.response else 'N/A'})") - except Exception as e: - logging.exception(f"Error uploading {filename} for user {user_id}") - errors.append(f"{filename}: {e}") - else: - errors.append("Получен пустой файл") + for file_storage in uploaded_files: + filename = file_storage.filename + if not filename: + errors.append("Received a file without a name.") + continue - if successful_uploads > 0 and not errors: - return jsonify({"status": "ok", "message": f"Загружено {successful_uploads} файл(ов)."}), 200 - elif successful_uploads > 0 and errors: - error_details = "; ".join(errors) - return jsonify({"status": "partial", "message": f"Загружено {successful_uploads} файл(ов). Ошибки: {error_details}"}), 207 # Multi-Status - else: - error_details = "; ".join(errors) - return jsonify({"status": "error", "message": f"Не удалось загрузить файлы. Ошибки: {error_details}"}), 500 + path_in_repo = f"{HF_UPLOAD_FOLDER}/{user_id_str}/{filename}" + file_content = file_storage.read() # Read content into memory + file_size = len(file_content) + content_type, _ = mimetypes.guess_type(filename) + try: + logging.info(f"Uploading '{filename}' for user {user_id} to {path_in_repo}...") + # Use upload_file with path_or_fileobj=BytesIO(file_content) + file_obj = io.BytesIO(file_content) + api.upload_file( + path_or_fileobj=file_obj, + path_in_repo=path_in_repo, + repo_id=REPO_ID, + repo_type="dataset", + commit_message=f"User {user_id} uploaded {filename}" + # Consider adding run_as_future=True for concurrency if needed, but handle results + ) + logging.info(f"Successfully uploaded '{filename}' for user {user_id}.") + now = time.time() + successful_uploads_metadata.append({ + "filename": filename, + "hf_path": path_in_repo, + "uploaded_at_ts": now, + "uploaded_at_str": datetime.fromtimestamp(now).strftime('%Y-%m-%d %H:%M:%S'), + "size": file_size, + "content_type": content_type + }) + except Exception as e: + logging.error(f"Failed to upload '{filename}' for user {user_id}: {e}", exc_info=True) + errors.append(f"Ошибка загрузки {filename}: {e}") -@app.route('/list_files', methods=['POST']) -def list_user_files(): - if not HF_TOKEN_READ: - return jsonify({"status": "error", "message": "Функция просмотра файлов недоступна (ошибка конфигурации сервера)."}), 503 + # Update metadata only if there were successful uploads + if successful_uploads_metadata: + if not update_user_file_metadata(user_id, successful_uploads_metadata): + # Add metadata update errors to the list shown to the user + errors.append("Ошибка обновления списка файлов после загрузки.") - req_data = request.get_json() - init_data_str = req_data.get('initData') + if not errors: + return jsonify({"status": "ok", "message": f"Загружено {len(successful_uploads_metadata)} файл(ов)."}), 200 + else: + # Return partial success/error message + return jsonify({ + "status": "error" if not successful_uploads_metadata else "partial_success", + "message": f"Загружено {len(successful_uploads_metadata)} из {len(uploaded_files)}. Ошибки: {'; '.join(errors)}", + "uploaded_files": [f['filename'] for f in successful_uploads_metadata], + "errors": errors + }), 207 # Multi-Status or choose appropriate code + + +@app.route('/download/', methods=['GET']) +def download_file(filename): + init_data_str = request.args.get('initData') if not init_data_str: - return jsonify({"status": "error", "message": "Ошибка авторизации: initData отсутствует."}), 400 + return "Authentication required.", 401 - user_id = get_user_id_from_init_data(init_data_str) - if not user_id: - _, _, verification_message = verify_telegram_data(init_data_str) - return jsonify({"status": "error", "message": f"Ошибка авторизации: {verification_message}"}), 403 + user_info, message = authenticate_and_get_user(init_data_str) + if not user_info: + return f"Access denied: {message}", 403 - user_folder = f"{HF_UPLOAD_FOLDER}/{user_id}" - files_list = [] + user_id = user_info['id'] + user_id_str = str(user_id) - try: - repo_files = list_repo_files(repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_READ, path_in_repo=user_folder) - - for file_path in repo_files: - # Ensure we only list files directly in the user's folder, not subfolders if any were created manually - if file_path.startswith(user_folder + "/") and file_path.count('/') == user_folder.count('/') + 1: - filename = os.path.basename(file_path) - file_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{quote(file_path)}" - files_list.append({ - "name": filename, - "url": file_url, - "path": file_path # Keep internal path if needed later - }) - return jsonify({"status": "ok", "files": files_list}) + # Check if user actually owns this file according to metadata + with _data_lock: + user_data = metadata_cache.get(user_id_str, {}) + user_files = user_data.get('files', []) + file_metadata = next((f for f in user_files if f['filename'] == filename), None) + + if not file_metadata: + logging.warning(f"User {user_id} attempted to download unlisted/unowned file: {filename}") + return "File not found or access denied.", 404 + + api = get_hf_api(write=False) + if not api: + return "Server error: Cannot connect to storage.", 500 + path_in_repo = file_metadata.get('hf_path', f"{HF_UPLOAD_FOLDER}/{user_id_str}/{filename}") + + try: + logging.info(f"User {user_id} requesting download of {path_in_repo}") + # Download the file from HF Hub to the server's cache + local_file_path = hf_hub_download( + repo_id=REPO_ID, + filename=path_in_repo, + repo_type="dataset", + token=api.token, + # local_dir=".", # Let it use the default HF cache + # local_dir_use_symlinks=False, + force_download=False, # Use cache if available + etag_timeout=10 + ) + logging.info(f"File {path_in_repo} downloaded to cache: {local_file_path}") + + # Send the file from the cache path + # Guess mimetype again for safety, or use stored one + content_type = file_metadata.get('content_type') or mimetypes.guess_type(filename)[0] or 'application/octet-stream' + + return send_file( + local_file_path, + mimetype=content_type, + as_attachment=False, # Try to display inline first for media + download_name=filename # Original filename for download prompt + ) + + except EntryNotFoundError: + logging.error(f"File not found on Hugging Face: {path_in_repo}") + return "File not found on storage.", 404 except RepositoryNotFoundError: - logging.warning(f"Repository {REPO_ID} or user folder {user_folder} not found.") - # This is expected if the user hasn't uploaded anything yet, return empty list - return jsonify({"status": "ok", "files": []}) + logging.error(f"Repository not found: {REPO_ID}") + return "Storage repository not found.", 500 except Exception as e: - logging.exception(f"Error listing files for user {user_id}") - return jsonify({"status": "error", "message": f"Не удалось получить список файлов: {e}"}), 500 + logging.error(f"Error downloading file {path_in_repo} for user {user_id}: {e}", exc_info=True) + return "Server error during download.", 500 + + +# --- Admin Routes --- +# WARNING: These routes are unprotected! Add proper authentication. + +@app.route('/admin') +def admin_panel(): + current_data = load_local_metadata() # Load latest from cache/local file + return render_template_string(ADMIN_TEMPLATE, users=current_data) + +@app.route('/admin/user/') +def admin_user_files(user_id): + current_data = load_local_metadata() + user_data = current_data.get(str(user_id)) + if not user_data: + return "User not found", 404 + + user_info = user_data.get("user_info", {"id": user_id}) # Basic info for display + files = user_data.get("files", []) + + return render_template_string(ADMIN_USER_FILES_TEMPLATE, + user_id=user_id, + user_info=user_info, + files=files) + +@app.route('/admin/download//', methods=['GET']) +def admin_download_file(user_id, filename): + # WARNING: Add admin authentication check here! + user_id_str = str(user_id) + logging.info(f"Admin request to download file '{filename}' for user {user_id}") + + api = get_hf_api(write=False) + if not api: + return "Server error: Cannot connect to storage.", 500 + + # Find the file path from metadata if possible for accuracy + path_in_repo = f"{HF_UPLOAD_FOLDER}/{user_id_str}/{filename}" # Default assumption + with _data_lock: + user_data = metadata_cache.get(user_id_str, {}) + user_files = user_data.get('files', []) + file_metadata = next((f for f in user_files if f['filename'] == filename), None) + if file_metadata and 'hf_path' in file_metadata: + path_in_repo = file_metadata['hf_path'] + try: + local_file_path = hf_hub_download( + repo_id=REPO_ID, + filename=path_in_repo, + repo_type="dataset", + token=api.token, + force_download=False, + etag_timeout=10 + ) + logging.info(f"Admin download: File {path_in_repo} cached at {local_file_path}") + + content_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream' + if file_metadata and 'content_type' in file_metadata: + content_type = file_metadata['content_type'] or content_type + + return send_file( + local_file_path, + mimetype=content_type, + as_attachment=True, # Force download for admin + download_name=filename + ) + except EntryNotFoundError: + logging.error(f"Admin download: File not found on Hugging Face: {path_in_repo}") + return "File not found on storage.", 404 + except Exception as e: + logging.error(f"Admin download: Error for file {path_in_repo}: {e}", exc_info=True) + return "Server error during download.", 500 + +@app.route('/admin/download_metadata', methods=['POST']) +def admin_trigger_download_metadata(): + # WARNING: Unprotected endpoint + success = download_metadata_from_hf() + if success: + return jsonify({"status": "ok", "message": "Скачивание data.json с Hugging Face завершено. Обновите страницу."}) + else: + return jsonify({"status": "error", "message": "Ошибка скачивания data.json. Проверьте логи."}), 500 + +# Removed admin upload metadata trigger - upload happens automatically on save # --- App Initialization --- if __name__ == '__main__': print("---") print("--- ZEUS CLOUD MINI APP SERVER ---") print("---") - print(f"Flask server starting on http://{HOST}:{PORT}") + print(f"Starting Flask server on http://{HOST}:{PORT}") print(f"Using Bot Token ID: {BOT_TOKEN.split(':')[0]}") + print(f"Metadata file (local): {DATA_FILE}") print(f"Hugging Face Repo: {REPO_ID}") - print(f"HF Upload Folder: {HF_UPLOAD_FOLDER}") + print(f"HF Metadata Path: {HF_DATA_FILE_PATH}") + print(f"HF Upload Folder: {HF_UPLOAD_FOLDER}//") + if not HF_TOKEN_READ or not HF_TOKEN_WRITE: print("---") print("--- WARNING: HUGGING FACE TOKEN(S) NOT SET ---") - print("--- File upload/listing WILL NOT WORK. Set HF_TOKEN_READ and HF_TOKEN_WRITE environment variables.") + print("--- Storage functionality requires HF_TOKEN_READ and HF_TOKEN_WRITE env vars.") print("---") else: print("--- Hugging Face tokens found.") + print("--- Attempting initial metadata download from Hugging Face...") + download_metadata_from_hf() # Attempt to get latest metadata on startup + + # Load initial data from local file (might have been updated by download) + load_local_metadata() + print(f"--- Initial metadata cache loaded with {len(metadata_cache)} user(s).") + print("---") + print("--- SECURITY WARNING ---") + print("--- The /admin routes are NOT protected by authentication.") + print("--- Implement proper auth before any production deployment.") + print("---") print("--- Server Ready ---") - # Use a production server like Waitress or Gunicorn instead of app.run() for deployment + + # Use Waitress or Gunicorn for production # from waitress import serve # serve(app, host=HOST, port=PORT) - app.run(host=HOST, port=PORT, debug=False) \ No newline at end of file + app.run(host=HOST, port=PORT, debug=False, threaded=True) # threaded=True useful for async tasks like HF uploads \ No newline at end of file