Eluza133 commited on
Commit
81949e3
·
verified ·
1 Parent(s): 9e5627b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +115 -337
app.py CHANGED
@@ -16,31 +16,28 @@ from huggingface_hub import HfApi, hf_hub_download, list_repo_files
16
  from huggingface_hub.utils import RepositoryNotFoundError, EntryNotFoundError
17
  import mimetypes
18
  import io
 
19
 
20
- # --- Configuration ---
21
- BOT_TOKEN = os.getenv("BOT_TOKEN", "6750208873:AAE2hvPlJ99dBdhGa_Brre0IIpUdOvXxHt4") # Use environment variable or default
22
  HOST = '0.0.0.0'
23
  PORT = 7860
24
- DATA_FILE = 'data.json' # Local file for user and file metadata
25
 
26
- # Hugging Face Settings
27
- REPO_ID = os.getenv("HF_REPO_ID", "Eluza133/Z1e1u") # Target repository
28
- HF_DATA_FILE_PATH = "data.json" # Path for the metadata file within the HF repo
29
- HF_UPLOAD_FOLDER = "uploads" # Folder within the HF repo to store user files
30
- HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE") # Token with write access
31
- HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") # Token with read access (can be same as write)
32
 
33
- # Constants
34
  MAX_UPLOAD_FILES = 20
35
- AUTH_TIMEOUT = 86400 # 24 hours validity for initData
36
 
37
  app = Flask(__name__)
38
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
39
- app.secret_key = os.urandom(24) # For potential future session use
40
 
41
- # --- Hugging Face & Data Handling ---
42
- _data_lock = threading.Lock()
43
- metadata_cache = {} # In-memory cache for data.json
44
 
45
  def get_hf_api(write=False):
46
  token = HF_TOKEN_WRITE if write else HF_TOKEN_READ
@@ -76,15 +73,12 @@ def download_metadata_from_hf():
76
  except (FileNotFoundError, json.JSONDecodeError) as e:
77
  logging.error(f"Error reading downloaded metadata file: {e}. Resetting cache.")
78
  metadata_cache = {}
79
- # Clean up downloaded file? hf_hub_download uses a cache, maybe not needed.
80
- # if os.path.exists(DATA_FILE) and download_path != DATA_FILE:
81
- # os.remove(download_path) # Remove temp download if different name
82
  return True
83
  except EntryNotFoundError:
84
  logging.warning(f"Metadata file '{HF_DATA_FILE_PATH}' not found in repo '{REPO_ID}'. Starting fresh.")
85
  with _data_lock:
86
  metadata_cache = {}
87
- return True # It's not an error, just no existing data
88
  except RepositoryNotFoundError:
89
  logging.error(f"Hugging Face repository '{REPO_ID}' not found. Cannot download metadata.")
90
  except Exception as e:
@@ -94,7 +88,7 @@ def download_metadata_from_hf():
94
  def load_local_metadata():
95
  global metadata_cache
96
  with _data_lock:
97
- if not metadata_cache: # Only load from file if cache is empty
98
  try:
99
  with open(DATA_FILE, 'r', encoding='utf-8') as f:
100
  metadata_cache = json.load(f)
@@ -115,16 +109,10 @@ def save_metadata(data_to_update=None):
115
  with _data_lock:
116
  try:
117
  if data_to_update:
118
- # Deep merge might be needed if updating nested structures
119
- # For now, simple update assumes top-level keys (user IDs)
120
  metadata_cache.update(data_to_update)
121
-
122
- # Save updated cache to local file
123
  with open(DATA_FILE, 'w', encoding='utf-8') as f:
124
  json.dump(metadata_cache, f, ensure_ascii=False, indent=4)
125
  logging.info(f"Metadata successfully saved locally to {DATA_FILE}.")
126
-
127
- # Trigger async upload to HF after successful local save
128
  upload_metadata_to_hf_async()
129
  return True
130
  except Exception as e:
@@ -136,11 +124,8 @@ def update_user_file_metadata(user_id, file_info_list):
136
  with _data_lock:
137
  if user_id_str not in metadata_cache:
138
  metadata_cache[user_id_str] = {"user_info": {}, "files": []}
139
-
140
  if "files" not in metadata_cache[user_id_str]:
141
  metadata_cache[user_id_str]["files"] = []
142
-
143
- # Add new files, potentially checking for duplicates if needed
144
  existing_filenames = {f['filename'] for f in metadata_cache[user_id_str]["files"]}
145
  new_files_added = 0
146
  for file_info in file_info_list:
@@ -149,24 +134,14 @@ def update_user_file_metadata(user_id, file_info_list):
149
  existing_filenames.add(file_info['filename'])
150
  new_files_added += 1
151
  else:
152
- # Handle update logic if a file with the same name is re-uploaded
153
- # For now, we just log it. Replace or versioning could be added.
154
  logging.warning(f"File '{file_info['filename']}' already exists for user {user_id}. Skipping add.")
155
- # Example: Update existing entry
156
- # for i, existing_file in enumerate(metadata_cache[user_id_str]["files"]):
157
- # if existing_file['filename'] == file_info['filename']:
158
- # metadata_cache[user_id_str]["files"][i] = file_info # Replace with new metadata
159
- # break
160
-
161
  if new_files_added > 0:
162
  logging.info(f"Added {new_files_added} file metadata entries for user {user_id}.")
163
- # Save metadata (which will trigger HF upload)
164
  if not save_metadata():
165
- return False # Propagate save error
166
  else:
167
  logging.info(f"No new file metadata added for user {user_id}.")
168
-
169
- return True # Indicate metadata was processed (even if no new files added)
170
 
171
  def _upload_metadata_to_hf_task():
172
  api = get_hf_api(write=True)
@@ -176,17 +151,12 @@ def _upload_metadata_to_hf_task():
176
  if not os.path.exists(DATA_FILE):
177
  logging.warning(f"{DATA_FILE} does not exist locally. Skipping upload.")
178
  return
179
-
180
  try:
181
- # Acquire lock only for reading the file path and ensuring it has content
182
  with _data_lock:
183
  if os.path.getsize(DATA_FILE) == 0:
184
  logging.warning(f"{DATA_FILE} is empty. Skipping upload.")
185
- # Optionally upload an empty file or a default structure like {}
186
- # For now, skip.
187
  return
188
- file_to_upload = DATA_FILE # Use the local file path
189
-
190
  logging.info(f"Attempting to upload {file_to_upload} to {REPO_ID}/{HF_DATA_FILE_PATH}...")
191
  api.upload_file(
192
  path_or_fileobj=file_to_upload,
@@ -203,41 +173,27 @@ def upload_metadata_to_hf_async():
203
  upload_thread = threading.Thread(target=_upload_metadata_to_hf_task, daemon=True)
204
  upload_thread.start()
205
 
206
- # --- Telegram Verification ---
207
  def verify_telegram_data(init_data_str):
208
  try:
209
  parsed_data = parse_qs(init_data_str)
210
  received_hash = parsed_data.pop('hash', [None])[0]
211
-
212
  if not received_hash:
213
  logging.warning("Verification failed: Hash missing from initData.")
214
  return None, False, "Hash missing"
215
-
216
  data_check_list = []
217
- # Sort keys alphabetically for consistent string generation
218
  for key, value in sorted(parsed_data.items()):
219
- # Make sure values are handled correctly (they are lists in parse_qs)
220
  data_check_list.append(f"{key}={value[0]}")
221
  data_check_string = "\n".join(data_check_list)
222
-
223
- # Calculate secret key
224
  secret_key = hmac.new("WebAppData".encode(), BOT_TOKEN.encode(), hashlib.sha256).digest()
225
- # Calculate data hash
226
  calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
227
-
228
- # Compare hashes
229
  if calculated_hash != received_hash:
230
  logging.warning(f"Verification failed: Hash mismatch. Calculated: {calculated_hash}, Received: {received_hash}")
231
  return parsed_data, False, "Invalid hash"
232
-
233
- # Check auth_date (timestamp)
234
  auth_date = int(parsed_data.get('auth_date', [0])[0])
235
  current_time = int(time.time())
236
  if current_time - auth_date > AUTH_TIMEOUT:
237
  logging.warning(f"Verification failed: initData expired. Auth time: {auth_date}, Current time: {current_time}")
238
  return parsed_data, False, "Data expired"
239
-
240
- # Extract user info if present
241
  user_info_dict = None
242
  if 'user' in parsed_data:
243
  try:
@@ -245,29 +201,21 @@ def verify_telegram_data(init_data_str):
245
  user_info_dict = json.loads(user_json_str)
246
  except Exception as e:
247
  logging.error(f"Could not parse user JSON from initData: {e}")
248
- # Continue verification, but user info might be missing
249
-
250
  logging.info(f"Telegram data verified successfully for user ID: {user_info_dict.get('id') if user_info_dict else 'Unknown'}")
251
  return user_info_dict, True, "Verified"
252
-
253
  except Exception as e:
254
  logging.error(f"Error during Telegram data verification: {e}", exc_info=True)
255
  return None, False, "Verification exception"
256
 
257
- # --- User Authentication & Data Update ---
258
  def authenticate_and_get_user(init_data_str):
259
  user_info, is_valid, message = verify_telegram_data(init_data_str)
260
  if not is_valid:
261
  return None, message
262
-
263
  user_id = user_info.get('id') if user_info else None
264
  if not user_id:
265
  logging.warning("Verification successful but user ID is missing in user data.")
266
  return None, "User ID missing"
267
-
268
  user_id_str = str(user_id)
269
-
270
- # Ensure user exists in metadata cache and update basic info if needed
271
  with _data_lock:
272
  should_save = False
273
  if user_id_str not in metadata_cache:
@@ -278,28 +226,14 @@ def authenticate_and_get_user(init_data_str):
278
  logging.info(f"New user registered: {user_id}")
279
  should_save = True
280
  else:
281
- # Optionally update user_info if it has changed (e.g., name, username, photo)
282
- # This requires comparing fields and deciding if an update is needed.
283
- # Simple approach: Always update if provided.
284
  if "user_info" not in metadata_cache[user_id_str] or metadata_cache[user_id_str]["user_info"] != user_info:
285
  metadata_cache[user_id_str]["user_info"] = user_info
286
- # logging.info(f"User info updated for user: {user_id}") # Can be noisy
287
- should_save = True # Save if info changed or was missing
288
-
289
- # Save metadata only if the user was new or info was updated
290
  if should_save:
291
- # save_metadata is already thread-safe and uploads async
292
  if not save_metadata():
293
- # Handle save failure, though it's unlikely to fail just for user info update
294
  logging.error(f"Failed to save metadata after updating/adding user {user_id}")
295
- # Decide if this should prevent the user from proceeding. For now, allow.
296
-
297
  return user_info, "Authenticated"
298
 
299
-
300
- # --- HTML Templates ---
301
-
302
- # Main User Interface Template
303
  USER_TEMPLATE = """
304
  <!DOCTYPE html>
305
  <html lang="ru">
@@ -334,8 +268,8 @@ USER_TEMPLATE = """
334
  background-color: var(--tg-theme-bg-color);
335
  color: var(--tg-theme-text-color);
336
  padding: var(--padding);
337
- padding-bottom: 100px; /* Space for upload button */
338
- visibility: hidden; /* Hide until ready */
339
  line-height: 1.5;
340
  }
