|
|
|
|
|
|
|
|
|
|
|
import os |
|
|
import flask |
|
|
from flask import Flask, request, Response, render_template_string, jsonify, redirect, url_for, send_file |
|
|
import hmac |
|
|
import hashlib |
|
|
import json |
|
|
from urllib.parse import unquote, parse_qs, quote |
|
|
import time |
|
|
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, EntryNotFoundError |
|
|
import mimetypes |
|
|
import io |
|
|
import math |
|
|
|
|
|
BOT_TOKEN = os.getenv("BOT_TOKEN", "6750208873:AAE2hvPlJ99dBdhGa_Brre0IIpUdOvXxHt4") |
|
|
HOST = '0.0.0.0' |
|
|
PORT = 7860 |
|
|
DATA_FILE = 'data.json' |
|
|
|
|
|
REPO_ID = os.getenv("HF_REPO_ID", "Eluza133/Z1e1u") |
|
|
HF_DATA_FILE_PATH = "data.json" |
|
|
HF_UPLOAD_FOLDER = "uploads" |
|
|
HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE") |
|
|
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") |
|
|
|
|
|
MAX_UPLOAD_FILES = 20 |
|
|
AUTH_TIMEOUT = 86400 |
|
|
|
|
|
app = Flask(__name__) |
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') |
|
|
app.secret_key = os.urandom(24) |
|
|
|
|
|
_data_lock = threading.RLock() |
|
|
metadata_cache = {} |
|
|
|
|
|
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 = {} |
|
|
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 |
|
|
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: |
|
|
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: |
|
|
metadata_cache.update(data_to_update) |
|
|
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}.") |
|
|
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"] = [] |
|
|
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: |
|
|
logging.warning(f"File '{file_info['filename']}' already exists for user {user_id}. Skipping add.") |
|
|
if new_files_added > 0: |
|
|
logging.info(f"Added {new_files_added} file metadata entries for user {user_id}.") |
|
|
if not save_metadata(): |
|
|
return False |
|
|
else: |
|
|
logging.info(f"No new file metadata added for user {user_id}.") |
|
|
return True |
|
|
|
|
|
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: |
|
|
with _data_lock: |
|
|
if os.path.getsize(DATA_FILE) == 0: |
|
|
logging.warning(f"{DATA_FILE} is empty. Skipping upload.") |
|
|
return |
|
|
file_to_upload = DATA_FILE |
|
|
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() |
|
|
|
|
|
def verify_telegram_data(init_data_str): |
|
|
try: |
|
|
parsed_data = parse_qs(init_data_str) |
|
|
received_hash = parsed_data.pop('hash', [None])[0] |
|
|
if not received_hash: |
|
|
logging.warning("Verification failed: Hash missing from initData.") |
|
|
return None, False, "Hash missing" |
|
|
data_check_list = [] |
|
|
for key, value in sorted(parsed_data.items()): |
|
|
data_check_list.append(f"{key}={value[0]}") |
|
|
data_check_string = "\n".join(data_check_list) |
|
|
secret_key = hmac.new("WebAppData".encode(), BOT_TOKEN.encode(), hashlib.sha256).digest() |
|
|
calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest() |
|
|
if calculated_hash != received_hash: |
|
|
logging.warning(f"Verification failed: Hash mismatch. Calculated: {calculated_hash}, Received: {received_hash}") |
|
|
return parsed_data, False, "Invalid hash" |
|
|
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" |
|
|
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}") |
|
|
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" |
|
|
except Exception as e: |
|
|
logging.error(f"Error during Telegram data verification: {e}", exc_info=True) |
|
|
return None, False, "Verification exception" |
|
|
|
|
|
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, 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) |
|
|
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: |
|
|
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 |
|
|
should_save = True |
|
|
if should_save: |
|
|
if not save_metadata(): |
|
|
logging.error(f"Failed to save metadata after updating/adding user {user_id}") |
|
|
return user_info, "Authenticated" |
|
|
|
|
|
USER_TEMPLATE = """ |
|
|
<!DOCTYPE html> |
|
|
<html lang="ru"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no, user-scalable=no, viewport-fit=cover"> |
|
|
<title>Zeus Cloud</title> |
|
|
<script src="https://telegram.org/js/telegram-web-app.js"></script> |
|
|
<link rel="preconnect" href="https://fonts.googleapis.com"> |
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> |
|
|
<style> |
|
|
:root { |
|
|
--tg-theme-bg-color: {{ theme.bg_color | default('#181818') }}; |
|
|
--tg-theme-text-color: {{ theme.text_color | default('#ffffff') }}; |
|
|
--tg-theme-hint-color: {{ theme.hint_color | default('#aaaaaa') }}; |
|
|
--tg-theme-link-color: {{ theme.link_color | default('#62bcf9') }}; |
|
|
--tg-theme-button-color: {{ theme.button_color | default('#31a5f5') }}; |
|
|
--tg-theme-button-text-color: {{ theme.button_text_color | default('#ffffff') }}; |
|
|
--tg-theme-secondary-bg-color: {{ theme.secondary_bg_color | default('#212121') }}; |
|
|
--accent-gradient: linear-gradient(95deg, var(--tg-theme-button-color, #007aff), #5856d6); |
|
|
--border-radius: 12px; |
|
|
--padding: 16px; |
|
|
--font-family: 'Inter', sans-serif; |
|
|
--card-bg: var(--tg-theme-secondary-bg-color); |
|
|
--shadow-color: rgba(0, 0, 0, 0.2); |
|
|
} |
|
|
* { box-sizing: border-box; margin: 0; padding: 0; } |
|
|
html { background-color: var(--tg-theme-bg-color); } |
|
|
body { |
|
|
font-family: var(--font-family); |
|
|
background-color: var(--tg-theme-bg-color); |
|
|
color: var(--tg-theme-text-color); |
|
|
padding: var(--padding); |
|
|
padding-bottom: 100px; |
|
|
visibility: hidden; |
|
|
line-height: 1.5; |
|
|
} |
|
|
.container { max-width: 700px; margin: 0 auto; display: flex; flex-direction: column; gap: var(--padding); } |
|
|
.header { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--padding); border-bottom: 1px solid rgba(255, 255, 255, 0.1); margin-bottom: var(--padding); } |
|
|
.header h1 { font-size: 1.8em; font-weight: 600; } |
|
|
.user-info { font-size: 0.9em; color: var(--tg-theme-hint-color); text-align: right; } |
|
|
.upload-section { |
|
|
background-color: var(--card-bg); |
|
|
padding: var(--padding); |
|
|
border-radius: var(--border-radius); |
|
|
box-shadow: 0 4px 15px var(--shadow-color); |
|
|
text-align: center; |
|
|
} |
|
|
.upload-section h2 { font-size: 1.3em; margin-bottom: 12px; font-weight: 500; } |
|
|
.file-input-label { |
|
|
display: inline-block; |
|
|
padding: 12px 25px; |
|
|
background: var(--accent-gradient); |
|
|
color: var(--tg-theme-button-text-color); |
|
|
border-radius: var(--border-radius); |
|
|
cursor: pointer; |
|
|
font-weight: 500; |
|
|
transition: opacity 0.2s ease; |
|
|
margin-bottom: 12px; |
|
|
} |
|
|
.file-input-label:hover { opacity: 0.9; } |
|
|
#file-input { display: none; } |
|
|
#upload-button { |
|
|
display: block; width: 100%; |
|
|
padding: 14px; |
|
|
background-color: var(--tg-theme-button-color); |
|
|
color: var(--tg-theme-button-text-color); |
|
|
border: none; border-radius: var(--border-radius); |
|
|
font-size: 1em; font-weight: 600; |
|
|
cursor: pointer; transition: background-color 0.2s ease; |
|
|
opacity: 0.5; |
|
|
pointer-events: none; |
|
|
} |
|
|
#upload-button.enabled { opacity: 1; pointer-events: auto; } |
|
|
#upload-status { margin-top: 12px; font-size: 0.9em; color: var(--tg-theme-hint-color); min-height: 1.2em; } |
|
|
.file-list-section { margin-top: var(--padding); } |
|
|
.file-list-section h2 { font-size: 1.3em; margin-bottom: 12px; font-weight: 500; } |
|
|
#file-list { list-style: none; padding: 0; display: grid; gap: 10px; } |
|
|
.file-item { |
|
|
background-color: var(--card-bg); |
|
|
padding: 12px var(--padding); |
|
|
border-radius: var(--border-radius); |
|
|
display: flex; align-items: center; justify-content: space-between; |
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1); |
|
|
transition: background-color 0.2s ease; |
|
|
word-break: break-word; |
|
|
} |
|
|
.file-item:hover { background-color: rgba(255, 255, 255, 0.08); } |
|
|
.file-info { flex-grow: 1; margin-right: 10px; overflow: hidden; } |
|
|
.file-name { font-weight: 500; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } |
|
|
.file-meta { font-size: 0.8em; color: var(--tg-theme-hint-color); } |
|
|
.file-actions a, .file-actions button { |
|
|
display: inline-block; |
|
|
padding: 6px 10px; |
|
|
margin-left: 8px; |
|
|
border-radius: 6px; |
|
|
text-decoration: none; |
|
|
font-size: 0.9em; |
|
|
font-weight: 500; |
|
|
cursor: pointer; |
|
|
border: none; |
|
|
transition: opacity 0.2s ease; |
|
|
} |
|
|
.file-actions a:hover, .file-actions button:hover { opacity: 0.8; } |
|
|
.download-btn { background-color: var(--tg-theme-button-color); color: var(--tg-theme-button-text-color); } |
|
|
.delete-btn { background-color: #dc3545; color: white; } |
|
|
.loading, .no-files { text-align: center; padding: 20px; color: var(--tg-theme-hint-color); } |
|
|
.progress-bar { width: 100%; background-color: #ddd; border-radius: 4px; height: 8px; margin-top: 5px; display: none; } |
|
|
.progress-bar-inner { height: 100%; width: 0%; background-color: var(--tg-theme-button-color); border-radius: 4px; transition: width 0.1s linear; } |
|
|
.modal { |
|
|
display: none; position: fixed; z-index: 1001; |
|
|
left: 0; top: 0; width: 100%; height: 100%; |
|
|
overflow: auto; background-color: rgba(0,0,0,0.8); |
|
|
backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px); |
|
|
animation: fadeIn 0.3s ease-out; |
|
|
} |
|
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } |
|
|
.modal-content { |
|
|
margin: auto; display: block; max-width: 90%; max-height: 85%; |
|
|
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); |
|
|
} |
|
|
.modal-content img, .modal-content video, .modal-content audio { |
|
|
display: block; width: auto; height: auto; max-width: 100%; max-height: 100%; margin: auto; |
|
|
background-color: var(--tg-theme-bg-color); |
|
|
} |
|
|
.modal-close { |
|
|
position: absolute; top: 15px; right: 35px; color: #f1f1f1; font-size: 40px; |
|
|
font-weight: bold; transition: 0.3s; cursor: pointer; z-index: 1002; |
|
|
text-shadow: 0 1px 3px rgba(0,0,0,0.5); |
|
|
} |
|
|
.modal-close:hover, .modal-close:focus { color: #bbb; text-decoration: none; } |
|
|
.modal-caption { |
|
|
margin: auto; display: block; width: 80%; max-width: 700px; text-align: center; |
|
|
color: #ccc; padding: 10px 0; height: 50px; position: absolute; bottom: 15px; left: 50%; transform: translateX(-50%); |
|
|
background: rgba(0,0,0,0.5); border-radius: 8px; |
|
|
} |
|
|
.spinner { |
|
|
border: 4px solid rgba(255, 255, 255, 0.3); |
|
|
border-radius: 50%; |
|
|
border-top: 4px solid var(--tg-theme-text-color); |
|
|
width: 40px; |
|
|
height: 40px; |
|
|
animation: spin 1s linear infinite; |
|
|
margin: 20px auto; |
|
|
display: none; |
|
|
} |
|
|
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="container"> |
|
|
<header class="header"> |
|
|
<h1>Zeus Cloud</h1> |
|
|
<div class="user-info" id="user-greeting">Загрузка...</div> |
|
|
</header> |
|
|
<section class="upload-section"> |
|
|
<h2>Загрузить файлы</h2> |
|
|
<label for="file-input" class="file-input-label">Выбрать файлы (до {{ max_files }})</label> |
|
|
<input type="file" id="file-input" multiple accept="*.*"> |
|
|
<div id="selected-files" style="margin-bottom: 10px; font-size: 0.9em; color: var(--tg-theme-hint-color);">Файлы не выбраны</div> |
|
|
<button id="upload-button">Загрузить</button> |
|
|
<div class="progress-bar" id="progress-bar"><div class="progress-bar-inner" id="progress-bar-inner"></div></div> |
|
|
<div id="upload-status"></div> |
|
|
</section> |
|
|
<section class="file-list-section"> |
|
|
<h2>Ваши файлы</h2> |
|
|
<div class="spinner" id="loading-spinner"></div> |
|
|
<ul id="file-list"> |
|
|
<li class="no-files" id="no-files-message" style="display: none;">У вас пока нет загруженных файлов.</li> |
|
|
</ul> |
|
|
</section> |
|
|
</div> |
|
|
<div id="viewerModal" class="modal"> |
|
|
<span class="modal-close" id="modalCloseBtn">×</span> |
|
|
<div id="modalContent" class="modal-content"> |
|
|
</div> |
|
|
<div id="modalCaption" class="modal-caption"></div> |
|
|
</div> |
|
|
<script> |
|
|
const tg = window.Telegram.WebApp; |
|
|
const MAX_FILES = {{ max_files }}; |
|
|
let currentFiles = []; |
|
|
let userInitData = ''; |
|
|
const fileInput = document.getElementById('file-input'); |
|
|
const uploadButton = document.getElementById('upload-button'); |
|
|
const selectedFilesDiv = document.getElementById('selected-files'); |
|
|
const uploadStatusDiv = document.getElementById('upload-status'); |
|
|
const fileListUl = document.getElementById('file-list'); |
|
|
const noFilesMessage = document.getElementById('no-files-message'); |
|
|
const userGreeting = document.getElementById('user-greeting'); |
|
|
const loadingSpinner = document.getElementById('loading-spinner'); |
|
|
const progressBar = document.getElementById('progress-bar'); |
|
|
const progressBarInner = document.getElementById('progress-bar-inner'); |
|
|
const modal = document.getElementById('viewerModal'); |
|
|
const modalContent = document.getElementById('modalContent'); |
|
|
const modalCaption = document.getElementById('modalCaption'); |
|
|
const modalCloseBtn = document.getElementById('modalCloseBtn'); |
|
|
|
|
|
function applyTheme(themeParams) { |
|
|
const root = document.documentElement; |
|
|
Object.keys(themeParams).forEach(key => { |
|
|
const cssVar = `--tg-theme-${key.replace(/_/g, '-')}`; |
|
|
root.style.setProperty(cssVar, themeParams[key]); |
|
|
}); |
|
|
const defaults = { |
|
|
'bg_color': '#181818', 'text_color': '#ffffff', 'hint_color': '#aaaaaa', |
|
|
'link_color': '#62bcf9', 'button_color': '#31a5f5', |
|
|
'button_text_color': '#ffffff', 'secondary_bg_color': '#212121' |
|
|
}; |
|
|
for (const key in defaults) { |
|
|
if (!themeParams[key]) { |
|
|
const cssVar = `--tg-theme-${key.replace(/_/g, '-')}`; |
|
|
root.style.setProperty(cssVar, defaults[key]); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
function formatBytes(bytes, decimals = 2) { |
|
|
if (bytes === 0) return '0 Bytes'; |
|
|
const k = 1024; |
|
|
const dm = decimals < 0 ? 0 : decimals; |
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; |
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k)); |
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; |
|
|
} |
|
|
|
|
|
function displayFiles(files) { |
|
|
fileListUl.innerHTML = ''; |
|
|
loadingSpinner.style.display = 'none'; |
|
|
if (!files || files.length === 0) { |
|
|
noFilesMessage.style.display = 'block'; |
|
|
return; |
|
|
} |
|
|
noFilesMessage.style.display = 'none'; |
|
|
files.sort((a, b) => b.uploaded_at_ts - a.uploaded_at_ts); |
|
|
files.forEach(file => { |
|
|
const li = document.createElement('li'); |
|
|
li.classList.add('file-item'); |
|
|
const fileInfoDiv = document.createElement('div'); |
|
|
fileInfoDiv.classList.add('file-info'); |
|
|
const fileNameSpan = document.createElement('span'); |
|
|
fileNameSpan.classList.add('file-name'); |
|
|
fileNameSpan.textContent = file.filename; |
|
|
fileInfoDiv.appendChild(fileNameSpan); |
|
|
const fileMetaSpan = document.createElement('span'); |
|
|
fileMetaSpan.classList.add('file-meta'); |
|
|
const date = new Date(file.uploaded_at_ts * 1000).toLocaleString(); |
|
|
const size = file.size ? formatBytes(file.size) : ''; |
|
|
fileMetaSpan.textContent = `${date}${size ? ' - ' + size : ''}`; |
|
|
fileInfoDiv.appendChild(fileMetaSpan); |
|
|
const fileActionsDiv = document.createElement('div'); |
|
|
fileActionsDiv.classList.add('file-actions'); |
|
|
const mimeType = file.content_type || ''; |
|
|
if (mimeType.startsWith('image/') || mimeType.startsWith('video/') || mimeType.startsWith('audio/')) { |
|
|
const viewButton = document.createElement('button'); |
|
|
viewButton.textContent = '👁️'; |
|
|
viewButton.classList.add('view-btn'); |
|
|
viewButton.style.backgroundColor = '#6f42c1'; |
|
|
viewButton.style.color = 'white'; |
|
|
viewButton.title = 'Просмотр'; |
|
|
viewButton.onclick = (e) => { |
|
|
e.stopPropagation(); |
|
|
openViewer(file); |
|
|
}; |
|
|
fileActionsDiv.appendChild(viewButton); |
|
|
} |
|
|
const downloadLink = document.createElement('a'); |
|
|
downloadLink.classList.add('download-btn'); |
|
|
downloadLink.textContent = 'Скачать'; |
|
|
downloadLink.href = `/download/${encodeURIComponent(file.filename)}?initData=${encodeURIComponent(userInitData)}`; |
|
|
downloadLink.target = '_blank'; |
|
|
downloadLink.title = 'Скачать файл'; |
|
|
downloadLink.onclick = (e) => e.stopPropagation(); |
|
|
fileActionsDiv.appendChild(downloadLink); |
|
|
li.appendChild(fileInfoDiv); |
|
|
li.appendChild(fileActionsDiv); |
|
|
fileListUl.appendChild(li); |
|
|
}); |
|
|
} |
|
|
|
|
|
async function fetchFiles() { |
|
|
loadingSpinner.style.display = 'block'; |
|
|
noFilesMessage.style.display = 'none'; |
|
|
fileListUl.innerHTML = ''; |
|
|
try { |
|
|
const response = await fetch('/files', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ initData: userInitData }) |
|
|
}); |
|
|
if (!response.ok) { |
|
|
const errorData = await response.json(); |
|
|
throw new Error(errorData.message || `HTTP error! status: ${response.status}`); |
|
|
} |
|
|
const data = await response.json(); |
|
|
displayFiles(data.files || []); |
|
|
} catch (error) { |
|
|
console.error('Error fetching files:', error); |
|
|
uploadStatusDiv.textContent = `Ошибка загрузки списка файлов: ${error.message}`; |
|
|
uploadStatusDiv.style.color = 'red'; |
|
|
loadingSpinner.style.display = 'none'; |
|
|
noFilesMessage.style.display = 'block'; |
|
|
noFilesMessage.textContent = 'Не удалось загрузить список файлов.'; |
|
|
} |
|
|
} |
|
|
|
|
|
function handleFileSelection(event) { |
|
|
currentFiles = Array.from(event.target.files); |
|
|
if (currentFiles.length > MAX_FILES) { |
|
|
alert(`Вы можете выбрать не более ${MAX_FILES} файлов за раз.`); |
|
|
currentFiles = currentFiles.slice(0, MAX_FILES); |
|
|
} |
|
|
if (currentFiles.length > 0) { |
|
|
selectedFilesDiv.textContent = `${currentFiles.length} файл(ов) выбрано: ${currentFiles.map(f => f.name).join(', ')}`; |
|
|
uploadButton.classList.add('enabled'); |
|
|
uploadButton.disabled = false; |
|
|
} else { |
|
|
selectedFilesDiv.textContent = 'Файлы не выбраны'; |
|
|
uploadButton.classList.remove('enabled'); |
|
|
uploadButton.disabled = true; |
|
|
} |
|
|
uploadStatusDiv.textContent = ''; |
|
|
progressBar.style.display = 'none'; |
|
|
progressBarInner.style.width = '0%'; |
|
|
} |
|
|
|
|
|
async function handleUpload() { |
|
|
if (currentFiles.length === 0 || !userInitData) { |
|
|
uploadStatusDiv.textContent = 'Выберите файлы для загрузки.'; |
|
|
return; |
|
|
} |
|
|
uploadButton.disabled = true; |
|
|
uploadButton.classList.remove('enabled'); |
|
|
uploadStatusDiv.textContent = 'Загрузка началась...'; |
|
|
uploadStatusDiv.style.color = 'var(--tg-theme-hint-color)'; |
|
|
progressBar.style.display = 'block'; |
|
|
progressBarInner.style.width = '0%'; |
|
|
const formData = new FormData(); |
|
|
currentFiles.forEach(file => { |
|
|
formData.append('files', file); |
|
|
}); |
|
|
formData.append('initData', userInitData); |
|
|
|
|
|
try { |
|
|
const responseText = await new Promise((resolve, reject) => { |
|
|
const xhr = new XMLHttpRequest(); |
|
|
xhr.open('POST', '/upload', true); |
|
|
xhr.upload.onprogress = function(event) { |
|
|
if (event.lengthComputable) { |
|
|
const percentComplete = (event.loaded / event.total) * 100; |
|
|
progressBarInner.style.width = percentComplete + '%'; |
|
|
uploadStatusDiv.textContent = `Загрузка... ${Math.round(percentComplete)}%`; |
|
|
} |
|
|
}; |
|
|
xhr.onload = function() { |
|
|
if (xhr.status >= 200 && xhr.status < 300) { |
|
|
resolve(xhr.responseText); |
|
|
} else { |
|
|
let errorMessage = `Ошибка загрузки (Статус: ${xhr.status})`; |
|
|
try { |
|
|
const errorData = JSON.parse(xhr.responseText); |
|
|
errorMessage = errorData.message || errorMessage; |
|
|
} catch (e) { /* use default */ } |
|
|
reject(new Error(errorMessage)); |
|
|
} |
|
|
}; |
|
|
xhr.onerror = function() { |
|
|
reject(new Error('Сетевая ошибка при загрузке.')); |
|
|
}; |
|
|
xhr.send(formData); |
|
|
}); |
|
|
|
|
|
const data = JSON.parse(responseText); |
|
|
uploadStatusDiv.textContent = data.message || 'Загрузка успешно завершена!'; |
|
|
uploadStatusDiv.style.color = 'green'; |
|
|
fileInput.value = ''; |
|
|
currentFiles = []; |
|
|
selectedFilesDiv.textContent = 'Файлы не выбраны'; |
|
|
fetchFiles(); |
|
|
} catch (error) { |
|
|
console.error('Upload error:', error); |
|
|
uploadStatusDiv.textContent = `Ошибка: ${error.message}`; |
|
|
uploadStatusDiv.style.color = 'red'; |
|
|
} finally { |
|
|
progressBar.style.display = 'none'; |
|
|
if (currentFiles.length > 0) { |
|
|
uploadButton.classList.add('enabled'); |
|
|
uploadButton.disabled = false; |
|
|
} else { |
|
|
uploadButton.classList.remove('enabled'); |
|
|
uploadButton.disabled = true; |
|
|
selectedFilesDiv.textContent = 'Файлы не выбраны'; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
function openViewer(file) { |
|
|
modal.style.display = 'block'; |
|
|
modalContent.innerHTML = ''; |
|
|
modalCaption.textContent = file.filename; |
|
|
const mimeType = file.content_type || ''; |
|
|
const downloadUrl = `/download/${encodeURIComponent(file.filename)}?initData=${encodeURIComponent(userInitData)}`; |
|
|
let element; |
|
|
if (mimeType.startsWith('image/')) { |
|
|
element = document.createElement('img'); |
|
|
element.src = downloadUrl; |
|
|
element.alt = file.filename; |
|
|
} else if (mimeType.startsWith('video/')) { |
|
|
element = document.createElement('video'); |
|
|
element.src = downloadUrl; |
|
|
element.controls = true; |
|
|
element.autoplay = true; |
|
|
} else if (mimeType.startsWith('audio/')) { |
|
|
element = document.createElement('audio'); |
|
|
element.src = downloadUrl; |
|
|
element.controls = true; |
|
|
element.autoplay = true; |
|
|
element.style.padding = '20px'; |
|
|
} |
|
|
if (element) { |
|
|
modalContent.appendChild(element); |
|
|
if (tg.HapticFeedback) { |
|
|
tg.HapticFeedback.impactOccurred('light'); |
|
|
} |
|
|
} else { |
|
|
modalCaption.textContent = 'Предпросмотр недоступен для этого типа файла.'; |
|
|
} |
|
|
} |
|
|
|
|
|
function closeViewer() { |
|
|
modal.style.display = 'none'; |
|
|
const mediaElement = modalContent.querySelector('video, audio'); |
|
|
if (mediaElement) { |
|
|
mediaElement.pause(); |
|
|
mediaElement.src = ''; |
|
|
} |
|
|
modalContent.innerHTML = ''; |
|
|
} |
|
|
|
|
|
modalCloseBtn.onclick = closeViewer; |
|
|
modal.onclick = function(event) { |
|
|
if (event.target === modal) { |
|
|
closeViewer(); |
|
|
} |
|
|
}; |
|
|
|
|
|
function setupTelegram() { |
|
|
if (!tg || !tg.initData) { |
|
|
console.error("Telegram WebApp script not loaded or initData is missing."); |
|
|
userGreeting.textContent = 'Ошибка: Не удалось инициализировать Telegram.'; |
|
|
document.body.style.visibility = 'visible'; |
|
|
return; |
|
|
} |
|
|
tg.ready(); |
|
|
tg.expand(); |
|
|
applyTheme(tg.themeParams); |
|
|
tg.onEvent('themeChanged', () => applyTheme(tg.themeParams)); |
|
|
userInitData = tg.initData; |
|
|
const user = tg.initDataUnsafe?.user; |
|
|
if (user) { |
|
|
const name = user.first_name || user.username || 'Пользователь'; |
|
|
userGreeting.textContent = `Привет, ${name}!`; |
|
|
} else { |
|
|
userGreeting.textContent = 'Привет!'; |
|
|
} |
|
|
fetchFiles(); |
|
|
fileInput.addEventListener('change', handleFileSelection); |
|
|
uploadButton.addEventListener('click', handleUpload); |
|
|
document.body.style.visibility = 'visible'; |
|
|
} |
|
|
|
|
|
if (window.Telegram && window.Telegram.WebApp) { |
|
|
setupTelegram(); |
|
|
} else { |
|
|
console.warn("Telegram WebApp script not immediately available, waiting for window.onload"); |
|
|
window.addEventListener('load', setupTelegram); |
|
|
setTimeout(() => { |
|
|
if (document.body.style.visibility !== 'visible') { |
|
|
console.error("Telegram WebApp script fallback timeout triggered."); |
|
|
userGreeting.textContent = 'Ошибка загрузки интерфейса Telegram.'; |
|
|
document.body.style.visibility = 'visible'; |
|
|
} |
|
|
}, 3500); |
|
|
} |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
""" |
|
|
|
|
|
ADMIN_TEMPLATE = """ |
|
|
<!DOCTYPE html> |
|
|
<html lang="ru"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Admin - Zeus Cloud</title> |
|
|
<link rel="preconnect" href="https://fonts.googleapis.com"> |
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"> |
|
|
<style> |
|
|
:root { |
|
|
--admin-bg: #f8f9fa; --admin-text: #212529; --admin-card-bg: #ffffff; |
|
|
--admin-border: #dee2e6; --admin-shadow: rgba(0, 0, 0, 0.05); |
|
|
--admin-primary: #0d6efd; --admin-secondary: #6c757d; |
|
|
--border-radius: 12px; --padding: 1.5rem; --font-family: 'Inter', sans-serif; |
|
|
} |
|
|
body { font-family: var(--font-family); background-color: var(--admin-bg); color: var(--admin-text); margin: 0; padding: var(--padding); line-height: 1.6; } |
|
|
.container { max-width: 1140px; margin: 0 auto; } |
|
|
h1 { text-align: center; color: var(--admin-secondary); margin-bottom: var(--padding); font-weight: 600; } |
|
|
.alert { background-color: #fff3cd; border-left: 6px solid #ffc107; margin-bottom: var(--padding); padding: 1rem 1.5rem; color: #664d03; border-radius: 8px; text-align: center; font-weight: 500; } |
|
|
.user-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: var(--padding); margin-top: var(--padding); } |
|
|
.user-card { |
|
|
background-color: var(--admin-card-bg); border-radius: var(--border-radius); padding: var(--padding); |
|
|
box-shadow: 0 4px 15px var(--admin-shadow); border: 1px solid var(--admin-border); |
|
|
display: flex; flex-direction: column; transition: transform 0.2s ease, box-shadow 0.2s ease; |
|
|
} |
|
|
.user-card:hover { transform: translateY(-5px); box-shadow: 0 8px 25px rgba(0, 0, 0, 0.08); } |
|
|
.user-header { display: flex; align-items: center; margin-bottom: 1rem; gap: 1rem; } |
|
|
.user-header img { width: 60px; height: 60px; border-radius: 50%; object-fit: cover; border: 3px solid var(--admin-border); background-color: #eee; } |
|
|
.user-info .name { font-weight: 600; font-size: 1.15em; color: var(--admin-primary); margin-bottom: 0.2rem; } |
|
|
.user-info .username { color: var(--admin-secondary); font-size: 0.9em; } |
|
|
.user-info .user-id { font-size: 0.8em; color: #888; } |
|
|
.user-details { font-size: 0.9em; color: #495057; } |
|
|
.user-details strong { color: var(--admin-text); } |
|
|
.file-count { margin-top: 1rem; font-weight: 500; } |
|
|
.view-files-btn { |
|
|
display: inline-block; margin-top: 1rem; padding: 8px 16px; background-color: var(--admin-primary); |
|
|
color: white; text-decoration: none; border-radius: 8px; font-size: 0.9em; text-align: center; |
|
|
transition: background-color 0.2s ease; |
|
|
} |
|
|
.view-files-btn:hover { background-color: #0b5ed7; } |
|
|
.no-users { text-align: center; color: var(--admin-secondary); margin-top: 2rem; font-size: 1.1em; } |
|
|
.admin-controls { |
|
|
background: var(--admin-card-bg); padding: var(--padding); border-radius: var(--border-radius); |
|
|
box-shadow: 0 4px 15px var(--admin-shadow); border: 1px solid var(--admin-border); |
|
|
margin-bottom: var(--padding); text-align: center; |
|
|
} |
|
|
.admin-controls h2 { margin-top: 0; margin-bottom: 1rem; font-weight: 600; color: var(--admin-secondary); } |
|
|
.admin-controls .btn { padding: 10px 18px; font-size: 0.95em; font-weight: 500; border: none; border-radius: 8px; cursor: pointer; transition: all 0.2s ease; margin: 0.5rem; color: #fff; } |
|
|
.admin-controls .btn-refresh-meta { background-color: var(--admin-primary); } |
|
|
.admin-controls .btn-refresh-meta:hover { background-color: #0b5ed7; } |
|
|
.admin-controls .status { font-size: 0.9em; margin-top: 1rem; color: var(--admin-secondary); min-height: 1.2em; } |
|
|
.admin-controls .loader { border: 4px solid #f3f3f3; border-radius: 50%; border-top: 4px solid var(--admin-primary); width: 20px; height: 20px; animation: spin 1s linear infinite; display: inline-block; margin-left: 10px; vertical-align: middle; display: none; } |
|
|
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="container"> |
|
|
<h1>Zeus Cloud - Админ Панель</h1> |
|
|
<div class="alert">ВНИМАНИЕ: Этот раздел не защищен! Добавьте аутентификацию для реального использования.</div> |
|
|
<div class="admin-controls"> |
|
|
<h2>Управление метаданными</h2> |
|
|
<button class="btn btn-refresh-meta" onclick="triggerDownloadMeta()">Скачать data.json с HF</button> |
|
|
<div class="loader" id="loader"></div> |
|
|
<div class="status" id="status-message"></div> |
|
|
</div> |
|
|
{% if users %} |
|
|
<div class="user-grid"> |
|
|
{% for user_id, data in users.items() %} |
|
|
<div class="user-card"> |
|
|
<div class="user-header"> |
|
|
<img src="{{ data.user_info.photo_url if data.user_info and data.user_info.photo_url else 'data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 100 100%27%3e%3crect width=%27100%27 height=%27100%27 fill=%27%23e9ecef%27/%3e%3ctext x=%2750%25%27 y=%2755%25%27 dominant-baseline=%27middle%27 text-anchor=%27middle%27 font-size=%2745%27 font-family=%27sans-serif%27 fill=%27%23adb5bd%27%3e?%3c/text%3e%3c/svg%3e' }}" alt="User Avatar"> |
|
|
<div class="user-info"> |
|
|
<div class="name">{{ data.user_info.first_name or 'N/A' }} {{ data.user_info.last_name or '' }}</div> |
|
|
{% if data.user_info and data.user_info.username %} |
|
|
<div class="username"><a href="https://t.me/{{ data.user_info.username }}" target="_blank" style="color: inherit; text-decoration: none;">@{{ data.user_info.username }}</a></div> |
|
|
{% endif %} |
|
|
<div class="user-id">ID: {{ user_id }}</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="user-details"> |
|
|
<div><strong>Язык:</strong> {{ data.user_info.language_code or 'N/A' }}</div> |
|
|
<div><strong>Premium:</strong> {{ 'Да' if data.user_info and data.user_info.is_premium else 'Нет' }}</div> |
|
|
</div> |
|
|
<div class="file-count"> |
|
|
Файлов загружено: {{ data.files|length if data.files else 0 }} |
|
|
</div> |
|
|
<a href="{{ url_for('admin_user_files', user_id=user_id) }}" class="view-files-btn">Просмотреть файлы</a> |
|
|
</div> |
|
|
{% endfor %} |
|
|
</div> |
|
|
{% else %} |
|
|
<p class="no-users">Пользователей не найдено.</p> |
|
|
{% endif %} |
|
|
</div> |
|
|
<script> |
|
|
const loader = document.getElementById('loader'); |
|
|
const statusMessage = document.getElementById('status-message'); |
|
|
async function handleFetch(url, action) { |
|
|
loader.style.display = 'inline-block'; |
|
|
statusMessage.textContent = `Выполняется ${action}...`; |
|
|
statusMessage.style.color = 'var(--admin-secondary)'; |
|
|
try { |
|
|
const response = await fetch(url, { method: 'POST' }); |
|
|
const data = await response.json(); |
|
|
if (response.ok && data.status === 'ok') { |
|
|
statusMessage.textContent = data.message; |
|
|
statusMessage.style.color = '#198754'; |
|
|
if (action === 'скачивание метаданных') { |
|
|
setTimeout(() => location.reload(), 1500); |
|
|
} |
|
|
} else { |
|
|
throw new Error(data.message || 'Произошла ошибка'); |
|
|
} |
|
|
} catch (error) { |
|
|
statusMessage.textContent = `Ошибка ${action}: ${error.message}`; |
|
|
statusMessage.style.color = '#dc3545'; |
|
|
console.error(`Error during ${action}:`, error); |
|
|
} finally { |
|
|
loader.style.display = 'none'; |
|
|
} |
|
|
} |
|
|
function triggerDownloadMeta() { |
|
|
handleFetch("{{ url_for('admin_trigger_download_metadata') }}", 'скачивание метаданных'); |
|
|
} |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
""" |
|
|
|
|
|
ADMIN_USER_FILES_TEMPLATE = """ |
|
|
<!DOCTYPE html> |
|
|
<html lang="ru"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Файлы пользователя {{ user_info.first_name or user_id }} - Admin</title> |
|
|
<link rel="preconnect" href="https://fonts.googleapis.com"> |
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"> |
|
|
<style> |
|
|
:root { |
|
|
--admin-bg: #f8f9fa; --admin-text: #212529; --admin-card-bg: #ffffff; |
|
|
--admin-border: #dee2e6; --admin-shadow: rgba(0, 0, 0, 0.05); |
|
|
--admin-primary: #0d6efd; --admin-secondary: #6c757d; |
|
|
--border-radius: 12px; --padding: 1.5rem; --font-family: 'Inter', sans-serif; |
|
|
} |
|
|
body { font-family: var(--font-family); background-color: var(--admin-bg); color: var(--admin-text); margin: 0; padding: var(--padding); line-height: 1.6; } |
|
|
.container { max-width: 960px; margin: 0 auto; } |
|
|
h1 { color: var(--admin-secondary); margin-bottom: 0.5rem; font-weight: 600; } |
|
|
.user-identifier { color: var(--admin-primary); font-weight: 500; margin-bottom: var(--padding); } |
|
|
.back-link { display: inline-block; margin-bottom: var(--padding); color: var(--admin-primary); text-decoration: none; font-weight: 500; } |
|
|
.back-link:hover { text-decoration: underline; } |
|
|
.file-table { width: 100%; border-collapse: collapse; margin-top: var(--padding); background-color: var(--admin-card-bg); border-radius: var(--border-radius); box-shadow: 0 4px 15px var(--admin-shadow); overflow: hidden; border: 1px solid var(--admin-border); } |
|
|
.file-table th, .file-table td { padding: 12px 15px; text-align: left; border-bottom: 1px solid var(--admin-border); } |
|
|
.file-table th { background-color: #f1f3f5; font-weight: 600; font-size: 0.9em; text-transform: uppercase; letter-spacing: 0.5px; } |
|
|
.file-table tr:last-child td { border-bottom: none; } |
|
|
.file-table tr:hover { background-color: #f8f9fa; } |
|
|
.file-table td { font-size: 0.95em; word-break: break-word; } |
|
|
.filename { font-weight: 500; } |
|
|
.filesize, .filedate { color: var(--admin-secondary); font-size: 0.9em; } |
|
|
.actions a { |
|
|
display: inline-block; padding: 6px 12px; background-color: var(--admin-primary); color: white; |
|
|
text-decoration: none; border-radius: 6px; font-size: 0.85em; transition: background-color 0.2s ease; |
|
|
} |
|
|
.actions a:hover { background-color: #0b5ed7; } |
|
|
.no-files { text-align: center; color: var(--admin-secondary); margin-top: 2rem; font-size: 1.1em; } |
|
|
@media screen and (max-width: 768px) { |
|
|
.file-table { border: 0; box-shadow: none; } |
|
|
.file-table thead { display: none; } |
|
|
.file-table tr { display: block; margin-bottom: 1rem; border: 1px solid var(--admin-border); border-radius: var(--border-radius); background-color: var(--admin-card-bg); box-shadow: 0 2px 8px var(--admin-shadow); } |
|
|
.file-table td { display: block; text-align: right; padding-left: 50%; position: relative; border-bottom: 1px solid #eee; } |
|
|
.file-table td::before { |
|
|
content: attr(data-label); position: absolute; left: 15px; width: calc(50% - 30px); |
|
|
padding-right: 10px; white-space: nowrap; text-align: left; font-weight: bold; |
|
|
font-size: 0.9em; text-transform: uppercase; color: var(--admin-secondary); |
|
|
} |
|
|
.file-table td:last-child { border-bottom: 0; } |
|
|
.actions { text-align: right !important; } |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="container"> |
|
|
<a href="{{ url_for('admin_panel') }}" class="back-link">← Назад к списку пользователей</a> |
|
|
<h1>Файлы пользователя</h1> |
|
|
<div class="user-identifier">{{ user_info.first_name or '' }} {{ user_info.last_name or '' }} (ID: {{ user_id }})</div> |
|
|
{% if files %} |
|
|
<table class="file-table"> |
|
|
<thead> |
|
|
<tr> |
|
|
<th>Имя файла</th> |
|
|
<th>Размер</th> |
|
|
<th>Дата загрузки</th> |
|
|
<th>Тип</th> |
|
|
<th>Действия</th> |
|
|
</tr> |
|
|
</thead> |
|
|
<tbody> |
|
|
{% for file in files|sort(attribute='uploaded_at_ts', reverse=true) %} |
|
|
<tr> |
|
|
<td data-label="Имя файла" class="filename">{{ file.filename }}</td> |
|
|
<td data-label="Размер" class="filesize">{{ file.size | filesizeformat if file.size else 'N/A' }}</td> |
|
|
<td data-label="Дата" class="filedate">{{ file.uploaded_at_str or 'N/A' }}</td> |
|
|
<td data-label="Тип">{{ file.content_type or 'N/A' }}</td> |
|
|
<td data-label="Действия" class="actions"> |
|
|
<a href="{{ url_for('admin_download_file', user_id=user_id, filename=file.filename) }}" target="_blank">Скачать</a> |
|
|
</td> |
|
|
</tr> |
|
|
{% endfor %} |
|
|
</tbody> |
|
|
</table> |
|
|
{% else %} |
|
|
<p class="no-files">У этого пользователя нет загруженных файлов.</p> |
|
|
{% endif %} |
|
|
</div> |
|
|
<script> |
|
|
function formatBytes(bytes, decimals = 2) { |
|
|
if (!+bytes) return '0 Bytes' |
|
|
const k = 1024 |
|
|
const dm = decimals < 0 ? 0 : decimals |
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] |
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k)) |
|
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}` |
|
|
} |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
""" |
|
|
|
|
|
@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: |
|
|
return 'N/A' |
|
|
|
|
|
@app.route('/') |
|
|
def index(): |
|
|
return render_template_string(USER_TEMPLATE, theme={}, max_files=MAX_UPLOAD_FILES) |
|
|
|
|
|
@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", "message": "Missing initData"}), 400 |
|
|
user_info, message = authenticate_and_get_user(init_data_str) |
|
|
if not user_info: |
|
|
return jsonify({"status": "error", "message": message}), 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(): |
|
|
init_data_str = request.form.get('initData') |
|
|
if not init_data_str: |
|
|
return jsonify({"status": "error", "message": "Missing initData"}), 400 |
|
|
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) |
|
|
uploaded_files = request.files.getlist('files') |
|
|
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 |
|
|
api = get_hf_api(write=True) |
|
|
if not api: |
|
|
return jsonify({"status": "error", "message": "Server error: Cannot connect to storage."}), 500 |
|
|
successful_uploads_metadata = [] |
|
|
errors = [] |
|
|
for file_storage in uploaded_files: |
|
|
filename = file_storage.filename |
|
|
if not filename: |
|
|
errors.append("Received a file without a name.") |
|
|
continue |
|
|
path_in_repo = f"{HF_UPLOAD_FOLDER}/{user_id_str}/{filename}" |
|
|
file_content = file_storage.read() |
|
|
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}...") |
|
|
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}" |
|
|
) |
|
|
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}: {str(e)}") |
|
|
if successful_uploads_metadata: |
|
|
if not update_user_file_metadata(user_id, successful_uploads_metadata): |
|
|
errors.append("Ошибка обновления списка файлов после загрузки.") |
|
|
if not errors: |
|
|
return jsonify({"status": "ok", "message": f"Загружено {len(successful_uploads_metadata)} файл(ов)."}), 200 |
|
|
else: |
|
|
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 |
|
|
|
|
|
@app.route('/download/<path:filename>', methods=['GET']) |
|
|
def download_file(filename): |
|
|
init_data_str = request.args.get('initData') |
|
|
if not init_data_str: |
|
|
return "Authentication required.", 401 |
|
|
user_info, message = authenticate_and_get_user(init_data_str) |
|
|
if not user_info: |
|
|
return f"Access denied: {message}", 403 |
|
|
user_id = user_info['id'] |
|
|
user_id_str = str(user_id) |
|
|
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}") |
|
|
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"File {path_in_repo} downloaded to cache: {local_file_path}") |
|
|
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, |
|
|
download_name=filename |
|
|
) |
|
|
except EntryNotFoundError: |
|
|
logging.error(f"File not found on Hugging Face: {path_in_repo}") |
|
|
return "File not found on storage.", 404 |
|
|
except RepositoryNotFoundError: |
|
|
logging.error(f"Repository not found: {REPO_ID}") |
|
|
return "Storage repository not found.", 500 |
|
|
except Exception as e: |
|
|
logging.error(f"Error downloading file {path_in_repo} for user {user_id}: {e}", exc_info=True) |
|
|
return "Server error during download.", 500 |
|
|
|
|
|
@app.route('/admin') |
|
|
def admin_panel(): |
|
|
current_data = load_local_metadata() |
|
|
return render_template_string(ADMIN_TEMPLATE, users=current_data) |
|
|
|
|
|
@app.route('/admin/user/<user_id>') |
|
|
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}) |
|
|
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/<user_id>/<path:filename>', methods=['GET']) |
|
|
def admin_download_file(user_id, filename): |
|
|
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 |
|
|
path_in_repo = f"{HF_UPLOAD_FOLDER}/{user_id_str}/{filename}" |
|
|
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, |
|
|
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(): |
|
|
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 |
|
|
|
|
|
if __name__ == '__main__': |
|
|
print("---") |
|
|
print("--- ZEUS CLOUD MINI APP SERVER ---") |
|
|
print("---") |
|
|
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 Metadata Path: {HF_DATA_FILE_PATH}") |
|
|
print(f"HF Upload Folder: {HF_UPLOAD_FOLDER}/<user_id>/") |
|
|
if not HF_TOKEN_READ or not HF_TOKEN_WRITE: |
|
|
print("---") |
|
|
print("--- WARNING: HUGGING FACE TOKEN(S) NOT SET ---") |
|
|
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() |
|
|
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 ---") |
|
|
app.run(host=HOST, port=PORT, debug=False, threaded=True) |