m / app.py
Eluza133's picture
Update app.py
81949e3 verified
raw
history blame
60.3 kB
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
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)