341
  .container { max-width: 700px; margin: 0 auto; display: flex; flex-direction: column; gap: var(--padding); }
@@ -371,8 +305,8 @@ USER_TEMPLATE = """
371
  border: none; border-radius: var(--border-radius);
372
  font-size: 1em; font-weight: 600;
373
  cursor: pointer; transition: background-color 0.2s ease;
374
- opacity: 0.5; /* Disabled initially */
375
- pointer-events: none; /* Disabled initially */
376
  }
377
  #upload-button.enabled { opacity: 1; pointer-events: auto; }
378
  #upload-status { margin-top: 12px; font-size: 0.9em; color: var(--tg-theme-hint-color); min-height: 1.2em; }
@@ -406,13 +340,10 @@ USER_TEMPLATE = """
406
  }
407
  .file-actions a:hover, .file-actions button:hover { opacity: 0.8; }
408
  .download-btn { background-color: var(--tg-theme-button-color); color: var(--tg-theme-button-text-color); }
409
- .delete-btn { background-color: #dc3545; color: white; } /* Add delete styling */
410
-
411
  .loading, .no-files { text-align: center; padding: 20px; color: var(--tg-theme-hint-color); }
412
  .progress-bar { width: 100%; background-color: #ddd; border-radius: 4px; height: 8px; margin-top: 5px; display: none; }
413
  .progress-bar-inner { height: 100%; width: 0%; background-color: var(--tg-theme-button-color); border-radius: 4px; transition: width 0.1s linear; }
414
-
415
- /* Modal for viewing */
416
  .modal {
417
  display: none; position: fixed; z-index: 1001;
418
  left: 0; top: 0; width: 100%; height: 100%;
@@ -427,7 +358,7 @@ USER_TEMPLATE = """
427
  }
428
  .modal-content img, .modal-content video, .modal-content audio {
429
  display: block; width: auto; height: auto; max-width: 100%; max-height: 100%; margin: auto;
430
- background-color: var(--tg-theme-bg-color); /* Add background for audio/video */
431
  }
432
  .modal-close {
433
  position: absolute; top: 15px; right: 35px; color: #f1f1f1; font-size: 40px;
@@ -440,7 +371,6 @@ USER_TEMPLATE = """
440
  color: #ccc; padding: 10px 0; height: 50px; position: absolute; bottom: 15px; left: 50%; transform: translateX(-50%);
441
  background: rgba(0,0,0,0.5); border-radius: 8px;
442
  }
443
- /* Add spinner styles */
444
  .spinner {
445
  border: 4px solid rgba(255, 255, 255, 0.3);
446
  border-radius: 50%;
@@ -449,10 +379,9 @@ USER_TEMPLATE = """
449
  height: 40px;
450
  animation: spin 1s linear infinite;
451
  margin: 20px auto;
452
- display: none; /* Initially hidden */
453
  }
454
  @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
455
-
456
  </style>
457
  </head>
458
  <body>
@@ -461,7 +390,6 @@ USER_TEMPLATE = """
461
  <h1>Zeus Cloud</h1>
462
  <div class="user-info" id="user-greeting">Загрузка...</div>
463
  </header>
464
-
465
  <section class="upload-section">
466
  <h2>Загрузить файлы</h2>
467
  <label for="file-input" class="file-input-label">Выбрать файлы (до {{ max_files }})</label>
@@ -471,7 +399,6 @@ USER_TEMPLATE = """
471
  <div class="progress-bar" id="progress-bar"><div class="progress-bar-inner" id="progress-bar-inner"></div></div>
472
  <div id="upload-status"></div>
473
  </section>
474
-
475
  <section class="file-list-section">
476
  <h2>Ваши файлы</h2>
477
  <div class="spinner" id="loading-spinner"></div>
@@ -480,23 +407,17 @@ USER_TEMPLATE = """
480
  </ul>
481
  </section>
482
  </div>
483
-
484
- <!-- The Modal -->
485
  <div id="viewerModal" class="modal">
486
  <span class="modal-close" id="modalCloseBtn">×</span>
487
  <div id="modalContent" class="modal-content">
488
- <!-- Content (img/video/audio) will be loaded here -->
489
  </div>
490
  <div id="modalCaption" class="modal-caption"></div>
491
  </div>
492
-
493
  <script>
494
  const tg = window.Telegram.WebApp;
495
  const MAX_FILES = {{ max_files }};
496
- let currentFiles = []; // To hold selected File objects
497
  let userInitData = '';
498
-
499
- // Elements
500
  const fileInput = document.getElementById('file-input');
501
  const uploadButton = document.getElementById('upload-button');
502
  const selectedFilesDiv = document.getElementById('selected-files');
@@ -507,8 +428,6 @@ USER_TEMPLATE = """
507
  const loadingSpinner = document.getElementById('loading-spinner');
508
  const progressBar = document.getElementById('progress-bar');
509
  const progressBarInner = document.getElementById('progress-bar-inner');
510
-
511
- // Modal elements
512
  const modal = document.getElementById('viewerModal');
513
  const modalContent = document.getElementById('modalContent');
514
  const modalCaption = document.getElementById('modalCaption');
@@ -520,7 +439,6 @@ USER_TEMPLATE = """
520
  const cssVar = `--tg-theme-${key.replace(/_/g, '-')}`;
521
  root.style.setProperty(cssVar, themeParams[key]);
522
  });
523
- // Add fallback defaults for CSS vars used in the template
524
  const defaults = {
525
  'bg_color': '#181818', 'text_color': '#ffffff', 'hint_color': '#aaaaaa',
526
  'link_color': '#62bcf9', 'button_color': '#31a5f5',
@@ -544,79 +462,53 @@ USER_TEMPLATE = """
544
  }
545
 
546
  function displayFiles(files) {
547
- fileListUl.innerHTML = ''; // Clear existing list
548
  loadingSpinner.style.display = 'none';
549
-
550
  if (!files || files.length === 0) {
551
  noFilesMessage.style.display = 'block';
552
  return;
553
  }
554
  noFilesMessage.style.display = 'none';
555
-
556
- files.sort((a, b) => b.uploaded_at_ts - a.uploaded_at_ts); // Sort by timestamp descending
557
-
558
  files.forEach(file => {
559
  const li = document.createElement('li');
560
  li.classList.add('file-item');
561
-
562
  const fileInfoDiv = document.createElement('div');
563
  fileInfoDiv.classList.add('file-info');
564
-
565
  const fileNameSpan = document.createElement('span');
566
  fileNameSpan.classList.add('file-name');
567
  fileNameSpan.textContent = file.filename;
568
  fileInfoDiv.appendChild(fileNameSpan);
569
-
570
  const fileMetaSpan = document.createElement('span');
571
  fileMetaSpan.classList.add('file-meta');
572
  const date = new Date(file.uploaded_at_ts * 1000).toLocaleString();
573
  const size = file.size ? formatBytes(file.size) : '';
574
  fileMetaSpan.textContent = `${date}${size ? ' - ' + size : ''}`;
575
  fileInfoDiv.appendChild(fileMetaSpan);
576
-
577
  const fileActionsDiv = document.createElement('div');
578
  fileActionsDiv.classList.add('file-actions');
579
-
580
- // Add View button for specific types
581
  const mimeType = file.content_type || '';
582
  if (mimeType.startsWith('image/') || mimeType.startsWith('video/') || mimeType.startsWith('audio/')) {
583
  const viewButton = document.createElement('button');
584
- viewButton.textContent = '👁️'; // View icon
585
- viewButton.classList.add('view-btn'); // Add class if needed for specific styling
586
- viewButton.style.backgroundColor = '#6f42c1'; // Purple-ish
587
  viewButton.style.color = 'white';
588
  viewButton.title = 'Просмотр';
589
  viewButton.onclick = (e) => {
590
- e.stopPropagation(); // Prevent triggering li click if any
591
  openViewer(file);
592
  };
593
  fileActionsDiv.appendChild(viewButton);
594
  }
595
-
596
-
597
  const downloadLink = document.createElement('a');
598
  downloadLink.classList.add('download-btn');
599
  downloadLink.textContent = 'Скачать';
600
  downloadLink.href = `/download/${encodeURIComponent(file.filename)}?initData=${encodeURIComponent(userInitData)}`;
601
- downloadLink.target = '_blank'; // Open in new tab or trigger download
602
  downloadLink.title = 'Скачать файл';
603
- // Prevent modal opening if clicking download
604
  downloadLink.onclick = (e) => e.stopPropagation();
605
-
606
  fileActionsDiv.appendChild(downloadLink);
607
-
608
- // Optional: Delete button
609
- /*
610
- const deleteButton = document.createElement('button');
611
- deleteButton.classList.add('delete-btn');
612
- deleteButton.textContent = 'Удалить';
613
- deleteButton.onclick = (e) => {
614
- e.stopPropagation();
615
- deleteFile(file.filename);
616
- };
617
- fileActionsDiv.appendChild(deleteButton);
618
- */
619
-
620
  li.appendChild(fileInfoDiv);
621
  li.appendChild(fileActionsDiv);
622
  fileListUl.appendChild(li);
@@ -644,20 +536,17 @@ USER_TEMPLATE = """
644
  uploadStatusDiv.textContent = `Ошибка загрузки списка файлов: ${error.message}`;
645
  uploadStatusDiv.style.color = 'red';
646
  loadingSpinner.style.display = 'none';
647
- noFilesMessage.style.display = 'block'; // Show no files message on error
648
  noFilesMessage.textContent = 'Не удалось загрузить список файлов.';
649
  }
650
  }
651
 
652
-
653
  function handleFileSelection(event) {
654
  currentFiles = Array.from(event.target.files);
655
  if (currentFiles.length > MAX_FILES) {
656
  alert(`Вы можете выбрать не более ${MAX_FILES} файлов за раз.`);
657
- currentFiles = currentFiles.slice(0, MAX_FILES); // Keep only the first MAX_FILES
658
- // Reset file input visually? Difficult, maybe just update text.
659
  }
660
-
661
  if (currentFiles.length > 0) {
662
  selectedFilesDiv.textContent = `${currentFiles.length} файл(ов) выбрано: ${currentFiles.map(f => f.name).join(', ')}`;
663
  uploadButton.classList.add('enabled');
@@ -667,7 +556,6 @@ USER_TEMPLATE = """
667
  uploadButton.classList.remove('enabled');
668
  uploadButton.disabled = true;
669
  }
670
- // Clear previous status
671
  uploadStatusDiv.textContent = '';
672
  progressBar.style.display = 'none';
673
  progressBarInner.style.width = '0%';
@@ -678,86 +566,77 @@ USER_TEMPLATE = """
678
  uploadStatusDiv.textContent = 'Выберите файлы для загрузки.';
679
  return;
680
  }
681
-
682
- uploadButton.classList.remove('enabled');
683
  uploadButton.disabled = true;
 
684
  uploadStatusDiv.textContent = 'Загрузка началась...';
685
  uploadStatusDiv.style.color = 'var(--tg-theme-hint-color)';
686
  progressBar.style.display = 'block';
687
  progressBarInner.style.width = '0%';
688
-
689
  const formData = new FormData();
690
  currentFiles.forEach(file => {
691
- formData.append('files', file); // Key must match Flask backend: request.files.getlist('files')
692
  });
693
- formData.append('initData', userInitData); // Send auth data
694
 
695
  try {
696
- // Use XMLHttpRequest for progress tracking
697
- const xhr = new XMLHttpRequest();
698
- xhr.open('POST', '/upload', true);
699
-
700
- // Progress event
701
- xhr.upload.onprogress = function(event) {
702
- if (event.lengthComputable) {
703
- const percentComplete = (event.loaded / event.total) * 100;
704
- progressBarInner.style.width = percentComplete + '%';
705
- uploadStatusDiv.textContent = `Загрузка... ${Math.round(percentComplete)}%`;
706
- }
707
- };
708
-
709
- // Upload finished event
710
- xhr.onload = function() {
711
- progressBar.style.display = 'none'; // Hide progress bar on completion
712
- if (xhr.status >= 200 && xhr.status < 300) {
713
- const data = JSON.parse(xhr.responseText);
714
- uploadStatusDiv.textContent = data.message || 'Загрузка успешно завершена!';
715
- uploadStatusDiv.style.color = 'green';
716
- // Clear selection and refresh file list
717
- fileInput.value = ''; // Reset file input
718
- currentFiles = [];
719
- selectedFilesDiv.textContent = 'Файлы не выбраны';
720
- fetchFiles(); // Refresh list
721
- } else {
722
- // Handle error
723
- let errorMessage = `Ошибка загрузки (Статус: ${xhr.status})`;
724
- try {
725
- const errorData = JSON.parse(xhr.responseText);
726
- errorMessage = errorData.message || errorMessage;
727
- } catch (e) {
728
- // Use default message if response is not JSON
729
- }
730
- throw new Error(errorMessage);
731
- }
732
- };
733
-
734
- // Upload error event
735
- xhr.onerror = function() {
736
- progressBar.style.display = 'none';
737
- throw new Error('Сетевая ошибка при загрузке.');
738
- };
739
-
740
- xhr.send(formData);
741
-
742
- } catch (error) {
743
- console.error('Upload error:', error);
744
- uploadStatusDiv.textContent = `Ошибка: ${error.message}`;
745
- uploadStatusDiv.style.color = 'red';
746
- // Re-enable button on error
747
- uploadButton.classList.add('enabled');
748
- uploadButton.disabled = false;
749
- progressBar.style.display = 'none';
750
- }
751
  }
752
 
753
- // --- Viewer Modal Logic ---
754
  function openViewer(file) {
755
  modal.style.display = 'block';
756
- modalContent.innerHTML = ''; // Clear previous content
757
  modalCaption.textContent = file.filename;
758
  const mimeType = file.content_type || '';
759
  const downloadUrl = `/download/${encodeURIComponent(file.filename)}?initData=${encodeURIComponent(userInitData)}`;
760
-
761
  let element;
762
  if (mimeType.startsWith('image/')) {
763
  element = document.createElement('img');
@@ -767,15 +646,14 @@ USER_TEMPLATE = """
767
  element = document.createElement('video');
768
  element.src = downloadUrl;
769
  element.controls = true;
770
- element.autoplay = true; // Optional: start playing
771
  } else if (mimeType.startsWith('audio/')) {
772
  element = document.createElement('audio');
773
  element.src = downloadUrl;
774
  element.controls = true;
775
- element.autoplay = true; // Optional: start playing
776
- element.style.padding = '20px'; // Add some padding for controls
777
  }
778
-
779
  if (element) {
780
  modalContent.appendChild(element);
781
  if (tg.HapticFeedback) {
@@ -788,43 +666,33 @@ USER_TEMPLATE = """
788
 
789
  function closeViewer() {
790
  modal.style.display = 'none';
791
- // Important: Stop video/audio playback when closing
792
  const mediaElement = modalContent.querySelector('video, audio');
793
  if (mediaElement) {
794
  mediaElement.pause();
795
- mediaElement.src = ''; // Detach source
796
  }
797
- modalContent.innerHTML = ''; // Clear content
798
  }
799
 
800
- // Close modal listeners
801
  modalCloseBtn.onclick = closeViewer;
802
  modal.onclick = function(event) {
803
- // Close if clicking on the background, not the content itself
804
  if (event.target === modal) {
805
  closeViewer();
806
  }
807
  };
808
- // --- End Viewer Modal Logic ---
809
-
810
 
811
  function setupTelegram() {
812
  if (!tg || !tg.initData) {
813
  console.error("Telegram WebApp script not loaded or initData is missing.");
814
  userGreeting.textContent = 'Ошибка: Не удалось инициализировать Telegram.';
815
- document.body.style.visibility = 'visible'; // Show body anyway
816
  return;
817
  }
818
-
819
  tg.ready();
820
  tg.expand();
821
-
822
  applyTheme(tg.themeParams);
823
  tg.onEvent('themeChanged', () => applyTheme(tg.themeParams));
824
-
825
  userInitData = tg.initData;
826
-
827
- // Display user greeting
828
  const user = tg.initDataUnsafe?.user;
829
  if (user) {
830
  const name = user.first_name || user.username || 'Пользователь';
@@ -832,25 +700,17 @@ USER_TEMPLATE = """
832
  } else {
833
  userGreeting.textContent = 'Привет!';
834
  }
835
-
836
- // Initial fetch of files
837
  fetchFiles();
838
-
839
- // Add event listeners
840
  fileInput.addEventListener('change', handleFileSelection);
841
  uploadButton.addEventListener('click', handleUpload);
842
-
843
  document.body.style.visibility = 'visible';
844
  }
845
 
846
-
847
- // --- Initialization ---
848
  if (window.Telegram && window.Telegram.WebApp) {
849
  setupTelegram();
850
  } else {
851
  console.warn("Telegram WebApp script not immediately available, waiting for window.onload");
852
  window.addEventListener('load', setupTelegram);
853
- // Fallback timeout
854
  setTimeout(() => {
855
  if (document.body.style.visibility !== 'visible') {
856
  console.error("Telegram WebApp script fallback timeout triggered.");
@@ -859,13 +719,11 @@ USER_TEMPLATE = """
859
  }
860
  }, 3500);
861
  }
862
-
863
  </script>
864
  </body>
865
  </html>
866
  """
867
 
868
- # Admin Panel Template
869
  ADMIN_TEMPLATE = """
870
  <!DOCTYPE html>
871
  <html lang="ru">
@@ -909,7 +767,7 @@ ADMIN_TEMPLATE = """
909
  }
910
  .view-files-btn:hover { background-color: #0b5ed7; }
911
  .no-users { text-align: center; color: var(--admin-secondary); margin-top: 2rem; font-size: 1.1em; }
912
- .admin-controls { /* Style as needed, similar to original */
913
  background: var(--admin-card-bg); padding: var(--padding); border-radius: var(--border-radius);
914
  box-shadow: 0 4px 15px var(--admin-shadow); border: 1px solid var(--admin-border);
915
  margin-bottom: var(--padding); text-align: center;
@@ -927,14 +785,12 @@ ADMIN_TEMPLATE = """
927
  <div class="container">
928
  <h1>Zeus Cloud - Админ Панель</h1>
929
  <div class="alert">ВНИМАНИЕ: Этот раздел не защищен! Добавьте аутентификацию для реального использования.</div>
930
-
931
  <div class="admin-controls">
932
  <h2>Управление метаданными</h2>
933
  <button class="btn btn-refresh-meta" onclick="triggerDownloadMeta()">Скачать data.json с HF</button>
934
  <div class="loader" id="loader"></div>
935
  <div class="status" id="status-message"></div>
936
  </div>
937
-
938
  {% if users %}
939
  <div class="user-grid">
940
  {% for user_id, data in users.items() %}
@@ -967,7 +823,6 @@ ADMIN_TEMPLATE = """
967
  <script>
968
  const loader = document.getElementById('loader');
969
  const statusMessage = document.getElementById('status-message');
970
-
971
  async function handleFetch(url, action) {
972
  loader.style.display = 'inline-block';
973
  statusMessage.textContent = `Выполняется ${action}...`;
@@ -977,16 +832,16 @@ ADMIN_TEMPLATE = """
977
  const data = await response.json();
978
  if (response.ok && data.status === 'ok') {
979
  statusMessage.textContent = data.message;
980
- statusMessage.style.color = '#198754'; // Success color
981
  if (action === 'скачивание метаданных') {
982
- setTimeout(() => location.reload(), 1500); // Reload after download success
983
  }
984
  } else {
985
  throw new Error(data.message || 'Произошла ошибка');
986
  }
987
  } catch (error) {
988
  statusMessage.textContent = `Ошибка ${action}: ${error.message}`;
989
- statusMessage.style.color = '#dc3545'; // Danger color
990
  console.error(`Error during ${action}:`, error);
991
  } finally {
992
  loader.style.display = 'none';
@@ -1011,7 +866,7 @@ ADMIN_USER_FILES_TEMPLATE = """
1011
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1012
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
1013
  <style>
1014
- :root { /* Use same vars as admin */
1015
  --admin-bg: #f8f9fa; --admin-text: #212529; --admin-card-bg: #ffffff;
1016
  --admin-border: #dee2e6; --admin-shadow: rgba(0, 0, 0, 0.05);
1017
  --admin-primary: #0d6efd; --admin-secondary: #6c757d;
@@ -1037,7 +892,6 @@ ADMIN_USER_FILES_TEMPLATE = """
1037
  }
1038
  .actions a:hover { background-color: #0b5ed7; }
1039
  .no-files { text-align: center; color: var(--admin-secondary); margin-top: 2rem; font-size: 1.1em; }
1040
- /* Responsive table */
1041
  @media screen and (max-width: 768px) {
1042
  .file-table { border: 0; box-shadow: none; }
1043
  .file-table thead { display: none; }
@@ -1049,7 +903,7 @@ ADMIN_USER_FILES_TEMPLATE = """
1049
  font-size: 0.9em; text-transform: uppercase; color: var(--admin-secondary);
1050
  }
1051
  .file-table td:last-child { border-bottom: 0; }
1052
- .actions { text-align: right !important; } /* Force right align actions on mobile */
1053
  }
1054
  </style>
1055
  </head>
@@ -1058,7 +912,6 @@ ADMIN_USER_FILES_TEMPLATE = """
1058
  <a href="{{ url_for('admin_panel') }}" class="back-link">← Назад к списку пользователей</a>
1059
  <h1>Файлы пользователя</h1>
1060
  <div class="user-identifier">{{ user_info.first_name or '' }} {{ user_info.last_name or '' }} (ID: {{ user_id }})</div>
1061
-
1062
  {% if files %}
1063
  <table class="file-table">
1064
  <thead>
@@ -1089,7 +942,6 @@ ADMIN_USER_FILES_TEMPLATE = """
1089
  {% endif %}
1090
  </div>
1091
  <script>
1092
- // Simple filesize formatter (alternative to Jinja filter if needed)
1093
  function formatBytes(bytes, decimals = 2) {
1094
  if (!+bytes) return '0 Bytes'
1095
  const k = 1024
@@ -1098,19 +950,11 @@ ADMIN_USER_FILES_TEMPLATE = """
1098
  const i = Math.floor(Math.log(bytes) / Math.log(k))
1099
  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`
1100
  }
1101
- // Example usage: Add this if not using Jinja filter
1102
- // document.querySelectorAll('.filesize').forEach(el => {
1103
- // const bytes = parseInt(el.getAttribute('data-bytes'), 10);
1104
- // if (!isNaN(bytes)) {
1105
- // el.textContent = formatBytes(bytes);
1106
- // }
1107
- // });
1108
  </script>
1109
  </body>
1110
  </html>
1111
  """
1112
 
1113
- # --- Jinja Filters ---
1114
  @app.template_filter('filesizeformat')
1115
  def filesizeformat(value):
1116
  try:
@@ -1122,17 +966,11 @@ def filesizeformat(value):
1122
  return f"{bytes_val / math.pow(k, i):.2f} {sizes[i]}"
1123
  except (ValueError, TypeError):
1124
  return value
1125
- except Exception: # Catch log(0) or other math errors
1126
  return 'N/A'
1127
 
1128
- import math # Needs import for the filter
1129
-
1130
- # --- Flask Routes ---
1131
-
1132
  @app.route('/')
1133
  def index():
1134
- # Render the main user interface
1135
- # Theme params will be applied by JS after tg.ready()
1136
  return render_template_string(USER_TEMPLATE, theme={}, max_files=MAX_UPLOAD_FILES)
1137
 
1138
  @app.route('/files', methods=['POST'])
@@ -1141,16 +979,13 @@ def get_user_files():
1141
  init_data_str = req_data.get('initData')
1142
  if not init_data_str:
1143
  return jsonify({"status": "error", "message": "Missing initData"}), 400
1144
-
1145
  user_info, message = authenticate_and_get_user(init_data_str)
1146
  if not user_info:
1147
  return jsonify({"status": "error", "message": message}), 403
1148
-
1149
  user_id_str = str(user_info['id'])
1150
  with _data_lock:
1151
  user_data = metadata_cache.get(user_id_str, {})
1152
  files = user_data.get('files', [])
1153
-
1154
  return jsonify({"status": "ok", "files": files}), 200
1155
 
1156
  @app.route('/upload', methods=['POST'])
@@ -1158,41 +993,32 @@ def upload_files():
1158
  init_data_str = request.form.get('initData')
1159
  if not init_data_str:
1160
  return jsonify({"status": "error", "message": "Missing initData"}), 400
1161
-
1162
  user_info, message = authenticate_and_get_user(init_data_str)
1163
  if not user_info:
1164
  return jsonify({"status": "error", "message": message}), 403
1165
-
1166
  user_id = user_info['id']
1167
  user_id_str = str(user_id)
1168
-
1169
- uploaded_files = request.files.getlist('files') # Key matches JS FormData
1170
  if not uploaded_files or len(uploaded_files) == 0:
1171
  return jsonify({"status": "error", "message": "No files selected for upload."}), 400
1172
  if len(uploaded_files) > MAX_UPLOAD_FILES:
1173
  return jsonify({"status": "error", "message": f"Cannot upload more than {MAX_UPLOAD_FILES} files at once."}), 400
1174
-
1175
  api = get_hf_api(write=True)
1176
  if not api:
1177
  return jsonify({"status": "error", "message": "Server error: Cannot connect to storage."}), 500
1178
-
1179
  successful_uploads_metadata = []
1180
  errors = []
1181
-
1182
  for file_storage in uploaded_files:
1183
  filename = file_storage.filename
1184
  if not filename:
1185
  errors.append("Received a file without a name.")
1186
  continue
1187
-
1188
  path_in_repo = f"{HF_UPLOAD_FOLDER}/{user_id_str}/{filename}"
1189
- file_content = file_storage.read() # Read content into memory
1190
  file_size = len(file_content)
1191
  content_type, _ = mimetypes.guess_type(filename)
1192
-
1193
  try:
1194
  logging.info(f"Uploading '{filename}' for user {user_id} to {path_in_repo}...")
1195
- # Use upload_file with path_or_fileobj=BytesIO(file_content)
1196
  file_obj = io.BytesIO(file_content)
1197
  api.upload_file(
1198
  path_or_fileobj=file_obj,
@@ -1200,7 +1026,6 @@ def upload_files():
1200
  repo_id=REPO_ID,
1201
  repo_type="dataset",
1202
  commit_message=f"User {user_id} uploaded {filename}"
1203
- # Consider adding run_as_future=True for concurrency if needed, but handle results
1204
  )
1205
  logging.info(f"Successfully uploaded '{filename}' for user {user_id}.")
1206
  now = time.time()
@@ -1214,81 +1039,59 @@ def upload_files():
1214
  })
1215
  except Exception as e:
1216
  logging.error(f"Failed to upload '{filename}' for user {user_id}: {e}", exc_info=True)
1217
- errors.append(f"Ошибка загрузки {filename}: {e}")
1218
-
1219
- # Update metadata only if there were successful uploads
1220
  if successful_uploads_metadata:
1221
  if not update_user_file_metadata(user_id, successful_uploads_metadata):
1222
- # Add metadata update errors to the list shown to the user
1223
  errors.append("Ошибка обновления списка файлов после загрузки.")
1224
-
1225
  if not errors:
1226
  return jsonify({"status": "ok", "message": f"Загружено {len(successful_uploads_metadata)} файл(ов)."}), 200
1227
  else:
1228
- # Return partial success/error message
1229
  return jsonify({
1230
  "status": "error" if not successful_uploads_metadata else "partial_success",
1231
  "message": f"Загружено {len(successful_uploads_metadata)} из {len(uploaded_files)}. Ошибки: {'; '.join(errors)}",
1232
  "uploaded_files": [f['filename'] for f in successful_uploads_metadata],
1233
  "errors": errors
1234
- }), 207 # Multi-Status or choose appropriate code
1235
-
1236
 
1237
  @app.route('/download/<path:filename>', methods=['GET'])
1238
  def download_file(filename):
1239
  init_data_str = request.args.get('initData')
1240
  if not init_data_str:
1241
  return "Authentication required.", 401
1242
-
1243
  user_info, message = authenticate_and_get_user(init_data_str)
1244
  if not user_info:
1245
  return f"Access denied: {message}", 403
1246
-
1247
  user_id = user_info['id']
1248
  user_id_str = str(user_id)
1249
-
1250
- # Check if user actually owns this file according to metadata
1251
  with _data_lock:
1252
  user_data = metadata_cache.get(user_id_str, {})
1253
  user_files = user_data.get('files', [])
1254
  file_metadata = next((f for f in user_files if f['filename'] == filename), None)
1255
-
1256
  if not file_metadata:
1257
  logging.warning(f"User {user_id} attempted to download unlisted/unowned file: {filename}")
1258
  return "File not found or access denied.", 404
1259
-
1260
  api = get_hf_api(write=False)
1261
  if not api:
1262
  return "Server error: Cannot connect to storage.", 500
1263
-
1264
  path_in_repo = file_metadata.get('hf_path', f"{HF_UPLOAD_FOLDER}/{user_id_str}/{filename}")
1265
-
1266
  try:
1267
  logging.info(f"User {user_id} requesting download of {path_in_repo}")
1268
- # Download the file from HF Hub to the server's cache
1269
  local_file_path = hf_hub_download(
1270
  repo_id=REPO_ID,
1271
  filename=path_in_repo,
1272
  repo_type="dataset",
1273
  token=api.token,
1274
- # local_dir=".", # Let it use the default HF cache
1275
- # local_dir_use_symlinks=False,
1276
- force_download=False, # Use cache if available
1277
  etag_timeout=10
1278
  )
1279
  logging.info(f"File {path_in_repo} downloaded to cache: {local_file_path}")
1280
-
1281
- # Send the file from the cache path
1282
- # Guess mimetype again for safety, or use stored one
1283
  content_type = file_metadata.get('content_type') or mimetypes.guess_type(filename)[0] or 'application/octet-stream'
1284
-
1285
  return send_file(
1286
  local_file_path,
1287
  mimetype=content_type,
1288
- as_attachment=False, # Try to display inline first for media
1289
- download_name=filename # Original filename for download prompt
1290
  )
1291
-
1292
  except EntryNotFoundError:
1293
  logging.error(f"File not found on Hugging Face: {path_in_repo}")
1294
  return "File not found on storage.", 404
@@ -1299,13 +1102,9 @@ def download_file(filename):
1299
  logging.error(f"Error downloading file {path_in_repo} for user {user_id}: {e}", exc_info=True)
1300
  return "Server error during download.", 500
1301
 
1302
-
1303
- # --- Admin Routes ---
1304
- # WARNING: These routes are unprotected! Add proper authentication.
1305
-
1306
  @app.route('/admin')
1307
  def admin_panel():
1308
- current_data = load_local_metadata() # Load latest from cache/local file
1309
  return render_template_string(ADMIN_TEMPLATE, users=current_data)
1310
 
1311
  @app.route('/admin/user/<user_id>')
@@ -1314,10 +1113,8 @@ def admin_user_files(user_id):
1314
  user_data = current_data.get(str(user_id))
1315
  if not user_data:
1316
  return "User not found", 404
1317
-
1318
- user_info = user_data.get("user_info", {"id": user_id}) # Basic info for display
1319
  files = user_data.get("files", [])
1320
-
1321
  return render_template_string(ADMIN_USER_FILES_TEMPLATE,
1322
  user_id=user_id,
1323
  user_info=user_info,
@@ -1325,23 +1122,18 @@ def admin_user_files(user_id):
1325
 
1326
  @app.route('/admin/download/<user_id>/<path:filename>', methods=['GET'])
1327
  def admin_download_file(user_id, filename):
1328
- # WARNING: Add admin authentication check here!
1329
  user_id_str = str(user_id)
1330
  logging.info(f"Admin request to download file '{filename}' for user {user_id}")
1331
-
1332
  api = get_hf_api(write=False)
1333
  if not api:
1334
  return "Server error: Cannot connect to storage.", 500
1335
-
1336
- # Find the file path from metadata if possible for accuracy
1337
- path_in_repo = f"{HF_UPLOAD_FOLDER}/{user_id_str}/{filename}" # Default assumption
1338
  with _data_lock:
1339
  user_data = metadata_cache.get(user_id_str, {})
1340
  user_files = user_data.get('files', [])
1341
  file_metadata = next((f for f in user_files if f['filename'] == filename), None)
1342
  if file_metadata and 'hf_path' in file_metadata:
1343
  path_in_repo = file_metadata['hf_path']
1344
-
1345
  try:
1346
  local_file_path = hf_hub_download(
1347
  repo_id=REPO_ID,
@@ -1352,15 +1144,13 @@ def admin_download_file(user_id, filename):
1352
  etag_timeout=10
1353
  )
1354
  logging.info(f"Admin download: File {path_in_repo} cached at {local_file_path}")
1355
-
1356
  content_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
1357
  if file_metadata and 'content_type' in file_metadata:
1358
  content_type = file_metadata['content_type'] or content_type
1359
-
1360
  return send_file(
1361
  local_file_path,
1362
  mimetype=content_type,
1363
- as_attachment=True, # Force download for admin
1364
  download_name=filename
1365
  )
1366
  except EntryNotFoundError:
@@ -1372,16 +1162,12 @@ def admin_download_file(user_id, filename):
1372
 
1373
  @app.route('/admin/download_metadata', methods=['POST'])
1374
  def admin_trigger_download_metadata():
1375
- # WARNING: Unprotected endpoint
1376
  success = download_metadata_from_hf()
1377
  if success:
1378
  return jsonify({"status": "ok", "message": "Скачивание data.json с Hugging Face завершено. Обновите страницу."})
1379
  else:
1380
  return jsonify({"status": "error", "message": "Ошибка скачивания data.json. Проверьте логи."}), 500
1381
 
1382
- # Removed admin upload metadata trigger - upload happens automatically on save
1383
-
1384
- # --- App Initialization ---
1385
  if __name__ == '__main__':
1386
  print("---")
1387
  print("--- ZEUS CLOUD MINI APP SERVER ---")
@@ -1392,7 +1178,6 @@ if __name__ == '__main__':
1392
  print(f"Hugging Face Repo: {REPO_ID}")
1393
  print(f"HF Metadata Path: {HF_DATA_FILE_PATH}")
1394
  print(f"HF Upload Folder: {HF_UPLOAD_FOLDER}/<user_id>/")
1395
-
1396
  if not HF_TOKEN_READ or not HF_TOKEN_WRITE:
1397
  print("---")
1398
  print("--- WARNING: HUGGING FACE TOKEN(S) NOT SET ---")
@@ -1401,20 +1186,13 @@ if __name__ == '__main__':
1401
  else:
1402
  print("--- Hugging Face tokens found.")
1403
  print("--- Attempting initial metadata download from Hugging Face...")
1404
- download_metadata_from_hf() # Attempt to get latest metadata on startup
1405
-
1406
- # Load initial data from local file (might have been updated by download)
1407
  load_local_metadata()
1408
  print(f"--- Initial metadata cache loaded with {len(metadata_cache)} user(s).")
1409
-
1410
  print("---")
1411
  print("--- SECURITY WARNING ---")
1412
  print("--- The /admin routes are NOT protected by authentication.")
1413
  print("--- Implement proper auth before any production deployment.")
1414
  print("---")
1415
  print("--- Server Ready ---")
1416
-
1417
- # Use Waitress or Gunicorn for production
1418
- # from waitress import serve
1419
- # serve(app, host=HOST, port=PORT)
1420
- app.run(host=HOST, port=PORT, debug=False, threaded=True) # threaded=True useful for async tasks like HF uploads
 
16
  from huggingface_hub.utils import RepositoryNotFoundError, EntryNotFoundError
17
  import mimetypes
18
  import io
19
+ import math
20
 
21
+ BOT_TOKEN = os.getenv("BOT_TOKEN", "6750208873:AAE2hvPlJ99dBdhGa_Brre0IIpUdOvXxHt4")
 
22
  HOST = '0.0.0.0'
23
  PORT = 7860
24
+ DATA_FILE = 'data.json'
25
 
26
+ REPO_ID = os.getenv("HF_REPO_ID", "Eluza133/Z1e1u")
27
+ HF_DATA_FILE_PATH = "data.json"
28
+ HF_UPLOAD_FOLDER = "uploads"
29
+ HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE")
30
+ HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
 
31
 
 
32
  MAX_UPLOAD_FILES = 20
33
+ AUTH_TIMEOUT = 86400
34
 
35
  app = Flask(__name__)
36
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
37
+ app.secret_key = os.urandom(24)
38
 
39
+ _data_lock = threading.RLock()
40
+ metadata_cache = {}
 
41
 
42
  def get_hf_api(write=False):
43
  token = HF_TOKEN_WRITE if write else HF_TOKEN_READ
 
73
  except (FileNotFoundError, json.JSONDecodeError) as e:
74
  logging.error(f"Error reading downloaded metadata file: {e}. Resetting cache.")
75
  metadata_cache = {}
 
 
 
76
  return True
77
  except EntryNotFoundError:
78
  logging.warning(f"Metadata file '{HF_DATA_FILE_PATH}' not found in repo '{REPO_ID}'. Starting fresh.")
79
  with _data_lock:
80
  metadata_cache = {}
81
+ return True
82
  except RepositoryNotFoundError:
83
  logging.error(f"Hugging Face repository '{REPO_ID}' not found. Cannot download metadata.")
84
  except Exception as e:
 
88
  def load_local_metadata():
89
  global metadata_cache
90
  with _data_lock:
91
+ if not metadata_cache:
92
  try:
93
  with open(DATA_FILE, 'r', encoding='utf-8') as f:
94
  metadata_cache = json.load(f)
 
109
  with _data_lock:
110
  try:
111
  if data_to_update:
 
 
112
  metadata_cache.update(data_to_update)
 
 
113
  with open(DATA_FILE, 'w', encoding='utf-8') as f:
114
  json.dump(metadata_cache, f, ensure_ascii=False, indent=4)
115
  logging.info(f"Metadata successfully saved locally to {DATA_FILE}.")
 
 
116
  upload_metadata_to_hf_async()
117
  return True
118
  except Exception as e:
 
124
  with _data_lock:
125
  if user_id_str not in metadata_cache:
126
  metadata_cache[user_id_str] = {"user_info": {}, "files": []}
 
127
  if "files" not in metadata_cache[user_id_str]:
128
  metadata_cache[user_id_str]["files"] = []
 
 
129
  existing_filenames = {f['filename'] for f in metadata_cache[user_id_str]["files"]}
130
  new_files_added = 0
131
  for file_info in file_info_list:
 
134
  existing_filenames.add(file_info['filename'])
135
  new_files_added += 1
136
  else:
 
 
137
  logging.warning(f"File '{file_info['filename']}' already exists for user {user_id}. Skipping add.")
 
 
 
 
 
 
138
  if new_files_added > 0:
139
  logging.info(f"Added {new_files_added} file metadata entries for user {user_id}.")
 
140
  if not save_metadata():
141
+ return False
142
  else:
143
  logging.info(f"No new file metadata added for user {user_id}.")
144
+ return True
 
145
 
146
  def _upload_metadata_to_hf_task():
147
  api = get_hf_api(write=True)
 
151
  if not os.path.exists(DATA_FILE):
152
  logging.warning(f"{DATA_FILE} does not exist locally. Skipping upload.")
153
  return
 
154
  try:
 
155
  with _data_lock:
156
  if os.path.getsize(DATA_FILE) == 0:
157
  logging.warning(f"{DATA_FILE} is empty. Skipping upload.")
 
 
158
  return
159
+ file_to_upload = DATA_FILE
 
160
  logging.info(f"Attempting to upload {file_to_upload} to {REPO_ID}/{HF_DATA_FILE_PATH}...")
161
  api.upload_file(
162
  path_or_fileobj=file_to_upload,
 
173
  upload_thread = threading.Thread(target=_upload_metadata_to_hf_task, daemon=True)
174
  upload_thread.start()
175
 
 
176
  def verify_telegram_data(init_data_str):
177
  try:
178
  parsed_data = parse_qs(init_data_str)
179
  received_hash = parsed_data.pop('hash', [None])[0]
 
180
  if not received_hash:
181
  logging.warning("Verification failed: Hash missing from initData.")
182
  return None, False, "Hash missing"
 
183
  data_check_list = []
 
184
  for key, value in sorted(parsed_data.items()):
 
185
  data_check_list.append(f"{key}={value[0]}")
186
  data_check_string = "\n".join(data_check_list)
 
 
187
  secret_key = hmac.new("WebAppData".encode(), BOT_TOKEN.encode(), hashlib.sha256).digest()
 
188
  calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
 
 
189
  if calculated_hash != received_hash:
190
  logging.warning(f"Verification failed: Hash mismatch. Calculated: {calculated_hash}, Received: {received_hash}")
191
  return parsed_data, False, "Invalid hash"
 
 
192
  auth_date = int(parsed_data.get('auth_date', [0])[0])
193
  current_time = int(time.time())
194
  if current_time - auth_date > AUTH_TIMEOUT:
195
  logging.warning(f"Verification failed: initData expired. Auth time: {auth_date}, Current time: {current_time}")
196
  return parsed_data, False, "Data expired"
 
 
197
  user_info_dict = None
198
  if 'user' in parsed_data:
199
  try:
 
201
  user_info_dict = json.loads(user_json_str)
202
  except Exception as e:
203
  logging.error(f"Could not parse user JSON from initData: {e}")
 
 
204
  logging.info(f"Telegram data verified successfully for user ID: {user_info_dict.get('id') if user_info_dict else 'Unknown'}")
205
  return user_info_dict, True, "Verified"
 
206
  except Exception as e:
207
  logging.error(f"Error during Telegram data verification: {e}", exc_info=True)
208
  return None, False, "Verification exception"
209
 
 
210
  def authenticate_and_get_user(init_data_str):
211
  user_info, is_valid, message = verify_telegram_data(init_data_str)
212
  if not is_valid:
213
  return None, message
 
214
  user_id = user_info.get('id') if user_info else None
215
  if not user_id:
216
  logging.warning("Verification successful but user ID is missing in user data.")
217
  return None, "User ID missing"
 
218
  user_id_str = str(user_id)
 
 
219
  with _data_lock:
220
  should_save = False
221
  if user_id_str not in metadata_cache:
 
226
  logging.info(f"New user registered: {user_id}")
227
  should_save = True
228
  else:
 
 
 
229
  if "user_info" not in metadata_cache[user_id_str] or metadata_cache[user_id_str]["user_info"] != user_info:
230
  metadata_cache[user_id_str]["user_info"] = user_info
231
+ should_save = True
 
 
 
232
  if should_save:
 
233
  if not save_metadata():
 
234
  logging.error(f"Failed to save metadata after updating/adding user {user_id}")
 
 
235
  return user_info, "Authenticated"
236
 
 
 
 
 
237
  USER_TEMPLATE = """
238
  <!DOCTYPE html>
239
  <html lang="ru">
 
268
  background-color: var(--tg-theme-bg-color);
269
  color: var(--tg-theme-text-color);
270
  padding: var(--padding);
271
+ padding-bottom: 100px;
272
+ visibility: hidden;
273
  line-height: 1.5;
274
  }
275
  .container { max-width: 700px; margin: 0 auto; display: flex; flex-direction: column; gap: var(--padding); }
 
305
  border: none; border-radius: var(--border-radius);
306
  font-size: 1em; font-weight: 600;
307
  cursor: pointer; transition: background-color 0.2s ease;
308
+ opacity: 0.5;
309
+ pointer-events: none;
310
  }
311
  #upload-button.enabled { opacity: 1; pointer-events: auto; }
312
  #upload-status { margin-top: 12px; font-size: 0.9em; color: var(--tg-theme-hint-color); min-height: 1.2em; }
 
340
  }
341
  .file-actions a:hover, .file-actions button:hover { opacity: 0.8; }
342
  .download-btn { background-color: var(--tg-theme-button-color); color: var(--tg-theme-button-text-color); }
343
+ .delete-btn { background-color: #dc3545; color: white; }
 
344
  .loading, .no-files { text-align: center; padding: 20px; color: var(--tg-theme-hint-color); }
345
  .progress-bar { width: 100%; background-color: #ddd; border-radius: 4px; height: 8px; margin-top: 5px; display: none; }
346
  .progress-bar-inner { height: 100%; width: 0%; background-color: var(--tg-theme-button-color); border-radius: 4px; transition: width 0.1s linear; }
 
 
347
  .modal {
348
  display: none; position: fixed; z-index: 1001;
349
  left: 0; top: 0; width: 100%; height: 100%;
 
358
  }
359
  .modal-content img, .modal-content video, .modal-content audio {
360
  display: block; width: auto; height: auto; max-width: 100%; max-height: 100%; margin: auto;
361
+ background-color: var(--tg-theme-bg-color);
362
  }
363
  .modal-close {
364
  position: absolute; top: 15px; right: 35px; color: #f1f1f1; font-size: 40px;
 
371
  color: #ccc; padding: 10px 0; height: 50px; position: absolute; bottom: 15px; left: 50%; transform: translateX(-50%);
372
  background: rgba(0,0,0,0.5); border-radius: 8px;
373
  }
 
374
  .spinner {
375
  border: 4px solid rgba(255, 255, 255, 0.3);
376
  border-radius: 50%;
 
379
  height: 40px;
380
  animation: spin 1s linear infinite;
381
  margin: 20px auto;
382
+ display: none;
383
  }
384
  @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
 
385
  </style>
386
  </head>
387
  <body>
 
390
  <h1>Zeus Cloud</h1>
391
  <div class="user-info" id="user-greeting">Загрузка...</div>
392
  </header>
 
393
  <section class="upload-section">
394
  <h2>Загрузить файлы</h2>
395
  <label for="file-input" class="file-input-label">Выбрать файлы (до {{ max_files }})</label>
 
399
  <div class="progress-bar" id="progress-bar"><div class="progress-bar-inner" id="progress-bar-inner"></div></div>
400
  <div id="upload-status"></div>
401
  </section>
 
402
  <section class="file-list-section">
403
  <h2>Ваши файлы</h2>
404
  <div class="spinner" id="loading-spinner"></div>
 
407
  </ul>
408
  </section>
409
  </div>
 
 
410
  <div id="viewerModal" class="modal">
411
  <span class="modal-close" id="modalCloseBtn">×</span>
412
  <div id="modalContent" class="modal-content">
 
413
  </div>
414
  <div id="modalCaption" class="modal-caption"></div>
415
  </div>
 
416
  <script>
417
  const tg = window.Telegram.WebApp;
418
  const MAX_FILES = {{ max_files }};
419
+ let currentFiles = [];
420
  let userInitData = '';
 
 
421
  const fileInput = document.getElementById('file-input');
422
  const uploadButton = document.getElementById('upload-button');
423
  const selectedFilesDiv = document.getElementById('selected-files');
 
428
  const loadingSpinner = document.getElementById('loading-spinner');
429
  const progressBar = document.getElementById('progress-bar');
430
  const progressBarInner = document.getElementById('progress-bar-inner');
 
 
431
  const modal = document.getElementById('viewerModal');
432
  const modalContent = document.getElementById('modalContent');
433
  const modalCaption = document.getElementById('modalCaption');
 
439
  const cssVar = `--tg-theme-${key.replace(/_/g, '-')}`;
440
  root.style.setProperty(cssVar, themeParams[key]);
441
  });
 
442
  const defaults = {
443
  'bg_color': '#181818', 'text_color': '#ffffff', 'hint_color': '#aaaaaa',
444
  'link_color': '#62bcf9', 'button_color': '#31a5f5',
 
462
  }
463
 
464
  function displayFiles(files) {
465
+ fileListUl.innerHTML = '';
466
  loadingSpinner.style.display = 'none';
 
467
  if (!files || files.length === 0) {
468
  noFilesMessage.style.display = 'block';
469
  return;
470
  }
471
  noFilesMessage.style.display = 'none';
472
+ files.sort((a, b) => b.uploaded_at_ts - a.uploaded_at_ts);
 
 
473
  files.forEach(file => {
474
  const li = document.createElement('li');
475
  li.classList.add('file-item');
 
476
  const fileInfoDiv = document.createElement('div');
477
  fileInfoDiv.classList.add('file-info');
 
478
  const fileNameSpan = document.createElement('span');
479
  fileNameSpan.classList.add('file-name');
480
  fileNameSpan.textContent = file.filename;
481
  fileInfoDiv.appendChild(fileNameSpan);
 
482
  const fileMetaSpan = document.createElement('span');
483
  fileMetaSpan.classList.add('file-meta');
484
  const date = new Date(file.uploaded_at_ts * 1000).toLocaleString();
485
  const size = file.size ? formatBytes(file.size) : '';
486
  fileMetaSpan.textContent = `${date}${size ? ' - ' + size : ''}`;
487
  fileInfoDiv.appendChild(fileMetaSpan);
 
488
  const fileActionsDiv = document.createElement('div');
489
  fileActionsDiv.classList.add('file-actions');
 
 
490
  const mimeType = file.content_type || '';
491
  if (mimeType.startsWith('image/') || mimeType.startsWith('video/') || mimeType.startsWith('audio/')) {
492
  const viewButton = document.createElement('button');
493
+ viewButton.textContent = '👁️';
494
+ viewButton.classList.add('view-btn');
495
+ viewButton.style.backgroundColor = '#6f42c1';
496
  viewButton.style.color = 'white';
497
  viewButton.title = 'Просмотр';
498
  viewButton.onclick = (e) => {
499
+ e.stopPropagation();
500
  openViewer(file);
501
  };
502
  fileActionsDiv.appendChild(viewButton);
503
  }
 
 
504
  const downloadLink = document.createElement('a');
505
  downloadLink.classList.add('download-btn');
506
  downloadLink.textContent = 'Скачать';
507
  downloadLink.href = `/download/${encodeURIComponent(file.filename)}?initData=${encodeURIComponent(userInitData)}`;
508
+ downloadLink.target = '_blank';
509
  downloadLink.title = 'Скачать файл';
 
510
  downloadLink.onclick = (e) => e.stopPropagation();
 
511
  fileActionsDiv.appendChild(downloadLink);
 
 
 
 
 
 
 
 
 
 
 
 
 
512
  li.appendChild(fileInfoDiv);
513
  li.appendChild(fileActionsDiv);
514
  fileListUl.appendChild(li);
 
536
  uploadStatusDiv.textContent = `Ошибка загрузки списка файлов: ${error.message}`;
537
  uploadStatusDiv.style.color = 'red';
538
  loadingSpinner.style.display = 'none';
539
+ noFilesMessage.style.display = 'block';
540
  noFilesMessage.textContent = 'Не удалось загрузить список файлов.';
541
  }
542
  }
543
 
 
544
  function handleFileSelection(event) {
545
  currentFiles = Array.from(event.target.files);
546
  if (currentFiles.length > MAX_FILES) {
547
  alert(`Вы можете выбрать не более ${MAX_FILES} файлов за раз.`);
548
+ currentFiles = currentFiles.slice(0, MAX_FILES);
 
549
  }
 
550
  if (currentFiles.length > 0) {
551
  selectedFilesDiv.textContent = `${currentFiles.length} файл(ов) выбрано: ${currentFiles.map(f => f.name).join(', ')}`;
552
  uploadButton.classList.add('enabled');
 
556
  uploadButton.classList.remove('enabled');
557
  uploadButton.disabled = true;
558
  }
 
559
  uploadStatusDiv.textContent = '';
560
  progressBar.style.display = 'none';
561
  progressBarInner.style.width = '0%';
 
566
  uploadStatusDiv.textContent = 'Выберите файлы для загрузки.';
567
  return;
568
  }
 
 
569
  uploadButton.disabled = true;
570
+ uploadButton.classList.remove('enabled');
571
  uploadStatusDiv.textContent = 'Загрузка началась...';
572
  uploadStatusDiv.style.color = 'var(--tg-theme-hint-color)';
573
  progressBar.style.display = 'block';
574
  progressBarInner.style.width = '0%';
 
575
  const formData = new FormData();
576
  currentFiles.forEach(file => {
577
+ formData.append('files', file);
578
  });
579
+ formData.append('initData', userInitData);
580
 
581
  try {
582
+ const responseText = await new Promise((resolve, reject) => {
583
+ const xhr = new XMLHttpRequest();
584
+ xhr.open('POST', '/upload', true);
585
+ xhr.upload.onprogress = function(event) {
586
+ if (event.lengthComputable) {
587
+ const percentComplete = (event.loaded / event.total) * 100;
588
+ progressBarInner.style.width = percentComplete + '%';
589
+ uploadStatusDiv.textContent = `Загрузка... ${Math.round(percentComplete)}%`;
590
+ }
591
+ };
592
+ xhr.onload = function() {
593
+ if (xhr.status >= 200 && xhr.status < 300) {
594
+ resolve(xhr.responseText);
595
+ } else {
596
+ let errorMessage = `Ошибка загрузки (Статус: ${xhr.status})`;
597
+ try {
598
+ const errorData = JSON.parse(xhr.responseText);
599
+ errorMessage = errorData.message || errorMessage;
600
+ } catch (e) { /* use default */ }
601
+ reject(new Error(errorMessage));
602
+ }
603
+ };
604
+ xhr.onerror = function() {
605
+ reject(new Error('Сетевая ошибка при загрузке.'));
606
+ };
607
+ xhr.send(formData);
608
+ });
609
+
610
+ const data = JSON.parse(responseText);
611
+ uploadStatusDiv.textContent = data.message || 'Загрузка успешно завершена!';
612
+ uploadStatusDiv.style.color = 'green';
613
+ fileInput.value = '';
614
+ currentFiles = [];
615
+ selectedFilesDiv.textContent = 'Файлы не выбраны';
616
+ fetchFiles();
617
+ } catch (error) {
618
+ console.error('Upload error:', error);
619
+ uploadStatusDiv.textContent = `Ошибка: ${error.message}`;
620
+ uploadStatusDiv.style.color = 'red';
621
+ } finally {
622
+ progressBar.style.display = 'none';
623
+ if (currentFiles.length > 0) {
624
+ uploadButton.classList.add('enabled');
625
+ uploadButton.disabled = false;
626
+ } else {
627
+ uploadButton.classList.remove('enabled');
628
+ uploadButton.disabled = true;
629
+ selectedFilesDiv.textContent = 'Файлы не выбраны';
630
+ }
631
+ }
 
 
 
 
 
632
  }
633
 
 
634
  function openViewer(file) {
635
  modal.style.display = 'block';
636
+ modalContent.innerHTML = '';
637
  modalCaption.textContent = file.filename;
638
  const mimeType = file.content_type || '';
639
  const downloadUrl = `/download/${encodeURIComponent(file.filename)}?initData=${encodeURIComponent(userInitData)}`;
 
640
  let element;
641
  if (mimeType.startsWith('image/')) {
642
  element = document.createElement('img');
 
646
  element = document.createElement('video');
647
  element.src = downloadUrl;
648
  element.controls = true;
649
+ element.autoplay = true;
650
  } else if (mimeType.startsWith('audio/')) {
651
  element = document.createElement('audio');
652
  element.src = downloadUrl;
653
  element.controls = true;
654
+ element.autoplay = true;
655
+ element.style.padding = '20px';
656
  }
 
657
  if (element) {
658
  modalContent.appendChild(element);
659
  if (tg.HapticFeedback) {
 
666
 
667
  function closeViewer() {
668
  modal.style.display = 'none';
 
669
  const mediaElement = modalContent.querySelector('video, audio');
670
  if (mediaElement) {
671
  mediaElement.pause();
672
+ mediaElement.src = '';
673
  }
674
+ modalContent.innerHTML = '';
675
  }
676
 
 
677
  modalCloseBtn.onclick = closeViewer;
678
  modal.onclick = function(event) {
 
679
  if (event.target === modal) {
680
  closeViewer();
681
  }
682
  };
 
 
683
 
684
  function setupTelegram() {
685
  if (!tg || !tg.initData) {
686
  console.error("Telegram WebApp script not loaded or initData is missing.");
687
  userGreeting.textContent = 'Ошибка: Не удалось инициализировать Telegram.';
688
+ document.body.style.visibility = 'visible';
689
  return;
690
  }
 
691
  tg.ready();
692
  tg.expand();
 
693
  applyTheme(tg.themeParams);
694
  tg.onEvent('themeChanged', () => applyTheme(tg.themeParams));
 
695
  userInitData = tg.initData;
 
 
696
  const user = tg.initDataUnsafe?.user;
697
  if (user) {
698
  const name = user.first_name || user.username || 'Пользователь';
 
700
  } else {
701
  userGreeting.textContent = 'Привет!';
702
  }
 
 
703
  fetchFiles();
 
 
704
  fileInput.addEventListener('change', handleFileSelection);
705
  uploadButton.addEventListener('click', handleUpload);
 
706
  document.body.style.visibility = 'visible';
707
  }
708
 
 
 
709
  if (window.Telegram && window.Telegram.WebApp) {
710
  setupTelegram();
711
  } else {
712
  console.warn("Telegram WebApp script not immediately available, waiting for window.onload");
713
  window.addEventListener('load', setupTelegram);
 
714
  setTimeout(() => {
715
  if (document.body.style.visibility !== 'visible') {
716
  console.error("Telegram WebApp script fallback timeout triggered.");
 
719
  }
720
  }, 3500);
721
  }
 
722
  </script>
723
  </body>
724
  </html>
725
  """
726
 
 
727
  ADMIN_TEMPLATE = """
728
  <!DOCTYPE html>
729
  <html lang="ru">
 
767
  }
768
  .view-files-btn:hover { background-color: #0b5ed7; }
769
  .no-users { text-align: center; color: var(--admin-secondary); margin-top: 2rem; font-size: 1.1em; }
770
+ .admin-controls {
771
  background: var(--admin-card-bg); padding: var(--padding); border-radius: var(--border-radius);
772
  box-shadow: 0 4px 15px var(--admin-shadow); border: 1px solid var(--admin-border);
773
  margin-bottom: var(--padding); text-align: center;
 
785
  <div class="container">
786
  <h1>Zeus Cloud - Админ Панель</h1>
787
  <div class="alert">ВНИМАНИЕ: Этот раздел не защищен! Добавьте аутентификацию для реального использования.</div>
 
788
  <div class="admin-controls">
789
  <h2>Управление метаданными</h2>
790
  <button class="btn btn-refresh-meta" onclick="triggerDownloadMeta()">Скачать data.json с HF</button>
791
  <div class="loader" id="loader"></div>
792
  <div class="status" id="status-message"></div>
793
  </div>
 
794
  {% if users %}
795
  <div class="user-grid">
796
  {% for user_id, data in users.items() %}
 
823
  <script>
824
  const loader = document.getElementById('loader');
825
  const statusMessage = document.getElementById('status-message');
 
826
  async function handleFetch(url, action) {
827
  loader.style.display = 'inline-block';
828
  statusMessage.textContent = `Выполняется ${action}...`;
 
832
  const data = await response.json();
833
  if (response.ok && data.status === 'ok') {
834
  statusMessage.textContent = data.message;
835
+ statusMessage.style.color = '#198754';
836
  if (action === 'скачивание метаданных') {
837
+ setTimeout(() => location.reload(), 1500);
838
  }
839
  } else {
840
  throw new Error(data.message || 'Произошла ошибка');
841
  }
842
  } catch (error) {
843
  statusMessage.textContent = `Ошибка ${action}: ${error.message}`;
844
+ statusMessage.style.color = '#dc3545';
845
  console.error(`Error during ${action}:`, error);
846
  } finally {
847
  loader.style.display = 'none';
 
866
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
867
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
868
  <style>
869
+ :root {
870
  --admin-bg: #f8f9fa; --admin-text: #212529; --admin-card-bg: #ffffff;
871
  --admin-border: #dee2e6; --admin-shadow: rgba(0, 0, 0, 0.05);
872
  --admin-primary: #0d6efd; --admin-secondary: #6c757d;
 
892
  }
893
  .actions a:hover { background-color: #0b5ed7; }
894
  .no-files { text-align: center; color: var(--admin-secondary); margin-top: 2rem; font-size: 1.1em; }
 
895
  @media screen and (max-width: 768px) {
896
  .file-table { border: 0; box-shadow: none; }
897
  .file-table thead { display: none; }
 
903
  font-size: 0.9em; text-transform: uppercase; color: var(--admin-secondary);
904
  }
905
  .file-table td:last-child { border-bottom: 0; }
906
+ .actions { text-align: right !important; }
907
  }
908
  </style>
909
  </head>
 
912
  <a href="{{ url_for('admin_panel') }}" class="back-link">← Назад к списку пользователей</a>
913
  <h1>Файлы пользователя</h1>
914
  <div class="user-identifier">{{ user_info.first_name or '' }} {{ user_info.last_name or '' }} (ID: {{ user_id }})</div>
 
915
  {% if files %}
916
  <table class="file-table">
917
  <thead>
 
942
  {% endif %}
943
  </div>
944
  <script>
 
945
  function formatBytes(bytes, decimals = 2) {
946
  if (!+bytes) return '0 Bytes'
947
  const k = 1024
 
950
  const i = Math.floor(Math.log(bytes) / Math.log(k))
951
  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`
952
  }
 
 
 
 
 
 
 
953
  </script>
954
  </body>
955
  </html>
956
  """
957
 
 
958
  @app.template_filter('filesizeformat')
959
  def filesizeformat(value):
960
  try:
 
966
  return f"{bytes_val / math.pow(k, i):.2f} {sizes[i]}"
967
  except (ValueError, TypeError):
968
  return value
969
+ except Exception:
970
  return 'N/A'
971
 
 
 
 
 
972
  @app.route('/')
973
  def index():
 
 
974
  return render_template_string(USER_TEMPLATE, theme={}, max_files=MAX_UPLOAD_FILES)
975
 
976
  @app.route('/files', methods=['POST'])
 
979
  init_data_str = req_data.get('initData')
980
  if not init_data_str:
981
  return jsonify({"status": "error", "message": "Missing initData"}), 400
 
982
  user_info, message = authenticate_and_get_user(init_data_str)
983
  if not user_info:
984
  return jsonify({"status": "error", "message": message}), 403
 
985
  user_id_str = str(user_info['id'])
986
  with _data_lock:
987
  user_data = metadata_cache.get(user_id_str, {})
988
  files = user_data.get('files', [])
 
989
  return jsonify({"status": "ok", "files": files}), 200
990
 
991
  @app.route('/upload', methods=['POST'])
 
993
  init_data_str = request.form.get('initData')
994
  if not init_data_str:
995
  return jsonify({"status": "error", "message": "Missing initData"}), 400
 
996
  user_info, message = authenticate_and_get_user(init_data_str)
997
  if not user_info:
998
  return jsonify({"status": "error", "message": message}), 403
 
999
  user_id = user_info['id']
1000
  user_id_str = str(user_id)
1001
+ uploaded_files = request.files.getlist('files')
 
1002
  if not uploaded_files or len(uploaded_files) == 0:
1003
  return jsonify({"status": "error", "message": "No files selected for upload."}), 400
1004
  if len(uploaded_files) > MAX_UPLOAD_FILES:
1005
  return jsonify({"status": "error", "message": f"Cannot upload more than {MAX_UPLOAD_FILES} files at once."}), 400
 
1006
  api = get_hf_api(write=True)
1007
  if not api:
1008
  return jsonify({"status": "error", "message": "Server error: Cannot connect to storage."}), 500
 
1009
  successful_uploads_metadata = []
1010
  errors = []
 
1011
  for file_storage in uploaded_files:
1012
  filename = file_storage.filename
1013
  if not filename:
1014
  errors.append("Received a file without a name.")
1015
  continue
 
1016
  path_in_repo = f"{HF_UPLOAD_FOLDER}/{user_id_str}/{filename}"
1017
+ file_content = file_storage.read()
1018
  file_size = len(file_content)
1019
  content_type, _ = mimetypes.guess_type(filename)
 
1020
  try:
1021
  logging.info(f"Uploading '{filename}' for user {user_id} to {path_in_repo}...")
 
1022
  file_obj = io.BytesIO(file_content)
1023
  api.upload_file(
1024
  path_or_fileobj=file_obj,
 
1026
  repo_id=REPO_ID,
1027
  repo_type="dataset",
1028
  commit_message=f"User {user_id} uploaded {filename}"
 
1029
  )
1030
  logging.info(f"Successfully uploaded '{filename}' for user {user_id}.")
1031
  now = time.time()
 
1039
  })
1040
  except Exception as e:
1041
  logging.error(f"Failed to upload '{filename}' for user {user_id}: {e}", exc_info=True)
1042
+ errors.append(f"Ошибка загрузки {filename}: {str(e)}")
 
 
1043
  if successful_uploads_metadata:
1044
  if not update_user_file_metadata(user_id, successful_uploads_metadata):
 
1045
  errors.append("Ошибка обновления списка файлов после загрузки.")
 
1046
  if not errors:
1047
  return jsonify({"status": "ok", "message": f"Загружено {len(successful_uploads_metadata)} файл(ов)."}), 200
1048
  else:
 
1049
  return jsonify({
1050
  "status": "error" if not successful_uploads_metadata else "partial_success",
1051
  "message": f"Загружено {len(successful_uploads_metadata)} из {len(uploaded_files)}. Ошибки: {'; '.join(errors)}",
1052
  "uploaded_files": [f['filename'] for f in successful_uploads_metadata],
1053
  "errors": errors
1054
+ }), 207
 
1055
 
1056
  @app.route('/download/<path:filename>', methods=['GET'])
1057
  def download_file(filename):
1058
  init_data_str = request.args.get('initData')
1059
  if not init_data_str:
1060
  return "Authentication required.", 401
 
1061
  user_info, message = authenticate_and_get_user(init_data_str)
1062
  if not user_info:
1063
  return f"Access denied: {message}", 403
 
1064
  user_id = user_info['id']
1065
  user_id_str = str(user_id)
 
 
1066
  with _data_lock:
1067
  user_data = metadata_cache.get(user_id_str, {})
1068
  user_files = user_data.get('files', [])
1069
  file_metadata = next((f for f in user_files if f['filename'] == filename), None)
 
1070
  if not file_metadata:
1071
  logging.warning(f"User {user_id} attempted to download unlisted/unowned file: {filename}")
1072
  return "File not found or access denied.", 404
 
1073
  api = get_hf_api(write=False)
1074
  if not api:
1075
  return "Server error: Cannot connect to storage.", 500
 
1076
  path_in_repo = file_metadata.get('hf_path', f"{HF_UPLOAD_FOLDER}/{user_id_str}/{filename}")
 
1077
  try:
1078
  logging.info(f"User {user_id} requesting download of {path_in_repo}")
 
1079
  local_file_path = hf_hub_download(
1080
  repo_id=REPO_ID,
1081
  filename=path_in_repo,
1082
  repo_type="dataset",
1083
  token=api.token,
1084
+ force_download=False,
 
 
1085
  etag_timeout=10
1086
  )
1087
  logging.info(f"File {path_in_repo} downloaded to cache: {local_file_path}")
 
 
 
1088
  content_type = file_metadata.get('content_type') or mimetypes.guess_type(filename)[0] or 'application/octet-stream'
 
1089
  return send_file(
1090
  local_file_path,
1091
  mimetype=content_type,
1092
+ as_attachment=False,
1093
+ download_name=filename
1094
  )
 
1095
  except EntryNotFoundError:
1096
  logging.error(f"File not found on Hugging Face: {path_in_repo}")
1097
  return "File not found on storage.", 404
 
1102
  logging.error(f"Error downloading file {path_in_repo} for user {user_id}: {e}", exc_info=True)
1103
  return "Server error during download.", 500
1104
 
 
 
 
 
1105
  @app.route('/admin')
1106
  def admin_panel():
1107
+ current_data = load_local_metadata()
1108
  return render_template_string(ADMIN_TEMPLATE, users=current_data)
1109
 
1110
  @app.route('/admin/user/<user_id>')
 
1113
  user_data = current_data.get(str(user_id))
1114
  if not user_data:
1115
  return "User not found", 404
1116
+ user_info = user_data.get("user_info", {"id": user_id})
 
1117
  files = user_data.get("files", [])
 
1118
  return render_template_string(ADMIN_USER_FILES_TEMPLATE,
1119
  user_id=user_id,
1120
  user_info=user_info,
 
1122
 
1123
  @app.route('/admin/download/<user_id>/<path:filename>', methods=['GET'])
1124
  def admin_download_file(user_id, filename):
 
1125
  user_id_str = str(user_id)
1126
  logging.info(f"Admin request to download file '{filename}' for user {user_id}")
 
1127
  api = get_hf_api(write=False)
1128
  if not api:
1129
  return "Server error: Cannot connect to storage.", 500
1130
+ path_in_repo = f"{HF_UPLOAD_FOLDER}/{user_id_str}/{filename}"
 
 
1131
  with _data_lock:
1132
  user_data = metadata_cache.get(user_id_str, {})
1133
  user_files = user_data.get('files', [])
1134
  file_metadata = next((f for f in user_files if f['filename'] == filename), None)
1135
  if file_metadata and 'hf_path' in file_metadata:
1136
  path_in_repo = file_metadata['hf_path']
 
1137
  try:
1138
  local_file_path = hf_hub_download(
1139
  repo_id=REPO_ID,
 
1144
  etag_timeout=10
1145
  )
1146
  logging.info(f"Admin download: File {path_in_repo} cached at {local_file_path}")
 
1147
  content_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
1148
  if file_metadata and 'content_type' in file_metadata:
1149
  content_type = file_metadata['content_type'] or content_type
 
1150
  return send_file(
1151
  local_file_path,
1152
  mimetype=content_type,
1153
+ as_attachment=True,
1154
  download_name=filename
1155
  )
1156
  except EntryNotFoundError:
 
1162
 
1163
  @app.route('/admin/download_metadata', methods=['POST'])
1164
  def admin_trigger_download_metadata():
 
1165
  success = download_metadata_from_hf()
1166
  if success:
1167
  return jsonify({"status": "ok", "message": "Скачивание data.json с Hugging Face завершено. Обновите страницу."})
1168
  else:
1169
  return jsonify({"status": "error", "message": "Ошибка скачивания data.json. Проверьте логи."}), 500
1170
 
 
 
 
1171
  if __name__ == '__main__':
1172
  print("---")
1173
  print("--- ZEUS CLOUD MINI APP SERVER ---")
 
1178
  print(f"Hugging Face Repo: {REPO_ID}")
1179
  print(f"HF Metadata Path: {HF_DATA_FILE_PATH}")
1180
  print(f"HF Upload Folder: {HF_UPLOAD_FOLDER}/<user_id>/")
 
1181
  if not HF_TOKEN_READ or not HF_TOKEN_WRITE:
1182
  print("---")
1183
  print("--- WARNING: HUGGING FACE TOKEN(S) NOT SET ---")
 
1186
  else:
1187
  print("--- Hugging Face tokens found.")
1188
  print("--- Attempting initial metadata download from Hugging Face...")
1189
+ download_metadata_from_hf()
 
 
1190
  load_local_metadata()
1191
  print(f"--- Initial metadata cache loaded with {len(metadata_cache)} user(s).")
 
1192
  print("---")
1193
  print("--- SECURITY WARNING ---")
1194
  print("--- The /admin routes are NOT protected by authentication.")
1195
  print("--- Implement proper auth before any production deployment.")
1196
  print("---")
1197
  print("--- Server Ready ---")
1198
+ app.run(host=HOST, port=PORT, debug=False, threaded=True)